@rathnasgala2/template 2.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/LICENSE +201 -0
- package/README.md +515 -0
- package/contracts/render-policy.jcs +1 -0
- package/contracts/theme-styling-contract.jcs +1 -0
- package/package.json +96 -0
- package/src/core/errors.js +136 -0
- package/src/core/index.js +758 -0
- package/src/core/internal/appearance/bootstrap-script.js +159 -0
- package/src/core/internal/appearance/contract.js +193 -0
- package/src/core/internal/appearance/controller-markup.js +80 -0
- package/src/core/internal/appearance/styling-contract.js +509 -0
- package/src/core/internal/canonical-jcs.js +0 -0
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/eleventy-render.js +220 -0
- package/src/core/internal/feeds.js +161 -0
- package/src/core/internal/fs-walk.js +38 -0
- package/src/core/internal/media/avif-probe.js +169 -0
- package/src/core/internal/media/font-policy.js +56 -0
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +129 -0
- package/src/core/internal/media/pipeline.js +353 -0
- package/src/core/internal/media/png-codec.js +406 -0
- package/src/core/internal/media/resize.js +147 -0
- package/src/core/internal/media/sniff.js +109 -0
- package/src/core/internal/media/webp-probe.js +139 -0
- package/src/core/internal/messages.js +78 -0
- package/src/core/internal/page-kinds.js +709 -0
- package/src/core/internal/provenance.js +49 -0
- package/src/core/internal/render-policy-content.js +295 -0
- package/src/core/internal/route-labels.js +68 -0
- package/src/core/internal/route.js +167 -0
- package/src/core/internal/search-index.js +119 -0
- package/src/core/internal/seo.js +114 -0
- package/src/core/internal/sitemap.js +77 -0
- package/src/core/internal/skeleton.js +413 -0
- package/src/core/internal/source-inventory.js +145 -0
- package/src/core/internal/text-direction.js +59 -0
- package/src/core/internal/theme-assets.js +387 -0
- package/src/core/internal/uuid.js +51 -0
- package/src/core/manifest.js +0 -0
- package/types/eleventy-plugin-syntaxhighlight.d.ts +26 -0
- package/types/eleventy.d.ts +38 -0
- package/types/index.d.ts +347 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO/localization metadata assembly (task packet S2-T08; brief S2 section 3
|
|
3
|
+
* "Required generated outputs": "canonical share and copy affordances,
|
|
4
|
+
* localization metadata, SEO metadata").
|
|
5
|
+
*
|
|
6
|
+
* This module resolves the per-page facts `internal/page-kinds.js` already
|
|
7
|
+
* decided (`description`, `ogType`, `socialImageRef`, `robotsContent`) into
|
|
8
|
+
* the concrete absolute-URL strings a page's `<head>` needs. It is kept
|
|
9
|
+
* separate from `page-kinds.js` because resolving a `socialImageRef` into an
|
|
10
|
+
* absolute derivative URL needs two facts only `src/core/index.js` has at
|
|
11
|
+
* that point: the S2-T05 media pipeline's own finished `assets` list (so the
|
|
12
|
+
* exact derivative file extension a given source image produced is known —
|
|
13
|
+
* `page-kinds.js` never decodes an image) and `buildInput.baseUrl` (the
|
|
14
|
+
* origin every absolute URL this renderer emits is resolved against).
|
|
15
|
+
*
|
|
16
|
+
* S2-T12 basePath fix (LOCAL-7 follow-up): every derivative-asset URL this
|
|
17
|
+
* module resolves is now `basePath`-joined through `internal/route.js`'s
|
|
18
|
+
* shared {@link import('./route.js').joinBasePathAndRoute} before being
|
|
19
|
+
* resolved against `baseUrl`, exactly like every other renderer-derived
|
|
20
|
+
* route (`internal/page-kinds.js`, `internal/print-stylesheet.js`). Before
|
|
21
|
+
* this fix, {@link resolveSocialImageUrl} joined a bare root-absolute
|
|
22
|
+
* `/${derivativePath}` against `baseUrl`, which 404s under a non-root
|
|
23
|
+
* `basePath` publication (`internal/theme-assets.js` is the S2-T12 module
|
|
24
|
+
* that fixes the equivalent theme/print stylesheet and appearance-script
|
|
25
|
+
* hrefs).
|
|
26
|
+
*
|
|
27
|
+
* hreflang note (documented scope decision, not a silent omission):
|
|
28
|
+
* `build-input:2.0.0` has no translation/locale-variant linkage between
|
|
29
|
+
* distinct `content[]` records (checked against
|
|
30
|
+
* `node_modules/@rathnasgala2/schemas/schemas/build-input.schema.json`: no
|
|
31
|
+
* `$defs` entry named anything like `translation`/`localizedContent`/
|
|
32
|
+
* `alternateLanguage` exists anywhere in the schema; `authorNormalized`'s own
|
|
33
|
+
* `localized` array carries only a translated author bio, never a route). A
|
|
34
|
+
* page's own `<html lang>` already carries its resolved language (S2-T06),
|
|
35
|
+
* which is the entire localization signal this build-input shape declares.
|
|
36
|
+
* `<link rel="alternate" hreflang="...">` is therefore correctly *empty* for
|
|
37
|
+
* S2 — brief S2 section 3's "hreflang/localization metadata... where the
|
|
38
|
+
* input declares translations" is satisfied vacuously, since the input never
|
|
39
|
+
* declares one. This module has no `hreflang` export for that reason; a
|
|
40
|
+
* later build-input major version that adds translation linkage is the
|
|
41
|
+
* natural place to add one, at which point every page-kind call site below
|
|
42
|
+
* gains a `translations` field the same way `socialImageRef` was added here.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { joinBasePathAndRoute } from './route.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Derive the content-addressed derivative output path
|
|
49
|
+
* `internal/media/pipeline.js`'s `derivativePath(sourceDigest, 'original',
|
|
50
|
+
* extension)` would have produced for a given `resolvedFile` reference,
|
|
51
|
+
* independent of that module (this renderer's one other module permitted to
|
|
52
|
+
* know this path shape, so a change to either stays a one-place fix).
|
|
53
|
+
*
|
|
54
|
+
* @param {readonly {path: string, mediaType: string}[]} mediaAssets the
|
|
55
|
+
* S2-T05 media pipeline's own finished `assets` list
|
|
56
|
+
* @param {{path: string, sourceDigest: string} | undefined} reference a
|
|
57
|
+
* `resolvedFile`-shaped reference (`publication.defaultImage`,
|
|
58
|
+
* `author.avatar`, `frontmatter.socialImage`, `frontmatter.hero.file`)
|
|
59
|
+
* @returns {string | undefined} the derivative asset's own output-relative
|
|
60
|
+
* path (never the raw source path), or `undefined` when no reference was
|
|
61
|
+
* supplied or the media pipeline produced no matching derivative (an image
|
|
62
|
+
* this renderer's own S2-T05 pipeline was never asked to process, e.g. a
|
|
63
|
+
* test fixture exercising SEO metadata without the media pipeline)
|
|
64
|
+
*/
|
|
65
|
+
export function resolveOriginalDerivativePath(mediaAssets, reference) {
|
|
66
|
+
if (!reference) return undefined;
|
|
67
|
+
const hex = reference.sourceDigest.replace(/^sha256:/, '');
|
|
68
|
+
const prefix = `assets/media/${hex}/original.`;
|
|
69
|
+
const match = mediaAssets.find((asset) => asset.path.startsWith(prefix));
|
|
70
|
+
return match?.path;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve one `og:image`/`twitter:image` absolute URL from a
|
|
75
|
+
* `resolvedFile`-shaped reference, or `undefined` when there is none to
|
|
76
|
+
* resolve.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} options resolution options
|
|
79
|
+
* @param {readonly {path: string, mediaType: string}[]} options.mediaAssets
|
|
80
|
+
* the S2-T05 media pipeline's own finished `assets` list
|
|
81
|
+
* @param {{path: string, sourceDigest: string} | undefined} options.reference
|
|
82
|
+
* the candidate `resolvedFile` reference
|
|
83
|
+
* @param {string} options.baseUrl the build input's origin-only `baseUrl`
|
|
84
|
+
* @param {string} options.basePath the build input's `basePath` (S2-T12: the
|
|
85
|
+
* derivative path is joined with this before being resolved against
|
|
86
|
+
* `baseUrl`, so a non-root publication's social-image URL is correct)
|
|
87
|
+
* @returns {string | undefined} the absolute derivative image URL, or
|
|
88
|
+
* `undefined`
|
|
89
|
+
*/
|
|
90
|
+
export function resolveSocialImageUrl({
|
|
91
|
+
mediaAssets,
|
|
92
|
+
reference,
|
|
93
|
+
baseUrl,
|
|
94
|
+
basePath,
|
|
95
|
+
}) {
|
|
96
|
+
const derivativePath = resolveOriginalDerivativePath(mediaAssets, reference);
|
|
97
|
+
if (!derivativePath) return undefined;
|
|
98
|
+
return new URL(
|
|
99
|
+
joinBasePathAndRoute(basePath, `/${derivativePath}`),
|
|
100
|
+
baseUrl,
|
|
101
|
+
).toString();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The Twitter Card type this renderer emits: `summary_large_image` when a
|
|
106
|
+
* social image resolved, `summary` otherwise. Never `app`/`player` (this
|
|
107
|
+
* renderer never emits an application or media-player card).
|
|
108
|
+
*
|
|
109
|
+
* @param {boolean} hasImage whether a social image URL resolved for this page
|
|
110
|
+
* @returns {'summary' | 'summary_large_image'} the card type
|
|
111
|
+
*/
|
|
112
|
+
export function twitterCardType(hasImage) {
|
|
113
|
+
return hasImage ? 'summary_large_image' : 'summary';
|
|
114
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitemap generation (task packet S2-T08; brief S2 section 3 "Required
|
|
3
|
+
* generated outputs": "sitemap"; task packet instructions: "published routes
|
|
4
|
+
* only, deterministic order, `lastmod` from content timestamps not build
|
|
5
|
+
* time").
|
|
6
|
+
*
|
|
7
|
+
* Every entry comes from `internal/page-kinds.js`'s own
|
|
8
|
+
* {@link import('./page-kinds.js').GeneratedPage} list — the single source
|
|
9
|
+
* of truth for which page kinds exist and what each one's own
|
|
10
|
+
* `robotsContent`/`lastModified` is — filtered to exclude any page this
|
|
11
|
+
* renderer itself marked `noindex` (task packet: "robots handling for
|
|
12
|
+
* unlisted content"; `status: "unlisted"` content is exactly the case
|
|
13
|
+
* `internal/page-kinds.js` sets `robotsContent` for). The generated `404`
|
|
14
|
+
* error page, the feeds, the sitemap document itself and the static search
|
|
15
|
+
* index are never `GeneratedPage` entries, so they are structurally excluded
|
|
16
|
+
* without a second exclusion list. A `manifestRedirect` route is likewise
|
|
17
|
+
* never in this list; a redirect's fixed byte-for-byte document already
|
|
18
|
+
* carries its own `<link rel="canonical">` to the real content page (brief
|
|
19
|
+
* S2 section 3), which is what a crawler should index instead.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { escapeHtml } from './skeleton.js';
|
|
23
|
+
import { joinBasePathAndRoute } from './route.js';
|
|
24
|
+
import { compareUtf8Bytes } from './source-inventory.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {import('./page-kinds.js').GeneratedPage} page a generated page
|
|
28
|
+
* @returns {boolean} `true` when this page must be excluded from the
|
|
29
|
+
* sitemap (its `robotsContent` includes `noindex`)
|
|
30
|
+
*/
|
|
31
|
+
function isNoindex(page) {
|
|
32
|
+
return (page.robotsContent ?? '').includes('noindex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the deterministic `sitemap.xml` document (Sitemaps 0.9) for every
|
|
37
|
+
* indexable generated page.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} options build options
|
|
40
|
+
* @param {readonly import('./page-kinds.js').GeneratedPage[]} options.generatedPages
|
|
41
|
+
* every page `internal/page-kinds.js` produced
|
|
42
|
+
* @param {string} options.basePath the build input's `basePath`
|
|
43
|
+
* @param {string} options.baseUrl the build input's origin-only `baseUrl`
|
|
44
|
+
* @returns {string} the exact UTF-8 `sitemap.xml` text
|
|
45
|
+
*/
|
|
46
|
+
export function buildSitemap({ generatedPages, basePath, baseUrl }) {
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} route an un-joined route
|
|
49
|
+
* @returns {string} the absolute `https://` URL
|
|
50
|
+
*/
|
|
51
|
+
const absoluteUrl = (route) =>
|
|
52
|
+
new URL(joinBasePathAndRoute(basePath, route), baseUrl).toString();
|
|
53
|
+
|
|
54
|
+
const entries = generatedPages
|
|
55
|
+
.filter((page) => !isNoindex(page))
|
|
56
|
+
.map((page) => ({
|
|
57
|
+
loc: absoluteUrl(page.route),
|
|
58
|
+
lastmod: page.lastModified,
|
|
59
|
+
}))
|
|
60
|
+
.sort((a, b) => compareUtf8Bytes(a.loc, b.loc));
|
|
61
|
+
|
|
62
|
+
const urlXml = entries
|
|
63
|
+
.map((entry) => {
|
|
64
|
+
const lastmodTag = entry.lastmod
|
|
65
|
+
? `<lastmod>${escapeHtml(entry.lastmod)}</lastmod>`
|
|
66
|
+
: '';
|
|
67
|
+
return `<url><loc>${escapeHtml(entry.loc)}</loc>${lastmodTag}</url>`;
|
|
68
|
+
})
|
|
69
|
+
.join('');
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
73
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
|
|
74
|
+
urlXml +
|
|
75
|
+
'</urlset>\n'
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core semantic HTML skeleton and navigation renderer (task packet S2-T06;
|
|
3
|
+
* brief S2 section 3 "Semantic skeleton and appearance controller").
|
|
4
|
+
*
|
|
5
|
+
* This module renders only inert, static, CSP-`script-src 'self'`-compatible
|
|
6
|
+
* markup: no inline `on*` attribute, no inline `<script>`, no `javascript:`
|
|
7
|
+
* URL. Every user-visible chrome string it emits comes from
|
|
8
|
+
* `internal/messages.js`'s catalog, never an inline literal, so localization
|
|
9
|
+
* can find every string in one place (task packet S2-T06).
|
|
10
|
+
*
|
|
11
|
+
* Landmark contract every page kind in `internal/page-kinds.js` composes
|
|
12
|
+
* through {@link renderPageBody}:
|
|
13
|
+
*
|
|
14
|
+
* - a skip link, the very first focusable element, targeting `#main-content`;
|
|
15
|
+
* - exactly one `<header>` (home link with the validated publication name and
|
|
16
|
+
* an optional core logo carrying required alternative text, plus the
|
|
17
|
+
* always-present, currently-empty `header-actions` slot);
|
|
18
|
+
* - exactly one primary `<nav>` built from `navigationNormalized.items`;
|
|
19
|
+
* - exactly one `<main id="main-content">`, carrying an optional breadcrumb
|
|
20
|
+
* `<nav>` before the page's own single `<h1>`;
|
|
21
|
+
* - exactly one `<footer>` with the publication name, resolved footer
|
|
22
|
+
* navigation, footer profile slot, copyright/license text and the
|
|
23
|
+
* template attribution line, plus the remaining always-present,
|
|
24
|
+
* currently-empty core slots (`footer-auxiliary`, `account-intent`,
|
|
25
|
+
* `conversation`, `newsletter`, `edition-selector`).
|
|
26
|
+
*
|
|
27
|
+
* Core owns nine versioned semantic slots plus the collapsed
|
|
28
|
+
* `article-footer-ad` slot (DEC-060): `header-actions`, `article-preamble`,
|
|
29
|
+
* `article-end`, `footer-profile`, `footer-auxiliary`, `account-intent`,
|
|
30
|
+
* `conversation`, `newsletter` and `edition-selector`. S2 has no module
|
|
31
|
+
* system (`build-input.modules` is always `{}`), so every slot renders empty
|
|
32
|
+
* except `footer-profile`, which always wraps the publication's own
|
|
33
|
+
* always-rendered footer card/author-profile content (there is no separate
|
|
34
|
+
* module content to distinguish it from in S2). Each slot is one
|
|
35
|
+
* `data-gala-slot="<name>"` attribute on an otherwise plain, non-landmark
|
|
36
|
+
* `<div>` — an empty element with no accessible name contributes nothing to
|
|
37
|
+
* the accessibility tree, so an empty slot is exactly as inert as one that
|
|
38
|
+
* does not exist. The collapsed `article-footer-ad` slot additionally
|
|
39
|
+
* carries the `hidden` attribute, so it produces no visible, accessibility,
|
|
40
|
+
* layout, network or telemetry effect at all (DEC-060) until a later module
|
|
41
|
+
* task (out of S2 scope) removes `hidden` for a selected placement — none
|
|
42
|
+
* exist in S2 (`build-input.placements` is always `[]`).
|
|
43
|
+
*
|
|
44
|
+
* S2-T07 amendment: `header-actions` is no longer always-empty. It is core's
|
|
45
|
+
* own home for the Light/Dark/System appearance control (brief S2 section 3:
|
|
46
|
+
* "the three-mode appearance control in the header-actions slot") — core
|
|
47
|
+
* content, not a module, so this slot's earlier "every slot ... renders
|
|
48
|
+
* empty" description no longer covers it. Every other slot is unaffected.
|
|
49
|
+
*
|
|
50
|
+
* The exact `selectorAtom`/hook-naming decisions here (`data-gala-slot`
|
|
51
|
+
* attribute, one slot name per value) are S2-T06's own scoped proposal for
|
|
52
|
+
* what S2-T12 later locks into the published
|
|
53
|
+
* `contracts/theme-styling-contract.jcs` catalog; T12 has not run yet
|
|
54
|
+
* (S2-T06 precedes it — S2-T07 then S2-T12), so this module documents the
|
|
55
|
+
* decision here rather than pre-authoring that contract file itself.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every `data-gala-slot` value this renderer ever writes (the nine
|
|
60
|
+
* versioned core slots plus the collapsed `article-footer-ad` slot,
|
|
61
|
+
* DEC-060), sorted by UTF-8 bytes (S2-T12: `contracts/theme-styling-contract.jcs`
|
|
62
|
+
* exposes one public theme hook per entry here, so this is the single
|
|
63
|
+
* source of truth both `renderSlot` below and that contract builder read
|
|
64
|
+
* from — never a second hand-typed literal list).
|
|
65
|
+
*
|
|
66
|
+
* @type {readonly string[]}
|
|
67
|
+
*/
|
|
68
|
+
export const SLOT_NAMES = Object.freeze(
|
|
69
|
+
[
|
|
70
|
+
'header-actions',
|
|
71
|
+
'article-preamble',
|
|
72
|
+
'article-end',
|
|
73
|
+
'article-footer-ad',
|
|
74
|
+
'footer-profile',
|
|
75
|
+
'footer-auxiliary',
|
|
76
|
+
'account-intent',
|
|
77
|
+
'conversation',
|
|
78
|
+
'newsletter',
|
|
79
|
+
'edition-selector',
|
|
80
|
+
].sort(),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/** @type {Readonly<Record<string, string>>} */
|
|
84
|
+
const HTML_ESCAPES = Object.freeze({
|
|
85
|
+
'&': '&',
|
|
86
|
+
'<': '<',
|
|
87
|
+
'>': '>',
|
|
88
|
+
'"': '"',
|
|
89
|
+
"'": ''',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Escape a string for safe insertion into HTML text or a quoted attribute
|
|
94
|
+
* value.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} value raw text
|
|
97
|
+
* @returns {string} HTML-escaped text
|
|
98
|
+
*/
|
|
99
|
+
export function escapeHtml(value) {
|
|
100
|
+
let escaped = '';
|
|
101
|
+
for (const character of value) {
|
|
102
|
+
escaped += HTML_ESCAPES[character] ?? character;
|
|
103
|
+
}
|
|
104
|
+
return escaped;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render one always-present, currently-empty core semantic slot.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} slotName one of the nine versioned slot names, or the
|
|
111
|
+
* collapsed `article-footer-ad` slot
|
|
112
|
+
* @param {object} [options] rendering options
|
|
113
|
+
* @param {boolean} [options.collapsed] when `true`, additionally sets
|
|
114
|
+
* `hidden` (DEC-060's collapsed `article-footer-ad` slot)
|
|
115
|
+
* @param {string} [options.html] already-rendered inner markup (S2-T07: the
|
|
116
|
+
* `header-actions` slot's appearance control); every other slot omits this
|
|
117
|
+
* and stays empty
|
|
118
|
+
* @returns {string} the slot element
|
|
119
|
+
*/
|
|
120
|
+
export function renderSlot(slotName, options = {}) {
|
|
121
|
+
const hiddenAttribute = options.collapsed ? ' hidden' : '';
|
|
122
|
+
const inner = options.html ?? '';
|
|
123
|
+
return `<div data-gala-slot="${escapeHtml(slotName)}"${hiddenAttribute}>${inner}</div>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Render the mandatory skip link, the first focusable element on every page.
|
|
128
|
+
*
|
|
129
|
+
* @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} messages
|
|
130
|
+
* the resolved message catalog
|
|
131
|
+
* @returns {string} the skip link markup
|
|
132
|
+
*/
|
|
133
|
+
export function renderSkipLink(messages) {
|
|
134
|
+
const label = escapeHtml(/** @type {string} */ (messages.skipToContent));
|
|
135
|
+
return `<a href="#main-content">${label}</a>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Render one navigation item and its bounded (at most one level of)
|
|
140
|
+
* children as an `<li>`.
|
|
141
|
+
*
|
|
142
|
+
* @param {import('../../../types/index.d.ts').NavigationItem} item a validated
|
|
143
|
+
* `navigationItem` (or `navigationLeaf`, which never has children)
|
|
144
|
+
* @param {string | undefined} currentRoute the current page's own joined
|
|
145
|
+
* route, so the matching link can carry `aria-current="page"`
|
|
146
|
+
* @returns {string} the rendered `<li>`
|
|
147
|
+
*/
|
|
148
|
+
function renderNavItem(item, currentRoute) {
|
|
149
|
+
const label = escapeHtml(item.label);
|
|
150
|
+
const href = escapeHtml(
|
|
151
|
+
/** @type {string} */ (item.type === 'internal' ? item.route : item.url),
|
|
152
|
+
);
|
|
153
|
+
const isCurrent = item.type === 'internal' && item.route === currentRoute;
|
|
154
|
+
const currentAttribute = isCurrent ? ' aria-current="page"' : '';
|
|
155
|
+
const children = item.children ?? [];
|
|
156
|
+
const childList =
|
|
157
|
+
children.length > 0
|
|
158
|
+
? `<ul>${children.map((child) => renderNavItem(child, currentRoute)).join('')}</ul>`
|
|
159
|
+
: '';
|
|
160
|
+
return `<li><a href="${href}"${currentAttribute}>${label}</a>${childList}</li>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Render the one primary `<nav>` from `navigationNormalized.items`.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} options rendering options
|
|
167
|
+
* @param {readonly import('../../../types/index.d.ts').NavigationItem[]} options.items
|
|
168
|
+
* `navigationNormalized.items` (0..100 root items, each with at most one
|
|
169
|
+
* level of children per the `navigation:2.0.0` schema)
|
|
170
|
+
* @param {string | undefined} options.currentRoute the current page's joined
|
|
171
|
+
* route
|
|
172
|
+
* @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
|
|
173
|
+
* the resolved message catalog
|
|
174
|
+
* @returns {string} the rendered primary navigation, or an empty string when
|
|
175
|
+
* there are no authored items (the landmark is still meaningful with no
|
|
176
|
+
* items for a publication with no authored navigation)
|
|
177
|
+
*/
|
|
178
|
+
export function renderPrimaryNavigation({ items, currentRoute, messages }) {
|
|
179
|
+
const label = escapeHtml(
|
|
180
|
+
/** @type {string} */ (messages.primaryNavigationLabel),
|
|
181
|
+
);
|
|
182
|
+
const itemsHtml = items
|
|
183
|
+
.map((item) => renderNavItem(item, currentRoute))
|
|
184
|
+
.join('');
|
|
185
|
+
return `<nav aria-label="${label}"><ul>${itemsHtml}</ul></nav>`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Render the footer `<nav>` from `navigationNormalized.footerItems`.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} options rendering options
|
|
192
|
+
* @param {readonly import('../../../types/index.d.ts').NavigationItem[]} options.items
|
|
193
|
+
* `navigationNormalized.footerItems` (0..50 root items)
|
|
194
|
+
* @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
|
|
195
|
+
* the resolved message catalog
|
|
196
|
+
* @returns {string} the rendered footer navigation
|
|
197
|
+
*/
|
|
198
|
+
export function renderFooterNavigation({ items, messages }) {
|
|
199
|
+
const label = escapeHtml(
|
|
200
|
+
/** @type {string} */ (messages.footerNavigationLabel),
|
|
201
|
+
);
|
|
202
|
+
const itemsHtml = items
|
|
203
|
+
.map((item) => renderNavItem(item, undefined))
|
|
204
|
+
.join('');
|
|
205
|
+
return `<nav aria-label="${label}"><ul>${itemsHtml}</ul></nav>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @typedef {object} BreadcrumbStep
|
|
210
|
+
* @property {string} label the visible crumb text
|
|
211
|
+
* @property {string} [route] the crumb's joined route; omitted for the
|
|
212
|
+
* current (last) step, which is rendered as plain text with
|
|
213
|
+
* `aria-current="page"`
|
|
214
|
+
*/
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Render an ordered breadcrumb trail. Required on every generated listing
|
|
218
|
+
* page (index, tag, series, archive) and author page; the publication
|
|
219
|
+
* profile and the top-level index carry no breadcrumb, as there is nothing
|
|
220
|
+
* above them to show.
|
|
221
|
+
*
|
|
222
|
+
* @param {object} options rendering options
|
|
223
|
+
* @param {readonly BreadcrumbStep[]} options.trail one or more steps, in
|
|
224
|
+
* display order, always starting with the home step
|
|
225
|
+
* @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
|
|
226
|
+
* the resolved message catalog
|
|
227
|
+
* @returns {string} the rendered breadcrumb `<nav>`
|
|
228
|
+
*/
|
|
229
|
+
export function renderBreadcrumbs({ trail, messages }) {
|
|
230
|
+
const label = escapeHtml(
|
|
231
|
+
/** @type {string} */ (messages.breadcrumbNavigationLabel),
|
|
232
|
+
);
|
|
233
|
+
const items = trail
|
|
234
|
+
.map((step, index) => {
|
|
235
|
+
const isLast = index === trail.length - 1;
|
|
236
|
+
const text = escapeHtml(step.label);
|
|
237
|
+
if (isLast) {
|
|
238
|
+
return `<li aria-current="page">${text}</li>`;
|
|
239
|
+
}
|
|
240
|
+
if (!step.route) {
|
|
241
|
+
return `<li>${text}</li>`;
|
|
242
|
+
}
|
|
243
|
+
return `<li><a href="${escapeHtml(step.route)}">${text}</a></li>`;
|
|
244
|
+
})
|
|
245
|
+
.join('');
|
|
246
|
+
return `<nav aria-label="${label}"><ol>${items}</ol></nav>`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Render deterministic pagination controls for a listing page (index or
|
|
251
|
+
* archive kind).
|
|
252
|
+
*
|
|
253
|
+
* @param {object} options rendering options
|
|
254
|
+
* @param {number} options.currentPage the current 1-based page number
|
|
255
|
+
* @param {number} options.totalPages the total page count (at least 1)
|
|
256
|
+
* @param {(pageNumber: number) => string} options.routeForPage maps a
|
|
257
|
+
* 1-based page number to its joined route
|
|
258
|
+
* @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
|
|
259
|
+
* the resolved message catalog
|
|
260
|
+
* @returns {string} the rendered pagination `<nav>`, or an empty string when
|
|
261
|
+
* there is exactly one page
|
|
262
|
+
*/
|
|
263
|
+
export function renderPagination({
|
|
264
|
+
currentPage,
|
|
265
|
+
totalPages,
|
|
266
|
+
routeForPage,
|
|
267
|
+
messages,
|
|
268
|
+
}) {
|
|
269
|
+
if (totalPages <= 1) return '';
|
|
270
|
+
const label = escapeHtml(
|
|
271
|
+
/** @type {string} */ (messages.paginationNavigationLabel),
|
|
272
|
+
);
|
|
273
|
+
const previousLabel = escapeHtml(
|
|
274
|
+
/** @type {string} */ (messages.previousPageLabel),
|
|
275
|
+
);
|
|
276
|
+
const nextLabel = escapeHtml(/** @type {string} */ (messages.nextPageLabel));
|
|
277
|
+
const statusLabel = escapeHtml(
|
|
278
|
+
/** @type {(current: string, total: string) => string} */ (
|
|
279
|
+
messages.pageStatusLabel
|
|
280
|
+
)(String(currentPage), String(totalPages)),
|
|
281
|
+
);
|
|
282
|
+
const previous =
|
|
283
|
+
currentPage > 1
|
|
284
|
+
? `<a rel="prev" href="${escapeHtml(routeForPage(currentPage - 1))}">${previousLabel}</a>`
|
|
285
|
+
: `<span>${previousLabel}</span>`;
|
|
286
|
+
const next =
|
|
287
|
+
currentPage < totalPages
|
|
288
|
+
? `<a rel="next" href="${escapeHtml(routeForPage(currentPage + 1))}">${nextLabel}</a>`
|
|
289
|
+
: `<span>${nextLabel}</span>`;
|
|
290
|
+
return (
|
|
291
|
+
`<nav aria-label="${label}"><ul>` +
|
|
292
|
+
`<li>${previous}</li>` +
|
|
293
|
+
`<li aria-current="page">${statusLabel}</li>` +
|
|
294
|
+
`<li>${next}</li>` +
|
|
295
|
+
`</ul></nav>`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Render the one `<header>` landmark.
|
|
301
|
+
*
|
|
302
|
+
* @param {object} options rendering options
|
|
303
|
+
* @param {string} options.homeRoute the publication's own home route
|
|
304
|
+
* (`basePath`, joined)
|
|
305
|
+
* @param {string} options.publicationName the validated publication title
|
|
306
|
+
* @param {{route: string, alt: string} | undefined} options.logo an optional
|
|
307
|
+
* core logo image, always carrying alternative text
|
|
308
|
+
* @param {string} [options.appearanceControlHtml] the already-rendered
|
|
309
|
+
* Light/Dark/System appearance control (S2-T07:
|
|
310
|
+
* `internal/appearance/controller-markup.js`'s `renderAppearanceControl`),
|
|
311
|
+
* inserted into the `header-actions` slot
|
|
312
|
+
* @returns {string} the rendered header
|
|
313
|
+
*/
|
|
314
|
+
export function renderHeader({
|
|
315
|
+
homeRoute,
|
|
316
|
+
publicationName,
|
|
317
|
+
logo,
|
|
318
|
+
appearanceControlHtml,
|
|
319
|
+
}) {
|
|
320
|
+
const name = escapeHtml(publicationName);
|
|
321
|
+
const href = escapeHtml(homeRoute);
|
|
322
|
+
const logoHtml = logo
|
|
323
|
+
? `<img src="${escapeHtml(logo.route)}" alt="${escapeHtml(logo.alt)}">`
|
|
324
|
+
: '';
|
|
325
|
+
return (
|
|
326
|
+
'<header>' +
|
|
327
|
+
`<a href="${href}">${logoHtml}<span>${name}</span></a>` +
|
|
328
|
+
renderSlot('header-actions', { html: appearanceControlHtml }) +
|
|
329
|
+
'</header>'
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Render the one `<footer>` landmark.
|
|
335
|
+
*
|
|
336
|
+
* @param {object} options rendering options
|
|
337
|
+
* @param {string} options.publicationName the validated publication title
|
|
338
|
+
* @param {string} options.footerNavHtml the already-rendered footer
|
|
339
|
+
* navigation (see {@link renderFooterNavigation})
|
|
340
|
+
* @param {string} options.footerProfileHtml the already-rendered
|
|
341
|
+
* `footer-profile` slot content (footer card and/or author cards; may be
|
|
342
|
+
* empty when the publication authored neither)
|
|
343
|
+
* @param {string | undefined} options.copyrightText an optional
|
|
344
|
+
* already-resolved copyright/license text line
|
|
345
|
+
* @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
|
|
346
|
+
* the resolved message catalog
|
|
347
|
+
* @returns {string} the rendered footer
|
|
348
|
+
*/
|
|
349
|
+
export function renderFooter({
|
|
350
|
+
publicationName,
|
|
351
|
+
footerNavHtml,
|
|
352
|
+
footerProfileHtml,
|
|
353
|
+
copyrightText,
|
|
354
|
+
messages,
|
|
355
|
+
}) {
|
|
356
|
+
const name = escapeHtml(publicationName);
|
|
357
|
+
const copyrightHtml = copyrightText
|
|
358
|
+
? `<p>${escapeHtml(copyrightText)}</p>`
|
|
359
|
+
: '';
|
|
360
|
+
const attribution = escapeHtml(
|
|
361
|
+
/** @type {string} */ (messages.footerAttributionLabel),
|
|
362
|
+
);
|
|
363
|
+
return (
|
|
364
|
+
'<footer>' +
|
|
365
|
+
`<p>${name}</p>` +
|
|
366
|
+
footerNavHtml +
|
|
367
|
+
`<div data-gala-slot="footer-profile">${footerProfileHtml}</div>` +
|
|
368
|
+
renderSlot('footer-auxiliary') +
|
|
369
|
+
renderSlot('account-intent') +
|
|
370
|
+
renderSlot('conversation') +
|
|
371
|
+
renderSlot('newsletter') +
|
|
372
|
+
renderSlot('edition-selector') +
|
|
373
|
+
copyrightHtml +
|
|
374
|
+
`<p>${attribution}</p>` +
|
|
375
|
+
'</footer>'
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Compose one complete page body (everything inside `<body>`) from its
|
|
381
|
+
* pre-rendered landmark parts. Every page kind in `internal/page-kinds.js`
|
|
382
|
+
* funnels through this one function, so the landmark order and count (one
|
|
383
|
+
* skip link, one header, one primary nav, one main, one footer) can never
|
|
384
|
+
* drift between page kinds.
|
|
385
|
+
*
|
|
386
|
+
* @param {object} options rendering options
|
|
387
|
+
* @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
|
|
388
|
+
* the resolved message catalog
|
|
389
|
+
* @param {string} options.headerHtml the already-rendered header
|
|
390
|
+
* @param {string} options.navHtml the already-rendered primary navigation
|
|
391
|
+
* @param {string} options.footerHtml the already-rendered footer
|
|
392
|
+
* @param {string} [options.breadcrumbHtml] an optional already-rendered
|
|
393
|
+
* breadcrumb, inserted before the page's own `<h1>`
|
|
394
|
+
* @param {string} options.mainHtml the page kind's own inner markup, always
|
|
395
|
+
* starting with exactly one `<h1>`
|
|
396
|
+
* @returns {string} the complete `<body>` inner HTML
|
|
397
|
+
*/
|
|
398
|
+
export function renderPageBody({
|
|
399
|
+
messages,
|
|
400
|
+
headerHtml,
|
|
401
|
+
navHtml,
|
|
402
|
+
footerHtml,
|
|
403
|
+
breadcrumbHtml,
|
|
404
|
+
mainHtml,
|
|
405
|
+
}) {
|
|
406
|
+
return (
|
|
407
|
+
renderSkipLink(messages) +
|
|
408
|
+
headerHtml +
|
|
409
|
+
navHtml +
|
|
410
|
+
`<main id="main-content">${breadcrumbHtml ?? ''}${mainHtml}</main>` +
|
|
411
|
+
footerHtml
|
|
412
|
+
);
|
|
413
|
+
}
|