@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,758 @@
1
+ /**
2
+ * `@rathnasgala2/template` public entry point.
3
+ *
4
+ * This is S2-T03's Eleventy-confined renderer adapter, extended by S2-T05's
5
+ * media pipeline: it consumes one validated
6
+ * `urn:gala:schema:build-input:2.0.0` instance and emits a candidate output
7
+ * directory plus one `urn:gala:schema:artifact-manifest:2.0.0` instance
8
+ * (brief S2 section 3).
9
+ *
10
+ * `renderPublication` is the only documented *rendering* entry point. It
11
+ * fails closed: an invalid `build-input` is rejected with
12
+ * {@link BuildInputValidationError} before anything is written, malformed
13
+ * adapter options are rejected with {@link RenderOptionsError} before
14
+ * Eleventy ever runs, and a rejected image or font is rejected with
15
+ * {@link MediaPipelineError} before the manifest is assembled.
16
+ *
17
+ * `normalizeAuthoredMarkdown` (re-exported here from S2-T04's
18
+ * `internal/content-security.js`) is this package's second public entry
19
+ * point: the upstream normalization step a `build-input:2.0.0` producer
20
+ * (`publish-kernel`/`publish-action`) calls once per authored Markdown
21
+ * source to produce the exact `renderableBody.body`/`bodyDigest` pair this
22
+ * renderer expects to receive. `renderPublication` never calls it itself —
23
+ * see `internal/content-security.js`'s module documentation for why a
24
+ * `renderableBody.body` is already policy-conformant HTML by the time it
25
+ * reaches this renderer, and how `renderPublication` verifies that instead
26
+ * of re-deriving it.
27
+ *
28
+ * S2-T05 adds `options.sourceDirectory` (the caller-mounted, read-only
29
+ * repository source tree `build-input`'s `resolvedFile` references point
30
+ * into) and runs `processMedia` after the Eleventy route listing is already
31
+ * computed, so generated derivative files under `assets/media/` are never
32
+ * mistaken for an HTML route.
33
+ *
34
+ * Output-security (markdown/sanitizer/highlighter/CSP; S2-T04) and the media
35
+ * pipeline (S2-T05) are layered on the same adapter. S2-T06 adds the core
36
+ * semantic skeleton and navigation renderer (`internal/skeleton.js`) and
37
+ * every generated page kind (`internal/page-kinds.js`): publication profile,
38
+ * author, article/page, index, tag, series and archive pages, each composed
39
+ * with one shared header/primary-navigation/footer chrome and, where the
40
+ * brief requires it, a breadcrumb trail. Feeds/search/sitemap remain
41
+ * S2-T08. This adapter also emits every authored static redirect, shaped so
42
+ * that later task extends the page-body pipeline without touching the
43
+ * confinement, route-projection or manifest-assembly mechanics here.
44
+ *
45
+ * S2-T07 adds the Light/Dark/System appearance controller
46
+ * (`internal/appearance/`): the server-rendered control in the header's
47
+ * `header-actions` slot (`controller-markup.js`), the one deterministic
48
+ * pre-paint bootstrap script this renderer ever emits, written into the
49
+ * candidate output directory and entered into the manifest as an ordinary
50
+ * `manifestAsset` row exactly like an S2-T05 media derivative
51
+ * (`bootstrap-script.js`), and the fixed `data-` attribute names, mode
52
+ * values and storage key every one of those pieces shares
53
+ * (`contract.js`) — the contract S2-T12's published theme styling catalog
54
+ * targets.
55
+ *
56
+ * `src/core/` is the only admitted source root in this repository. No
57
+ * `src/modules/` tree, module import edge, registration stub, module
58
+ * configuration, module output or module runtime is ever added beside it
59
+ * (brief S2 section 3; DEC-097 sections 2 and 5).
60
+ */
61
+
62
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
63
+ import path from 'node:path';
64
+
65
+ import { validateGalaDocument } from '@rathnasgala2/schemas';
66
+
67
+ import {
68
+ APPEARANCE_BOOTSTRAP_SCRIPT_MEDIA_TYPE,
69
+ APPEARANCE_BOOTSTRAP_SCRIPT_PATH,
70
+ appearanceBootstrapScriptHref,
71
+ } from './internal/appearance/contract.js';
72
+ import { APPEARANCE_BOOTSTRAP_SCRIPT_SOURCE } from './internal/appearance/bootstrap-script.js';
73
+ import { renderAppearanceControl } from './internal/appearance/controller-markup.js';
74
+ import {
75
+ ArtifactManifestValidationError,
76
+ BuildInputValidationError,
77
+ RenderOptionsError,
78
+ RenderPolicyViolationError,
79
+ } from './errors.js';
80
+ import { digestBytes } from './internal/canonical-jcs.js';
81
+ import {
82
+ assertPolicyConformantHtml,
83
+ assertRenderPolicyIdentity,
84
+ computeRenderPolicyIdentity,
85
+ } from './internal/content-security.js';
86
+ import { renderPagesWithEleventy } from './internal/eleventy-render.js';
87
+ import {
88
+ ATOM_FEED_ROUTE,
89
+ RSS_FEED_ROUTE,
90
+ buildFeeds,
91
+ } from './internal/feeds.js';
92
+ import { listFilesSortedByUtf8Bytes } from './internal/fs-walk.js';
93
+ import { getMessages } from './internal/messages.js';
94
+ import { processMedia } from './internal/media/pipeline.js';
95
+ import {
96
+ buildGeneratedPages,
97
+ contentRoute,
98
+ renderErrorPageBody,
99
+ } from './internal/page-kinds.js';
100
+ import { assertProvenance } from './internal/provenance.js';
101
+ import {
102
+ errorDocumentPath,
103
+ joinBasePathAndRoute,
104
+ projectFixedAssetPath,
105
+ projectRouteToFilePath,
106
+ renderRedirectDocument,
107
+ } from './internal/route.js';
108
+ import { buildSearchIndex } from './internal/search-index.js';
109
+ import { resolveSocialImageUrl, twitterCardType } from './internal/seo.js';
110
+ import { buildSitemap } from './internal/sitemap.js';
111
+ import {
112
+ defaultThemeStylesheetLinksHtml,
113
+ loadThemeAssets,
114
+ } from './internal/theme-assets.js';
115
+ import { resolveTextDirection } from './internal/text-direction.js';
116
+ import {
117
+ renderFooter,
118
+ renderFooterNavigation,
119
+ renderHeader,
120
+ renderPageBody,
121
+ renderPrimaryNavigation,
122
+ } from './internal/skeleton.js';
123
+ import {
124
+ collectIncludedSources,
125
+ compareUtf8Bytes,
126
+ } from './internal/source-inventory.js';
127
+ import { generateUuidV7 } from './internal/uuid.js';
128
+ import { buildArtifactManifest } from './manifest.js';
129
+
130
+ /** @type {string} the un-joined route `sitemap.xml` is published at. */
131
+ const SITEMAP_ROUTE = '/sitemap.xml';
132
+ /** @type {string} the un-joined route the static search index is published
133
+ * at. */
134
+ const SEARCH_INDEX_ROUTE = '/search-index.json';
135
+
136
+ /** @type {string} */
137
+ const BUILD_INPUT_SCHEMA_ID = 'urn:gala:schema:build-input:2.0.0';
138
+ /** @type {string} */
139
+ const ARTIFACT_MANIFEST_SCHEMA_ID = 'urn:gala:schema:artifact-manifest:2.0.0';
140
+
141
+ /**
142
+ * The published npm package name for this renderer.
143
+ *
144
+ * @type {string}
145
+ */
146
+ export const TEMPLATE_PACKAGE_NAME = '@rathnasgala2/template';
147
+
148
+ /**
149
+ * The renderer's published major.minor.patch version, kept in sync with
150
+ * `package.json` by the packaging test in `test/pinned-versions.test.js`.
151
+ *
152
+ * @type {string}
153
+ */
154
+ export const TEMPLATE_PACKAGE_VERSION = '2.0.0';
155
+
156
+ /**
157
+ * Validate the caller-supplied render options, independent of build-input
158
+ * validity. Directories must be supplied as absolute paths and must be
159
+ * distinct, so this renderer never has to guess where "outside the
160
+ * declared input/output/work directories" begins.
161
+ *
162
+ * @param {import('../../types/index.d.ts').RenderOptions} options caller
163
+ * options
164
+ * @returns {void}
165
+ */
166
+ function assertOptions(options) {
167
+ if (!options || typeof options !== 'object') {
168
+ throw new RenderOptionsError('render options are required');
169
+ }
170
+ for (const key of /** @type {const} */ ([
171
+ 'outputDirectory',
172
+ 'workDirectory',
173
+ 'sourceDirectory',
174
+ ])) {
175
+ const value = options[key];
176
+ if (typeof value !== 'string' || value.length === 0) {
177
+ throw new RenderOptionsError(`options.${key} must be a non-empty string`);
178
+ }
179
+ if (!path.isAbsolute(value)) {
180
+ throw new RenderOptionsError(`options.${key} must be an absolute path`);
181
+ }
182
+ }
183
+ // S2-T12: an optional caller-mounted, read-only extracted theme package
184
+ // directory (`internal/theme-assets.js`). Absent entirely by default
185
+ // (every pre-S2-T12 caller, including this repository's own test suite)
186
+ // so this addition is purely additive.
187
+ if (options.themeDirectory !== undefined) {
188
+ if (
189
+ typeof options.themeDirectory !== 'string' ||
190
+ options.themeDirectory.length === 0 ||
191
+ !path.isAbsolute(options.themeDirectory)
192
+ ) {
193
+ throw new RenderOptionsError(
194
+ 'options.themeDirectory, when supplied, must be a non-empty absolute path',
195
+ );
196
+ }
197
+ }
198
+ const resolvedDirectories = [
199
+ path.resolve(options.outputDirectory),
200
+ path.resolve(options.workDirectory),
201
+ path.resolve(options.sourceDirectory),
202
+ ...(options.themeDirectory ? [path.resolve(options.themeDirectory)] : []),
203
+ ];
204
+ if (new Set(resolvedDirectories).size !== resolvedDirectories.length) {
205
+ throw new RenderOptionsError(
206
+ 'options.outputDirectory, options.workDirectory, options.sourceDirectory ' +
207
+ 'and (when supplied) options.themeDirectory must all be distinct',
208
+ );
209
+ }
210
+ if (
211
+ options.routeNormalizationProfile !== undefined &&
212
+ options.routeNormalizationProfile !== 'directory-index' &&
213
+ options.routeNormalizationProfile !== 'explicit-file'
214
+ ) {
215
+ throw new RenderOptionsError(
216
+ "options.routeNormalizationProfile must be 'directory-index' or 'explicit-file'",
217
+ );
218
+ }
219
+ // Checked in full here, before any directory is created or Eleventy ever
220
+ // runs, so an incomplete provenance bundle fails closed exactly like an
221
+ // invalid build-input does: nothing is written.
222
+ assertProvenance(options.provenance);
223
+ }
224
+
225
+ /**
226
+ * Verify every `renderableBody.renderPolicy` in the publication profile,
227
+ * footer card and every content record byte-equals the currently published
228
+ * render-policy identity, and that no module or placement value leaked
229
+ * through (belt-and-suspenders on top of the `build-input:2.0.0` schema's
230
+ * own `modules: {}` / `placements: []` closure). Fails closed before any
231
+ * directory is created or Eleventy ever runs (DEC-097 section 5: "An absent
232
+ * policy file, wrong path/name/version/digest, mixed identity between
233
+ * records or body produced under a different policy rejects before build").
234
+ *
235
+ * @param {import('../../types/index.d.ts').NormalizedBuildInput} validatedInput
236
+ * the schema-validated build input
237
+ * @returns {Promise<void>} resolves once every reference is verified
238
+ */
239
+ async function assertRenderPolicyCompliance(validatedInput) {
240
+ if (
241
+ Object.keys(validatedInput.modules).length !== 0 ||
242
+ validatedInput.placements.length !== 0
243
+ ) {
244
+ throw new RenderPolicyViolationError(
245
+ 'build-input.modules and build-input.placements must both be empty in S2',
246
+ );
247
+ }
248
+
249
+ const identity = await computeRenderPolicyIdentity();
250
+
251
+ if (validatedInput.publication.profile) {
252
+ const { body } = validatedInput.publication.profile;
253
+ assertRenderPolicyIdentity(
254
+ body.renderPolicy,
255
+ identity,
256
+ 'publication.profile.body',
257
+ );
258
+ assertPolicyConformantHtml(
259
+ body.body,
260
+ body.bodyDigest,
261
+ 'publication.profile.body',
262
+ );
263
+ }
264
+ if (validatedInput.publication.footerCard) {
265
+ const { body } = validatedInput.publication.footerCard;
266
+ assertRenderPolicyIdentity(
267
+ body.renderPolicy,
268
+ identity,
269
+ 'publication.footerCard.body',
270
+ );
271
+ assertPolicyConformantHtml(
272
+ body.body,
273
+ body.bodyDigest,
274
+ 'publication.footerCard.body',
275
+ );
276
+ }
277
+ for (const record of validatedInput.content) {
278
+ const location = `content[${record.frontmatter.id}]`;
279
+ assertRenderPolicyIdentity(record.renderPolicy, identity, location);
280
+ assertPolicyConformantHtml(record.body, record.bodyDigest, location);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Render one validated `build-input:2.0.0` instance into a candidate output
286
+ * directory plus a closed `artifact-manifest:2.0.0` instance.
287
+ *
288
+ * @param {unknown} buildInput a candidate `build-input:2.0.0` document
289
+ * @param {import('../../types/index.d.ts').RenderOptions} options adapter
290
+ * options: output/work directories, route normalization profile and the
291
+ * provenance facts this renderer cannot derive from `build-input` alone
292
+ * @returns {Promise<{outputDirectory: string, manifest: Record<string, unknown>}>}
293
+ * the absolute candidate output directory and the assembled manifest
294
+ */
295
+ export async function renderPublication(buildInput, options) {
296
+ const inputValidation = validateGalaDocument(
297
+ BUILD_INPUT_SCHEMA_ID,
298
+ buildInput,
299
+ );
300
+ if (!inputValidation.valid) {
301
+ throw new BuildInputValidationError(inputValidation.diagnostics);
302
+ }
303
+ const validatedInput =
304
+ /** @type {import('../../types/index.d.ts').NormalizedBuildInput} */ (
305
+ buildInput
306
+ );
307
+
308
+ await assertRenderPolicyCompliance(validatedInput);
309
+ assertOptions(options);
310
+ const routeProfile = options.routeNormalizationProfile ?? 'directory-index';
311
+
312
+ const outputDirectory = path.resolve(options.outputDirectory);
313
+ const workDirectory = path.resolve(options.workDirectory);
314
+ await mkdir(outputDirectory, { recursive: true });
315
+ await mkdir(workDirectory, { recursive: true });
316
+
317
+ // The site-wide message catalog for core chrome (skip link, navigation,
318
+ // breadcrumb, pagination and footer labels). Every page's own <html lang>
319
+ // attribute still reflects that individual page's own language (see
320
+ // `page.language` below); only the *chrome strings surrounding it* are
321
+ // rendered in one consistent language, matching a reader's expectation
322
+ // that navigation/footer text does not flip language from page to page
323
+ // (task packet S2-T06 scoped decision; see `internal/messages.js`).
324
+ const siteMessages = getMessages(validatedInput.publication.defaultLanguage);
325
+ const homeRoute = joinBasePathAndRoute(validatedInput.basePath, '/');
326
+
327
+ const appearanceControlHtml = renderAppearanceControl({
328
+ messages: siteMessages,
329
+ });
330
+ const headerHtml = renderHeader({
331
+ homeRoute,
332
+ publicationName: validatedInput.publication.title,
333
+ logo: undefined,
334
+ appearanceControlHtml,
335
+ });
336
+ const footerNavHtml = renderFooterNavigation({
337
+ items: validatedInput.navigation.footerItems,
338
+ messages: siteMessages,
339
+ });
340
+ const footerCard = validatedInput.publication.footerCard;
341
+ // Already verified render-policy-conformant HTML by
342
+ // assertRenderPolicyCompliance's assertPolicyConformantHtml call above —
343
+ // inserted as-is, never re-parsed as Markdown.
344
+ const footerProfileHtml = footerCard?.enabled ? footerCard.body.body : '';
345
+ const footerHtml = renderFooter({
346
+ publicationName: validatedInput.publication.title,
347
+ footerNavHtml,
348
+ footerProfileHtml,
349
+ copyrightText: undefined,
350
+ messages: siteMessages,
351
+ });
352
+
353
+ // Run before page assembly, not after (task packet S2-T08): resolving
354
+ // each page's own `og:image`/`twitter:image` needs the S2-T05 media
355
+ // pipeline's own finished `assets` list (only it knows which derivative
356
+ // file extension a given source image's `sourceDigest` produced — see
357
+ // `internal/seo.js`). This only moves the in-memory computation earlier;
358
+ // `mediaFiles`' actual bytes are still written to `outputDirectory` only
359
+ // after the route listing below (unchanged from S2-T05), so
360
+ // `outputDirectory/assets/media/` still can never be mistaken for an
361
+ // Eleventy-rendered HTML route.
362
+ const { assets: mediaAssets, files: mediaFiles } = await processMedia(
363
+ validatedInput,
364
+ { sourceDirectory: path.resolve(options.sourceDirectory) },
365
+ );
366
+
367
+ // S2-T08: the site-wide feeds, resolved once so every page's feed-
368
+ // discovery `<link>` tags and the feed documents themselves agree on the
369
+ // exact same self URLs.
370
+ const feeds = buildFeeds(validatedInput);
371
+ const siteName = validatedInput.publication.title;
372
+ const appearanceScriptHrefValue = appearanceBootstrapScriptHref(
373
+ validatedInput.basePath,
374
+ );
375
+
376
+ // S2-T12: the selected theme package's copied stylesheets/passive assets
377
+ // and the ordered `<link>` markup every generated page's `<head>` inserts.
378
+ // Run before page assembly (same reasoning as the media pipeline above):
379
+ // every page's `data.themeStylesheetLinksHtml` needs the same one
380
+ // already-rendered fragment.
381
+ const themeAssetsResult = options.themeDirectory
382
+ ? await loadThemeAssets({
383
+ themeDirectory: path.resolve(options.themeDirectory),
384
+ basePath: validatedInput.basePath,
385
+ })
386
+ : undefined;
387
+ const themeStylesheetLinksHtml = themeAssetsResult
388
+ ? themeAssetsResult.linkTagsHtml
389
+ : defaultThemeStylesheetLinksHtml(validatedInput.basePath);
390
+
391
+ /** @type {{virtualPath: string, content: string, permalink: string, layout?: string, data?: Record<string, unknown>}[]} */
392
+ const contentPages = [];
393
+ /** @type {{joinedSource: string, joinedTarget: string, filePath: string}[]} */
394
+ const redirectProjections = [];
395
+
396
+ const generatedPages = buildGeneratedPages(validatedInput);
397
+ generatedPages.forEach((page, index) => {
398
+ const joinedRoute = joinBasePathAndRoute(
399
+ validatedInput.basePath,
400
+ page.route,
401
+ );
402
+ const navHtml = renderPrimaryNavigation({
403
+ items: validatedInput.navigation.items,
404
+ currentRoute: joinedRoute,
405
+ messages: siteMessages,
406
+ });
407
+ const bodyHtml = renderPageBody({
408
+ messages: siteMessages,
409
+ headerHtml,
410
+ navHtml,
411
+ footerHtml,
412
+ breadcrumbHtml: page.breadcrumbHtml,
413
+ mainHtml: page.bodyHtml,
414
+ });
415
+ const ogImage = resolveSocialImageUrl({
416
+ mediaAssets,
417
+ reference: page.socialImageRef,
418
+ baseUrl: validatedInput.baseUrl,
419
+ basePath: validatedInput.basePath,
420
+ });
421
+ contentPages.push({
422
+ virtualPath: `pages/${page.kind}-${index}.html`,
423
+ content: bodyHtml,
424
+ permalink: projectRouteToFilePath(joinedRoute, routeProfile),
425
+ layout: 'skeleton.njk',
426
+ data: {
427
+ title: page.title,
428
+ lang: page.language,
429
+ dir: page.direction,
430
+ pageKind: page.kind,
431
+ description: page.description,
432
+ robotsContent: page.robotsContent,
433
+ canonicalUrl: new URL(joinedRoute, validatedInput.baseUrl).toString(),
434
+ siteName,
435
+ ogType: page.ogType ?? 'website',
436
+ ogImage,
437
+ twitterCard: twitterCardType(Boolean(ogImage)),
438
+ atomFeedUrl: feeds.atomSelfUrl,
439
+ rssFeedUrl: feeds.rssSelfUrl,
440
+ appearanceScriptHref: appearanceScriptHrefValue,
441
+ themeStylesheetLinksHtml,
442
+ },
443
+ });
444
+ });
445
+
446
+ for (const record of validatedInput.content) {
447
+ const joined = joinBasePathAndRoute(
448
+ validatedInput.basePath,
449
+ contentRoute(record.frontmatter),
450
+ );
451
+ for (const sourceRoute of record.frontmatter.redirects ?? []) {
452
+ const joinedSource = joinBasePathAndRoute(
453
+ validatedInput.basePath,
454
+ sourceRoute,
455
+ );
456
+ redirectProjections.push({
457
+ joinedSource,
458
+ joinedTarget: joined,
459
+ filePath: projectRouteToFilePath(joinedSource, routeProfile),
460
+ });
461
+ }
462
+ }
463
+
464
+ const redirectPages = redirectProjections.map((projection, index) => ({
465
+ virtualPath: `redirects/${index}.html`,
466
+ content: renderRedirectDocument(projection.joinedTarget),
467
+ permalink: projection.filePath,
468
+ }));
469
+
470
+ if (contentPages.length === 0 && redirectPages.length === 0) {
471
+ throw new RenderOptionsError(
472
+ 'build input selects no publication profile and no content; there is nothing to render',
473
+ );
474
+ }
475
+
476
+ // S2-T08: the one ordinary generated `404.html` error page, using the
477
+ // S2-T06 `error` page kind's own pure body function. Pushed after the
478
+ // guard above so the guard's own "is there anything authored to render"
479
+ // check is unaffected by this always-present synthetic page.
480
+ const errorNavHtml = renderPrimaryNavigation({
481
+ items: validatedInput.navigation.items,
482
+ currentRoute: undefined,
483
+ messages: siteMessages,
484
+ });
485
+ const errorBodyHtml = renderPageBody({
486
+ messages: siteMessages,
487
+ headerHtml,
488
+ navHtml: errorNavHtml,
489
+ footerHtml,
490
+ mainHtml: renderErrorPageBody({ messages: siteMessages, homeRoute }),
491
+ });
492
+ const errorFilePath = errorDocumentPath(validatedInput.basePath);
493
+ contentPages.push({
494
+ virtualPath: 'pages/error-404.html',
495
+ content: errorBodyHtml,
496
+ permalink: errorFilePath,
497
+ layout: 'skeleton.njk',
498
+ data: {
499
+ title: /** @type {string} */ (siteMessages.errorPageHeading),
500
+ lang: validatedInput.publication.defaultLanguage,
501
+ dir: resolveTextDirection(validatedInput.publication.defaultLanguage),
502
+ pageKind: 'error',
503
+ // No `canonicalUrl`/`ogImage`: an error page has no real resource to
504
+ // canonicalize or illustrate.
505
+ robotsContent: 'noindex, follow',
506
+ siteName,
507
+ ogType: 'website',
508
+ twitterCard: twitterCardType(false),
509
+ atomFeedUrl: feeds.atomSelfUrl,
510
+ rssFeedUrl: feeds.rssSelfUrl,
511
+ appearanceScriptHref: appearanceScriptHrefValue,
512
+ themeStylesheetLinksHtml,
513
+ },
514
+ });
515
+
516
+ // S2-T08: feeds, sitemap and the static search index. Each is a fixed,
517
+ // literal-extension file — never route-normalized like an ordinary
518
+ // content route (`internal/route.js#projectFixedAssetPath`) — and each
519
+ // bypasses the shared HTML skeleton layout entirely (no `layout` field,
520
+ // exactly like a generated redirect document above): these are raw
521
+ // XML/JSON bytes, not HTML pages.
522
+ const atomFilePath = projectFixedAssetPath(
523
+ validatedInput.basePath,
524
+ ATOM_FEED_ROUTE,
525
+ );
526
+ const rssFilePath = projectFixedAssetPath(
527
+ validatedInput.basePath,
528
+ RSS_FEED_ROUTE,
529
+ );
530
+ const sitemapFilePath = projectFixedAssetPath(
531
+ validatedInput.basePath,
532
+ SITEMAP_ROUTE,
533
+ );
534
+ const searchIndexFilePath = projectFixedAssetPath(
535
+ validatedInput.basePath,
536
+ SEARCH_INDEX_ROUTE,
537
+ );
538
+ const sitemapXml = buildSitemap({
539
+ generatedPages,
540
+ basePath: validatedInput.basePath,
541
+ baseUrl: validatedInput.baseUrl,
542
+ });
543
+ const searchIndexJson = buildSearchIndex(validatedInput);
544
+ const assetPages = [
545
+ {
546
+ virtualPath: 'feeds/atom.html',
547
+ content: feeds.atomXml,
548
+ permalink: atomFilePath,
549
+ },
550
+ {
551
+ virtualPath: 'feeds/rss.html',
552
+ content: feeds.rssXml,
553
+ permalink: rssFilePath,
554
+ },
555
+ {
556
+ virtualPath: 'sitemap.html',
557
+ content: sitemapXml,
558
+ permalink: sitemapFilePath,
559
+ },
560
+ {
561
+ virtualPath: 'search-index.html',
562
+ content: searchIndexJson,
563
+ permalink: searchIndexFilePath,
564
+ },
565
+ ];
566
+
567
+ await renderPagesWithEleventy({
568
+ pages: [...contentPages, ...redirectPages, ...assetPages],
569
+ inputDirectory: path.join(workDirectory, 'input'),
570
+ outputDirectory,
571
+ });
572
+
573
+ // S2-T08: the exact `routeClass`/`mediaType` this renderer's own generated
574
+ // non-HTML routes carry, keyed by their fixed output path. Every other
575
+ // route defaults to `'html'`/`text/html; charset=utf-8`, unchanged from
576
+ // S2-T03.
577
+ /** @type {Map<string, {routeClass: import('../../types/index.d.ts').ManifestRouteEntry['routeClass'], mediaType: string}>} */
578
+ const specialRoutes = new Map([
579
+ [
580
+ errorFilePath,
581
+ { routeClass: 'error', mediaType: 'text/html; charset=utf-8' },
582
+ ],
583
+ [
584
+ atomFilePath,
585
+ { routeClass: 'feed', mediaType: 'application/atom+xml; charset=utf-8' },
586
+ ],
587
+ [
588
+ rssFilePath,
589
+ { routeClass: 'feed', mediaType: 'application/rss+xml; charset=utf-8' },
590
+ ],
591
+ [
592
+ sitemapFilePath,
593
+ { routeClass: 'sitemap', mediaType: 'application/xml; charset=utf-8' },
594
+ ],
595
+ [
596
+ searchIndexFilePath,
597
+ { routeClass: 'asset', mediaType: 'application/json; charset=utf-8' },
598
+ ],
599
+ ]);
600
+
601
+ const outputFilePaths = await listFilesSortedByUtf8Bytes(outputDirectory);
602
+ /** @type {import('../../types/index.d.ts').ManifestRouteEntry[]} */
603
+ const routes = [];
604
+ for (const filePath of outputFilePaths) {
605
+ const bytes = await readFile(path.join(outputDirectory, filePath));
606
+ const special = specialRoutes.get(filePath);
607
+ routes.push({
608
+ path: filePath,
609
+ mediaType: special?.mediaType ?? 'text/html; charset=utf-8',
610
+ byteLength: String(bytes.byteLength),
611
+ sha256: digestBytes(bytes),
612
+ routeClass: special?.routeClass ?? 'html',
613
+ interactionBearing: false,
614
+ });
615
+ }
616
+ routes.sort((a, b) => compareUtf8Bytes(a.path, b.path));
617
+
618
+ const routeByPath = new Map(routes.map((route) => [route.path, route]));
619
+ const redirects = redirectProjections
620
+ .map((projection) => {
621
+ const backingRoute = routeByPath.get(projection.filePath);
622
+ if (!backingRoute) {
623
+ throw new RenderOptionsError(
624
+ `internal: no rendered file at redirect backing path ${projection.filePath}`,
625
+ );
626
+ }
627
+ return /** @type {import('../../types/index.d.ts').ManifestRedirectEntry} */ ({
628
+ sourceRoute: projection.joinedSource,
629
+ targetRoute: projection.joinedTarget,
630
+ status: /** @type {200} */ (200),
631
+ backingPath: projection.filePath,
632
+ sha256: backingRoute.sha256,
633
+ });
634
+ })
635
+ .sort((a, b) => compareUtf8Bytes(a.sourceRoute, b.sourceRoute));
636
+
637
+ // The media pipeline itself ran earlier (see `mediaAssets` above, needed
638
+ // for SEO image resolution, which matches against these same *unprefixed*
639
+ // paths); only its actual output *bytes* are written here, after the
640
+ // routes listing above and never before, so
641
+ // `outputDirectory/<basePath>/assets/media/` still can never be mistaken
642
+ // for an Eleventy-rendered HTML route.
643
+ //
644
+ // basePath fix (independent-review finding B2, LOCAL-7 follow-up): the
645
+ // physical file and its manifest `path` are now joined with `basePath`
646
+ // through the same `projectFixedAssetPath` every other fixed-name
647
+ // generated asset (404, feeds, sitemap, search index) already uses, so
648
+ // the local-directory adapter — which serves a manifest `path` verbatim —
649
+ // finds this file at exactly the `<basePath>`-prefixed location
650
+ // `internal/seo.js`'s already-`basePath`-joined `og:image`/`twitter:image`
651
+ // href points at.
652
+ const joinedMediaAssets = mediaAssets.map((asset) => ({
653
+ ...asset,
654
+ path: projectFixedAssetPath(validatedInput.basePath, `/${asset.path}`),
655
+ }));
656
+ for (const file of mediaFiles) {
657
+ const joinedPath = projectFixedAssetPath(
658
+ validatedInput.basePath,
659
+ `/${file.path}`,
660
+ );
661
+ const destination = path.join(outputDirectory, joinedPath);
662
+ await mkdir(path.dirname(destination), { recursive: true });
663
+ await writeFile(destination, file.bytes);
664
+ }
665
+
666
+ // S2-T12: the selected theme package's stylesheet/passive-asset bytes,
667
+ // written for the same reason and at the same point as the media pipeline
668
+ // output immediately above (`themeAssetsResult` is `undefined` when no
669
+ // `options.themeDirectory` was supplied, in which case there is nothing to
670
+ // write — the fallback `defaultThemeStylesheetLinksHtml` link above points
671
+ // at a file this renderer does not itself produce).
672
+ if (themeAssetsResult) {
673
+ for (const file of themeAssetsResult.files) {
674
+ const destination = path.join(outputDirectory, file.path);
675
+ await mkdir(path.dirname(destination), { recursive: true });
676
+ await writeFile(destination, file.bytes);
677
+ }
678
+ }
679
+
680
+ // S2-T07's one deterministic pre-paint appearance-controller bootstrap
681
+ // script (brief S2 section 3: "the appearance controller is the only
682
+ // browser bootstrap"). Written after the routes listing above for the same
683
+ // reason as the media pipeline output immediately above: it must never be
684
+ // mistaken for an Eleventy-rendered HTML route. Its bytes are a pure
685
+ // function of `internal/appearance/contract.js`'s own fixed constants, so
686
+ // this asset row is identical on every build regardless of build input.
687
+ const appearanceScriptBytes = Buffer.from(
688
+ APPEARANCE_BOOTSTRAP_SCRIPT_SOURCE,
689
+ 'utf8',
690
+ );
691
+ // basePath fix (independent-review finding B2): joined the exact same way
692
+ // `appearanceScriptHrefValue` above was, so the `<script src>` this
693
+ // renderer emits and the physical file/manifest path it writes can never
694
+ // drift apart.
695
+ const joinedAppearanceScriptPath = projectFixedAssetPath(
696
+ validatedInput.basePath,
697
+ `/${APPEARANCE_BOOTSTRAP_SCRIPT_PATH}`,
698
+ );
699
+ const appearanceScriptDestination = path.join(
700
+ outputDirectory,
701
+ joinedAppearanceScriptPath,
702
+ );
703
+ await mkdir(path.dirname(appearanceScriptDestination), { recursive: true });
704
+ await writeFile(appearanceScriptDestination, appearanceScriptBytes);
705
+ /** @type {import('../../types/index.d.ts').ManifestAssetEntry} */
706
+ const appearanceScriptAsset = {
707
+ path: joinedAppearanceScriptPath,
708
+ mediaType: APPEARANCE_BOOTSTRAP_SCRIPT_MEDIA_TYPE,
709
+ byteLength: String(appearanceScriptBytes.byteLength),
710
+ sha256: digestBytes(appearanceScriptBytes),
711
+ // Not content-addressed (a fixed, non-digest-bearing path), unlike the
712
+ // media pipeline's derivative paths above: `immutable` here documents
713
+ // cacheability, not path-collision safety.
714
+ immutable: false,
715
+ };
716
+
717
+ const assets = [
718
+ ...joinedMediaAssets,
719
+ appearanceScriptAsset,
720
+ ...(themeAssetsResult ? themeAssetsResult.assets : []),
721
+ ].sort((a, b) => compareUtf8Bytes(a.path, b.path));
722
+
723
+ const includedSources = collectIncludedSources(validatedInput);
724
+
725
+ const manifest = buildArtifactManifest({
726
+ buildInput: validatedInput,
727
+ routes,
728
+ assets,
729
+ redirects,
730
+ includedSources,
731
+ excludedInputs: options.provenance.additionalExcludedInputs ?? [],
732
+ provenance: options.provenance,
733
+ generateArtifactId: generateUuidV7,
734
+ });
735
+
736
+ const manifestValidation = validateGalaDocument(
737
+ ARTIFACT_MANIFEST_SCHEMA_ID,
738
+ manifest,
739
+ );
740
+ if (!manifestValidation.valid) {
741
+ throw new ArtifactManifestValidationError(manifestValidation.diagnostics);
742
+ }
743
+
744
+ return { outputDirectory, manifest };
745
+ }
746
+
747
+ export {
748
+ BuildInputValidationError,
749
+ RenderOptionsError,
750
+ ArtifactManifestValidationError,
751
+ MediaPipelineError,
752
+ RenderPolicyViolationError,
753
+ } from './errors.js';
754
+
755
+ export {
756
+ computeBodyDigest,
757
+ normalizeAuthoredMarkdown,
758
+ } from './internal/content-security.js';