@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.
- package/README.md +95 -59
- package/contracts/render-policy.jcs +1 -1
- package/contracts/theme-styling-contract.jcs +1 -1
- package/package.json +2 -2
- package/src/core/index.js +209 -60
- 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 +79 -8
- 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 +922 -293
- 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/route.js +42 -0
- package/src/core/internal/search-index.js +5 -2
- package/src/core/internal/seo.js +57 -2
- package/src/core/internal/sitemap.js +5 -4
- package/src/core/internal/skeleton.js +73 -132
- package/src/core/internal/structured-data.js +247 -0
- package/src/core/internal/theme-assets.js +21 -2
|
@@ -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
|
+
}
|
|
@@ -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 {
|
|
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(
|
|
80
|
+
new URL(
|
|
81
|
+
joinPublicRoute(derivePublicBasePath(baseUrl, basePath), route),
|
|
82
|
+
baseUrl,
|
|
83
|
+
).toString();
|
|
81
84
|
|
|
82
85
|
const documents = content
|
|
83
86
|
.filter(
|
package/src/core/internal/seo.js
CHANGED
|
@@ -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
|
|
82
|
-
* @param {string} options.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 {
|
|
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
|
|
41
|
-
*
|
|
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(
|
|
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>` (
|
|
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`,
|
|
@@ -88,6 +89,8 @@ const HTML_ESCAPES = Object.freeze({
|
|
|
88
89
|
"'": ''',
|
|
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} */ (
|
|
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({
|
|
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
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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 {
|
|
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
|
|
306
|
+
* @returns {string} the toast element
|
|
351
307
|
*/
|
|
352
|
-
export function
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
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
|
-
*
|
|
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}
|
|
396
|
-
* breadcrumb
|
|
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
|
-
|
|
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
|
}
|