@rathnasgala2/template 2.1.0 → 3.0.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.
@@ -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,78 @@
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 `publication.crawlers.ai` (`"allow"` | `"block"`, absent
7
+ * means allow). The field is read defensively, exactly like
8
+ * `publication.newsletter`: a build input that predates it, or carries any
9
+ * other value, is treated as `"allow"`.
10
+ *
11
+ * Unlisted (`noindex`) pages are deliberately not disallowed here: a
12
+ * crawler can only honour a `noindex` directive on a page it is allowed to
13
+ * fetch.
14
+ *
15
+ * Only a `robots.txt` at the host root is honoured by crawlers. This file is
16
+ * always written at the output root; when the site is served from a path
17
+ * (for example a GitHub project site), the host's own root `robots.txt` is
18
+ * what crawlers read, and the `Sitemap:` line here still names the absolute
19
+ * sitemap URL so it can be copied there.
20
+ */
21
+
22
+ /**
23
+ * The AI crawler and assistant user agents blocked by `crawlers.ai:
24
+ * "block"`: training crawlers, search crawlers and user-initiated fetchers
25
+ * of the major AI providers.
26
+ *
27
+ * @type {readonly string[]}
28
+ */
29
+ export const AI_CRAWLER_USER_AGENTS = Object.freeze([
30
+ 'GPTBot',
31
+ 'ChatGPT-User',
32
+ 'OAI-SearchBot',
33
+ 'ClaudeBot',
34
+ 'Claude-User',
35
+ 'Claude-SearchBot',
36
+ 'anthropic-ai',
37
+ 'Google-Extended',
38
+ 'PerplexityBot',
39
+ 'Perplexity-User',
40
+ 'CCBot',
41
+ 'Applebot-Extended',
42
+ 'Bytespider',
43
+ 'Meta-ExternalAgent',
44
+ 'Amazonbot',
45
+ ]);
46
+
47
+ /**
48
+ * @param {unknown} publication `publication` (may predate `crawlers`)
49
+ * @returns {'allow' | 'block'} the AI crawler policy
50
+ */
51
+ export function readAiCrawlerPolicy(publication) {
52
+ const crawlers = /** @type {{crawlers?: unknown} | undefined} */ (
53
+ publication && typeof publication === 'object' ? publication : undefined
54
+ )?.crawlers;
55
+ const ai =
56
+ crawlers && typeof crawlers === 'object'
57
+ ? /** @type {{ai?: unknown}} */ (crawlers).ai
58
+ : undefined;
59
+ return ai === 'block' ? 'block' : 'allow';
60
+ }
61
+
62
+ /**
63
+ * @param {object} options
64
+ * @param {'allow' | 'block'} options.aiPolicy
65
+ * @param {string} options.sitemapUrl the absolute sitemap URL
66
+ * @returns {string} the exact `robots.txt` text
67
+ */
68
+ export function buildRobotsTxt({ aiPolicy, sitemapUrl }) {
69
+ /** @type {string[]} */
70
+ const groups = ['User-agent: *\nAllow: /'];
71
+ if (aiPolicy === 'block') {
72
+ for (const agent of AI_CRAWLER_USER_AGENTS) {
73
+ groups.push(`User-agent: ${agent}\nDisallow: /`);
74
+ }
75
+ }
76
+ groups.push(`Sitemap: ${sitemapUrl}`);
77
+ return `${groups.join('\n\n')}\n`;
78
+ }
@@ -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
+ }
@@ -33,6 +33,48 @@ export function joinBasePathAndRoute(basePath, route) {
33
33
  return `/${segments.join('/')}`;
34
34
  }
35
35
 
