@rathnasgala2/template 2.0.0 → 2.1.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 (42) hide show
  1. package/README.md +204 -137
  2. package/contracts/render-policy.jcs +1 -1
  3. package/contracts/theme-styling-contract.jcs +1 -1
  4. package/package.json +4 -5
  5. package/src/core/errors.js +13 -13
  6. package/src/core/index.js +102 -66
  7. package/src/core/internal/appearance/base-layer.js +180 -0
  8. package/src/core/internal/appearance/bootstrap-script.js +11 -11
  9. package/src/core/internal/appearance/contract.js +59 -44
  10. package/src/core/internal/appearance/controller-markup.js +7 -8
  11. package/src/core/internal/appearance/styling-contract.js +132 -29
  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 +52 -35
  15. package/src/core/internal/feeds.js +10 -13
  16. package/src/core/internal/fs-walk.js +2 -2
  17. package/src/core/internal/media/avif-probe.js +4 -4
  18. package/src/core/internal/media/font-policy.js +2 -2
  19. package/src/core/internal/media/jpeg-codec.js +0 -0
  20. package/src/core/internal/media/limits.js +32 -34
  21. package/src/core/internal/media/pipeline.js +4 -4
  22. package/src/core/internal/media/png-codec.js +7 -7
  23. package/src/core/internal/media/resize.js +2 -2
  24. package/src/core/internal/media/sniff.js +4 -4
  25. package/src/core/internal/media/theme-svg-sanitizer.js +278 -0
  26. package/src/core/internal/media/webp-probe.js +3 -3
  27. package/src/core/internal/messages.js +8 -8
  28. package/src/core/internal/page-kinds.js +46 -48
  29. package/src/core/internal/render-policy-content.js +57 -21
  30. package/src/core/internal/route-labels.js +5 -6
  31. package/src/core/internal/route.js +16 -20
  32. package/src/core/internal/search-index.js +12 -14
  33. package/src/core/internal/semver-range.js +84 -0
  34. package/src/core/internal/seo.js +21 -23
  35. package/src/core/internal/sitemap.js +6 -8
  36. package/src/core/internal/skeleton.js +42 -39
  37. package/src/core/internal/source-inventory.js +4 -5
  38. package/src/core/internal/text-direction.js +1 -2
  39. package/src/core/internal/theme-assets.js +294 -67
  40. package/src/core/internal/uuid.js +4 -5
  41. package/src/core/manifest.js +0 -0
  42. package/types/index.d.ts +22 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala2/template",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Deterministic Eleventy-backed static publication renderer for Galascribe portable repositories.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -25,12 +25,11 @@
25
25
  "contracts/",
26
26
  "types/",
27
27
  "LICENSE",
28
- "NOTICE",
29
28
  "README.md"
30
29
  ],
31
30
  "engines": {
32
- "node": "24.18.0",
33
- "npm": "11.16.0"
31
+ "node": ">=24.0.0",
32
+ "npm": ">=11.0.0"
34
33
  },
