@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,709 @@
1
+ /**
2
+ * Page-kind assembly (task packet S2-T06): builds every generated page's
3
+ * `<main>` content, breadcrumb trail and route from a validated
4
+ * `build-input:2.0.0` instance, and the shared site chrome (header, primary
5
+ * navigation, footer) every page kind is composed with through
6
+ * `internal/skeleton.js`'s {@link import('./skeleton.js').renderPageBody}.
7
+ *
8
+ * Page kinds this module produces, matching brief S2 section 3's "Required
9
+ * generated outputs" and task packet S2-T06's page-kind list:
10
+ *
11
+ * - `profile` — the publication's own optional profile page
12
+ * (`publication.profile`);
13
+ * - `author` — one page per entry in `build-input.authors` (DEC-097 section 5:
14
+ * this array is already exactly the set of authors referenced by the
15
+ * selected content, publication contact or footer card — never an
16
+ * unreferenced author), at `/authors/<id>`;
17
+ * - `article` / `page` — one page per `content[]` record, at its own
18
+ * `contentFrontmatterNormalized` route;
19
+ * - `index` — a paginated reverse-chronological listing of every
20
+ * `status: "published"` `kind: "article"` record (an `unlisted` record is
21
+ * reachable only by its own direct route or an authored navigation entry,
22
+ * never through a generated listing — the ordinary meaning of "unlisted");
23
+ * - `tag` / `series` — one listing page per distinct authored tag/series
24
+ * value, plus one `/tags`/`/series` root page linking to each, present only
25
+ * when at least one record declares that field;
26
+ * - `archive` — one listing page per UTC calendar year (from `publishedAt`),
27
+ * plus one `/archive` root page linking to each year.
28
+ *
29
+ * The `error` page kind ({@link renderErrorPageBody}) is exported as a pure
30
+ * function, not wired into any generated route here: brief S2 section 3
31
+ * assigns the one generated `404.html` artifact to S2-T08, which this task
32
+ * packet's own scope notes forbid touching. S2-T08 composes its `404.html`
33
+ * from this same function.
34
+ *
35
+ * Every generated page's own `route` field (see {@link GeneratedPage}) is an
36
+ * un-joined `canonicalRoute`, in the same convention `internal/index.js`
37
+ * already uses for content pages: the caller joins it with `basePath` via
38
+ * `internal/route.js`'s `joinBasePathAndRoute` before route projection. Every
39
+ * *link href* this module writes into a page's own body (breadcrumbs, tag and
40
+ * series listings, pagination controls) is, by contrast, already joined with
41
+ * `basePath` here, because it is read back only as literal HTML text, never
42
+ * re-joined by a caller. This mirrors DEC-097 section 5's navigation
43
+ * normalization rule: an *authored* navigation item's route is used exactly
44
+ * as authored, never synthesized or rewritten (so `internal/skeleton.js`'s
45
+ * navigation renderer never joins it), while every route this renderer itself
46
+ * derives (every page kind here) is basePath-joined at the point it becomes
47
+ * link text, exactly like S2-T03's own redirect/content route projection.
48
+ *
49
+ * Every listing here is a deliberately scoped decision this module documents
50
+ * rather than assumes: page size, sort tie-break and the tag/series/archive
51
+ * root pages are not specified verbatim by the brief text this task read.
52
+ */
53
+
54
+ import {
55
+ escapeHtml,
56
+ renderBreadcrumbs,
57
+ renderPagination,
58
+ renderSlot,
59
+ } from './skeleton.js';
60
+ import { getMessages } from './messages.js';
61
+ import { resolveTextDirection } from './text-direction.js';
62
+ import { routeSegmentForLabel } from './route-labels.js';
63
+ import { joinBasePathAndRoute } from './route.js';
64
+
65
+ /** Listing page size (index and per-year archive pages). Scoped decision:
66
+ * the brief does not fix a page size; 10 is a deterministic, documented
67
+ * default with no author-facing configuration surface in S2.
68
+ * @type {number} */
69
+ export const LISTING_PAGE_SIZE = 10;
70
+
71
+ /**
72
+ * The DEC-097-style semantic template-owned attribute this renderer sets on
73
+ * every generated page's `<body>` (S2-T12: `contracts/theme-styling-contract.jcs`
74
+ * exposes this as a public theme hook, one per {@link PAGE_KIND_VALUES}
75
+ * entry, so a theme can vary presentation by page kind — e.g. a different
76
+ * article vs. listing background — without the template ever promoting a
77
+ * private/incidental selector). Scoped decision: this attribute did not
78
+ * exist before S2-T12; it is this task packet's own addition, introduced
79
+ * specifically because the published styling contract needs a
80
+ * page-kind-targeting hook the brief's "every page-kind ... class/attribute"
81
+ * language calls for.
82
+ *
83
+ * @type {string}
84
+ */
85
+ export const PAGE_KIND_ATTRIBUTE = 'data-gala-page-kind';
86
+
87
+ /**
88
+ * Every value {@link PAGE_KIND_ATTRIBUTE} may carry: every {@link GeneratedPage}
89
+ * `kind` plus the synthetic `error` kind (`internal/index.js`'s always-present
90
+ * generated `404.html`), sorted by UTF-8 bytes (DEC-097's own attribute-value
91
+ * sort convention).
92
+ *
93
+ * @type {readonly string[]}
94
+ */
95
+ export const PAGE_KIND_VALUES = Object.freeze(
96
+ [
97
+ 'archive',
98
+ 'article',
99
+ 'author',
100
+ 'error',
101
+ 'index',
102
+ 'page',
103
+ 'profile',
104
+ 'series',
105
+ 'tag',
106
+ ].sort(),
107
+ );
108
+
109
+ /**
110
+ * @param {import('../../../types/index.d.ts').ContentFrontmatterNormalized} frontmatter
111
+ * @returns {string} the record's own canonical route (un-joined)
112
+ */
113
+ export function contentRoute(frontmatter) {
114
+ return frontmatter.route ?? `/${frontmatter.slug}`;
115
+ }
116
+
117
+ /**
118
+ * Split an array into fixed-size, order-preserving pages.
119
+ *
120
+ * @template T
121
+ * @param {readonly T[]} items items, already in final display order
122
+ * @param {number} pageSize maximum items per page (at least 1)
123
+ * @returns {T[][]} one array per page, always at least one (possibly empty)
124
+ * page
125
+ */
126
+ function paginate(items, pageSize) {
127
+ if (items.length === 0) return [[]];
128
+ /** @type {T[][]} */
129
+ const pages = [];
130
+ for (let index = 0; index < items.length; index += pageSize) {
131
+ pages.push(items.slice(index, index + pageSize));
132
+ }
133
+ return pages;
134
+ }
135
+
136
+ /**
137
+ * Resolve an author's display name by ID, failing closed on a dangling
138
+ * reference (an adapter defect: `resolvedAuthorIds` is schema/DEC-097
139
+ * guaranteed to resolve).
140
+ *
141
+ * @param {ReadonlyMap<string, import('../../../types/index.d.ts').AuthorNormalized>} authorsById
142
+ * @param {string} authorId
143
+ * @returns {import('../../../types/index.d.ts').AuthorNormalized}
144
+ */
145
+ function requireAuthor(authorsById, authorId) {
146
+ const author = authorsById.get(authorId);
147
+ if (!author) {
148
+ throw new Error(`internal: no author record for id ${authorId}`);
149
+ }
150
+ return author;
151
+ }
152
+
153
+ /**
154
+ * @typedef {object} GeneratedPage
155
+ * @property {'profile' | 'author' | 'article' | 'page' | 'index' | 'tag' | 'series' | 'archive'} kind
156
+ * @property {string} route an un-joined `canonicalRoute` (caller joins with
157
+ * `basePath`)
158
+ * @property {string} title the page's `<title>` text
159
+ * @property {string} language the page's BCP-47 language tag
160
+ * @property {string} [direction] the page's resolved base text direction (assigned by buildGeneratedPages's own return mapping)
161
+ * @property {string} bodyHtml the page's complete `<main>` inner HTML,
162
+ * starting with exactly one `<h1>`
163
+ * @property {string} [breadcrumbHtml] an optional already-rendered breadcrumb
164
+ * @property {string} [description] task packet S2-T08: an optional plain-text
165
+ * description this page's SEO/Open-Graph/Twitter `<meta>` tags are built
166
+ * from (never HTML, never the sanitized body — a distinct authored or
167
+ * publication-level plain-text field)
168
+ * @property {'website' | 'article' | 'profile'} [ogType] task packet S2-T08:
169
+ * the Open Graph `og:type` this page kind maps to; defaults to `'website'`
170
+ * when omitted
171
+ * @property {{path: string, sourceDigest: string} | undefined} [socialImageRef]
172
+ * task packet S2-T08: an optional `resolvedFile` reference this page's
173
+ * social image is resolved from (the caller, `src/core/index.js`, is the
174
+ * one place that can turn this into an absolute derivative URL, because
175
+ * only it has run the S2-T05 media pipeline and knows the derivative
176
+ * output path a given `sourceDigest` produced)
177
+ * @property {string} [robotsContent] task packet S2-T08: an optional
178
+ * `<meta name="robots">` content value (e.g. `'noindex, follow'` for
179
+ * `status: "unlisted"` content); omitted entirely for ordinarily indexable
180
+ * pages, matching this renderer's "documented, not silently assumed" style
181
+ * elsewhere — every page kind's robots decision is made once, here, rather
182
+ * than re-derived by every later consumer (sitemap, search index)
183
+ * @property {string} [lastModified] task packet S2-T08: an optional
184
+ * `rfc3339` timestamp the generated sitemap's `<lastmod>` for this page is
185
+ * drawn from — always derived from authored content timestamps
186
+ * (`publishedAt`/`updatedAt`), never build time (brief S2 section 3:
187
+ * "sitemap ... `lastmod` from content timestamps not build time"). Omitted
188
+ * for a page kind with no natural underlying timestamp (`profile`,
189
+ * `author`), which the generated sitemap then emits with no `<lastmod>`
190
+ * element at all (a sitemap's own `<lastmod>` is optional per the Sitemaps
191
+ * 0.9 protocol).
192
+ */
193
+
194
+ /**
195
+ * The reverse-chronological, deterministically tie-broken set of every
196
+ * `status: "published"`, `kind: "article"` record — the exact selection and
197
+ * ordering the `index` page kind paginates, and (task packet S2-T08) the
198
+ * same selection the generated feeds draw from, so a feed's item order can
199
+ * never drift from the index listing's own order. Extracted to its own
200
+ * export rather than duplicated, since `internal/fs-walk.js`'s duplication
201
+ * gate (`jscpd`, 3%/50 tokens) would otherwise flag a second copy of this
202
+ * sort comparator.
203
+ *
204
+ * @param {readonly import('../../../types/index.d.ts').ContentBuildRecord[]} content
205
+ * @returns {import('../../../types/index.d.ts').ContentBuildRecord[]} the
206
+ * selected records, most recently published first
207
+ */
208
+ export function selectPublishedArticles(content) {
209
+ return content
210
+ .filter(
211
+ (record) =>
212
+ record.frontmatter.kind === 'article' &&
213
+ record.frontmatter.status === 'published',
214
+ )
215
+ .slice()
216
+ .sort((a, b) => {
217
+ if (a.frontmatter.publishedAt !== b.frontmatter.publishedAt) {
218
+ return a.frontmatter.publishedAt < b.frontmatter.publishedAt ? 1 : -1;
219
+ }
220
+ return a.frontmatter.id < b.frontmatter.id ? -1 : 1;
221
+ });
222
+ }
223
+
224
+ /**
225
+ * The most recent of a content record's own `updatedAt` (when authored) or
226
+ * `publishedAt` — this renderer's one definition of "a content record's own
227
+ * last-modified instant" (task packet S2-T08's sitemap `<lastmod>` and the
228
+ * static search index both read this, rather than each re-deriving it).
229
+ *
230
+ * @param {import('../../../types/index.d.ts').ContentFrontmatterNormalized} frontmatter
231
+ * @returns {string} an `rfc3339` timestamp
232
+ */
233
+ export function contentLastModified(frontmatter) {
234
+ return frontmatter.updatedAt ?? frontmatter.publishedAt;
235
+ }
236
+
237
+ /**
238
+ * The latest {@link contentLastModified} across a non-empty set of records —
239
+ * this renderer's definition of "a generated listing page's own
240
+ * `<lastmod>`" (task packet S2-T08): the most recent authored timestamp
241
+ * among the content actually shown on that page, never build time.
242
+ *
243
+ * @param {readonly import('../../../types/index.d.ts').ContentBuildRecord[]} records
244
+ * @returns {string | undefined} the latest timestamp, or `undefined` for an
245
+ * empty set
246
+ */
247
+ function latestContentTimestamp(records) {
248
+ return records.reduce(
249
+ /** @type {(latest: string | undefined, record: import('../../../types/index.d.ts').ContentBuildRecord) => string} */ (
250
+ latest,
251
+ record,
252
+ ) => {
253
+ const candidate = contentLastModified(record.frontmatter);
254
+ return !latest || candidate > latest ? candidate : latest;
255
+ },
256
+ /** @type {string | undefined} */ (undefined),
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Build every generated page (every kind except `error`) from a validated
262
+ * `build-input:2.0.0` instance.
263
+ *
264
+ * @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
265
+ * @returns {GeneratedPage[]} every generated page, in a stable, deterministic
266
+ * order
267
+ */
268
+ export function buildGeneratedPages(validatedInput) {
269
+ const { publication, authors, content, basePath } = validatedInput;
270
+ const messages = getMessages(publication.defaultLanguage);
271
+ const authorsById = new Map(authors.map((author) => [author.id, author]));
272
+
273
+ /**
274
+ * @param {string} route an un-joined route
275
+ * @returns {string} the `basePath`-joined absolute route
276
+ */
277
+ const site = (route) => joinBasePathAndRoute(basePath, route);
278
+
279
+ /**
280
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
281
+ * @returns {string} one `<li>` summary
282
+ */
283
+ const renderContentSummary = (record) => {
284
+ const { frontmatter } = record;
285
+ const route = escapeHtml(site(contentRoute(frontmatter)));
286
+ const title = escapeHtml(frontmatter.title);
287
+ const authorNames = frontmatter.authorIds
288
+ .map((id) => requireAuthor(authorsById, id).displayName)
289
+ .join(', ');
290
+ const byLine = escapeHtml(
291
+ /** @type {(displayName: string) => string} */ (messages.byLineLabel)(
292
+ authorNames,
293
+ ),
294
+ );
295
+ const time = `<time datetime="${escapeHtml(frontmatter.publishedAt)}">${escapeHtml(frontmatter.publishedAt)}</time>`;
296
+ const tags =
297
+ frontmatter.tags.length > 0
298
+ ? `<ul>${frontmatter.tags
299
+ .map(
300
+ (tag) =>
301
+ `<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
302
+ )
303
+ .join('')}</ul>`
304
+ : '';
305
+ return (
306
+ `<li><h2><a href="${route}">${title}</a></h2>` +
307
+ `<p>${byLine}</p>${time}${tags}</li>`
308
+ );
309
+ };
310
+
311
+ /** @type {GeneratedPage[]} */
312
+ const pages = [];
313
+
314
+ if (publication.profile) {
315
+ pages.push({
316
+ kind: 'profile',
317
+ route: publication.profile.route,
318
+ title: publication.title,
319
+ language: publication.defaultLanguage,
320
+ description: publication.description,
321
+ ogType: 'website',
322
+ socialImageRef: publication.defaultImage,
323
+ bodyHtml: `<h1>${escapeHtml(publication.title)}</h1>${publication.profile.body.body}`,
324
+ });
325
+ }
326
+
327
+ for (const author of authors) {
328
+ const pronouns = author.pronouns
329
+ ? `<p>${escapeHtml(author.pronouns)}</p>`
330
+ : '';
331
+ const biography = author.biography
332
+ ? `<p>${escapeHtml(author.biography)}</p>`
333
+ : '';
334
+ const links =
335
+ author.links.length > 0
336
+ ? `<ul>${author.links
337
+ .map((link) => {
338
+ const label = escapeHtml(link.label ?? link.uri);
339
+ return `<li><a href="${escapeHtml(link.uri)}">${label}</a></li>`;
340
+ })
341
+ .join('')}</ul>`
342
+ : '';
343
+ const heading = /** @type {(displayName: string) => string} */ (
344
+ messages.aboutAuthorHeading
345
+ )(author.displayName);
346
+ pages.push({
347
+ kind: 'author',
348
+ route: `/authors/${author.id}`,
349
+ title: heading,
350
+ language: publication.defaultLanguage,
351
+ description: author.biography || undefined,
352
+ ogType: 'profile',
353
+ socialImageRef: author.avatar,
354
+ breadcrumbHtml: renderBreadcrumbs({
355
+ trail: [
356
+ {
357
+ label: /** @type {string} */ (messages.homeLinkLabel),
358
+ route: site('/'),
359
+ },
360
+ { label: /** @type {string} */ (messages.authorsSectionLabel) },
361
+ { label: author.displayName },
362
+ ],
363
+ messages,
364
+ }),
365
+ bodyHtml:
366
+ `<h1>${escapeHtml(heading)}</h1>` + pronouns + biography + links,
367
+ });
368
+ }
369
+
370
+ for (const record of content) {
371
+ const { frontmatter } = record;
372
+ const authorNames = frontmatter.authorIds
373
+ .map((id) => requireAuthor(authorsById, id).displayName)
374
+ .join(', ');
375
+ const byLine = `<p>${escapeHtml(
376
+ /** @type {(displayName: string) => string} */ (messages.byLineLabel)(
377
+ authorNames,
378
+ ),
379
+ )}</p>`;
380
+ const time = `<time datetime="${escapeHtml(frontmatter.publishedAt)}">${escapeHtml(frontmatter.publishedAt)}</time>`;
381
+ const tags =
382
+ frontmatter.tags.length > 0
383
+ ? `<ul>${frontmatter.tags
384
+ .map(
385
+ (tag) =>
386
+ `<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
387
+ )
388
+ .join('')}</ul>`
389
+ : '';
390
+ const seriesHtml = frontmatter.series
391
+ ? `<p><a href="${escapeHtml(site(`/series/${routeSegmentForLabel(frontmatter.series)}`))}">${escapeHtml(frontmatter.series)}</a></p>`
392
+ : '';
393
+ const breadcrumbTrail =
394
+ frontmatter.kind === 'article'
395
+ ? [
396
+ {
397
+ label: /** @type {string} */ (messages.homeLinkLabel),
398
+ route: site('/'),
399
+ },
400
+ {
401
+ label: /** @type {string} */ (messages.indexHeading),
402
+ route: site('/'),
403
+ },
404
+ { label: frontmatter.title },
405
+ ]
406
+ : [
407
+ {
408
+ label: /** @type {string} */ (messages.homeLinkLabel),
409
+ route: site('/'),
410
+ },
411
+ { label: frontmatter.title },
412
+ ];
413
+ pages.push({
414
+ kind: frontmatter.kind,
415
+ route: contentRoute(frontmatter),
416
+ title: frontmatter.title,
417
+ language: frontmatter.language,
418
+ description: frontmatter.description,
419
+ ogType: frontmatter.kind === 'article' ? 'article' : 'website',
420
+ socialImageRef: frontmatter.socialImage ?? frontmatter.hero?.file,
421
+ robotsContent:
422
+ frontmatter.status === 'unlisted' ? 'noindex, follow' : undefined,
423
+ lastModified: contentLastModified(frontmatter),
424
+ breadcrumbHtml: renderBreadcrumbs({ trail: breadcrumbTrail, messages }),
425
+ bodyHtml:
426
+ `<article>` +
427
+ renderSlot('article-preamble') +
428
+ `<h1>${escapeHtml(frontmatter.title)}</h1>` +
429
+ byLine +
430
+ time +
431
+ seriesHtml +
432
+ tags +
433
+ record.body +
434
+ renderSlot('article-end') +
435
+ renderSlot('article-footer-ad', { collapsed: true }) +
436
+ `</article>`,
437
+ });
438
+ }
439
+
440
+ const publishedArticles = selectPublishedArticles(content);
441
+
442
+ const indexPages = paginate(publishedArticles, LISTING_PAGE_SIZE);
443
+ indexPages.forEach((items, pageIndex) => {
444
+ const pageNumber = pageIndex + 1;
445
+ const route = pageNumber === 1 ? '/' : `/page/${pageNumber}`;
446
+ pages.push({
447
+ kind: 'index',
448
+ route,
449
+ title: /** @type {string} */ (messages.indexHeading),
450
+ language: publication.defaultLanguage,
451
+ description: publication.description,
452
+ ogType: 'website',
453
+ socialImageRef: publication.defaultImage,
454
+ lastModified: latestContentTimestamp(items),
455
+ bodyHtml:
456
+ `<h1>${escapeHtml(/** @type {string} */ (messages.indexHeading))}</h1>` +
457
+ `<ul>${items.map((record) => renderContentSummary(record)).join('')}</ul>` +
458
+ renderPagination({
459
+ currentPage: pageNumber,
460
+ totalPages: indexPages.length,
461
+ routeForPage: (n) => site(n === 1 ? '/' : `/page/${n}`),
462
+ messages,
463
+ }),
464
+ });
465
+ });
466
+
467
+ /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
468
+ const byTag = new Map();
469
+ for (const record of publishedArticles) {
470
+ for (const tag of record.frontmatter.tags) {
471
+ const bucket = byTag.get(tag) ?? [];
472
+ bucket.push(record);
473
+ byTag.set(tag, bucket);
474
+ }
475
+ }
476
+ const sortedTags = [...byTag.keys()].sort();
477
+ if (sortedTags.length > 0) {
478
+ pages.push({
479
+ kind: 'tag',
480
+ route: '/tags',
481
+ title: /** @type {string} */ (messages.tagsSectionLabel),
482
+ language: publication.defaultLanguage,
483
+ description: publication.description,
484
+ ogType: 'website',
485
+ socialImageRef: publication.defaultImage,
486
+ lastModified: latestContentTimestamp(publishedArticles),
487
+ bodyHtml:
488
+ `<h1>${escapeHtml(/** @type {string} */ (messages.tagsSectionLabel))}</h1>` +
489
+ `<ul>${sortedTags
490
+ .map(
491
+ (tag) =>
492
+ `<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
493
+ )
494
+ .join('')}</ul>`,
495
+ });
496
+ }
497
+ for (const tag of sortedTags) {
498
+ const items =
499
+ /** @type {import('../../../types/index.d.ts').ContentBuildRecord[]} */ (
500
+ byTag.get(tag)
501
+ );
502
+ const heading = `${/** @type {string} */ (messages.tagIndexLabelPrefix)} ${tag}`;
503
+ pages.push({
504
+ kind: 'tag',
505
+ route: `/tags/${routeSegmentForLabel(tag)}`,
506
+ title: heading,
507
+ language: publication.defaultLanguage,
508
+ ogType: 'website',
509
+ socialImageRef: publication.defaultImage,
510
+ lastModified: latestContentTimestamp(items),
511
+ breadcrumbHtml: renderBreadcrumbs({
512
+ trail: [
513
+ {
514
+ label: /** @type {string} */ (messages.homeLinkLabel),
515
+ route: site('/'),
516
+ },
517
+ {
518
+ label: /** @type {string} */ (messages.tagsSectionLabel),
519
+ route: site('/tags'),
520
+ },
521
+ { label: tag },
522
+ ],
523
+ messages,
524
+ }),
525
+ bodyHtml:
526
+ `<h1>${escapeHtml(heading)}</h1>` +
527
+ `<ul>${items.map((record) => renderContentSummary(record)).join('')}</ul>`,
528
+ });
529
+ }
530
+
531
+ /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
532
+ const bySeries = new Map();
533
+ for (const record of content) {
534
+ const series = record.frontmatter.series;
535
+ if (!series || record.frontmatter.status !== 'published') continue;
536
+ const bucket = bySeries.get(series) ?? [];
537
+ bucket.push(record);
538
+ bySeries.set(series, bucket);
539
+ }
540
+ const sortedSeries = [...bySeries.keys()].sort();
541
+ if (sortedSeries.length > 0) {
542
+ pages.push({
543
+ kind: 'series',
544
+ route: '/series',
545
+ title: /** @type {string} */ (messages.seriesSectionLabel),
546
+ language: publication.defaultLanguage,
547
+ description: publication.description,
548
+ ogType: 'website',
549
+ socialImageRef: publication.defaultImage,
550
+ lastModified: latestContentTimestamp([...bySeries.values()].flat()),
551
+ bodyHtml:
552
+ `<h1>${escapeHtml(/** @type {string} */ (messages.seriesSectionLabel))}</h1>` +
553
+ `<ul>${sortedSeries
554
+ .map(
555
+ (series) =>
556
+ `<li><a href="${escapeHtml(site(`/series/${routeSegmentForLabel(series)}`))}">${escapeHtml(series)}</a></li>`,
557
+ )
558
+ .join('')}</ul>`,
559
+ });
560
+ }
561
+ for (const series of sortedSeries) {
562
+ const items =
563
+ /** @type {import('../../../types/index.d.ts').ContentBuildRecord[]} */ (
564
+ bySeries.get(series)
565
+ )
566
+ .slice()
567
+ .sort(
568
+ (a, b) =>
569
+ (a.frontmatter.seriesOrder ?? 0) - (b.frontmatter.seriesOrder ?? 0),
570
+ );
571
+ const heading = `${/** @type {string} */ (messages.seriesIndexLabelPrefix)} ${series}`;
572
+ pages.push({
573
+ kind: 'series',
574
+ route: `/series/${routeSegmentForLabel(series)}`,
575
+ title: heading,
576
+ language: publication.defaultLanguage,
577
+ ogType: 'website',
578
+ socialImageRef: publication.defaultImage,
579
+ lastModified: latestContentTimestamp(items),
580
+ breadcrumbHtml: renderBreadcrumbs({
581
+ trail: [
582
+ {
583
+ label: /** @type {string} */ (messages.homeLinkLabel),
584
+ route: site('/'),
585
+ },
586
+ {
587
+ label: /** @type {string} */ (messages.seriesSectionLabel),
588
+ route: site('/series'),
589
+ },
590
+ { label: series },
591
+ ],
592
+ messages,
593
+ }),
594
+ bodyHtml:
595
+ `<h1>${escapeHtml(heading)}</h1>` +
596
+ `<ol>${items.map((record) => renderContentSummary(record)).join('')}</ol>`,
597
+ });
598
+ }
599
+
600
+ /** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
601
+ const byYear = new Map();
602
+ for (const record of publishedArticles) {
603
+ const year = record.frontmatter.publishedAt.slice(0, 4);
604
+ const bucket = byYear.get(year) ?? [];
605
+ bucket.push(record);
606
+ byYear.set(year, bucket);
607
+ }
608
+ const sortedYears = [...byYear.keys()].sort().reverse();
609
+ if (sortedYears.length > 0) {
610
+ pages.push({
611
+ kind: 'archive',
612
+ route: '/archive',
613
+ title: /** @type {string} */ (messages.archiveSectionLabel),
614
+ language: publication.defaultLanguage,
615
+ description: publication.description,
616
+ ogType: 'website',
617
+ socialImageRef: publication.defaultImage,
618
+ lastModified: latestContentTimestamp(publishedArticles),
619
+ bodyHtml:
620
+ `<h1>${escapeHtml(/** @type {string} */ (messages.archiveSectionLabel))}</h1>` +
621
+ `<ul>${sortedYears
622
+ .map(
623
+ (year) =>
624
+ `<li><a href="${escapeHtml(site(`/archive/${year}`))}">${escapeHtml(year)}</a></li>`,
625
+ )
626
+ .join('')}</ul>`,
627
+ });
628
+ }
629
+ for (const year of sortedYears) {
630
+ const items =
631
+ /** @type {import('../../../types/index.d.ts').ContentBuildRecord[]} */ (
632
+ byYear.get(year)
633
+ );
634
+ const yearPages = paginate(items, LISTING_PAGE_SIZE);
635
+ yearPages.forEach((pageItems, pageIndex) => {
636
+ const pageNumber = pageIndex + 1;
637
+ const route =
638
+ pageNumber === 1
639
+ ? `/archive/${year}`
640
+ : `/archive/${year}/page/${pageNumber}`;
641
+ const heading = `${/** @type {string} */ (messages.archiveYearLabelPrefix)} ${year}`;
642
+ pages.push({
643
+ kind: 'archive',
644
+ route,
645
+ title: heading,
646
+ language: publication.defaultLanguage,
647
+ ogType: 'website',
648
+ socialImageRef: publication.defaultImage,
649
+ lastModified: latestContentTimestamp(pageItems),
650
+ breadcrumbHtml: renderBreadcrumbs({
651
+ trail: [
652
+ {
653
+ label: /** @type {string} */ (messages.homeLinkLabel),
654
+ route: site('/'),
655
+ },
656
+ {
657
+ label: /** @type {string} */ (messages.archiveSectionLabel),
658
+ route: site('/archive'),
659
+ },
660
+ { label: year },
661
+ ],
662
+ messages,
663
+ }),
664
+ bodyHtml:
665
+ `<h1>${escapeHtml(heading)}</h1>` +
666
+ `<ul>${pageItems.map((record) => renderContentSummary(record)).join('')}</ul>` +
667
+ renderPagination({
668
+ currentPage: pageNumber,
669
+ totalPages: yearPages.length,
670
+ routeForPage: (n) =>
671
+ site(n === 1 ? `/archive/${year}` : `/archive/${year}/page/${n}`),
672
+ messages,
673
+ }),
674
+ });
675
+ });
676
+ }
677
+
678
+ return pages.map((page) => ({
679
+ ...page,
680
+ direction: resolveTextDirection(page.language),
681
+ }));
682
+ }
683
+
684
+ /**
685
+ * Render the `error` page kind's `<main>` body (task packet S2-T06). This is
686
+ * a pure function with no route/build-input dependency, exported for a
687
+ * future caller (S2-T08's generated `404.html`, out of this task packet's
688
+ * scope) and for this repository's own structural/golden tests.
689
+ *
690
+ * @param {object} options rendering options
691
+ * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
692
+ * the resolved message catalog
693
+ * @param {string} options.homeRoute the publication's own (already joined)
694
+ * home route
695
+ * @returns {string} the error page's `<main>` inner HTML, starting with
696
+ * exactly one `<h1>`
697
+ */
698
+ export function renderErrorPageBody({ messages, homeRoute }) {
699
+ const heading = escapeHtml(/** @type {string} */ (messages.errorPageHeading));
700
+ const body = escapeHtml(/** @type {string} */ (messages.errorPageBody));
701
+ const returnLabel = escapeHtml(
702
+ /** @type {string} */ (messages.errorPageReturnHomeLabel),
703
+ );
704
+ return (
705
+ `<h1>${heading}</h1>` +
706
+ `<p>${body}</p>` +
707
+ `<p><a href="${escapeHtml(homeRoute)}">${returnLabel}</a></p>`
708
+ );
709
+ }