36
+ /**
37
+ * Derive the public base path every emitted URL must carry from the build
38
+ * input: the path component of `baseUrl` (for example `/g9/` for a GitHub
39
+ * project site published at `https://owner.github.io/g9/`) joined with the
40
+ * build input's own `basePath`. Only emitted URLs (hrefs, `src`, canonical,
41
+ * feed, sitemap and search-index URLs) use this value; candidate-directory
42
+ * output file paths keep using `basePath` alone, because the artifact is
43
+ * deployed at the root of the hosting site.
44
+ *
45
+ * @param {string} baseUrl the build input's validated `baseUrl`
46
+ * @param {string} basePath the build input's `basePath`
47
+ * @returns {string} the public base path with a leading slash and no double
48
+ * slashes. When `baseUrl` carries a path it also has a trailing slash
49
+ * (`/g9/`); with an origin-only `baseUrl` the result is exactly
50
+ * `basePath`'s legacy joined form (`/` or `/blog`), so origin-only output
51
+ * is byte-identical to earlier releases.
52
+ */
53
+ export function derivePublicBasePath(baseUrl, basePath) {
54
+ const urlPath = new URL(baseUrl).pathname;
55
+ const joined = joinBasePathAndRoute(
56
+ joinBasePathAndRoute(urlPath, basePath),
57
+ '/',
58
+ );
59
+ return joined !== '/' && urlPath !== '/' ? `${joined}/` : joined;
60
+ }
61
+
62
+ /**
63
+ * Join a public base path (see {@link derivePublicBasePath}) and a route
64
+ * into one emitted URL path. The site root route is the public base path
65
+ * itself (`/site/` when `baseUrl` carries a path); every other route is
66
+ * joined exactly like {@link joinBasePathAndRoute}.
67
+ *
68
+ * @param {string} publicBasePath the public base path
69
+ * @param {string} route a `canonicalRoute`
70
+ * @returns {string} the emitted absolute URL path
71
+ */
72
+ export function joinPublicRoute(publicBasePath, route) {
73
+ return route === '/'
74
+ ? publicBasePath
75
+ : joinBasePathAndRoute(publicBasePath, route);
76
+ }
77
+
36
78
  /**
37
79
  * Project one already-joined absolute route to its candidate-directory-
38
80
  * relative output file path.
@@ -32,7 +32,7 @@
32
32
  * full-text search.
33
33
  */
34
34
 
35
- import { joinBasePathAndRoute } from './route.js';
35
+ import { derivePublicBasePath, joinPublicRoute } from './route.js';
36
36
  import { compareUtf8Bytes } from './source-inventory.js';
37
37
  import { contentLastModified, contentRoute } from './page-kinds.js';
38
38
 
