@rathnasgala2/template 2.2.0 → 3.0.1

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.
@@ -271,7 +271,7 @@ export const CSP_HEADER_ONLY_DIRECTIVES = Object.freeze([
271
271
  export const CONTENT_SECURITY_POLICY_META_BASELINE =
272
272
  "default-src 'none'; base-uri 'none'; object-src 'none'; " +
273
273
  "form-action 'none'; script-src 'self'; " +
274
- "style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'none'; " +
274
+ "style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; " +
275
275
  "media-src 'self'; manifest-src 'self'; worker-src 'none'";
276
276
 
277
277
  /**
@@ -287,7 +287,7 @@ export const CONTENT_SECURITY_POLICY_META_BASELINE =
287
287
  export const CONTENT_SECURITY_POLICY_COMPLETE =
288
288
  "default-src 'none'; base-uri 'none'; object-src 'none'; " +
289
289
  "frame-ancestors 'none'; form-action 'none'; script-src 'self'; " +
290
- "style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'none'; " +
290
+ "style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; " +
291
291
  "media-src 'self'; manifest-src 'self'; worker-src 'none'";
292
292
 
293
293
  /**
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `robots.txt` generation.
3
+ *
4
+ * Every site allows all crawlers and names its sitemap by absolute URL. The
5
+ * publication may additionally opt out of AI training and AI-assistant
6
+ * crawlers through the validated `publication.crawlers.ai` (`"allow"` |
7
+ * `"block"`; absent means allow).
8
+ *
9
+ * Unlisted (`noindex`) pages are deliberately not disallowed here: a
10
+ * crawler can only honour a `noindex` directive on a page it is allowed to
11
+ * fetch.
12
+ *
13
+ * Only a `robots.txt` at the host root is honoured by crawlers. This file is
14
+ * always written at the output root; when the site is served from a path
15
+ * (for example a GitHub project site), the host's own root `robots.txt` is
16
+ * what crawlers read, and the `Sitemap:` line here still names the absolute
17
+ * sitemap URL so it can be copied there.
18
+ */
19
+
20
+ /**
21
+ * The AI crawler and assistant user agents blocked by `crawlers.ai:
22
+ * "block"`: training crawlers, search crawlers and user-initiated fetchers
23
+ * of the major AI providers.
24
+ *
25
+ * @type {readonly string[]}
26
+ */
27
+ export const AI_CRAWLER_USER_AGENTS = Object.freeze([
28
+ 'GPTBot',
29
+ 'ChatGPT-User',
30
+ 'OAI-SearchBot',
31
+ 'ClaudeBot',
32
+ 'Claude-User',
33
+ 'Claude-SearchBot',
34
+ 'anthropic-ai',
35
+ 'Google-Extended',
36
+ 'PerplexityBot',
37
+ 'Perplexity-User',
38
+ 'CCBot',
39
+ 'Applebot-Extended',
40
+ 'Bytespider',
41
+ 'Meta-ExternalAgent',
42
+ 'Amazonbot',
43
+ ]);
44
+
45
+ /**
46
+ * @param {{crawlers?: {ai: 'allow' | 'block'}}} publication the validated
47
+ * `publication` (`crawlers` is optional; absent means allow)
48
+ * @returns {'allow' | 'block'} the AI crawler policy
49
+ */
50
+ export function readAiCrawlerPolicy(publication) {
51
+ return publication.crawlers?.ai ?? 'allow';
52
+ }
53
+
54
+ /**
55
+ * @param {object} options
56
+ * @param {'allow' | 'block'} options.aiPolicy
57
+ * @param {string} options.sitemapUrl the absolute sitemap URL
58
+ * @returns {string} the exact `robots.txt` text
59
+ */
60
+ export function buildRobotsTxt({ aiPolicy, sitemapUrl }) {
61
+ /** @type {string[]} */
62
+ const groups = ['User-agent: *\nAllow: /'];
63
+ if (aiPolicy === 'block') {
64
+ for (const agent of AI_CRAWLER_USER_AGENTS) {
65
+ groups.push(`User-agent: ${agent}\nDisallow: /`);
66
+ }
67
+ }
68
+ groups.push(`Sitemap: ${sitemapUrl}`);
69
+ return `${groups.join('\n\n')}\n`;
70
+ }
@@ -65,3 +65,34 @@ export function routeSegmentForLabel(label) {
65
65
  const prefix = asciiReadablePrefix(label);
66
66
  return prefix.length > 0 ? `${prefix}-${hash}` : hash;
67
67
  }
68
+
69
+ /**
70
+ * Derive each author's URL segment: a readable slug of the display name
71
+ * (lowercase ASCII, at most 48 characters), stable for a given name. When a
72
+ * name yields no ASCII characters or two authors yield the same slug, the
73
+ * author with the lower id keeps the slug and every other one falls back to
74
+ * its id, so a segment is always unique and a later colliding author never
75
+ * moves an existing author's URL.
76
+ *
77
+ * @param {readonly {id: string, displayName: string}[]} authors
78
+ * @returns {Map<string, string>} author id to URL segment
79
+ */
80
+ export function deriveAuthorSlugs(authors) {
81
+ /** @type {Map<string, string>} */
82
+ const slugs = new Map();
83
+ const taken = new Set(authors.map((author) => author.id));
84
+ const ordered = authors.slice().sort((a, b) => (a.id < b.id ? -1 : 1));
85
+ for (const author of ordered) {
86
+ const slug = asciiReadablePrefix(author.displayName);
87
+ if (
88
+ slug !== '' &&
89
+ !taken.has(slug) &&
90
+ ![...slugs.values()].includes(slug)
91
+ ) {
92
+ slugs.set(author.id, slug);
93
+ } else {
94
+ slugs.set(author.id, author.id);
95
+ }
96
+ }
97
+ return slugs;
98
+ }
@@ -112,3 +112,56 @@ export function resolveSocialImageUrl({
112
112
  export function twitterCardType(hasImage) {
113
113
  return hasImage ? 'summary_large_image' : 'summary';
114
114
  }
115
+
116
+ /**
117
+ * Shorten plain text to at most `max` characters at a word boundary,
118
+ * appending an ellipsis when anything was cut (a `<meta name="description">`
119
+ * is read as a short snippet, so a clean cut beats a mid-word one).
120
+ *
121
+ * @param {string} text plain text
122
+ * @param {number} max the longest result, ellipsis included
123
+ * @returns {string} the original text when it fits, else the shortened text
124
+ */
125
+ export function truncateAtWord(text, max) {
126
+ const collapsed = text.replace(/\s+/g, ' ').trim();
127
+ const characters = Array.from(collapsed);
128
+ if (characters.length <= max) return collapsed;
129
+ const clipped = characters.slice(0, max - 1).join('');
130
+ const cut = /\s/.test(clipped) ? clipped.replace(/\s+\S*$/, '') : clipped;
131
+ return `${cut.replace(/[\s,;:.–-]+$/, '')}…`;
132
+ }
133
+
134
+ /**
135
+ * Resolve one social image to its absolute URL and pixel dimensions.
136
+ *
137
+ * @param {object} options
138
+ * @param {readonly {path: string, mediaType: string}[]} options.mediaAssets
139
+ * @param {Readonly<Record<string, {width: number, height: number}>>} [options.mediaDimensions]
140
+ * the media pipeline's pixel dimensions per derivative path
141
+ * @param {{path: string, sourceDigest: string} | undefined} options.reference
142
+ * @param {string} options.baseUrl
143
+ * @param {string} options.basePath the public base path
144
+ * @returns {{url: string, width?: number, height?: number} | undefined}
145
+ */
146
+ export function resolveSocialImage({
147
+ mediaAssets,
148
+ mediaDimensions = {},
149
+ reference,
150
+ baseUrl,
151
+ basePath,
152
+ }) {
153
+ const url = resolveSocialImageUrl({
154
+ mediaAssets,
155
+ reference,
156
+ baseUrl,
157
+ basePath,
158
+ });
159
+ if (!url) return undefined;
160
+ const size =
161
+ mediaDimensions[
162
+ /** @type {string} */ (
163
+ resolveOriginalDerivativePath(mediaAssets, reference)
164
+ )
165
+ ];
166
+ return { url, width: size?.width, height: size?.height };
167
+ }
@@ -11,17 +11,18 @@
11
11
  * through {@link renderPageBody}:
12
12
  *
13
13
  * - a skip link, the very first focusable element, targeting `#main-content`;
14
- * - exactly one `<header>` (home link with the validated publication name and
15
- * an optional core logo carrying required alternative text, plus the
16
- * always-present, currently-empty `header-actions` slot);
17
- * - exactly one primary `<nav>` built from `navigationNormalized.items`;
18
- * - exactly one `<main id="main-content">`, carrying an optional breadcrumb
19
- * `<nav>` before the page's own single `<h1>`;
20
- * - exactly one `<footer>` with the publication name, resolved footer
21
- * navigation, footer profile slot, copyright/license text and the
22
- * template attribution line, plus the remaining always-present,
23
- * currently-empty core slots (`footer-auxiliary`, `account-intent`,
24
- * `conversation`, `newsletter`, `edition-selector`).
14
+ * - exactly one `<header>` (brand mark or monogram, publication name and
15
+ * tagline, the one primary `<nav>` built from `navigationNormalized.items`,
16
+ * a script-revealed search button, the `header-actions` slot carrying the
17
+ * appearance control, and a `<details>` mobile menu), rendered by
18
+ * `internal/chrome.js`;
19
+ * - exactly one `<main id="main-content">`; each page kind places its own
20
+ * breadcrumb `<nav>` and single `<h1>` inside it;
21
+ * - exactly one `<footer>` (brand, description, social links, navigation
22
+ * columns, author profile, copyright and the template attribution line),
23
+ * plus the always-present, currently-empty core slots (`footer-auxiliary`,
24
+ * `account-intent`, `conversation`, `newsletter`, `edition-selector`),
25
+ * rendered by `internal/chrome.js`.
25
26
  *
26
27
  * Core owns nine versioned semantic slots plus the collapsed
27
28
  * `article-footer-ad` slot: `header-actions`, `article-preamble`,
@@ -133,7 +134,7 @@ export function renderSlot(slotName, options = {}) {
133
134
  */
134
135
  export function renderSkipLink(messages) {
135
136
  const label = escapeHtml(/** @type {string} */ (messages.skipToContent));
136
- return `<a href="#main-content">${label}</a>`;
137
+ return `<a class="g-skip" href="#main-content">${label}</a>`;
137
138
  }
138
139
 
139
140
  /**
@@ -148,7 +149,7 @@ export function renderSkipLink(messages) {
148
149
  * internal route's href (`/` when `baseUrl` has no path)
149
150
  * @returns {string} the rendered `<li>`
150
151
  */
151
- function renderNavItem(item, currentRoute, urlPrefix) {
152
+ export function renderNavItem(item, currentRoute, urlPrefix) {
152
153
  const label = escapeHtml(item.label);
153
154
  const href = escapeHtml(
154
155
  /** @type {string} */ (
@@ -200,28 +201,7 @@ export function renderPrimaryNavigation({
200
201
  const itemsHtml = items
201
202
  .map((item) => renderNavItem(item, currentRoute, urlPrefix))
202
203
  .join('');
203
- return `<nav aria-label="${label}"><ul>${itemsHtml}</ul></nav>`;
204
- }
205
-
206
- /**
207
- * Render the footer `<nav>` from `navigationNormalized.footerItems`.
208
- *
209
- * @param {object} options rendering options
210
- * @param {readonly import('../../../types/index.d.ts').NavigationItem[]} options.items
211
- * `navigationNormalized.footerItems` (0..50 root items)
212
- * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
213
- * the resolved message catalog
214
- * @param {string} [options.urlPrefix] the `baseUrl` path prefix (default `/`)
215
- * @returns {string} the rendered footer navigation
216
- */
217
- export function renderFooterNavigation({ items, messages, urlPrefix = '/' }) {
218
- const label = escapeHtml(
219
- /** @type {string} */ (messages.footerNavigationLabel),
220
- );
221
- const itemsHtml = items
222
- .map((item) => renderNavItem(item, undefined, urlPrefix))
223
- .join('');
224
- return `<nav aria-label="${label}"><ul>${itemsHtml}</ul></nav>`;
204
+ return `<nav class="g-nav" aria-label="${label}"><ul>${itemsHtml}</ul></nav>`;
225
205
  }
226
206
 
227
207
  /**
@@ -262,7 +242,7 @@ export function renderBreadcrumbs({ trail, messages }) {
262
242
  return `<li><a href="${escapeHtml(step.route)}">${text}</a></li>`;
263
243
  })
264
244
  .join('');
265
- return `<nav aria-label="${label}"><ol>${items}</ol></nav>`;
245
+ return `<nav class="g-crumbs" aria-label="${label}"><ol>${items}</ol></nav>`;
266
246
  }
267
247
 
268
248
  /**
@@ -307,7 +287,7 @@ export function renderPagination({
307
287
  ? `<a rel="next" href="${escapeHtml(routeForPage(currentPage + 1))}">${nextLabel}</a>`
308
288
  : `<span>${nextLabel}</span>`;
309
289
  return (
310
- `<nav aria-label="${label}"><ul>` +
290
+ `<nav class="g-pagination" aria-label="${label}"><ul>` +
311
291
  `<li>${previous}</li>` +
312
292
  `<li aria-current="page">${statusLabel}</li>` +
313
293
  `<li>${next}</li>` +
@@ -316,117 +296,62 @@ export function renderPagination({
316
296
  }
317
297
 
318
298
  /**
319
- * Render the one `<header>` landmark.
299
+ * Render the (initially invisible) status region the site script uses for
300
+ * short confirmations, and the carrier of every localized string the script
301
+ * needs: each one is a plain `data-*` attribute, so the script ships no
302
+ * language of its own. With JavaScript off it is an empty, inert live region.
320
303
  *
321
- * @param {object} options rendering options
322
- * @param {string} options.homeRoute the publication's own home route
323
- * (`basePath`, joined)
324
- * @param {string} options.publicationName the validated publication title
325
- * @param {{route: string, alt: string} | undefined} options.logo an optional
326
- * core logo image, always carrying alternative text
327
- * @param {string} [options.appearanceControlHtml] the already-rendered
328
- * Light/Dark/System appearance control
329
- * (`internal/appearance/controller-markup.js`'s `renderAppearanceControl`),
330
- * inserted into the `header-actions` slot
331
- * @returns {string} the rendered header
332
- */
333
- export function renderHeader({
334
- homeRoute,
335
- publicationName,
336
- logo,
337
- appearanceControlHtml,
338
- }) {
339
- const name = escapeHtml(publicationName);
340
- const href = escapeHtml(homeRoute);
341
- const logoHtml = logo
342
- ? `<img src="${escapeHtml(logo.route)}" alt="${escapeHtml(logo.alt)}">`
343
- : '';
344
- return (
345
- '<header>' +
346
- `<a href="${href}">${logoHtml}<span>${name}</span></a>` +
347
- renderSlot('header-actions', { html: appearanceControlHtml }) +
348
- '</header>'
349
- );
350
- }
351
-
352
- /**
353
- * Render the one `<footer>` landmark.
354
- *
355
- * @param {object} options rendering options
356
- * @param {string} options.publicationName the validated publication title
357
- * @param {string} options.footerNavHtml the already-rendered footer
358
- * navigation (see {@link renderFooterNavigation})
359
- * @param {string} options.footerProfileHtml the already-rendered
360
- * `footer-profile` slot content (footer card and/or author cards; may be
361
- * empty when the publication authored neither)
362
- * @param {string | undefined} options.copyrightText an optional
363
- * already-resolved copyright/license text line
364
- * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
304
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} messages
365
305
  * the resolved message catalog
366
- * @returns {string} the rendered footer
306
+ * @returns {string} the toast element
367
307
  */
368
- export function renderFooter({
369
- publicationName,
370
- footerNavHtml,
371
- footerProfileHtml,
372
- copyrightText,
373
- messages,
374
- }) {
375
- const name = escapeHtml(publicationName);
376
- const copyrightHtml = copyrightText
377
- ? `<p>${escapeHtml(copyrightText)}</p>`
378
- : '';
379
- const attribution = escapeHtml(
380
- /** @type {string} */ (messages.footerAttributionLabel),
381
- );
382
- return (
383
- '<footer>' +
384
- `<p>${name}</p>` +
385
- footerNavHtml +
386
- `<div data-gala-slot="footer-profile">${footerProfileHtml}</div>` +
387
- renderSlot('footer-auxiliary') +
388
- renderSlot('account-intent') +
389
- renderSlot('conversation') +
390
- renderSlot('newsletter') +
391
- renderSlot('edition-selector') +
392
- copyrightHtml +
393
- `<p>${attribution}</p>` +
394
- '</footer>'
395
- );
308
+ export function renderToast(messages) {
309
+ /** @type {readonly [string, string][]} */
310
+ const strings = [
311
+ ['search-label', 'searchButtonAriaLabel'],
312
+ ['search-placeholder', 'searchPlaceholderLabel'],
313
+ ['search-empty', 'searchEmptyLabel'],
314
+ ['search-unavailable', 'searchUnavailableLabel'],
315
+ ['copy', 'copyCodeLabel'],
316
+ ['copied', 'copiedLabel'],
317
+ ['link-copied', 'linkCopiedLabel'],
318
+ ['copy-blocked', 'copyBlockedLabel'],
319
+ ['code-selected', 'codeSelectedLabel'],
320
+ ['saved', 'savedLabel'],
321
+ ['unsaved', 'unsavedLabel'],
322
+ ];
323
+ const attributes = strings
324
+ .map(
325
+ ([name, key]) =>
326
+ ` data-${name}="${escapeHtml(/** @type {string} */ (messages[key]))}"`,
327
+ )
328
+ .join('');
329
+ return `<div class="g-toast" role="status" aria-live="polite"${attributes}></div>`;
396
330
  }
397
331
 
398
332
  /**
399
333
  * Compose one complete page body (everything inside `<body>`) from its
400
334
  * pre-rendered landmark parts. Every page kind in `internal/page-kinds.js`
401
335
  * funnels through this one function, so the landmark order and count (one
402
- * skip link, one header, one primary nav, one main, one footer) can never
403
- * drift between page kinds.
336
+ * skip link, one header carrying the one primary nav, one main, one footer)
337
+ * can never drift between page kinds.
404
338
  *
405
339
  * @param {object} options rendering options
406
340
  * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
407
341
  * the resolved message catalog
408
- * @param {string} options.headerHtml the already-rendered header
409
- * @param {string} options.navHtml the already-rendered primary navigation
342
+ * @param {string} options.headerHtml the already-rendered header (which
343
+ * contains the primary navigation; see `internal/chrome.js`)
410
344
  * @param {string} options.footerHtml the already-rendered footer
411
- * @param {string} [options.breadcrumbHtml] an optional already-rendered
412
- * breadcrumb, inserted before the page's own `<h1>`
413
- * @param {string} options.mainHtml the page kind's own inner markup, always
414
- * starting with exactly one `<h1>`
345
+ * @param {string} options.mainHtml the page kind's own inner markup,
346
+ * including its breadcrumb (when it has one) and exactly one `<h1>`
415
347
  * @returns {string} the complete `<body>` inner HTML
416
348
  */
417
- export function renderPageBody({
418
- messages,
419
- headerHtml,
420
- navHtml,
421
- footerHtml,
422
- breadcrumbHtml,
423
- mainHtml,
424
- }) {
349
+ export function renderPageBody({ messages, headerHtml, footerHtml, mainHtml }) {
425
350
  return (
426
351
  renderSkipLink(messages) +
352
+ renderToast(messages) +
427
353
  headerHtml +
428
- navHtml +
429
- `<main id="main-content">${breadcrumbHtml ?? ''}${mainHtml}</main>` +
354
+ `<main id="main-content" class="g-main" tabindex="-1">${mainHtml}</main>` +
430
355
  footerHtml
431
356
  );
432
357
  }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * schema.org structured data (JSON-LD) for every generated page. One
3
+ * `<script type="application/ld+json">` carrying one `@graph` per page:
4
+ *
5
+ * - home: `WebSite` + `Blog` (no `SearchAction`: the site search is a
6
+ * client-side dialog over a static index, there is no search-results URL
7
+ * a crawler could call, and an invented one would be a false claim);
8
+ * - article: `BlogPosting` + `BreadcrumbList`;
9
+ * - tag / series / archive / paginated index: `CollectionPage` +
10
+ * `BreadcrumbList`;
11
+ * - author: `ProfilePage` whose `mainEntity` is the `Person`;
12
+ * - authored page / publication profile: `WebPage` (+ `BreadcrumbList`).
13
+ *
14
+ * The block is data, never executed: a browser does not run a script whose
15
+ * `type` is not a JavaScript type, and the page CSP (`script-src 'self'`)
16
+ * does not govern it. The "exactly one executable script per page" rule
17
+ * therefore stays true: the one executable script is the `<script src>`; the
18
+ * data block is distinguished by its `type`.
19
+ */
20
+
21
+ /** @type {number} Google's recommended maximum `headline` length. */
22
+ const HEADLINE_MAX = 110;
23
+
24
+ /**
25
+ * Serialize a JSON-LD document for safe embedding inside a `<script>`
26
+ * element: `<`, `>`, `&`, U+2028 and U+2029 become `\uXXXX` escapes, so the
27
+ * text can never close the element or start a comment, and stays valid
28
+ * JSON.
29
+ *
30
+ * @param {unknown} document the JSON-LD value
31
+ * @returns {string} compact, embed-safe JSON
32
+ */
33
+ export function serializeJsonLd(document) {
34
+ return JSON.stringify(document).replace(
35
+ /[<>&\u2028\u2029]/g,
36
+ (character) =>
37
+ `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`,
38
+ );
39
+ }
40
+
41
+ /**
42
+ * @typedef {{url: string, width?: number, height?: number}} ImageInfo
43
+ * @typedef {{label: string, route?: string}} CrumbStep
44
+ */
45
+
46
+ /**
47
+ * @param {ImageInfo | undefined} image
48
+ * @returns {Record<string, unknown> | undefined}
49
+ */
50
+ function imageObject(image) {
51
+ return image
52
+ ? {
53
+ '@type': 'ImageObject',
54
+ url: image.url,
55
+ width: image.width,
56
+ height: image.height,
57
+ }
58
+ : undefined;
59
+ }
60
+
61
+ /**
62
+ * @param {string} text
63
+ * @param {number} max
64
+ * @returns {string}
65
+ */
66
+ function bound(text, max) {
67
+ const characters = Array.from(text);
68
+ return characters.length > max
69
+ ? `${characters
70
+ .slice(0, max - 1)
71
+ .join('')
72
+ .trimEnd()}…`
73
+ : text;
74
+ }
75
+
76
+ /**
77
+ * Bind the JSON-LD builders to one build.
78
+ *
79
+ * @param {object} context
80
+ * @param {{title: string, description: string, defaultLanguage: string}} context.publication
81
+ * @param {(route: string) => string} context.absolute the absolute URL of a
82
+ * `basePath`-joined route
83
+ * @param {ImageInfo | undefined} context.brandMark the appearance brand mark
84
+ * @returns {{
85
+ * serialize: (graph: readonly unknown[]) => string,
86
+ * home: (input: {posts: readonly {url: string, headline: string, datePublished: string}[]}) => unknown[],
87
+ * article: (input: ArticleInput) => unknown[],
88
+ * collection: (input: {name: string, description?: string, url: string, language: string, trail: readonly CrumbStep[], image?: ImageInfo}) => unknown[],
89
+ * profile: (input: {name: string, description?: string, url: string, language: string, image?: ImageInfo, sameAs: readonly string[]}) => unknown[],
90
+ * webPage: (input: {name: string, description?: string, url: string, language: string, trail: readonly CrumbStep[], dateModified?: string}) => unknown[],
91
+ * }}
92
+ */
93
+ export function createStructuredData({ publication, absolute, brandMark }) {
94
+ const origin = absolute('/');
95
+ const organization = {
96
+ '@type': 'Organization',
97
+ '@id': `${origin}#organization`,
98
+ name: publication.title,
99
+ url: origin,
100
+ logo: imageObject(brandMark),
101
+ };
102
+
103
+ /**
104
+ * @param {readonly CrumbStep[]} trail
105
+ * @returns {Record<string, unknown>}
106
+ */
107
+ const breadcrumbs = (trail) => ({
108
+ '@type': 'BreadcrumbList',
109
+ itemListElement: trail.map((step, index) => ({
110
+ '@type': 'ListItem',
111
+ position: index + 1,
112
+ name: step.label,
113
+ item: step.route ? absolute(step.route) : undefined,
114
+ })),
115
+ });
116
+
117
+ return {
118
+ serialize: (graph) =>
119
+ serializeJsonLd({ '@context': 'https://schema.org', '@graph': graph }),
120
+
121
+ home: ({ posts }) => [
122
+ {
123
+ '@type': 'WebSite',
124
+ '@id': `${origin}#website`,
125
+ url: origin,
126
+ name: publication.title,
127
+ description: publication.description,
128
+ inLanguage: publication.defaultLanguage,
129
+ publisher: { '@id': organization['@id'] },
130
+ // No SearchAction: this site has no search-results URL (see module
131
+ // documentation).
132
+ },
133
+ {
134
+ '@type': 'Blog',
135
+ '@id': `${origin}#blog`,
136
+ url: origin,
137
+ name: publication.title,
138
+ description: publication.description,
139
+ inLanguage: publication.defaultLanguage,
140
+ publisher: { '@id': organization['@id'] },
141
+ blogPost: posts.map((post) => ({
142
+ '@type': 'BlogPosting',
143
+ headline: bound(post.headline, HEADLINE_MAX),
144
+ url: post.url,
145
+ datePublished: post.datePublished,
146
+ })),
147
+ },
148
+ organization,
149
+ ],
150
+
151
+ article: (input) => {
152
+ const authors = input.authors.map((author) => ({
153
+ '@type': 'Person',
154
+ name: author.name,
155
+ url: author.url,
156
+ sameAs: author.sameAs.length > 0 ? [...author.sameAs] : undefined,
157
+ }));
158
+ return [
159
+ {
160
+ '@type': 'BlogPosting',
161
+ '@id': `${input.url}#article`,
162
+ mainEntityOfPage: { '@type': 'WebPage', '@id': input.url },
163
+ url: input.url,
164
+ headline: bound(input.headline, HEADLINE_MAX),
165
+ description: input.description,
166
+ image: imageObject(input.image),
167
+ datePublished: input.datePublished,
168
+ dateModified: input.dateModified,
169
+ inLanguage: input.language,
170
+ author: authors.length === 1 ? authors[0] : authors,
171
+ publisher: organization,
172
+ keywords: input.tags.length > 0 ? input.tags.join(', ') : undefined,
173
+ isPartOf: input.series
174
+ ? {
175
+ '@type': 'CreativeWorkSeries',
176
+ name: input.series.name,
177
+ url: input.series.url,
178
+ }
179
+ : undefined,
180
+ },
181
+ breadcrumbs(input.trail),
182
+ ];
183
+ },
184
+
185
+ collection: ({ name, description, url, language, trail, image }) => [
186
+ {
187
+ '@type': 'CollectionPage',
188
+ '@id': `${url}#page`,
189
+ url,
190
+ name,
191
+ description,
192
+ inLanguage: language,
193
+ primaryImageOfPage: imageObject(image),
194
+ isPartOf: { '@id': `${origin}#website` },
195
+ },
196
+ breadcrumbs(trail),
197
+ ],
198
+
199
+ profile: ({ name, description, url, language, image, sameAs }) => [
200
+ {
201
+ '@type': 'ProfilePage',
202
+ '@id': `${url}#page`,
203
+ url,
204
+ name,
205
+ inLanguage: language,
206
+ mainEntity: {
207
+ '@type': 'Person',
208
+ '@id': `${url}#person`,
209
+ name,
210
+ url,
211
+ description,
212
+ image: imageObject(image),
213
+ sameAs: sameAs.length > 0 ? [...sameAs] : undefined,
214
+ },
215
+ },
216
+ ],
217
+
218
+ webPage: ({ name, description, url, language, trail, dateModified }) => [
219
+ {
220
+ '@type': 'WebPage',
221
+ '@id': `${url}#page`,
222
+ url,
223
+ name,
224
+ description,
225
+ inLanguage: language,
226
+ dateModified,
227
+ isPartOf: { '@id': `${origin}#website` },
228
+ },
229
+ ...(trail.length > 0 ? [breadcrumbs(trail)] : []),
230
+ ],
231
+ };
232
+ }
233
+
234
+ /**
235
+ * @typedef {object} ArticleInput
236
+ * @property {string} url the article's absolute URL
237
+ * @property {string} headline
238
+ * @property {string} [description]
239
+ * @property {ImageInfo} [image]
240
+ * @property {string} datePublished
241
+ * @property {string} dateModified
242
+ * @property {string} language
243
+ * @property {readonly {name: string, url: string, sameAs: readonly string[]}[]} authors
244
+ * @property {readonly string[]} tags
245
+ * @property {{name: string, url: string}} [series]
246
+ * @property {readonly CrumbStep[]} trail
247
+ */