@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.
Files changed (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +515 -0
  3. package/contracts/render-policy.jcs +1 -0
  4. package/contracts/theme-styling-contract.jcs +1 -0
  5. package/package.json +96 -0
  6. package/src/core/errors.js +136 -0
  7. package/src/core/index.js +758 -0
  8. package/src/core/internal/appearance/bootstrap-script.js +159 -0
  9. package/src/core/internal/appearance/contract.js +193 -0
  10. package/src/core/internal/appearance/controller-markup.js +80 -0
  11. package/src/core/internal/appearance/styling-contract.js +509 -0
  12. package/src/core/internal/canonical-jcs.js +0 -0
  13. package/src/core/internal/content-security.js +0 -0
  14. package/src/core/internal/eleventy-render.js +220 -0
  15. package/src/core/internal/feeds.js +161 -0
  16. package/src/core/internal/fs-walk.js +38 -0
  17. package/src/core/internal/media/avif-probe.js +169 -0
  18. package/src/core/internal/media/font-policy.js +56 -0
  19. package/src/core/internal/media/jpeg-codec.js +0 -0
  20. package/src/core/internal/media/limits.js +129 -0
  21. package/src/core/internal/media/pipeline.js +353 -0
  22. package/src/core/internal/media/png-codec.js +406 -0
  23. package/src/core/internal/media/resize.js +147 -0
  24. package/src/core/internal/media/sniff.js +109 -0
  25. package/src/core/internal/media/webp-probe.js +139 -0
  26. package/src/core/internal/messages.js +78 -0
  27. package/src/core/internal/page-kinds.js +709 -0
  28. package/src/core/internal/provenance.js +49 -0
  29. package/src/core/internal/render-policy-content.js +295 -0
  30. package/src/core/internal/route-labels.js +68 -0
  31. package/src/core/internal/route.js +167 -0
  32. package/src/core/internal/search-index.js +119 -0
  33. package/src/core/internal/seo.js +114 -0
  34. package/src/core/internal/sitemap.js +77 -0
  35. package/src/core/internal/skeleton.js +413 -0
  36. package/src/core/internal/source-inventory.js +145 -0
  37. package/src/core/internal/text-direction.js +59 -0
  38. package/src/core/internal/theme-assets.js +387 -0
  39. package/src/core/internal/uuid.js +51 -0
  40. package/src/core/manifest.js +0 -0
  41. package/types/eleventy-plugin-syntaxhighlight.d.ts +26 -0
  42. package/types/eleventy.d.ts +38 -0
  43. 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
+ '&': '&amp;',
86
+ '<': '&lt;',
87
+ '>': '&gt;',
88
+ '"': '&quot;',
89
+ "'": '&#39;',
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
+ }