@@ -77,7 +77,10 @@ export function buildSearchIndex(validatedInput) {
77
77
  * @returns {string} the absolute `https://` URL
78
78
  */
79
79
  const absoluteUrl = (route) =>
80
- new URL(joinBasePathAndRoute(basePath, route), baseUrl).toString();
80
+ new URL(
81
+ joinPublicRoute(derivePublicBasePath(baseUrl, basePath), route),
82
+ baseUrl,
83
+ ).toString();
81
84
 
82
85
  const documents = content
83
86
  .filter(
@@ -78,8 +78,10 @@ export function resolveOriginalDerivativePath(mediaAssets, reference) {
78
78
  * the media pipeline's own finished `assets` list
79
79
  * @param {{path: string, sourceDigest: string} | undefined} options.reference
80
80
  * the candidate `resolvedFile` reference
81
- * @param {string} options.baseUrl the build input's origin-only `baseUrl`
82
- * @param {string} options.basePath the build input's `basePath` (the
81
+ * @param {string} options.baseUrl the build input's `baseUrl`
82
+ * @param {string} options.basePath the public base path
83
+ * (`route.js#derivePublicBasePath`, the `baseUrl` path joined with the
84
+ * build input's `basePath`; the
83
85
  * derivative path is joined with this before being resolved against
84
86
  * `baseUrl`, so a non-root publication's social-image URL is correct)
85
87
  * @returns {string | undefined} the absolute derivative image URL, or
@@ -110,3 +112,56 @@ export function resolveSocialImageUrl({
110
112
  export function twitterCardType(hasImage) {
111
113
  return hasImage ? 'summary_large_image' : 'summary';
112
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
+ }
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { escapeHtml } from './skeleton.js';
21
- import { joinBasePathAndRoute } from './route.js';
21
+ import { joinPublicRoute } from './route.js';
22
22
  import { compareUtf8Bytes } from './source-inventory.js';
23
23
 
24
24
  /**
@@ -37,8 +37,9 @@ function isNoindex(page) {
37
37
  * @param {object} options build options
38
38
  * @param {readonly import('./page-kinds.js').GeneratedPage[]} options.generatedPages
39
39
  * every page `internal/page-kinds.js` produced
40
- * @param {string} options.basePath the build input's `basePath`
41
- * @param {string} options.baseUrl the build input's origin-only `baseUrl`
40
+ * @param {string} options.basePath the public base path
41
+ * (`route.js#derivePublicBasePath`)
42
+ * @param {string} options.baseUrl the build input's `baseUrl`
42
43
  * @returns {string} the exact UTF-8 `sitemap.xml` text
43
44
  */
44
45
  export function buildSitemap({ generatedPages, basePath, baseUrl }) {
@@ -47,7 +48,7 @@ export function buildSitemap({ generatedPages, basePath, baseUrl }) {
47
48
  * @returns {string} the absolute `https://` URL
48
49
  */
49
50
  const absoluteUrl = (route) =>
50
- new URL(joinBasePathAndRoute(basePath, route), baseUrl).toString();
51
+ new URL(joinPublicRoute(basePath, route), baseUrl).toString();
51
52
 
52
53
  const entries = generatedPages
53
54
  .filter((page) => !isNoindex(page))
@@ -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`,
@@ -88,6 +89,8 @@ const HTML_ESCAPES = Object.freeze({
88
89
  "'": '&#39;',
89
90
  });
90
91
 
92
+ import { joinPublicRoute } from './route.js';
93
+
91
94
  /**
92
95
  * Escape a string for safe insertion into HTML text or a quoted attribute
93
96
  * value.
@@ -131,7 +134,7 @@ export function renderSlot(slotName, options = {}) {
131
134
  */
132
135
  export function renderSkipLink(messages) {
133
136
  const label = escapeHtml(/** @type {string} */ (messages.skipToContent));
134
- return `<a href="#main-content">${label}</a>`;
137
+ return `<a class="g-skip" href="#main-content">${label}</a>`;
135
138
  }
136
139
 
137
140
  /**
@@ -142,19 +145,25 @@ export function renderSkipLink(messages) {
142
145
  * `navigationItem` (or `navigationLeaf`, which never has children)
143
146
  * @param {string | undefined} currentRoute the current page's own joined
144
147
  * route, so the matching link can carry `aria-current="page"`
148
+ * @param {string} urlPrefix the `baseUrl` path prefix prepended to an
149
+ * internal route's href (`/` when `baseUrl` has no path)
145
150
  * @returns {string} the rendered `<li>`
146
151
  */
147
- function renderNavItem(item, currentRoute) {
152
+ export function renderNavItem(item, currentRoute, urlPrefix) {
148
153
  const label = escapeHtml(item.label);
149
154
  const href = escapeHtml(
150
- /** @type {string} */ (item.type === 'internal' ? item.route : item.url),
155
+ /** @type {string} */ (
156
+ item.type === 'internal'
157
+ ? joinPublicRoute(urlPrefix, /** @type {string} */ (item.route))
158
+ : item.url
159
+ ),
151
160
  );
152
161
  const isCurrent = item.type === 'internal' && item.route === currentRoute;
153
162
  const currentAttribute = isCurrent ? ' aria-current="page"' : '';
154
163
  const children = item.children ?? [];
155
164
  const childList =
156
165
  children.length > 0
157
- ? `<ul>${children.map((child) => renderNavItem(child, currentRoute)).join('')}</ul>`
166
+ ? `<ul>${children.map((child) => renderNavItem(child, currentRoute, urlPrefix)).join('')}</ul>`
158
167
  : '';
159
168
  return `<li><a href="${href}"${currentAttribute}>${label}</a>${childList}</li>`;
160
169
  }
@@ -170,6 +179,8 @@ function renderNavItem(item, currentRoute) {
170
179
  * route
171
180
  * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
172
181
  * the resolved message catalog
182
+ * @param {string} [options.urlPrefix] the `baseUrl` path prefix for internal
183
+ * hrefs (default `/`)
173
184
  * @returns {string} the rendered primary navigation `<nav>`, always present
174
185
  * even when `items` is empty (TPL-M3): every page kind renders exactly
175
186
  * one primary-navigation landmark unconditionally
@@ -178,34 +189,19 @@ function renderNavItem(item, currentRoute) {
178
189
  * on every page regardless of whether that publication has authored any
179
190
  * navigation items yet.
180
191
  */
181
- export function renderPrimaryNavigation({ items, currentRoute, messages }) {
192
+ export function renderPrimaryNavigation({
193
+ items,
194
+ currentRoute,
195
+ messages,
196
+ urlPrefix = '/',
197
+ }) {
182
198
  const label = escapeHtml(
183
199
  /** @type {string} */ (messages.primaryNavigationLabel),
184
200
  );
185
201
  const itemsHtml = items
186
- .map((item) => renderNavItem(item, currentRoute))
187
- .join('');
188
- return `<nav aria-label="${label}"><ul>${itemsHtml}</ul></nav>`;
189
- }
190
-
191
- /**
192
- * Render the footer `<nav>` from `navigationNormalized.footerItems`.
193
- *
194
- * @param {object} options rendering options
195
- * @param {readonly import('../../../types/index.d.ts').NavigationItem[]} options.items
196
- * `navigationNormalized.footerItems` (0..50 root items)
197
- * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
198
- * the resolved message catalog
199
- * @returns {string} the rendered footer navigation
200
- */
201
- export function renderFooterNavigation({ items, messages }) {
202
- const label = escapeHtml(
203
- /** @type {string} */ (messages.footerNavigationLabel),
204
- );
205
- const itemsHtml = items
206
- .map((item) => renderNavItem(item, undefined))
202
+ .map((item) => renderNavItem(item, currentRoute, urlPrefix))
207
203
  .join('');
208
- return `<nav aria-label="${label}"><ul>${itemsHtml}</ul></nav>`;
204
+ return `<nav class="g-nav" aria-label="${label}"><ul>${itemsHtml}</ul></nav>`;
209
205
  }
210
206
 
211
207
  /**
@@ -246,7 +242,7 @@ export function renderBreadcrumbs({ trail, messages }) {
246
242
  return `<li><a href="${escapeHtml(step.route)}">${text}</a></li>`;
247
243
  })
248
244
  .join('');
249
- return `<nav aria-label="${label}"><ol>${items}</ol></nav>`;
245
+ return `<nav class="g-crumbs" aria-label="${label}"><ol>${items}</ol></nav>`;
250
246
  }
251
247
 
252
248
  /**
@@ -291,7 +287,7 @@ export function renderPagination({
291
287
  ? `<a rel="next" href="${escapeHtml(routeForPage(currentPage + 1))}">${nextLabel}</a>`
292
288
  : `<span>${nextLabel}</span>`;
293
289
  return (
294
- `<nav aria-label="${label}"><ul>` +
290
+ `<nav class="g-pagination" aria-label="${label}"><ul>` +
295
291
  `<li>${previous}</li>` +
296
292
  `<li aria-current="page">${statusLabel}</li>` +
297
293
  `<li>${next}</li>` +
@@ -300,117 +296,62 @@ export function renderPagination({
300
296
  }
301
297
 
302
298
  /**
303
- * Render the one `<header>` landmark.
304
- *
305
- * @param {object} options rendering options
306
- * @param {string} options.homeRoute the publication's own home route
307
- * (`basePath`, joined)
308
- * @param {string} options.publicationName the validated publication title
309
- * @param {{route: string, alt: string} | undefined} options.logo an optional
310
- * core logo image, always carrying alternative text
311
- * @param {string} [options.appearanceControlHtml] the already-rendered
312
- * Light/Dark/System appearance control
313
- * (`internal/appearance/controller-markup.js`'s `renderAppearanceControl`),
314
- * inserted into the `header-actions` slot
315
- * @returns {string} the rendered header
316
- */
317
- export function renderHeader({
318
- homeRoute,
319
- publicationName,
320
- logo,
321
- appearanceControlHtml,
322
- }) {
323
- const name = escapeHtml(publicationName);
324
- const href = escapeHtml(homeRoute);
325
- const logoHtml = logo
326
- ? `<img src="${escapeHtml(logo.route)}" alt="${escapeHtml(logo.alt)}">`
327
- : '';
328
- return (
329
- '<header>' +
330
- `<a href="${href}">${logoHtml}<span>${name}</span></a>` +
331
- renderSlot('header-actions', { html: appearanceControlHtml }) +
332
- '</header>'
333
- );
334
- }
335
-
336
- /**
337
- * Render the one `<footer>` 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.
338
303
  *
339
- * @param {object} options rendering options
340
- * @param {string} options.publicationName the validated publication title
341
- * @param {string} options.footerNavHtml the already-rendered footer
342
- * navigation (see {@link renderFooterNavigation})
343
- * @param {string} options.footerProfileHtml the already-rendered
344
- * `footer-profile` slot content (footer card and/or author cards; may be
345
- * empty when the publication authored neither)
346
- * @param {string | undefined} options.copyrightText an optional
347
- * already-resolved copyright/license text line
348
- * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
304
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} messages
349
305
  * the resolved message catalog
350
- * @returns {string} the rendered footer
306
+ * @returns {string} the toast element
351
307
  */
352
- export function renderFooter({
353
- publicationName,
354
- footerNavHtml,
355
- footerProfileHtml,
356
- copyrightText,
357
- messages,
358
- }) {
359
- const name = escapeHtml(publicationName);
360
- const copyrightHtml = copyrightText
361
- ? `<p>${escapeHtml(copyrightText)}</p>`
362
- : '';
363
- const attribution = escapeHtml(
364
- /** @type {string} */ (messages.footerAttributionLabel),
365
- );
366
- return (
367
- '<footer>' +
368
- `<p>${name}</p>` +
369
- footerNavHtml +
370
- `<div data-gala-slot="footer-profile">${footerProfileHtml}</div>` +
371
- renderSlot('footer-auxiliary') +
372
- renderSlot('account-intent') +
373
- renderSlot('conversation') +
374
- renderSlot('newsletter') +
375
- renderSlot('edition-selector') +
376
- copyrightHtml +
377
- `<p>${attribution}</p>` +
378
- '</footer>'
379
- );
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>`;
380
330
  }
381
331
 
382
332
  /**
383
333
  * Compose one complete page body (everything inside `<body>`) from its
384
334
  * pre-rendered landmark parts. Every page kind in `internal/page-kinds.js`
385
335
  * funnels through this one function, so the landmark order and count (one
386
- * skip link, one header, one primary nav, one main, one footer) can never
387
- * 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.
388
338
  *
389
339
  * @param {object} options rendering options
390
340
  * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
391
341
  * the resolved message catalog
392
- * @param {string} options.headerHtml the already-rendered header
393
- * @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`)
394
344
  * @param {string} options.footerHtml the already-rendered footer
395
- * @param {string} [options.breadcrumbHtml] an optional already-rendered
396
- * breadcrumb, inserted before the page's own `<h1>`
397
- * @param {string} options.mainHtml the page kind's own inner markup, always
398
- * 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>`
399
347
  * @returns {string} the complete `<body>` inner HTML
400
348
  */
401
- export function renderPageBody({
402
- messages,
403
- headerHtml,
404
- navHtml,
405
- footerHtml,
406
- breadcrumbHtml,
407
- mainHtml,
408
- }) {
349
+ export function renderPageBody({ messages, headerHtml, footerHtml, mainHtml }) {
409
350
  return (
410
351
  renderSkipLink(messages) +
352
+ renderToast(messages) +
411
353
  headerHtml +
412
- navHtml +
413
- `<main id="main-content">${breadcrumbHtml ?? ''}${mainHtml}</main>` +
354
+ `<main id="main-content" class="g-main" tabindex="-1">${mainHtml}</main>` +
414
355
  footerHtml
415
356
  );
416
357
  }