@mindful-web/marko-web-digital-edition 1.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintignore +1 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/components/ad.marko +81 -0
- package/components/ad.marko.js +102 -0
- package/components/article-body.marko +134 -0
- package/components/article-body.marko.js +186 -0
- package/components/author.marko +30 -0
- package/components/author.marko.js +59 -0
- package/components/gate.marko +158 -0
- package/components/gate.marko.js +147 -0
- package/components/ld-json.marko +5 -0
- package/components/ld-json.marko.js +29 -0
- package/components/marko.json +78 -0
- package/components/reflow.marko +67 -0
- package/components/reflow.marko.js +106 -0
- package/components/replica.marko +86 -0
- package/components/replica.marko.js +128 -0
- package/components/scroll.marko +134 -0
- package/components/scroll.marko.js +23 -0
- package/components/theme.marko +73 -0
- package/components/theme.marko.js +30 -0
- package/components/toc.marko +28 -0
- package/components/toc.marko.js +53 -0
- package/components/toolbar.marko +47 -0
- package/components/toolbar.marko.js +54 -0
- package/components/tracking.marko +211 -0
- package/components/tracking.marko.js +60 -0
- package/lib/authors.js +125 -0
- package/lib/entities.js +97 -0
- package/lib/index.js +453 -0
- package/marko.json +6 -0
- package/package.json +33 -0
- package/routes/index.js +129 -0
- package/scss/index.scss +679 -0
- package/templates/article.marko +108 -0
- package/templates/article.marko.js +238 -0
- package/templates/hub.marko +94 -0
- package/templates/hub.marko.js +219 -0
- package/templates/partial.marko +17 -0
- package/templates/partial.marko.js +44 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
const { existsSync, readFileSync } = require('fs');
|
|
2
|
+
const { resolve } = require('path');
|
|
3
|
+
const entities = require('./entities');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Digital edition support.
|
|
7
|
+
*
|
|
8
|
+
* The edition is described entirely by a committed manifest JSON,
|
|
9
|
+
* `<configDir>/<issueId>.json`, produced by the digital-edition ingest pipeline.
|
|
10
|
+
* Nothing here reads or writes CMS fields — page images and ad crops are imgix URLs
|
|
11
|
+
* built from the issue PDF that already hangs off the magazine issue.
|
|
12
|
+
*
|
|
13
|
+
* When this graduates, the manifest moves to a Mongo collection and this loader is
|
|
14
|
+
* replaced by a GraphQL query; the shapes below are meant to survive that move.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Per-process defaults, set once by the routes factory. A website process serves a
|
|
19
|
+
* single site, so module state is the right scope: it keeps `editionPath` callable
|
|
20
|
+
* from every template without threading the mount point through each call.
|
|
21
|
+
*/
|
|
22
|
+
const defaults = {
|
|
23
|
+
configDir: null,
|
|
24
|
+
mountPoint: '/magazine',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const configure = ({ configDir, mountPoint } = {}) => {
|
|
28
|
+
if (configDir) defaults.configDir = resolve(configDir);
|
|
29
|
+
if (mountPoint) defaults.mountPoint = mountPoint.replace(/\/+$/, '');
|
|
30
|
+
return { ...defaults };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const cache = new Map();
|
|
34
|
+
|
|
35
|
+
/** Load a manifest by issue id, or null when the issue has no edition. */
|
|
36
|
+
const loadManifest = (issueId, { configDir = defaults.configDir } = {}) => {
|
|
37
|
+
const id = String(issueId);
|
|
38
|
+
if (!/^\d+$/.test(id)) return null;
|
|
39
|
+
if (!configDir) throw new Error('marko-web-digital-edition: no configDir configured. Pass it to the routes factory.');
|
|
40
|
+
const key = `${configDir}:${id}`;
|
|
41
|
+
if (cache.has(key)) return cache.get(key);
|
|
42
|
+
const path = resolve(configDir, `${id}.json`);
|
|
43
|
+
const manifest = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
44
|
+
// Cache misses too — a missing edition is the common case on a 404 probe.
|
|
45
|
+
if (process.env.NODE_ENV === 'production') cache.set(key, manifest);
|
|
46
|
+
return manifest;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const qs = (params) => Object.entries(params)
|
|
50
|
+
.filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
51
|
+
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
|
52
|
+
.join('&');
|
|
53
|
+
|
|
54
|
+
const baseUrl = ({ host, key }) => `https://${host}/${String(key).replace(/^\/+/, '')}`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Full-page render.
|
|
58
|
+
* imgix rasterises PDF pages natively via `page=N`.
|
|
59
|
+
*/
|
|
60
|
+
const pageImageUrl = (manifest, page, { w = 1200, fm = 'webp', dpr } = {}) => `${baseUrl(manifest.pdf)}?${qs({
|
|
61
|
+
page, fm, w, dpr,
|
|
62
|
+
})}`;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Region crop.
|
|
66
|
+
* `bbox` is [x, y, w, h] as FRACTIONS of the page. imgix `rect` wants integers in
|
|
67
|
+
* the PDF's base render space (manifest.pdf.baseWidth/baseHeight), so multiply here
|
|
68
|
+
* and nowhere else.
|
|
69
|
+
*/
|
|
70
|
+
const cropUrl = (manifest, page, bbox, {
|
|
71
|
+
w = 1400, fm = 'webp', dpr, pad = 0,
|
|
72
|
+
} = {}) => {
|
|
73
|
+
const { baseWidth: bw, baseHeight: bh } = manifest.pdf;
|
|
74
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
75
|
+
const x0 = clamp((bbox[0] - pad) * bw, 0, bw);
|
|
76
|
+
const y0 = clamp((bbox[1] - pad) * bh, 0, bh);
|
|
77
|
+
const x1 = clamp((bbox[0] + bbox[2] + pad) * bw, 0, bw);
|
|
78
|
+
const y1 = clamp((bbox[1] + bbox[3] + pad) * bh, 0, bh);
|
|
79
|
+
const rect = [
|
|
80
|
+
Math.round(x0), Math.round(y0),
|
|
81
|
+
Math.max(1, Math.round(x1 - x0)), Math.max(1, Math.round(y1 - y0)),
|
|
82
|
+
].join(',');
|
|
83
|
+
return `${baseUrl(manifest.pdf)}?${qs({
|
|
84
|
+
page, rect, fm, w, dpr,
|
|
85
|
+
})}`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** `srcset` across a few widths, so phones don't pull a 1600px page image. */
|
|
89
|
+
const pageSrcset = (manifest, page, widths = [480, 800, 1200, 1600]) => widths
|
|
90
|
+
.map((w) => `${pageImageUrl(manifest, page, { w })} ${w}w`)
|
|
91
|
+
.join(', ');
|
|
92
|
+
|
|
93
|
+
const cropSrcset = (manifest, page, bbox, widths = [480, 800, 1200]) => widths
|
|
94
|
+
.map((w) => `${cropUrl(manifest, page, bbox, { w })} ${w}w`)
|
|
95
|
+
.join(', ');
|
|
96
|
+
|
|
97
|
+
/** Aspect ratio of the printed page, so the replica reserves layout space. */
|
|
98
|
+
const pageAspect = (manifest) => manifest.pdf.baseWidth / manifest.pdf.baseHeight;
|
|
99
|
+
|
|
100
|
+
const isAd = (item) => item.kind === 'ad' || item.kind === 'advertorial' || item.kind === 'house_ad';
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* An editorial item that earns a URL. Requires a non-empty reflow body: a group
|
|
104
|
+
* whose only text is its headline (p46's "We're Exhibiting at…" panel introducing a
|
|
105
|
+
* paid exhibitor showcase) would otherwise publish as a thin, headline-only page.
|
|
106
|
+
*/
|
|
107
|
+
const isPublishable = (item) => item.kind === 'article' && Boolean(item.slug) && item.bodyChars > 0;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Articles held from search by the ingest (`noindex: true` on the item). They stay
|
|
111
|
+
* readable and linked for subscribers, but emit a robots noindex and are left out
|
|
112
|
+
* of the issue's structured-data contents.
|
|
113
|
+
*/
|
|
114
|
+
const isIndexable = (item) => !item.noindex;
|
|
115
|
+
|
|
116
|
+
/** Contents entries for the reflow TOC — editorial only. */
|
|
117
|
+
const tocItems = (manifest) => manifest.items
|
|
118
|
+
.filter((i) => isPublishable(i) && i.headline)
|
|
119
|
+
.sort((a, b) => a.printPage - b.printPage);
|
|
120
|
+
|
|
121
|
+
/** Ad regions that carry a landing URL, keyed by page, for replica hotspots. */
|
|
122
|
+
const hotspotsForPage = (manifest, pageNumber) => {
|
|
123
|
+
const page = manifest.pages.find((p) => p.n === pageNumber);
|
|
124
|
+
if (!page) return [];
|
|
125
|
+
return page.regions.filter((r) => r.href);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const anchorFor = (item) => `item-${String(item.id).replace(/[^a-z0-9]+/gi, '-')}`;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Canonical path for an issue's digital edition — a child of the magazine issue
|
|
132
|
+
* route (`<mountPoint>/<issueId>/digital-edition`). Defined here only, so the route,
|
|
133
|
+
* the toolbar links and the canonical URL cannot drift apart.
|
|
134
|
+
*/
|
|
135
|
+
const editionPath = (issueId, { view, slug, mountPoint = defaults.mountPoint } = {}) => {
|
|
136
|
+
const base = `${mountPoint}/${issueId}/digital-edition`;
|
|
137
|
+
if (slug) return `${base}/${slug}`;
|
|
138
|
+
return view === 'print' ? `${base}?view=print` : base;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Publishable articles in print order — the spine of the reader and of prev/next. */
|
|
142
|
+
const articles = (manifest) => manifest.items
|
|
143
|
+
.filter(isPublishable)
|
|
144
|
+
.slice()
|
|
145
|
+
.sort((a, b) => a.printSequence - b.printSequence);
|
|
146
|
+
|
|
147
|
+
const articleBySlug = (manifest, slug) => articles(manifest).find((a) => a.slug === slug) || null;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Neighbours by reading order. `printSequence` comes from the grouping pass and skips
|
|
151
|
+
* numbers (advertorial groups consume them), so position in the filtered list is what
|
|
152
|
+
* matters, not the raw sequence value.
|
|
153
|
+
*/
|
|
154
|
+
const prevNext = (manifest, slug) => {
|
|
155
|
+
const list = articles(manifest);
|
|
156
|
+
const i = list.findIndex((a) => a.slug === slug);
|
|
157
|
+
if (i === -1) {
|
|
158
|
+
return {
|
|
159
|
+
prev: null, next: null, position: null, total: list.length,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
prev: i > 0 ? list[i - 1] : null,
|
|
164
|
+
next: i < list.length - 1 ? list[i + 1] : null,
|
|
165
|
+
position: i + 1,
|
|
166
|
+
total: list.length,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// In-article ad placement.
|
|
172
|
+
//
|
|
173
|
+
// Print put every ad on a page inside or facing the article it ran beside; the
|
|
174
|
+
// reflow reader keeps that adjacency by floating the ad into the article body at
|
|
175
|
+
// the depth matching its print position. Each paid ad is claimed by exactly ONE
|
|
176
|
+
// article — pass A: the article whose page span contains the ad's page (tie →
|
|
177
|
+
// earlier print sequence); pass B: the article one page away (tie → earlier
|
|
178
|
+
// sequence). Claims big enough to float (≥ ~15% of a page — p77's 4:1 directory
|
|
179
|
+
// strips are not) go inline; the rest trail the article at roughly print scale.
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/** Floats smaller than this fraction of a page cannot carry a wrap. */
|
|
183
|
+
const INLINE_MIN_AREA = 0.15;
|
|
184
|
+
/** Minimum paragraphs of copy between two floats, tuned to a full-page ad's height. */
|
|
185
|
+
const INLINE_MIN_GAP = 4;
|
|
186
|
+
/** Articles shorter than this trail everything — no room for a wrap. */
|
|
187
|
+
const INLINE_MIN_PARAS = 4;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Paragraph slots of a reflow body. Bodies are flat `<p>`/`<h2>` HTML from the
|
|
191
|
+
* ingest compose stage, so a tag scan is structural, not heuristic. Slot i sits
|
|
192
|
+
* after the (i+1)-th `</p>` and can host a float only when a `<p>` follows — a
|
|
193
|
+
* float directly above an `<h2>` reads as belonging to the previous section.
|
|
194
|
+
*/
|
|
195
|
+
const bodySlots = (bodyHtml) => {
|
|
196
|
+
const html = String(bodyHtml || '');
|
|
197
|
+
const slots = [];
|
|
198
|
+
const re = /<\/p>/g;
|
|
199
|
+
let m = re.exec(html);
|
|
200
|
+
while (m) {
|
|
201
|
+
const end = m.index + m[0].length;
|
|
202
|
+
slots.push({ end, floatable: /^\s*<p[\s>]/.test(html.slice(end, end + 12)) });
|
|
203
|
+
m = re.exec(html);
|
|
204
|
+
}
|
|
205
|
+
return slots;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const spanOf = (a) => {
|
|
209
|
+
const refs = a.pageRefs && a.pageRefs.length ? a.pageRefs : [a.printPage];
|
|
210
|
+
return { lo: Math.min(...refs), hi: Math.max(...refs) };
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const buildPlacements = (manifest) => {
|
|
214
|
+
const arts = articles(manifest);
|
|
215
|
+
const claims = new Map();
|
|
216
|
+
arts.forEach((a) => claims.set(a.id, []));
|
|
217
|
+
|
|
218
|
+
manifest.items.filter((i) => i.kind === 'ad').forEach((ad) => {
|
|
219
|
+
// Pass A: span containment. `arts` is in printSequence order, so the first
|
|
220
|
+
// match is the tie-break.
|
|
221
|
+
let owner = arts.find((a) => {
|
|
222
|
+
const s = spanOf(a);
|
|
223
|
+
return ad.printPage >= s.lo && ad.printPage <= s.hi;
|
|
224
|
+
});
|
|
225
|
+
if (!owner) {
|
|
226
|
+
// Pass B: facing page.
|
|
227
|
+
owner = arts.find((a) => {
|
|
228
|
+
const s = spanOf(a);
|
|
229
|
+
return ad.printPage === s.lo - 1 || ad.printPage === s.hi + 1;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (owner) claims.get(owner.id).push(ad);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const placements = new Map();
|
|
236
|
+
arts.forEach((a) => {
|
|
237
|
+
const claimed = claims.get(a.id);
|
|
238
|
+
const slots = bodySlots(a.bodyHtml);
|
|
239
|
+
const pCount = slots.length;
|
|
240
|
+
const inline = [];
|
|
241
|
+
const trailing = [];
|
|
242
|
+
// Print order: page, then top of page — this decides which ad comes first,
|
|
243
|
+
// even though the depths themselves are evenly distributed below.
|
|
244
|
+
claimed.sort((x, y) => x.printPage - y.printPage || x.bbox[1] - y.bbox[1]);
|
|
245
|
+
|
|
246
|
+
const candidates = [];
|
|
247
|
+
claimed.forEach((ad) => {
|
|
248
|
+
const area = ad.bbox ? ad.bbox[2] * ad.bbox[3] : 0;
|
|
249
|
+
if (area < INLINE_MIN_AREA || pCount < INLINE_MIN_PARAS) trailing.push(ad);
|
|
250
|
+
else candidates.push(ad);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Even distribution through the copy: one ad sits ~50% of the way through,
|
|
254
|
+
// two at ~33%/66%, three at 25/50/75 — j/(N+1). Print position decides the
|
|
255
|
+
// ORDER of the ads, not their depth: mapping depth from the ad's print page
|
|
256
|
+
// put a facing-page ad at 100% and it beached at the very end of the body.
|
|
257
|
+
let lastSlot = -Infinity;
|
|
258
|
+
candidates.forEach((ad, j) => {
|
|
259
|
+
const fraction = (j + 1) / (candidates.length + 1);
|
|
260
|
+
const target = Math.min(Math.max(Math.round(fraction * pCount), 2), pCount - 1);
|
|
261
|
+
const floor = Math.max(1, lastSlot + INLINE_MIN_GAP);
|
|
262
|
+
const start = Math.max(target - 1, floor);
|
|
263
|
+
let chosen = -1;
|
|
264
|
+
for (let i = start; i <= pCount - 2; i += 1) {
|
|
265
|
+
if (slots[i].floatable) { chosen = i; break; }
|
|
266
|
+
}
|
|
267
|
+
for (let i = Math.min(start, pCount - 2); chosen === -1 && i >= floor; i -= 1) {
|
|
268
|
+
if (slots[i].floatable) chosen = i;
|
|
269
|
+
}
|
|
270
|
+
if (chosen === -1) {
|
|
271
|
+
trailing.push(ad);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
inline.push({ ad, slot: chosen });
|
|
275
|
+
lastSlot = chosen;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
placements.set(a.id, { inline, trailing });
|
|
279
|
+
});
|
|
280
|
+
return placements;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// The manifest object is cached per issue in production, so placements memoize
|
|
284
|
+
// against it; in dev the manifest is re-read per request and this is a cheap miss.
|
|
285
|
+
const placementCache = new WeakMap();
|
|
286
|
+
const inlineAdPlacements = (manifest) => {
|
|
287
|
+
if (placementCache.has(manifest)) return placementCache.get(manifest);
|
|
288
|
+
const placements = buildPlacements(manifest);
|
|
289
|
+
placementCache.set(manifest, placements);
|
|
290
|
+
return placements;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* One article's body, split for rendering: `segments` in order, each an html
|
|
295
|
+
* chunk optionally preceded by an inline (floated) ad; `trailing` ads render
|
|
296
|
+
* after the article body at print scale.
|
|
297
|
+
*/
|
|
298
|
+
const articleAdLayout = (manifest, item) => {
|
|
299
|
+
const plan = inlineAdPlacements(manifest).get(item.id) || { inline: [], trailing: [] };
|
|
300
|
+
const html = String(item.bodyHtml || '');
|
|
301
|
+
const slots = bodySlots(html);
|
|
302
|
+
const sorted = plan.inline.slice().sort((x, y) => x.slot - y.slot);
|
|
303
|
+
const cuts = sorted.map((p) => slots[p.slot].end);
|
|
304
|
+
const segments = [{ ad: null, html: html.slice(0, cuts.length ? cuts[0] : html.length) }];
|
|
305
|
+
sorted.forEach((p, i) => {
|
|
306
|
+
segments.push({
|
|
307
|
+
ad: p.ad,
|
|
308
|
+
html: html.slice(cuts[i], i + 1 < cuts.length ? cuts[i + 1] : html.length),
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
return { segments, trailing: plan.trailing };
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Canonical URL for one article.
|
|
316
|
+
*
|
|
317
|
+
* A print-exclusive article is original content and self-canonicalizes. An article
|
|
318
|
+
* matched to an existing web article points at that instead, so the edition does not
|
|
319
|
+
* compete with it. Returned absolute — a relative canonical is a silent no-op.
|
|
320
|
+
*/
|
|
321
|
+
const articleCanonical = ({ manifest, item, requestOrigin }) => {
|
|
322
|
+
const origin = requestOrigin || '';
|
|
323
|
+
if (item.match && item.match.path) return `${origin}${item.match.path}`;
|
|
324
|
+
return `${origin}${editionPath(manifest.issueId, { slug: item.slug })}`;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** First paragraph only — what an unentitled visitor is allowed to read. */
|
|
328
|
+
const teaserHtml = (item) => {
|
|
329
|
+
const html = String(item.bodyHtml || '');
|
|
330
|
+
const end = html.indexOf('</p>');
|
|
331
|
+
return end === -1 ? '' : html.slice(0, end + 4);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Google "paywalled content" structured data for an issue.
|
|
336
|
+
*
|
|
337
|
+
* Mirrors what content pages already emit from marko-web's
|
|
338
|
+
* `page/metadata/google-structured-data/content.js` (isAccessibleForFree:false plus
|
|
339
|
+
* hasPart.cssSelector). That helper is content-node specific and lives in
|
|
340
|
+
* mindful-web, so the issue-level equivalent lives here.
|
|
341
|
+
*
|
|
342
|
+
* This is what lets the edition be gated AND indexable.
|
|
343
|
+
*/
|
|
344
|
+
const buildLdJson = ({ manifest, gated, canonicalUrl }) => {
|
|
345
|
+
// Held (noindex) articles are omitted: advertising a part we ask Google not to
|
|
346
|
+
// index is a conflicting signal.
|
|
347
|
+
const toc = tocItems(manifest).filter(isIndexable);
|
|
348
|
+
const parts = toc.slice(0, 60).map((i) => {
|
|
349
|
+
const part = {
|
|
350
|
+
'@type': 'Article',
|
|
351
|
+
headline: i.headline,
|
|
352
|
+
pagination: String(i.printPage),
|
|
353
|
+
isAccessibleForFree: !gated,
|
|
354
|
+
};
|
|
355
|
+
if (i.byline) part.author = { '@type': 'Person', name: i.byline };
|
|
356
|
+
if (i.match && i.match.path) part.sameAs = i.match.path;
|
|
357
|
+
return part;
|
|
358
|
+
});
|
|
359
|
+
if (gated) {
|
|
360
|
+
parts.push({
|
|
361
|
+
'@type': 'WebPageElement',
|
|
362
|
+
isAccessibleForFree: false,
|
|
363
|
+
cssSelector: '.edition-gated-body',
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const ld = {
|
|
367
|
+
'@context': 'https://schema.org',
|
|
368
|
+
'@type': 'PublicationIssue',
|
|
369
|
+
name: `${manifest.publication} — ${manifest.name}`,
|
|
370
|
+
issueNumber: String(manifest.issueId),
|
|
371
|
+
isAccessibleForFree: !gated,
|
|
372
|
+
isPartOf: { '@type': 'Periodical', name: manifest.publication },
|
|
373
|
+
};
|
|
374
|
+
if (manifest.mailed) ld.datePublished = new Date(manifest.mailed).toISOString();
|
|
375
|
+
if (canonicalUrl) {
|
|
376
|
+
ld.url = canonicalUrl;
|
|
377
|
+
ld.mainEntityOfPage = { '@type': 'WebPage', '@id': canonicalUrl };
|
|
378
|
+
}
|
|
379
|
+
if (manifest.coverImage) ld.image = manifest.coverImage;
|
|
380
|
+
if (parts.length) ld.hasPart = parts;
|
|
381
|
+
return ld;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Structured data for a single article page. Separate from the issue-level
|
|
386
|
+
* PublicationIssue block: this is what makes an individual story citable.
|
|
387
|
+
*/
|
|
388
|
+
const buildArticleLdJson = ({
|
|
389
|
+
manifest, item, canonicalUrl, gated, authorNode, requestOrigin,
|
|
390
|
+
}) => {
|
|
391
|
+
const ld = {
|
|
392
|
+
'@context': 'https://schema.org',
|
|
393
|
+
'@type': 'Article',
|
|
394
|
+
headline: item.headline || undefined,
|
|
395
|
+
isAccessibleForFree: !gated,
|
|
396
|
+
mainEntityOfPage: { '@type': 'WebPage', '@id': canonicalUrl },
|
|
397
|
+
url: canonicalUrl,
|
|
398
|
+
isPartOf: {
|
|
399
|
+
'@type': 'PublicationIssue',
|
|
400
|
+
name: `${manifest.publication} — ${manifest.name}`,
|
|
401
|
+
issueNumber: String(manifest.issueId),
|
|
402
|
+
},
|
|
403
|
+
publisher: { '@type': 'Organization', name: manifest.publication },
|
|
404
|
+
};
|
|
405
|
+
if (item.deck) ld.description = item.deck;
|
|
406
|
+
if (item.printPage) ld.pagination = String(item.printPage);
|
|
407
|
+
if (manifest.mailed) ld.datePublished = new Date(manifest.mailed).toISOString();
|
|
408
|
+
if (manifest.coverImage) ld.image = manifest.coverImage;
|
|
409
|
+
if (gated) {
|
|
410
|
+
ld.hasPart = [{
|
|
411
|
+
'@type': 'WebPageElement',
|
|
412
|
+
isAccessibleForFree: false,
|
|
413
|
+
cssSelector: '.edition-gated-body',
|
|
414
|
+
}];
|
|
415
|
+
}
|
|
416
|
+
if (authorNode) {
|
|
417
|
+
ld.author = { '@type': 'Person', name: authorNode.name };
|
|
418
|
+
if (authorNode.siteContext && authorNode.siteContext.path) {
|
|
419
|
+
ld.author.sameAs = `${requestOrigin || ''}${authorNode.siteContext.path}`;
|
|
420
|
+
}
|
|
421
|
+
if (authorNode.title) ld.author.jobTitle = authorNode.title;
|
|
422
|
+
} else if (item.byline) {
|
|
423
|
+
ld.author = { '@type': 'Person', name: item.byline };
|
|
424
|
+
}
|
|
425
|
+
return ld;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
module.exports = {
|
|
429
|
+
configure,
|
|
430
|
+
loadManifest,
|
|
431
|
+
pageImageUrl,
|
|
432
|
+
pageSrcset,
|
|
433
|
+
cropUrl,
|
|
434
|
+
cropSrcset,
|
|
435
|
+
pageAspect,
|
|
436
|
+
tocItems,
|
|
437
|
+
hotspotsForPage,
|
|
438
|
+
anchorFor,
|
|
439
|
+
isAd,
|
|
440
|
+
isPublishable,
|
|
441
|
+
isIndexable,
|
|
442
|
+
buildLdJson,
|
|
443
|
+
teaserHtml,
|
|
444
|
+
editionPath,
|
|
445
|
+
articles,
|
|
446
|
+
articleBySlug,
|
|
447
|
+
prevNext,
|
|
448
|
+
inlineAdPlacements,
|
|
449
|
+
articleAdLayout,
|
|
450
|
+
articleCanonical,
|
|
451
|
+
buildArticleLdJson,
|
|
452
|
+
...entities,
|
|
453
|
+
};
|
package/marko.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mindful-web/marko-web-digital-edition",
|
|
3
|
+
"version": "1.95.0",
|
|
4
|
+
"description": "Native digital edition (reflow + replica readers) for magazine issues, driven by an ingest manifest, for Mindful Web/Marko websites.",
|
|
5
|
+
"author": "Brandon Krigbaum <brandon@parameter1.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": "https://github.com/parameter1/mindful-web/tree/main/packages/marko-web-digital-edition",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"lint:fix": "yarn lint --fix",
|
|
10
|
+
"lint": "eslint --ext .js --ext .vue --max-warnings 5 ./",
|
|
11
|
+
"compile": "mindful-web-marko-compile compile",
|
|
12
|
+
"prepublish": "yarn compile --silent",
|
|
13
|
+
"test": "yarn compile --no-clean && yarn lint"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@mindful-web/marko-web-gtm": "^1.92.0",
|
|
17
|
+
"@mindful-web/marko-web-identity-x": "^1.92.0",
|
|
18
|
+
"@mindful-web/marko-web-p1-events": "^1.93.1",
|
|
19
|
+
"@mindful-web/object-path": "^1.92.0",
|
|
20
|
+
"@mindful-web/utils": "^1.92.0",
|
|
21
|
+
"graphql": "^14.7.0",
|
|
22
|
+
"graphql-tag": "^2.12.6",
|
|
23
|
+
"http-errors": "^1.7.3"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@mindful-web/marko-core": "^1.0.0",
|
|
27
|
+
"@mindful-web/marko-web": "^1.0.0"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"gitHead": "ca256c07007f7e4bed54d6e7b79df0f53b0b3a0a"
|
|
33
|
+
}
|
package/routes/index.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const createError = require('http-errors');
|
|
2
|
+
const { asyncRoute } = require('@mindful-web/utils');
|
|
3
|
+
const hubTemplate = require('../templates/hub');
|
|
4
|
+
const articleTemplate = require('../templates/article');
|
|
5
|
+
const partialTemplate = require('../templates/partial');
|
|
6
|
+
const { attachAuthors } = require('../lib/authors');
|
|
7
|
+
const {
|
|
8
|
+
configure, loadManifest, editionPath, articles, articleBySlug, prevNext, articleCanonical,
|
|
9
|
+
} = require('../lib');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Native digital edition — a child of the magazine issue it belongs to.
|
|
13
|
+
*
|
|
14
|
+
* <mountPoint>/32023/digital-edition hub: cover, contents, first article
|
|
15
|
+
* <mountPoint>/32023/digital-edition?view=print replica page view
|
|
16
|
+
* <mountPoint>/32023/digital-edition/<slug> one article, server-rendered
|
|
17
|
+
* <mountPoint>/32023/digital-edition/<slug>?partial=1 fragment for the scroll reader
|
|
18
|
+
*
|
|
19
|
+
* Every article has a real URL so it is independently crawlable and citable; the hub's
|
|
20
|
+
* continuous-scroll reader appends those same URLs' fragments and pushStates to them.
|
|
21
|
+
*
|
|
22
|
+
* Mount this BEFORE the site's magazine routes so a future loosening of the issue
|
|
23
|
+
* pattern cannot swallow these child routes.
|
|
24
|
+
*
|
|
25
|
+
* Options:
|
|
26
|
+
* configDir required. Directory holding `<issueId>.json` manifests.
|
|
27
|
+
* mountPoint URL prefix the issue lives under. Default `/magazine`.
|
|
28
|
+
* siteId () => the running site's id. Default `app.locals.config.website('id')`.
|
|
29
|
+
* A manifest whose `siteId` differs is a 404: shared packages ship to
|
|
30
|
+
* every site in a repo, and only the issue's own site may serve it.
|
|
31
|
+
* noIndexKey site-config path that, when truthy, emits a robots noindex on every
|
|
32
|
+
* edition page (a review hold). Default `digitalEdition.native.noIndex`.
|
|
33
|
+
* authorNode optional compiled Marko template rendering `{ author }` — e.g. the
|
|
34
|
+
* monorail theme's `components/nodes/author.marko`. Without it the
|
|
35
|
+
* byline renders as a plain (linked) name.
|
|
36
|
+
* templates optional overrides: `{ hub, article, partial }`.
|
|
37
|
+
* legacyRedirect when true, 301 `/edition/:issueId` → the hub (the AB spike's first URL).
|
|
38
|
+
*/
|
|
39
|
+
module.exports = (app, {
|
|
40
|
+
configDir,
|
|
41
|
+
mountPoint = '/magazine',
|
|
42
|
+
siteId = () => app.locals.config.website('id'),
|
|
43
|
+
noIndexKey = 'digitalEdition.native.noIndex',
|
|
44
|
+
authorNode = null,
|
|
45
|
+
templates = {},
|
|
46
|
+
legacyRedirect = false,
|
|
47
|
+
} = {}) => {
|
|
48
|
+
if (!configDir) throw new Error('marko-web-digital-edition routes: `configDir` is required.');
|
|
49
|
+
const resolved = configure({ configDir, mountPoint });
|
|
50
|
+
const base = resolved.mountPoint;
|
|
51
|
+
|
|
52
|
+
// Reaches templates as `out.global.digitalEdition` (app.locals merge into out.global).
|
|
53
|
+
const { locals } = app;
|
|
54
|
+
locals.digitalEdition = { mountPoint: base, noIndexKey, authorNode };
|
|
55
|
+
|
|
56
|
+
const hub = templates.hub || hubTemplate;
|
|
57
|
+
const article = templates.article || articleTemplate;
|
|
58
|
+
const partial = templates.partial || partialTemplate;
|
|
59
|
+
|
|
60
|
+
/** Resolve the issue for this site, or 404. */
|
|
61
|
+
const getManifest = (req) => {
|
|
62
|
+
const issueId = Number(req.params.issueId);
|
|
63
|
+
const manifest = loadManifest(issueId);
|
|
64
|
+
if (!manifest || manifest.siteId !== siteId()) {
|
|
65
|
+
throw createError(404, `No digital edition has been published for issue ${issueId}.`);
|
|
66
|
+
}
|
|
67
|
+
return manifest;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// --- single article (and its fragment) ------------------------------------
|
|
71
|
+
app.get(`${base}/:issueId(\\d+)/digital-edition/:slug([a-z0-9-]+)`, asyncRoute(async (req, res) => {
|
|
72
|
+
const manifest = getManifest(req);
|
|
73
|
+
const item = articleBySlug(manifest, req.params.slug);
|
|
74
|
+
if (!item) throw createError(404, `No article "${req.params.slug}" in issue ${manifest.issueId}.`);
|
|
75
|
+
|
|
76
|
+
await attachAuthors({
|
|
77
|
+
apollo: req.apollo, siteId: siteId(), items: [item],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (req.query.partial) {
|
|
81
|
+
// Entitlement is resolved inside the template by the same access component the
|
|
82
|
+
// page uses. Private + no-store because the response varies by reader. The
|
|
83
|
+
// article's print-adjacent ads render inside the article component itself.
|
|
84
|
+
res.set('cache-control', 'private, no-store');
|
|
85
|
+
return res.marko(partial, { manifest, item });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const {
|
|
89
|
+
prev, next, position, total,
|
|
90
|
+
} = prevNext(manifest, item.slug);
|
|
91
|
+
return res.marko(article, {
|
|
92
|
+
manifest,
|
|
93
|
+
item,
|
|
94
|
+
canonicalUrl: articleCanonical({ manifest, item, requestOrigin: res.locals.requestOrigin || '' }),
|
|
95
|
+
prev,
|
|
96
|
+
next,
|
|
97
|
+
position,
|
|
98
|
+
total,
|
|
99
|
+
toc: articles(manifest),
|
|
100
|
+
});
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
// --- hub -----------------------------------------------------------------
|
|
104
|
+
app.get(`${base}/:issueId(\\d+)/digital-edition`, asyncRoute(async (req, res) => {
|
|
105
|
+
const manifest = getManifest(req);
|
|
106
|
+
const view = req.query.view === 'print' ? 'print' : 'read';
|
|
107
|
+
const { pageCount } = manifest.pdf;
|
|
108
|
+
const requested = Number.parseInt(req.query.page, 10);
|
|
109
|
+
const page = Number.isFinite(requested) ? Math.min(Math.max(requested, 1), pageCount) : 1;
|
|
110
|
+
|
|
111
|
+
// Only the first article is rendered into the hub, so only it needs an author.
|
|
112
|
+
if (view === 'read') {
|
|
113
|
+
const first = articles(manifest)[0];
|
|
114
|
+
if (first) await attachAuthors({ apollo: req.apollo, siteId: siteId(), items: [first] });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return res.marko(hub, {
|
|
118
|
+
manifest, view, page, issueId: manifest.issueId,
|
|
119
|
+
});
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
if (legacyRedirect) {
|
|
123
|
+
app.get('/edition/:issueId(\\d+)', (req, res) => {
|
|
124
|
+
const qs = req.originalUrl.split('?')[1];
|
|
125
|
+
const to = editionPath(req.params.issueId) + (qs ? `?${qs}` : '');
|
|
126
|
+
res.redirect(301, to);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
};
|