@rathnasgala2/template 2.2.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.
- package/README.md +88 -59
- package/contracts/render-policy.jcs +1 -1
- package/contracts/theme-styling-contract.jcs +1 -1
- package/package.json +1 -1
- package/src/core/index.js +180 -50
- package/src/core/internal/appearance/base-layer.js +711 -137
- package/src/core/internal/appearance/bootstrap-script.js +570 -83
- package/src/core/internal/appearance/contract.js +6 -5
- package/src/core/internal/appearance/controller-markup.js +46 -55
- package/src/core/internal/appearance/minify-script.js +69 -0
- package/src/core/internal/appearance/styling-contract.js +199 -17
- package/src/core/internal/chrome.js +199 -0
- package/src/core/internal/components.js +379 -0
- package/src/core/internal/eleventy-render.js +13 -4
- package/src/core/internal/feeds.js +74 -6
- package/src/core/internal/icons.js +52 -0
- package/src/core/internal/llms.js +252 -0
- package/src/core/internal/media/pipeline.js +32 -17
- package/src/core/internal/messages.js +104 -0
- package/src/core/internal/page-kinds.js +918 -290
- package/src/core/internal/render-policy-content.js +2 -2
- package/src/core/internal/robots.js +78 -0
- package/src/core/internal/route-labels.js +31 -0
- package/src/core/internal/seo.js +53 -0
- package/src/core/internal/skeleton.js +54 -129
- package/src/core/internal/structured-data.js +247 -0
|
@@ -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 '
|
|
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 '
|
|
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
|
+
}
|
package/src/core/internal/seo.js
CHANGED
|
@@ -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>` (
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* currently-empty core slots (`footer-auxiliary`,
|
|
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
|
|
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 {
|
|
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
|
|
306
|
+
* @returns {string} the toast element
|
|
367
307
|
*/
|
|
368
|
-
export function
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
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
|
-
*
|
|
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}
|
|
412
|
-
* breadcrumb
|
|
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
|
-
|
|
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
|
}
|