35
34
  "devEngines": {
36
35
  "runtime": {
@@ -61,7 +60,7 @@
61
60
  "test": "node --test",
62
61
  "licenses:generate": "node scripts/generate-license-inventory.mjs",
63
62
  "licenses:check": "node scripts/generate-license-inventory.mjs --check",
64
- "sbom:generate": "cyclonedx-npm --output-file sbom.cdx.json --output-format JSON --spec-version 1.6 && node scripts/apply-sbom-normalization.mjs",
63
+ "sbom:generate": "cyclonedx-npm --package-lock-only --output-file sbom.cdx.json --output-format JSON --spec-version 1.6 && node scripts/apply-sbom-normalization.mjs",
65
64
  "sbom:check": "node scripts/check-sbom-current.mjs",
66
65
  "workflows:check": "node scripts/check-workflow-pins.mjs",
67
66
  "workflows:drift": "node scripts/check-workflow-drift.mjs",
@@ -4,7 +4,7 @@
4
4
  * bare `Error`, so a caller (eventually `publish-kernel`) can distinguish
5
5
  * "the supplied build input is not schema-valid" from "the caller's own
6
6
  * adapter options are incomplete or unsafe" without string-matching a
7
- * message (brief S2 section 3: "fails closed on invalid input").
7
+ * message: this renderer fails closed on invalid input.
8
8
  */
9
9
 
10
10
  /**
@@ -43,14 +43,14 @@ export class RenderOptionsError extends Error {
43
43
  }
44
44
 
45
45
  /**
46
- * The media pipeline (S2-T05) rejected an image or font reference: an
47
- * unrecognized or malformed format, a decompression or resource ceiling
48
- * crossing, an unconditionally-rejected SVG image input, a font that is not
49
- * bounded WOFF2, or a source file whose bytes do not match its declared
46
+ * The media pipeline rejected an image or font reference: an unrecognized or
47
+ * malformed format, a decompression or resource ceiling crossing, an
48
+ * unconditionally-rejected SVG image input, a font that is not bounded
49
+ * WOFF2, or a source file whose bytes do not match its declared
50
50
  * `build-input` digest. Every one of these fails closed — before any
51
51
  * derivative is written and before rendering completes — never as a warning
52
- * or silent fallback (brief S2 section 3: "Reject decompression or resource
53
- * excess"; "SVG is never processed as author media").
52
+ * or silent fallback: decompression or resource excess is always rejected,
53
+ * and SVG is never processed as author media.
54
54
  */
55
55
  export class MediaPipelineError extends Error {
56
56
  /**
@@ -78,8 +78,8 @@ export class MediaPipelineError extends Error {
78
78
  * byte-equal the published render-policy identity, a non-empty module or
79
79
  * placement value, or sanitized output that still matched a forbidden
80
80
  * pattern (an adapter defect, never expected of admitted content). Rendering
81
- * never completes for the affected build (brief S2 section 3: "fail-closed
82
- * behaviour on any content the policy rejects").
81
+ * never completes for the affected build: this renderer has fail-closed
82
+ * behaviour on any content the policy rejects.
83
83
  */
84
84
  export class RenderPolicyViolationError extends Error {
85
85
  /**
@@ -95,14 +95,14 @@ export class RenderPolicyViolationError extends Error {
95
95
  * The selected theme package's `theme.json` (or its declared file set) did
96
96
  * not conform to the closed shape `internal/theme-assets.js` needs to copy
97
97
  * its stylesheets/passive assets and render its `<link>` elements: an
98
- * invalid `stylesheets` list shape, a `cssLayers` projection that does not
99
- * byte-equal the corresponding ordered layer names, or a missing declared
100
- * file (task packet S2-T12).
98
+ * invalid `stylesheets` list shape, a schema/contract-integrity violation
99
+ * (which also covers an inadmissible `cssLayers` projection — see
100
+ * `urn:gala:schema:theme-contract:2.0.0`), or a missing declared file.
101
101
  */
102
102
  export class ThemeAssetError extends Error {
103
103
  /**
104
104
  * @param {string} reasonCode a stable, machine-checkable reason code (for
105
- * example `THEME_STYLESHEETS_INVALID`, `THEME_CSS_LAYERS_INVALID`)
105
+ * example `THEME_STYLESHEETS_INVALID`, `THEME_CONTRACT_SCHEMA_INVALID`)
106
106
  * @param {string} message a human-readable description
107
107
  */
108
108
  constructor(reasonCode, message) {
package/src/core/index.js CHANGED
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * `@rathnasgala2/template` public entry point.
3
3
  *
4
- * This is S2-T03's Eleventy-confined renderer adapter, extended by S2-T05's
5
- * media pipeline: it consumes one validated
4
+ * This is the Eleventy-confined renderer adapter: it consumes one validated
6
5
  * `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).
6
+ * directory plus one `urn:gala:schema:artifact-manifest:2.0.0` instance.
9
7
  *
10
8
  * `renderPublication` is the only documented *rendering* entry point. It
11
9
  * fails closed: an invalid `build-input` is rejected with
@@ -14,7 +12,7 @@
14
12
  * Eleventy ever runs, and a rejected image or font is rejected with
15
13
  * {@link MediaPipelineError} before the manifest is assembled.
16
14
  *
17
- * `normalizeAuthoredMarkdown` (re-exported here from S2-T04's
15
+ * `normalizeAuthoredMarkdown` (re-exported here from
18
16
  * `internal/content-security.js`) is this package's second public entry
19
17
  * point: the upstream normalization step a `build-input:2.0.0` producer
20
18
  * (`publish-kernel`/`publish-action`) calls once per authored Markdown
@@ -25,38 +23,36 @@
25
23
  * reaches this renderer, and how `renderPublication` verifies that instead
26
24
  * of re-deriving it.
27
25
  *
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.
26
+ * `options.sourceDirectory` is the caller-mounted, read-only repository
27
+ * source tree `build-input`'s `resolvedFile` references point into.
28
+ * `processMedia` runs after the Eleventy route listing is already computed,
29
+ * so generated derivative files under `assets/media/` are never mistaken for
30
+ * an HTML route.
33
31
  *
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,
32
+ * Output-security (markdown/sanitizer/highlighter/CSP) and the media
33
+ * pipeline are layered on the same adapter, alongside the core semantic
34
+ * skeleton and navigation renderer (`internal/skeleton.js`) and every
35
+ * generated page kind (`internal/page-kinds.js`): publication profile,
38
36
  * 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.
37
+ * with one shared header/primary-navigation/footer chrome and, where
38
+ * required, a breadcrumb trail. This adapter also emits every authored
39
+ * static redirect and the feeds/search/sitemap outputs, shaped so that later
40
+ * work can extend the page-body pipeline without touching the confinement,
41
+ * route-projection or manifest-assembly mechanics here.
44
42
  *
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
43
+ * The Light/Dark/System appearance controller (`internal/appearance/`) adds:
44
+ * the server-rendered control in the header's `header-actions` slot
45
+ * (`controller-markup.js`), the one deterministic pre-paint bootstrap script
46
+ * this renderer ever emits, written into the candidate output directory and
47
+ * entered into the manifest as an ordinary `manifestAsset` row exactly like a
48
+ * media derivative (`bootstrap-script.js`), and the fixed `data-` attribute
49
+ * names, mode values and storage key every one of those pieces shares
50
+ * (`contract.js`) — the same contract the published theme styling catalog
54
51
  * targets.
55
52
  *
56
53
  * `src/core/` is the only admitted source root in this repository. No
57
54
  * `src/modules/` tree, module import edge, registration stub, module
58
55
  * configuration, module output or module runtime is ever added beside it
59
- * (brief S2 section 3; DEC-097 sections 2 and 5).
60
56
  */
61
57
 
62
58
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
@@ -70,6 +66,11 @@ import {
70
66
  appearanceBootstrapScriptHref,
71
67
  } from './internal/appearance/contract.js';
72
68
  import { APPEARANCE_BOOTSTRAP_SCRIPT_SOURCE } from './internal/appearance/bootstrap-script.js';
69
+ import {
70
+ GALA_BASE_STYLESHEET_MEDIA_TYPE,
71
+ GALA_BASE_STYLESHEET_PATH,
72
+ GALA_BASE_STYLESHEET_SOURCE,
73
+ } from './internal/appearance/base-layer.js';
73
74
  import { renderAppearanceControl } from './internal/appearance/controller-markup.js';
74
75
  import {
75
76
  ArtifactManifestValidationError,
@@ -151,7 +152,7 @@ export const TEMPLATE_PACKAGE_NAME = '@rathnasgala2/template';
151
152
  *
152
153
  * @type {string}
153
154
  */
154
- export const TEMPLATE_PACKAGE_VERSION = '2.0.0';
155
+ export const TEMPLATE_PACKAGE_VERSION = '2.1.0';
155
156
 
156
157
  /**
157
158
  * Validate the caller-supplied render options, independent of build-input
@@ -180,10 +181,9 @@ function assertOptions(options) {
180
181
  throw new RenderOptionsError(`options.${key} must be an absolute path`);
181
182
  }
182
183
  }
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.
184
+ // An optional caller-mounted, read-only extracted theme package directory
185
+ // (`internal/theme-assets.js`). Absent entirely by default, so this
186
+ // addition is purely additive.
187
187
  if (options.themeDirectory !== undefined) {
188
188
  if (
189
189
  typeof options.themeDirectory !== 'string' ||
@@ -228,9 +228,9 @@ function assertOptions(options) {
228
228
  * render-policy identity, and that no module or placement value leaked
229
229
  * through (belt-and-suspenders on top of the `build-input:2.0.0` schema's
230
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").
231
+ * directory is created or Eleventy ever runs: an absent policy file, wrong
232
+ * path/name/version/digest, mixed identity between records, or body
233
+ * produced under a different policy rejects before build.
234
234
  *
235
235
  * @param {import('../../types/index.d.ts').NormalizedBuildInput} validatedInput
236
236
  * the schema-validated build input
@@ -320,7 +320,7 @@ export async function renderPublication(buildInput, options) {
320
320
  // `page.language` below); only the *chrome strings surrounding it* are
321
321
  // rendered in one consistent language, matching a reader's expectation
322
322
  // that navigation/footer text does not flip language from page to page
323
- // (task packet S2-T06 scoped decision; see `internal/messages.js`).
323
+ // (see `internal/messages.js`).
324
324
  const siteMessages = getMessages(validatedInput.publication.defaultLanguage);
325
325
  const homeRoute = joinBasePathAndRoute(validatedInput.basePath, '/');
326
326
 
@@ -350,31 +350,38 @@ export async function renderPublication(buildInput, options) {
350
350
  messages: siteMessages,
351
351
  });
352
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.
353
+ // Run before page assembly, not after: resolving each page's own
354
+ // `og:image`/`twitter:image` needs the media pipeline's own finished
355
+ // `assets` list (only it knows which derivative file extension a given
356
+ // source image's `sourceDigest` produced — see `internal/seo.js`). This
357
+ // only moves the in-memory computation earlier; `mediaFiles`' actual
358
+ // bytes are still written to `outputDirectory` only after the route
359
+ // listing below, so `outputDirectory/assets/media/` still can never be
360
+ // mistaken for an Eleventy-rendered HTML route.
362
361
  const { assets: mediaAssets, files: mediaFiles } = await processMedia(
363
362
  validatedInput,
364
363
  { sourceDirectory: path.resolve(options.sourceDirectory) },
365
364
  );
366
365
 
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.
366
+ // The site-wide feeds, resolved once so every page's feed-discovery
367
+ // `<link>` tags and the feed documents themselves agree on the exact same
368
+ // self URLs.
370
369
  const feeds = buildFeeds(validatedInput);
371
370
  const siteName = validatedInput.publication.title;
372
371
  const appearanceScriptHrefValue = appearanceBootstrapScriptHref(
373
372
  validatedInput.basePath,
374
373
  );
374
+ // TPL-H3/TPL-M7: the template-owned gala-base stylesheet's own basePath-
375
+ // joined href, computed the same way as the appearance script href above
376
+ // so it can never drift from the physical file/manifest path written
377
+ // below.
378
+ const baseStylesheetHrefValue = `/${projectFixedAssetPath(
379
+ validatedInput.basePath,
380
+ `/${GALA_BASE_STYLESHEET_PATH}`,
381
+ )}`;
375
382
 
376
- // S2-T12: the selected theme package's copied stylesheets/passive assets
377
- // and the ordered `<link>` markup every generated page's `<head>` inserts.
383
+ // The selected theme package's copied stylesheets/passive assets and the
384
+ // ordered `<link>` markup every generated page's `<head>` inserts.
378
385
  // Run before page assembly (same reasoning as the media pipeline above):
379
386
  // every page's `data.themeStylesheetLinksHtml` needs the same one
380
387
  // already-rendered fragment.
@@ -438,6 +445,7 @@ export async function renderPublication(buildInput, options) {
438
445
  atomFeedUrl: feeds.atomSelfUrl,
439
446
  rssFeedUrl: feeds.rssSelfUrl,
440
447
  appearanceScriptHref: appearanceScriptHrefValue,
448
+ baseStylesheetHref: baseStylesheetHrefValue,
441
449
  themeStylesheetLinksHtml,
442
450
  },
443
451
  });
@@ -473,10 +481,10 @@ export async function renderPublication(buildInput, options) {
473
481
  );
474
482
  }
475
483
 
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.
484
+ // The one ordinary generated `404.html` error page, using the `error`
485
+ // page kind's own pure body function. Pushed after the guard above so the
486
+ // guard's own "is there anything authored to render" check is unaffected
487
+ // by this always-present synthetic page.
480
488
  const errorNavHtml = renderPrimaryNavigation({
481
489
  items: validatedInput.navigation.items,
482
490
  currentRoute: undefined,
@@ -509,11 +517,12 @@ export async function renderPublication(buildInput, options) {
509
517
  atomFeedUrl: feeds.atomSelfUrl,
510
518
  rssFeedUrl: feeds.rssSelfUrl,
511
519
  appearanceScriptHref: appearanceScriptHrefValue,
520
+ baseStylesheetHref: baseStylesheetHrefValue,
512
521
  themeStylesheetLinksHtml,
513
522
  },
514
523
  });
515
524
 
516
- // S2-T08: feeds, sitemap and the static search index. Each is a fixed,
525
+ // Feeds, sitemap and the static search index. Each is a fixed,
517
526
  // literal-extension file — never route-normalized like an ordinary
518
527
  // content route (`internal/route.js#projectFixedAssetPath`) — and each
519
528
  // bypasses the shared HTML skeleton layout entirely (no `layout` field,
@@ -570,10 +579,9 @@ export async function renderPublication(buildInput, options) {
570
579
  outputDirectory,
571
580
  });
572
581
 
573
- // S2-T08: the exact `routeClass`/`mediaType` this renderer's own generated
582
+ // The exact `routeClass`/`mediaType` this renderer's own generated
574
583
  // 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.
584
+ // route defaults to `'html'`/`text/html; charset=utf-8`.
577
585
  /** @type {Map<string, {routeClass: import('../../types/index.d.ts').ManifestRouteEntry['routeClass'], mediaType: string}>} */
578
586
  const specialRoutes = new Map([
579
587
  [
@@ -663,8 +671,8 @@ export async function renderPublication(buildInput, options) {
663
671
  await writeFile(destination, file.bytes);
664
672
  }
665
673
 
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
674
+ // The selected theme package's stylesheet/passive-asset bytes, written
675
+ // for the same reason and at the same point as the media pipeline
668
676
  // output immediately above (`themeAssetsResult` is `undefined` when no
669
677
  // `options.themeDirectory` was supplied, in which case there is nothing to
670
678
  // write — the fallback `defaultThemeStylesheetLinksHtml` link above points
@@ -677,10 +685,10 @@ export async function renderPublication(buildInput, options) {
677
685
  }
678
686
  }
679
687
 
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
688
+ // The one deterministic pre-paint appearance-controller bootstrap script:
689
+ // the appearance controller is the only browser bootstrap this renderer
690
+ // ever emits. Written after the routes listing above for the same reason
691
+ // as the media pipeline output immediately above: it must never be
684
692
  // mistaken for an Eleventy-rendered HTML route. Its bytes are a pure
685
693
  // function of `internal/appearance/contract.js`'s own fixed constants, so
686
694
  // this asset row is identical on every build regardless of build input.
@@ -714,9 +722,36 @@ export async function renderPublication(buildInput, options) {
714
722
  immutable: false,
715
723
  };
716
724
 
725
+ // TPL-H3/TPL-M7: the template-owned gala-base stylesheet, written and
726
+ // manifested for the same reason and at the same point as the appearance
727
+ // bootstrap script immediately above. Always emitted, independent of
728
+ // whether a theme was selected (`baseStylesheetHrefValue` above is
729
+ // likewise unconditional), because it is what makes contract-mandated
730
+ // chrome presentable even with no theme at all.
731
+ const baseStylesheetBytes = Buffer.from(GALA_BASE_STYLESHEET_SOURCE, 'utf8');
732
+ const joinedBaseStylesheetPath = projectFixedAssetPath(
733
+ validatedInput.basePath,
734
+ `/${GALA_BASE_STYLESHEET_PATH}`,
735
+ );
736
+ const baseStylesheetDestination = path.join(
737
+ outputDirectory,
738
+ joinedBaseStylesheetPath,
739
+ );
740
+ await mkdir(path.dirname(baseStylesheetDestination), { recursive: true });
741
+ await writeFile(baseStylesheetDestination, baseStylesheetBytes);
742
+ /** @type {import('../../types/index.d.ts').ManifestAssetEntry} */
743
+ const baseStylesheetAsset = {
744
+ path: joinedBaseStylesheetPath,
745
+ mediaType: GALA_BASE_STYLESHEET_MEDIA_TYPE,
746
+ byteLength: String(baseStylesheetBytes.byteLength),
747
+ sha256: digestBytes(baseStylesheetBytes),
748
+ immutable: false,
749
+ };
750
+
717
751
  const assets = [
718
752
  ...joinedMediaAssets,
719
753
  appearanceScriptAsset,
754
+ baseStylesheetAsset,
720
755
  ...(themeAssetsResult ? themeAssetsResult.assets : []),
721
756
  ].sort((a, b) => compareUtf8Bytes(a.path, b.path));
722
757
 
@@ -754,5 +789,6 @@ export {
754
789
 
755
790
  export {
756
791
  computeBodyDigest,
792
+ computeRenderPolicyIdentity,
757
793
  normalizeAuthoredMarkdown,
758
794
  } from './internal/content-security.js';
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The template-owned `gala-base` cascade layer (TPL-H3/TPL-M7): a
3
+ * stylesheet this renderer itself writes on every build, independent of
4
+ * whether a theme is selected, carrying sane defaults for every
5
+ * contract-mandated chrome element (`internal/skeleton.js`'s skip link,
6
+ * headings, code/table overflow, image sizing, responsive spacing) plus the
7
+ * one rule that makes the closed token catalog's
8
+ * `color-focus`/`focus-width` tokens actually paint a ring (THD-H1): a real
9
+ * `outline-style: solid` under `:focus-visible`.
10
+ *
11
+ * This file is not admitted through, or generated from, the theme package
12
+ * pipeline in `internal/theme-assets.js` — it is not subject to
13
+ * `check-css-hooks.mjs`'s closed selector-admission grammar and may use any
14
+ * selector, property or pseudo-class, including `:focus-visible`,
15
+ * `html`/`body` and functional colors, because it is authored by the
16
+ * template, not by a theme. Its bytes are a pure function of this module's
17
+ * own literal source, not of any per-publication or per-theme input, so a
18
+ * fixed, non-content-addressed output path carries no collision risk
19
+ * (mirrors `internal/appearance/bootstrap-script.js`'s own reasoning for its
20
+ * fixed path).
21
+ *
22
+ * Every custom property this file reads (`--gala-*`) is read through a
23
+ * fallback value, since a theme is optional (`internal/theme-assets.js`'s
24
+ * `defaultThemeStylesheetLinksHtml` fallback path never defines any
25
+ * `--gala-*` property at all) — this layer must still produce a sane,
26
+ * legible default with no theme selected whatsoever.
27
+ *
28
+ * Cascade-layer precedence (TPL-M7): this layer's own declarations always
29
+ * lose to a theme's `gala-tokens`/`gala-components`/`gala-utilities` layer
30
+ * declarations for the same property on the same element, because this
31
+ * stylesheet's own first line is the fixed `@layer gala-base, gala-tokens,
32
+ * gala-components, gala-utilities, gala-print;` order statement — layer
33
+ * order is declared once, explicitly, independent of `<link>` emission
34
+ * order, and independent of which optional stylesheets a theme ships (a
35
+ * theme that omits `utilities.css` never populates `gala-utilities`, which
36
+ * is harmless: an empty layer in a declared order does nothing). A bare
37
+ * `@layer name, name, ...;` statement is not itself a style rule, so it
38
+ * needs no `'unsafe-inline'`/nonce/hash exemption from the
39
+ * `style-src 'self'` CSP baseline the way an inline `<style>` element would
40
+ * (TPL-H4): it ships as the first bytes of this external, same-origin
41
+ * stylesheet, and `internal/eleventy-render.js` always emits this
42
+ * stylesheet's own `<link>` before every theme stylesheet `<link>`.
43
+ */
44
+
45
+ import { ORDERED_LAYERS } from './styling-contract.js';
46
+
47
+ /** @type {string} the candidate-output-directory-relative path this
48
+ * stylesheet is always written to (never content-addressed; see module
49
+ * documentation). */
50
+ export const GALA_BASE_STYLESHEET_PATH = 'assets/gala-base-v1.css';
51
+
52
+ /** @type {string} the `manifestAsset`/`manifestRoute` `mediaType` for this
53
+ * stylesheet. */
54
+ export const GALA_BASE_STYLESHEET_MEDIA_TYPE = 'text/css; charset=utf-8';
55
+
56
+ /**
57
+ * The exact `gala-base` layer source, byte-for-byte deterministic across
58
+ * builds (no interpolated per-build value of any kind). Its first line is
59
+ * the fixed layer-order statement every published page relies on (see
60
+ * module documentation).
61
+ *
62
+ * @type {string}
63
+ */
64
+ export const GALA_BASE_STYLESHEET_SOURCE = `@layer ${ORDERED_LAYERS.join(', ')};
65
+
66
+ @layer gala-base {
67
+ [data-gala-publication-root] {
68
+ box-sizing: border-box;
69
+ }
70
+ [data-gala-publication-root] *,
71
+ [data-gala-publication-root] *::before,
72
+ [data-gala-publication-root] *::after {
73
+ box-sizing: inherit;
74
+ }
75
+
76
+ /* Skip link: the first focusable element on every page, visually hidden
77
+ until it receives keyboard focus, so it is announced and reachable by
78
+ assistive technology and keyboard users without being the first visible
79
+ line of every published page. */
80
+ [data-gala-publication-root] a[href="#main-content"] {
81
+ position: absolute;
82
+ top: -9999px;
83
+ left: 0;
84
+ z-index: 9999;
85
+ padding: var(--gala-space-2, 0.5rem) var(--gala-space-4, 1rem);
86
+ background-color: var(--gala-color-surface, Canvas);
87
+ color: var(--gala-color-text, CanvasText);
88
+ }
89
+ [data-gala-publication-root] a[href="#main-content"]:focus-visible {
90
+ top: 0;
91
+ }
92
+
93
+ /* The one rule that makes the closed token catalog's color-focus/
94
+ focus-width tokens actually paint a ring: outline-style is never set by
95
+ the token catalog alone (outline-color/outline-width longhands paint
96
+ nothing without it), and this layer's own low cascade precedence means
97
+ any theme-declared outline-color/outline-width in gala-components still
98
+ wins for the same element. */
99
+ [data-gala-publication-root] :focus-visible {
100
+ outline-style: solid;
101
+ outline-width: var(--gala-focus-width, 2px);
102
+ outline-color: var(--gala-color-focus, Highlight);
103
+ outline-offset: 2px;
104
+ }
105
+
106
+ /* Type scale, expressed with clamp() so it scales between a phone and a
107
+ desktop viewport without a discrete breakpoint; no theme sets this. */
108
+ [data-gala-publication-root] h1 {
109
+ font-size: clamp(1.75rem, 1.4rem + 1.5vw, 2.5rem);
110
+ line-height: 1.2;
111
+ }
112
+ [data-gala-publication-root] h2 {
113
+ font-size: clamp(1.375rem, 1.2rem + 0.8vw, 1.875rem);
114
+ line-height: 1.25;
115
+ }
116
+ [data-gala-publication-root] h3 {
117
+ font-size: clamp(1.125rem, 1.05rem + 0.4vw, 1.375rem);
118
+ line-height: 1.3;
119
+ }
120
+ [data-gala-publication-root] h4,
121
+ [data-gala-publication-root] h5,
122
+ [data-gala-publication-root] h6 {
123
+ font-size: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
124
+ line-height: 1.35;
125
+ }
126
+ [data-gala-publication-root] p,
127
+ [data-gala-publication-root] li {
128
+ font-size: 1rem;
129
+ line-height: 1.6;
130
+ }
131
+ [data-gala-publication-root] code,
132
+ [data-gala-publication-root] pre {
133
+ /* Corrects the monospace optical-size mismatch against surrounding
134
+ proportional text at the same nominal font-size. */
135
+ font-size: 0.9em;
136
+ }
137
+
138
+ /* Overflow/wrap containment: constrains a fenced code block or an
139
+ unbroken long token in prose to the viewport, never the whole page. */
140
+ [data-gala-publication-root] pre {
141
+ overflow-x: auto;
142
+ }
143
+ [data-gala-publication-root] article {
144
+ overflow-wrap: anywhere;
145
+ }
146
+ [data-gala-publication-root] img {
147
+ max-width: 100%;
148
+ height: auto;
149
+ }
150
+
151
+ /* Responsive spacing: a small step at a tablet-and-up viewport width, so
152
+ header/main padding is not identical at 320px and 2560px. */
153
+ [data-gala-publication-root] header,
154
+ [data-gala-publication-root] footer {
155
+ padding: var(--gala-space-4, 1rem) var(--gala-space-4, 1rem);
156
+ }
157
+ [data-gala-publication-root] main {
158
+ padding: var(--gala-space-4, 1rem);
159
+ }
160
+ @media (min-width: 48rem) {
161
+ [data-gala-publication-root] header,
162
+ [data-gala-publication-root] footer {
163
+ padding: var(--gala-space-4, 1rem) var(--gala-space-6, 1.5rem);
164
+ }
165
+ [data-gala-publication-root] main {
166
+ padding: var(--gala-space-6, 1.5rem);
167
+ }
168
+ }
169
+
170
+ @media (prefers-reduced-motion: reduce) {
171
+ [data-gala-publication-root] *,
172
+ [data-gala-publication-root] *::before,
173
+ [data-gala-publication-root] *::after {
174
+ animation-duration: 0.001ms !important;
175
+ animation-iteration-count: 1 !important;
176
+ transition-duration: 0.001ms !important;
177
+ }
178
+ }
179
+ }
180
+ `;
@@ -1,15 +1,15 @@
1
1
  /**
2
- * The exact deterministic pre-paint appearance bootstrap script (brief S2
3
- * section 3: "a versioned deterministic pre-paint bootstrap owned by
4
- * `template`/`core` resolves local selection plus system preference and sets
2
+ * The exact deterministic pre-paint appearance bootstrap script: a versioned
3
+ * deterministic pre-paint bootstrap owned by `template`/`core` that resolves
4
+ * local selection plus system preference and sets
5
5
  * the root state before first themed paint. It is covered by the artifact
6
6
  * digest, is CSP-compatible under `script-src 'self'` (therefore an external
7
7
  * file, not inline), touches no network, account, content or unrelated
8
- * storage key, and cannot hide the document indefinitely").
8
+ * storage key, and cannot hide the document indefinitely.
9
9
  *
10
- * This is the one and only browser bootstrap S2 ever emits ("Module absence
11
- * and CSP equality": "the appearance controller is the only browser
12
- * bootstrap"). Every name and value it embeds is read from
10
+ * This is the one and only browser bootstrap this renderer ever emits: the
11
+ * appearance controller is the only browser
12
+ * bootstrap it ships. Every name and value it embeds is read from
13
13
  * `internal/appearance/contract.js`, the one module that owns them, so the
14
14
  * server-rendered control markup and this script can never drift apart on a
15
15
  * literal.
@@ -29,12 +29,12 @@
29
29
  * any earlier: the control has not been parsed yet at phase 1's own
30
30
  * execution point) and subscribes to live `prefers-color-scheme` changes,
31
31
  * updating the resolved attribute only while the current selection is
32
- * exactly `system` (brief: "an explicit `light`/`dark` choice is not
33
- * overridden by later system changes").
32
+ * exactly `system`: an explicit `light`/`dark` choice is not
33
+ * overridden by later system changes.
34
34
  *
35
35
  * Deliberately absent: no `document.documentElement.style` mutation of any
36
- * kind (in particular, no hide-until-ready visibility toggle — brief:
37
- * "cannot hide the document indefinitely" — this script never needs one,
36
+ * kind (in particular, no hide-until-ready visibility toggle — this script
37
+ * must never hide the document indefinitely, and never needs to,
38
38
  * since it only ever sets attributes, never inline style), no network
39
39
  * request, no cookie, no account/tracking identifier, no storage key other
40
40
  * than {@link APPEARANCE_STORAGE_KEY}.