@rathnasgala2/template 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -436,6 +436,18 @@ allowlist: raster formats are admitted directly, and SVG is admitted only after
436
436
  in scope and expected** (TPL-H6 decision): this sanitizer is what makes a
437
437
  theme-declared SVG icon mark safe to publish.
438
438
 
439
+ - **Publication palette and "Visitors see" (4.1.0).** Every build with a theme
440
+ derives the theme's palette-driven tokens from `appearance.tokens` (`{}` is
441
+ the theme's default palette) with `@rathnasgala2/schemas/palette`, checks the
442
+ derived values and every contrast pair in both modes (a failure refuses the
443
+ build with `APPEARANCE_PALETTE_CONTRAST`, naming the pair and mode), and
444
+ writes `assets/gala-palette-<12 hex of sha256>.css`, linked last with SRI and
445
+ listed in the manifest. A theme without a `palette` block is refused
446
+ (`THEME_PALETTE_MISSING`; malformed: `THEME_PALETTE_INVALID`).
447
+ `appearance.colorMode` reaches the page as `data-gala-color-mode-default` and
448
+ `data-gala-color-mode-allowed` on the root; a `light`/`dark` default is the
449
+ server-resolved mode, and a single allowed mode renders no toggle and sets
450
+ `color-scheme` to it.
439
451
  - **`baseUrl` path prefix (2.2.0).** `buildInput.baseUrl` may carry a path (a
440
452
  GitHub project site, `https://owner.github.io/g9/`); it is not origin-only.
441
453
  `internal/route.js#derivePublicBasePath(baseUrl, basePath)` joins that path
@@ -657,7 +669,7 @@ npm run audit # npm audit --audit-level=high
657
669
  ```
658
670
 
659
671
  Source development does not require a sibling schema checkout. The exact
660
- `@rathnasgala2/schemas@3.8.0` release is installed from the npm registry, and
672
+ `@rathnasgala2/schemas@3.10.0` release is installed from the npm registry, and
661
673
  the lockfile carries its registry integrity. The exact peer is the public
662
674
  consumer contract.
663
675
 
@@ -684,10 +696,10 @@ editions: they are a rendered content kind (see Editions).
684
696
 
685
697
  ## Consuming `@rathnasgala2/schemas`
686
698
 
687
- This source targets `@rathnasgala2/schemas@3.8.0` with an exact manifest pin.
699
+ This source targets `@rathnasgala2/schemas@3.10.0` with an exact manifest pin.
688
700
  Generation-4 development verifies against the schema sibling/packed candidate.
689
- The historical `package-lock.json` is not a 3.8.0 registry claim: it must be
690
- regenerated, together with the SBOM, from the authoritative published 3.8.0
701
+ The historical `package-lock.json` is not a 3.10.0 registry claim: it must be
702
+ regenerated, together with the SBOM, from the authoritative published 3.10.0
691
703
  artifact before release. The 2.8.0->2.11.0 convergence changes none of the four
692
704
  roots this renderer validates, for the same reason the 2.7.0-2.8.0 delta below
693
705
  did not. Only the `"."` export (`validateGalaDocument`, `GALA_SCHEMA_IDS`) is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rathnasgala2/template",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "Deterministic Eleventy-backed static publication renderer for Galascribe portable repositories.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -77,7 +77,7 @@
77
77
  "sanitize-html": "2.17.7"
78
78
  },
79
79
  "peerDependencies": {
80
- "@rathnasgala2/schemas": "3.8.0"
80
+ "@rathnasgala2/schemas": "3.10.0"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@cyclonedx/cyclonedx-npm": "6.0.1",
@@ -104,12 +104,16 @@ export class ThemeAssetError extends Error {
104
104
  * @param {string} reasonCode a stable, machine-checkable reason code (for
105
105
  * example `THEME_STYLESHEETS_INVALID`, `THEME_CONTRACT_SCHEMA_INVALID`)
106
106
  * @param {string} message a human-readable description
107
+ * @param {Record<string, unknown>} [detail] optional machine-readable
108
+ * detail (the palette refusals carry the failing pair and mode)
107
109
  */
108
- constructor(reasonCode, message) {
110
+ constructor(reasonCode, message, detail) {
109
111
  super(`${reasonCode}: ${message}`);
110
112
  this.name = 'ThemeAssetError';
111
113
  /** @type {string} */
112
114
  this.reasonCode = reasonCode;
115
+ /** @type {Record<string, unknown> | undefined} */
116
+ this.detail = detail;
113
117
  }
114
118
  }
115
119
 
package/src/core/index.js CHANGED
@@ -77,6 +77,8 @@ import {
77
77
  GALA_BASE_STYLESHEET_SOURCE,
78
78
  } from './internal/appearance/base-layer.js';
79
79
  import { renderAppearanceControl } from './internal/appearance/controller-markup.js';
80
+ import { resolveColorModePresentation } from './internal/appearance/contract.js';
81
+ import { TEMPLATE_RENDERER_VERSION } from './internal/package-version.js';
80
82
  import {
81
83
  ArtifactManifestValidationError,
82
84
  BuildInputValidationError,
@@ -190,7 +192,7 @@ export const TEMPLATE_PACKAGE_NAME = '@rathnasgala2/template';
190
192
  *
191
193
  * @type {string}
192
194
  */
193
- export const TEMPLATE_PACKAGE_VERSION = '4.0.0';
195
+ export const TEMPLATE_PACKAGE_VERSION = TEMPLATE_RENDERER_VERSION;
194
196
 
195
197
  /**
196
198
  * Validate the caller-supplied render options, independent of build-input
@@ -401,8 +403,21 @@ export async function renderPublication(buildInput, options) {
401
403
  themeDirectory: path.resolve(options.themeDirectory),
402
404
  basePath: validatedInput.basePath,
403
405
  publicBasePath,
406
+ appearanceTokens: validatedInput.appearance.tokens,
404
407
  })
405
408
  : undefined;
409
+ // "Visitors see": the author's colour-mode default and allowed modes.
410
+ const colorMode = resolveColorModePresentation(
411
+ /** @type {{default: string, allowed: readonly string[]} | undefined} */ (
412
+ validatedInput.appearance.colorMode
413
+ ),
414
+ );
415
+ const colorModeData = {
416
+ resolvedColorMode: colorMode.resolvedMode,
417
+ colorModeDefault: colorMode.defaultMode,
418
+ colorModeAllowed: colorMode.allowedModes.join(' '),
419
+ colorScheme: colorMode.colorScheme,
420
+ };
406
421
  const themeStylesheetLinksHtml = themeAssetsResult
407
422
  ? themeAssetsResult.linkTagsHtml
408
423
  : defaultThemeStylesheetLinksHtml(publicBasePath);
@@ -410,6 +425,7 @@ export async function renderPublication(buildInput, options) {
410
425
 
411
426
  const appearanceControlHtml = renderAppearanceControl({
412
427
  messages: siteMessages,
428
+ toggle: colorMode.toggle,
413
429
  });
414
430
 
415
431
  // Run before page assembly, not after: resolving each page's own images
@@ -636,6 +652,7 @@ export async function renderPublication(buildInput, options) {
636
652
  baseStylesheetHref: baseStylesheetHrefValue,
637
653
  themeStylesheetLinksHtml,
638
654
  composition,
655
+ ...colorModeData,
639
656
  },
640
657
  });
641
658
  });
@@ -709,6 +726,7 @@ export async function renderPublication(buildInput, options) {
709
726
  baseStylesheetHref: baseStylesheetHrefValue,
710
727
  themeStylesheetLinksHtml,
711
728
  composition,
729
+ ...colorModeData,
712
730
  },
713
731
  });
714
732
 
@@ -459,8 +459,8 @@ export const GALA_BASE_COMPONENT_CSS = `
459
459
  .g-card-media { margin: 0; aspect-ratio: 16 / 10; overflow: hidden; border-radius: var(--gala-radius-media); background: var(--gala-color-surface-raised); border-bottom: var(--gala-border-media-divider); max-width: 100%; }
460
460
  .g-card-media img { width: 100%; height: 100%; object-fit: cover; filter: var(--gala-media-filter); transition: transform 1s var(--gala-ease-standard), filter var(--gala-duration-slow) var(--gala-ease-standard); }
461
461
  .g-card:hover .g-card-media img { transform: scale(var(--gala-media-zoom)); filter: var(--gala-media-filter-hover); }
462
- .g-card-placeholder { display: grid; place-items: center; aspect-ratio: 16 / 10; width: 100%; overflow: hidden; border-radius: var(--gala-radius-media); background: var(--gala-paint-panel); color: var(--gala-color-panel-text); }
463
- .g-card-placeholder .g-icon { width: 2.25rem; height: 2.25rem; opacity: .85; }
462
+ .g-card-placeholder { display: grid; place-items: center; aspect-ratio: 16 / 10; width: 100%; overflow: hidden; border-radius: var(--gala-radius-media); background: var(--gala-color-surface-raised); color: var(--gala-color-text-muted); }
463
+ .g-card-placeholder .g-icon { width: 2.25rem; height: 2.25rem; opacity: .7; }
464
464
  .g-hero-media .g-card-placeholder { height: 100%; aspect-ratio: auto; border-radius: 0; }
465
465
  .g-card-body { display: flex; flex-direction: column; gap: .6rem; padding: var(--gala-card-pad); flex: 1; min-width: 0; }
466
466
  .g-card-top { display: flex; align-items: center; justify-content: space-between; gap: var(--gala-space-2); min-height: 1.6rem; }
@@ -41,6 +41,8 @@
41
41
 
42
42
  import { minifyScript } from './minify-script.js';
43
43
  import {
44
+ APPEARANCE_ALLOWED_ATTRIBUTE,
45
+ APPEARANCE_DEFAULT_ATTRIBUTE,
44
46
  APPEARANCE_MODE_DARK,
45
47
  APPEARANCE_MODE_LIGHT,
46
48
  APPEARANCE_MODE_SYSTEM,
@@ -65,6 +67,8 @@ const READABLE_SCRIPT_SOURCE = String.raw`(function () {
65
67
  var SAVED_KEY = ${JSON.stringify(SAVED_STORAGE_KEY)};
66
68
  var RESOLVED_ATTRIBUTE = ${JSON.stringify(APPEARANCE_RESOLVED_MODE_ATTRIBUTE)};
67
69
  var SELECTION_ATTRIBUTE = ${JSON.stringify(APPEARANCE_SELECTION_ATTRIBUTE)};
70
+ var DEFAULT_ATTRIBUTE = ${JSON.stringify(APPEARANCE_DEFAULT_ATTRIBUTE)};
71
+ var ALLOWED_ATTRIBUTE = ${JSON.stringify(APPEARANCE_ALLOWED_ATTRIBUTE)};
68
72
  var TOGGLE_ID = ${JSON.stringify(APPEARANCE_TOGGLE_ID)};
69
73
  var MODE_LIGHT = ${JSON.stringify(APPEARANCE_MODE_LIGHT)};
70
74
  var MODE_DARK = ${JSON.stringify(APPEARANCE_MODE_DARK)};
@@ -75,14 +79,32 @@ const READABLE_SCRIPT_SOURCE = String.raw`(function () {
75
79
  function isKnownMode(value) {
76
80
  return value === MODE_LIGHT || value === MODE_DARK || value === MODE_SYSTEM;
77
81
  }
82
+ function allowedModes() {
83
+ var listed = (root.getAttribute(ALLOWED_ATTRIBUTE) || '').split(' ');
84
+ var modes = [];
85
+ for (var i = 0; i < listed.length; i++) {
86
+ if (isKnownMode(listed[i])) modes.push(listed[i]);
87
+ }
88
+ return modes.length ? modes : [MODE_SYSTEM, MODE_LIGHT, MODE_DARK];
89
+ }
90
+ function isAllowed(value) {
91
+ return isKnownMode(value) && allowedModes().indexOf(value) !== -1;
92
+ }
93
+ // The author's "Visitors see" default, else system.
94
+ function authorDefault() {
95
+ var value = root.getAttribute(DEFAULT_ATTRIBUTE);
96
+ return isAllowed(value) ? value : MODE_SYSTEM;
97
+ }
98
+ // The reader's stored choice (when the author still allows it), else the
99
+ // author's default, else system.
78
100
  function readStoredSelection() {
79
101
  try {
80
102
  var value = window.localStorage.getItem(STORAGE_KEY);
81
- if (isKnownMode(value)) return value;
103
+ if (isAllowed(value)) return value;
82
104
  } catch (error) {
83
- // Storage blocked: fall back to MODE_SYSTEM below.
105
+ // Storage blocked: fall back to the author's default below.
84
106
  }
85
- return MODE_SYSTEM;
107
+ return authorDefault();
86
108
  }
87
109
  function writeStoredSelection(mode) {
88
110
  try {
@@ -230,7 +252,8 @@ const READABLE_SCRIPT_SOURCE = String.raw`(function () {
230
252
  function enhanceMode() {
231
253
  var button = document.getElementById(TOGGLE_ID);
232
254
  if (!button) return;
233
- var order = [MODE_SYSTEM, MODE_LIGHT, MODE_DARK];
255
+ var order = [MODE_SYSTEM, MODE_LIGHT, MODE_DARK].filter(isAllowed);
256
+ if (order.length < 2) return;
234
257
  var sync = function () {
235
258
  var label = button.getAttribute('data-label-' + readStoredSelection());
236
259
  if (label) {
@@ -67,6 +67,27 @@ export const APPEARANCE_RESOLVED_MODE_ATTRIBUTE =
67
67
  */
68
68
  export const APPEARANCE_SELECTION_ATTRIBUTE = 'data-gala-color-mode-selection';
69
69
 
70
+ /**
71
+ * Template-internal bookkeeping attribute carrying the author's "Visitors
72
+ * see" default (`appearance.colorMode.default`: `light`, `dark` or
73
+ * `system`), rendered on the root by the server. Not part of the published
74
+ * theme styling contract; no theme selector may target it.
75
+ *
76
+ * @type {string}
77
+ */
78
+ export const APPEARANCE_DEFAULT_ATTRIBUTE = 'data-gala-color-mode-default';
79
+
80
+ /**
81
+ * Template-internal bookkeeping attribute carrying the modes the author
82
+ * allows (`appearance.colorMode.allowed`), space separated, rendered on the
83
+ * root so the page script ignores a stored choice the author no longer
84
+ * offers and cycles only through allowed modes. Not part of the published
85
+ * theme styling contract.
86
+ *
87
+ * @type {string}
88
+ */
89
+ export const APPEARANCE_ALLOWED_ATTRIBUTE = 'data-gala-color-mode-allowed';
90
+
70
91
  /** @type {'light'} */
71
92
  export const APPEARANCE_MODE_LIGHT = 'light';
72
93
  /** @type {'dark'} */
@@ -117,6 +138,61 @@ export const APPEARANCE_RESOLVED_MODE_VALUES = Object.freeze([
117
138
  */
118
139
  export const APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE = APPEARANCE_MODE_LIGHT;
119
140
 
141
+ /**
142
+ * What the server renders for one publication's `appearance.colorMode`
143
+ * (the "Visitors see" setting).
144
+ *
145
+ * - `defaultMode`: the author's default, a root attribute.
146
+ * - `allowedModes`: the author's allowed list, in canonical order.
147
+ * - `resolvedMode`: the server-resolved mode. `light` and `dark` defaults
148
+ * are used as is; `system` keeps {@link APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE}
149
+ * (the script resolves it against the reader's system preference).
150
+ * - `colorScheme`: the `<meta name="color-scheme">` content. A single
151
+ * allowed `light` or `dark` is that mode alone; everything else is
152
+ * {@link COLOR_SCHEME_META_CONTENT}.
153
+ * - `toggle`: whether the colour-mode control is rendered (two or more
154
+ * allowed modes).
155
+ *
156
+ * @param {{default: string, allowed: readonly string[]} | undefined} colorMode
157
+ * `appearance.colorMode`; absent behaves as the former fixed behaviour
158
+ * (all three modes allowed, default `system`)
159
+ * @returns {{
160
+ * defaultMode: string,
161
+ * allowedModes: string[],
162
+ * resolvedMode: string,
163
+ * colorScheme: string,
164
+ * toggle: boolean,
165
+ * }} the render facts
166
+ */
167
+ export function resolveColorModePresentation(colorMode) {
168
+ const allowedModes = APPEARANCE_MODE_VALUES.filter((mode) =>
169
+ /** @type {readonly string[]} */ (
170
+ colorMode?.allowed ?? APPEARANCE_MODE_VALUES
171
+ ).includes(mode),
172
+ );
173
+ const defaultMode =
174
+ colorMode !== undefined &&
175
+ /** @type {readonly string[]} */ (allowedModes).includes(colorMode.default)
176
+ ? colorMode.default
177
+ : APPEARANCE_DEFAULT_MODE;
178
+ const resolvedMode =
179
+ defaultMode === APPEARANCE_MODE_LIGHT ||
180
+ defaultMode === APPEARANCE_MODE_DARK
181
+ ? defaultMode
182
+ : APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE;
183
+ const only = allowedModes.length === 1 ? allowedModes[0] : undefined;
184
+ return {
185
+ defaultMode,
186
+ allowedModes,
187
+ resolvedMode,
188
+ colorScheme:
189
+ only === APPEARANCE_MODE_LIGHT || only === APPEARANCE_MODE_DARK
190
+ ? only
191
+ : COLOR_SCHEME_META_CONTENT,
192
+ toggle: allowedModes.length > 1,
193
+ };
194
+ }
195
+
120
196
  /**
121
197
  * The initial reader default is `system`.
122
198
  *
@@ -30,9 +30,13 @@ import {
30
30
  * @param {object} options rendering options
31
31
  * @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
32
32
  * the resolved message catalog
33
- * @returns {string} the control markup
33
+ * @param {boolean} [options.toggle] whether the control is rendered; `false`
34
+ * when the author allows a single colour mode ("Visitors see" offers the
35
+ * reader nothing to choose), so no dead control is shown
36
+ * @returns {string} the control markup (empty when `toggle` is `false`)
34
37
  */
35
- export function renderAppearanceControl({ messages }) {
38
+ export function renderAppearanceControl({ messages, toggle = true }) {
39
+ if (!toggle) return '';
36
40
  const control = /** @type {string} */ (messages.appearanceControlLabel);
37
41
  /** @type {Record<string, string>} */
38
42
  const modeText = {
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The publication palette stylesheet: every build that supplies a theme
3
+ * package derives the theme's palette-driven tokens from the author's
4
+ * `appearance.tokens` (`{}` or absent selects the theme's own default
5
+ * palette), checks them, and writes them as one content-addressed
6
+ * `assets/gala-palette-<12 hex>.css` file linked after the theme's own
7
+ * stylesheets.
8
+ *
9
+ * All colour mathematics lives in `@rathnasgala2/schemas/palette`
10
+ * (`derivePalette`, `checkPalette`); this module never re-implements any of
11
+ * it. It only
12
+ *
13
+ * 1. derives the final token values,
14
+ * 2. re-checks every derived value against the theme-contract token grammar
15
+ * (the same closed schema every theme token already passes),
16
+ * 3. checks every contrast pair in both modes, and
17
+ * 4. serialises the bound tokens into four blocks inside
18
+ * `@layer gala-tokens`: the bare root (light), the
19
+ * `prefers-color-scheme: dark` root, and the two resolved-mode selectors,
20
+ * exactly the four blocks a theme's own `tokens.css` uses. The sheet is an
21
+ * external same-origin file, so the CSP stays free of inline styles.
22
+ *
23
+ * The bytes are a pure function of the theme's palette-relevant data and the
24
+ * author's picks, so sites on the same theme default share identical bytes.
25
+ */
26
+
27
+ import { createHash } from 'node:crypto';
28
+
29
+ import { validateGalaDocument } from '@rathnasgala2/schemas';
30
+ import {
31
+ checkPalette,
32
+ derivePalette,
33
+ PaletteError,
34
+ } from '@rathnasgala2/schemas/palette';
35
+
36
+ import { ThemeAssetError } from '../../errors.js';
37
+ import {
38
+ APPEARANCE_RESOLVED_MODE_ATTRIBUTE,
39
+ APPEARANCE_ROOT_ATTRIBUTE,
40
+ } from './contract.js';
41
+
42
+ /** @type {string} */
43
+ export const PALETTE_STYLESHEET_DIRECTORY = 'assets';
44
+ /** @type {string} */
45
+ export const PALETTE_STYLESHEET_PREFIX = 'gala-palette-';
46
+ /** @type {string} */
47
+ export const PALETTE_STYLESHEET_MEDIA_TYPE = 'text/css; charset=utf-8';
48
+ /** @type {number} the palette sheet's size ceiling (design D4). */
49
+ export const PALETTE_STYLESHEET_MAXIMUM_BYTES = 16384;
50
+
51
+ const THEME_CONTRACT_SCHEMA_ID = 'urn:gala:schema:theme-contract:2.0.0';
52
+
53
+ /**
54
+ * @param {Record<string, string>} declarations `--gala-<key>` -> value
55
+ * @param {string} indent line indent
56
+ * @returns {string} the declaration lines
57
+ */
58
+ function declarationLines(declarations, indent) {
59
+ return Object.entries(declarations)
60
+ .map(([key, value]) => `${indent}--gala-${key}: ${value};\n`)
61
+ .join('');
62
+ }
63
+
64
+ /**
65
+ * Serialise the bound tokens into the four-block sheet.
66
+ *
67
+ * @param {Record<string, {light: string, dark: string}>} tokens derived tokens
68
+ * @param {readonly string[]} bound the bound keys, in the theme's order
69
+ * @returns {string} the stylesheet text
70
+ */
71
+ function serialisePaletteSheet(tokens, bound) {
72
+ const keys = [...bound].sort((a, b) =>
73
+ Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')),
74
+ );
75
+ /** @type {Record<string, string>} */
76
+ const light = {};
77
+ /** @type {Record<string, string>} */
78
+ const dark = {};
79
+ for (const key of keys) {
80
+ light[key] = /** @type {{light: string, dark: string}} */ (
81
+ tokens[key]
82
+ ).light;
83
+ dark[key] = /** @type {{light: string, dark: string}} */ (tokens[key]).dark;
84
+ }
85
+ const root = `[${APPEARANCE_ROOT_ATTRIBUTE}]`;
86
+ const resolved = (/** @type {string} */ mode) =>
87
+ `[${APPEARANCE_ROOT_ATTRIBUTE}][${APPEARANCE_RESOLVED_MODE_ATTRIBUTE}="${mode}"]`;
88
+ return (
89
+ `@layer gala-tokens {\n` +
90
+ ` ${root} {\n${declarationLines(light, ' ')} }\n` +
91
+ ` @media (prefers-color-scheme: dark) {\n` +
92
+ ` ${root} {\n${declarationLines(dark, ' ')} }\n` +
93
+ ` }\n` +
94
+ ` ${resolved('light')} {\n${declarationLines(light, ' ')} }\n` +
95
+ ` ${resolved('dark')} {\n${declarationLines(dark, ' ')} }\n` +
96
+ `}\n`
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Map a schemas `PaletteError` to the template's refusal shape.
102
+ *
103
+ * @param {unknown} error the thrown value
104
+ * @returns {never} always throws
105
+ */
106
+ function rethrowPaletteError(error) {
107
+ if (error instanceof PaletteError) {
108
+ throw new ThemeAssetError(error.code, error.detail);
109
+ }
110
+ throw error;
111
+ }
112
+
113
+ /**
114
+ * Admit a theme's `palette` block: the theme must declare one and it must be
115
+ * well formed. There is no fallback for a theme without it.
116
+ *
117
+ * @param {Record<string, unknown>} themeJson the parsed `theme.json`
118
+ * @returns {void}
119
+ * @throws {ThemeAssetError} `THEME_PALETTE_MISSING` or `THEME_PALETTE_INVALID`
120
+ */
121
+ export function assertThemePalette(themeJson) {
122
+ try {
123
+ derivePalette(
124
+ /** @type {Parameters<typeof derivePalette>[0]} */ (themeJson),
125
+ {},
126
+ );
127
+ } catch (error) {
128
+ rethrowPaletteError(error);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Derive, check and serialise the palette sheet for one build.
134
+ *
135
+ * @param {Record<string, unknown>} themeJson the admitted `theme.json`
136
+ * @param {unknown} appearanceTokens `appearance.tokens` (`{}` or absent for
137
+ * the theme's default palette)
138
+ * @returns {{
139
+ * text: string,
140
+ * bytes: Buffer,
141
+ * fileName: string,
142
+ * }} the sheet and its content-addressed file name
143
+ * @throws {ThemeAssetError} `APPEARANCE_PALETTE_INVALID` (malformed picks or
144
+ * a derived value outside the token grammar), `APPEARANCE_PALETTE_CONTRAST`
145
+ * (a pair below its minimum; the message names the pair, mode and ratio)
146
+ */
147
+ export function buildPaletteStylesheet(themeJson, appearanceTokens) {
148
+ const theme = /** @type {Parameters<typeof derivePalette>[0]} */ (themeJson);
149
+ /** @type {ReturnType<typeof derivePalette>} */
150
+ let derived;
151
+ try {
152
+ derived = derivePalette(
153
+ theme,
154
+ /** @type {Parameters<typeof derivePalette>[1]} */ (
155
+ appearanceTokens ?? {}
156
+ ),
157
+ );
158
+ } catch (error) {
159
+ return rethrowPaletteError(error);
160
+ }
161
+
162
+ // Every derived value passes the closed token grammar: the theme-contract
163
+ // schema validates each token's value against its type, so substitute the
164
+ // derived values into the theme's own token rows and validate.
165
+ const finalTokens =
166
+ /** @type {{key: string, light: string, dark: string}[]} */ (
167
+ themeJson.tokens
168
+ ).map((row) => ({
169
+ ...row,
170
+ light: derived.tokens[row.key]?.light ?? row.light,
171
+ dark: derived.tokens[row.key]?.dark ?? row.dark,
172
+ }));
173
+ const validation = validateGalaDocument(THEME_CONTRACT_SCHEMA_ID, {
174
+ ...themeJson,
175
+ tokens: finalTokens,
176
+ });
177
+ if (!validation.valid) {
178
+ throw new ThemeAssetError(
179
+ 'APPEARANCE_PALETTE_INVALID',
180
+ `a derived palette value is outside the token grammar ` +
181
+ `(${validation.diagnostics.length} diagnostic(s)): ` +
182
+ JSON.stringify(validation.diagnostics),
183
+ );
184
+ }
185
+
186
+ for (const mode of /** @type {const} */ (['light', 'dark'])) {
187
+ const check = checkPalette(derived, mode);
188
+ const failure = check.failures[0];
189
+ if (failure) {
190
+ const ratio =
191
+ failure.ratio === null
192
+ ? (failure.reason ?? 'unmeasurable')
193
+ : `${failure.ratio.toFixed(2)}:1`;
194
+ throw new ThemeAssetError(
195
+ 'APPEARANCE_PALETTE_CONTRAST',
196
+ `${failure.pair} in ${mode} mode is ${ratio}, below ${failure.minimum}:1 ` +
197
+ `(${check.failures.length} failing pair(s) in ${mode} mode)`,
198
+ { pair: failure.pair, mode, failures: check.failures },
199
+ );
200
+ }
201
+ }
202
+
203
+ const text = serialisePaletteSheet(derived.tokens, derived.bound);
204
+ const bytes = Buffer.from(text, 'utf8');
205
+ if (bytes.byteLength > PALETTE_STYLESHEET_MAXIMUM_BYTES) {
206
+ throw new ThemeAssetError(
207
+ 'APPEARANCE_PALETTE_INVALID',
208
+ `the palette stylesheet is ${bytes.byteLength} bytes, over ${PALETTE_STYLESHEET_MAXIMUM_BYTES}`,
209
+ );
210
+ }
211
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 12);
212
+ return {
213
+ text,
214
+ bytes,
215
+ fileName: `${PALETTE_STYLESHEET_PREFIX}${hash}.css`,
216
+ };
217
+ }
@@ -19,6 +19,7 @@ import { escapeHtml } from './skeleton.js';
19
19
  import { icon } from './icons.js';
20
20
  import { joinBasePathAndRoute } from './route.js';
21
21
  import { resolveOriginalDerivativePath } from './seo.js';
22
+ import { cardMediaOf } from './first-body-image.js';
22
23
 
23
24
  /** Reading speed used for every reading-time estimate. */
24
25
  export const WORDS_PER_MINUTE = 230;
@@ -395,9 +396,10 @@ export function createComponents(context) {
395
396
  const card = (record, options = {}, variant = 'grid') => {
396
397
  const fm = record.frontmatter;
397
398
  const href = escapeHtml(recordHref(record));
398
- const hero = mediaImage(fm.hero?.file);
399
+ const cardMedia = cardMediaOf(record);
400
+ const hero = mediaImage(cardMedia?.file);
399
401
  const media = hero
400
- ? `<figure class="g-card-media">${renderImage({ image: hero, alt: fm.hero?.role === 'decorative' ? '' : (fm.hero?.alt ?? '') })}</figure>`
402
+ ? `<figure class="g-card-media">${renderImage({ image: hero, alt: cardMedia?.role === 'decorative' ? '' : (cardMedia?.alt ?? '') })}</figure>`
401
403
  : `<div class="g-card-placeholder" aria-hidden="true">${icon('sparkle')}</div>`;
402
404
  const seriesTag = options.partLabel
403
405
  ? `<span class="g-series-tag">${icon('book')}${escapeHtml(options.partLabel)}</span>`
@@ -33,10 +33,10 @@
33
33
  import Eleventy from '@11ty/eleventy';
34
34
 
35
35
  import {
36
+ APPEARANCE_ALLOWED_ATTRIBUTE,
37
+ APPEARANCE_DEFAULT_ATTRIBUTE,
36
38
  APPEARANCE_RESOLVED_MODE_ATTRIBUTE,
37
39
  APPEARANCE_ROOT_ATTRIBUTE,
38
- APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE,
39
- COLOR_SCHEME_META_CONTENT,
40
40
  } from './appearance/contract.js';
41
41
  import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
42
42
 
@@ -78,7 +78,8 @@ import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
78
78
  * rendered independent of JavaScript, so a theme's own no-JS
79
79
  * `prefers-color-scheme` fallback CSS has a stable root scope to key off;
80
80
  * - (TPL-C1 fix) `data-gala-resolved-color-mode`, server-rendered as
81
- * {@link APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE} (`"light"`) on the same
81
+ * `data.resolvedColorMode` (the author's `light`/`dark` default, else
82
+ * `"light"` for a `system` default) on the same
82
83
  * `<html>` element, so `RESOLVED_PALETTE_SELECTORS.light` already matches
83
84
  * before any script runs and a page is never unstyled UA-default HTML for
84
85
  * a no-JS reader, a text-mode/archival crawler, or a load where the
@@ -87,7 +88,12 @@ import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
87
88
  * this attribute with the resolved value for the reader's actual stored
88
89
  * selection/system preference the instant it runs, so a scripted reader's
89
90
  * experience is unchanged;
90
- * - `<meta name="color-scheme" content="light dark">`, so user-agent styling
91
+ * - `data-gala-color-mode-default` and `data-gala-color-mode-allowed`, the
92
+ * author's "Visitors see" default and allowed modes
93
+ * (`appearance.colorMode`), which the bootstrap script prefers over
94
+ * `system` when the reader has no stored choice;
95
+ * - `<meta name="color-scheme">`, `light dark` unless the author allows a
96
+ * single `light` or `dark` mode, so user-agent styling
91
97
  * (form controls, scrollbars) and the initial paint already follow
92
98
  * `prefers-color-scheme` before any script runs;
93
99
  * - the one `<script src>` this renderer ever emits (the appearance
@@ -128,12 +134,12 @@ import { PAGE_KIND_ATTRIBUTE } from './page-kinds.js';
128
134
  */
129
135
  const SKELETON_LAYOUT_SOURCE = [
130
136
  '<!doctype html>',
131
- `<html lang="{{ lang }}" dir="{{ dir }}" ${APPEARANCE_ROOT_ATTRIBUTE} ${APPEARANCE_RESOLVED_MODE_ATTRIBUTE}="${APPEARANCE_SERVER_DEFAULT_RESOLVED_MODE}">`,
137
+ `<html lang="{{ lang }}" dir="{{ dir }}" ${APPEARANCE_ROOT_ATTRIBUTE} ${APPEARANCE_RESOLVED_MODE_ATTRIBUTE}="{{ resolvedColorMode }}" ${APPEARANCE_DEFAULT_ATTRIBUTE}="{{ colorModeDefault }}" ${APPEARANCE_ALLOWED_ATTRIBUTE}="{{ colorModeAllowed }}">`,
132
138
  '<head>',
133
139
  '<meta charset="utf-8">',
134
140
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
135
141
  '{{ cspMetaTag | safe }}',
136
- `<meta name="color-scheme" content="${COLOR_SCHEME_META_CONTENT}">`,
142
+ '<meta name="color-scheme" content="{{ colorScheme }}">',
137
143
  // The interactions script (article pages, module on) is deferred and sits
138
144
  // on the appearance script's line, so a page without it is byte-identical
139
145
  // to before.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The picture a post shows where the post is summarised (cards, the home
3
+ * hero, `og:image`) when its frontmatter carries no `hero`.
4
+ *
5
+ * A content record's `body` is already render-policy-conformant HTML (the
6
+ * authored Markdown after `normalizeAuthoredMarkdown`), so inline images
7
+ * (`![alt](assets/x.png)`), `./`-prefixed paths and reference-style images
8
+ * (`![alt][ref]` with a `[ref]: assets/x.png` definition) have all become
9
+ * `<img src alt>` elements by the time this module sees them; code samples
10
+ * are `<pre>`/`<code>` text whose `<` is entity-encoded. The scan is
11
+ * therefore over `<img>` elements, with the same path rule the prose
12
+ * renderer resolves them by (`media/content-images.js`).
13
+ */
14
+
15
+ import { unescapeHtml } from './skeleton.js';
16
+ import { repositoryPathOfImageSource } from './media/content-images.js';
17
+
18
+ /** Code regions, removed before the scan so an image shown as code is skipped. */
19
+ const CODE_REGION = /<pre\b[\s\S]*?<\/pre>|<code\b[\s\S]*?<\/code>/g;
20
+
21
+ const IMG_ELEMENT = /<img\b[^>]*>/g;
22
+ const ATTRIBUTE = /\s([a-zA-Z][a-zA-Z0-9-]*)="([^"]*)"/g;
23
+
24
+ /** The media types a card picture may be (same set as body images). */
25
+ const IMAGE_MEDIA_TYPES = new Set([
26
+ 'image/png',
27
+ 'image/jpeg',
28
+ 'image/webp',
29
+ 'image/avif',
30
+ 'image/gif',
31
+ ]);
32
+
33
+ /**
34
+ * @param {string} body a content record's policy-conformant HTML body
35
+ * @returns {{path: string, alt: string} | undefined} the first image whose
36
+ * source is a repository-relative `assets/` path (remote, `data:` and
37
+ * code-region images are skipped), with its decoded alt text
38
+ */
39
+ export function firstBodyImage(body) {
40
+ for (const element of body.replace(CODE_REGION, '').match(IMG_ELEMENT) ??
41
+ []) {
42
+ /** @type {{src?: string, alt?: string}} */
43
+ const attributes = {};
44
+ for (const [, name, value] of element.matchAll(ATTRIBUTE)) {
45
+ if (name === 'src' || name === 'alt') attributes[name] = value;
46
+ }
47
+ const path = repositoryPathOfImageSource(attributes.src);
48
+ if (path?.startsWith('assets/')) {
49
+ return { path, alt: unescapeHtml(attributes.alt ?? '') };
50
+ }
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ /** @type {WeakMap<object, unknown>} */
56
+ const cache = new WeakMap();
57
+
58
+ /**
59
+ * The picture a record shows on cards, the home hero and as its social
60
+ * image: `frontmatter.hero` when present, else the first body image that the
61
+ * record's own `media[]` inventories (so it carries the `sourceDigest` the
62
+ * media pipeline processed it by). Computed once per record.
63
+ *
64
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
65
+ * @returns {{file: {path: string, sourceDigest: string}, alt?: string, role: 'informative' | 'decorative'} | undefined}
66
+ */
67
+ export function cardMediaOf(record) {
68
+ if (cache.has(record)) {
69
+ return /** @type {ReturnType<typeof cardMediaOf>} */ (cache.get(record));
70
+ }
71
+ /** @type {ReturnType<typeof cardMediaOf>} */
72
+ let media = /** @type {any} */ (record.frontmatter.hero);
73
+ if (!media) {
74
+ const image = firstBodyImage(record.body);
75
+ const entry = image
76
+ ? record.media?.find(
77
+ (file) =>
78
+ file.path === image.path && IMAGE_MEDIA_TYPES.has(file.mediaType),
79
+ )
80
+ : undefined;
81
+ if (image && entry) {
82
+ media = {
83
+ file: { path: entry.path, sourceDigest: entry.sourceDigest },
84
+ alt: image.alt,
85
+ role: 'informative',
86
+ };
87
+ }
88
+ }
89
+ cache.set(record, media);
90
+ return media;
91
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The renderer's own published version, the single constant both the public
3
+ * `TEMPLATE_PACKAGE_VERSION` export and theme `templateRange` admission read.
4
+ * It is deliberately separate from the styling contract's
5
+ * `templateVersion` (`appearance/styling-contract.js`), which records the
6
+ * version the contract was published at and is part of the contract digest:
7
+ * a renderer release that does not change the contract keeps that digest.
8
+ *
9
+ * @type {string}
10
+ */
11
+ export const TEMPLATE_RENDERER_VERSION = '4.1.0';
@@ -78,6 +78,7 @@ import {
78
78
  resolveEditions,
79
79
  } from './editions.js';
80
80
  import { truncateAtWord } from './seo.js';
81
+ import { cardMediaOf } from './first-body-image.js';
81
82
  import { createStructuredData } from './structured-data.js';
82
83
  import { icon } from './icons.js';
83
84
  import {
@@ -499,21 +500,22 @@ export function buildGeneratedPages(validatedInput, options) {
499
500
  }
500
501
  : {};
501
502
  /**
502
- * @param {import('../../../types/index.d.ts').ContentFrontmatterNormalized} fm
503
+ * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
503
504
  * @returns {{socialImageRef?: {path: string, sourceDigest: string}, socialImageAlt?: string}}
504
505
  */
505
- const socialOfContent = (fm) =>
506
- fm.socialImage
506
+ const socialOfContent = (record) => {
507
+ const fm = record.frontmatter;
508
+ const media = cardMediaOf(record);
509
+ return fm.socialImage
507
510
  ? { socialImageRef: fm.socialImage, socialImageAlt: fm.title }
508
- : fm.hero
511
+ : media
509
512
  ? {
510
- socialImageRef: fm.hero.file,
513
+ socialImageRef: media.file,
511
514
  socialImageAlt:
512
- fm.hero.role === 'informative' && fm.hero.alt
513
- ? fm.hero.alt
514
- : fm.title,
515
+ media.role === 'informative' && media.alt ? media.alt : fm.title,
515
516
  }
516
517
  : defaultSocial;
518
+ };
517
519
  /**
518
520
  * @param {import('../../../types/index.d.ts').ContentBuildRecord} record
519
521
  * @returns {string} the page description: authored, else the body's
@@ -1004,15 +1006,15 @@ export function buildGeneratedPages(validatedInput, options) {
1004
1006
  // An edition with no social image of its own shares its article's.
1005
1007
  const social = socialOfContent(
1006
1008
  isEdition && !frontmatter.socialImage && !frontmatter.hero
1007
- ? original.frontmatter
1008
- : frontmatter,
1009
+ ? original
1010
+ : record,
1009
1011
  );
1010
1012
  const articleGraph = isArticle
1011
1013
  ? sd.article({
1012
1014
  url: ownUrl,
1013
1015
  headline: frontmatter.title,
1014
1016
  description: contentDescription(record),
1015
- image: absoluteImage(socialOfContent(frontmatter).socialImageRef),
1017
+ image: absoluteImage(socialOfContent(record).socialImageRef),
1016
1018
  datePublished: frontmatter.publishedAt,
1017
1019
  dateModified: contentLastModified(frontmatter),
1018
1020
  language: frontmatter.language,
@@ -1095,7 +1097,8 @@ export function buildGeneratedPages(validatedInput, options) {
1095
1097
  const homeFeatured = featured
1096
1098
  ? (() => {
1097
1099
  const fm = featured.frontmatter;
1098
- const hero = mediaImage(fm.hero?.file);
1100
+ const cardMedia = cardMediaOf(featured);
1101
+ const hero = mediaImage(cardMedia?.file);
1099
1102
  const href = escapeHtml(recordHref(featured));
1100
1103
  return (
1101
1104
  `<section class="g-wrap g-hero" aria-labelledby="hero-title">` +
@@ -1103,7 +1106,10 @@ export function buildGeneratedPages(validatedInput, options) {
1103
1106
  (hero
1104
1107
  ? renderImage({
1105
1108
  image: hero,
1106
- alt: fm.hero?.role === 'decorative' ? '' : (fm.hero?.alt ?? ''),
1109
+ alt:
1110
+ cardMedia?.role === 'decorative'
1111
+ ? ''
1112
+ : (cardMedia?.alt ?? ''),
1107
1113
  priority: true,
1108
1114
  })
1109
1115
  : `<span class="g-card-placeholder">${icon('sparkle')}</span>`) +
@@ -6,6 +6,8 @@
6
6
  * appears once, and its role is the earliest value in the enum order.
7
7
  */
8
8
 
9
+ import { cardMediaOf } from './first-body-image.js';
10
+
9
11
  /** @type {readonly string[]} */
10
12
  const ROLE_PRIORITY = Object.freeze([
11
13
  'publication',
@@ -77,13 +79,12 @@ export function collectIncludedSources(buildInput) {
77
79
  'content',
78
80
  contentRecord.sourceRevision,
79
81
  );
80
- if (contentRecord.frontmatter.hero) {
81
- recordResolvedFile(
82
- record,
83
- contentRecord.frontmatter.hero.file,
84
- contentRecord.sourceRevision,
85
- );
86
- }
82
+ // The hero, else the first body image a card shows in its place.
83
+ recordResolvedFile(
84
+ record,
85
+ cardMediaOf(contentRecord)?.file,
86
+ contentRecord.sourceRevision,
87
+ );
87
88
  recordResolvedFile(
88
89
  record,
89
90
  contentRecord.frontmatter.socialImage,
@@ -77,9 +77,13 @@ import { validateGalaDocument } from '@rathnasgala2/schemas';
77
77
  import { ThemeAssetError } from '../errors.js';
78
78
  import { digestBytes } from './canonical-jcs.js';
79
79
  import {
80
- buildTemplateStylingContract,
81
- TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION,
82
- } from './appearance/styling-contract.js';
80
+ assertThemePalette,
81
+ buildPaletteStylesheet,
82
+ PALETTE_STYLESHEET_DIRECTORY,
83
+ PALETTE_STYLESHEET_MEDIA_TYPE,
84
+ } from './appearance/palette-sheet.js';
85
+ import { buildTemplateStylingContract } from './appearance/styling-contract.js';
86
+ import { TEMPLATE_RENDERER_VERSION } from './package-version.js';
83
87
  import { MEDIA_TYPE_BY_FORMAT, sniffMediaFormat } from './media/sniff.js';
84
88
  import { sanitizeThemeSvg } from './media/theme-svg-sanitizer.js';
85
89
  import { projectFixedAssetPath } from './route.js';
@@ -414,6 +418,21 @@ function admitPassiveAssetBytes(relativePath, bytes) {
414
418
  function assertThemeContractIntegrity(themeJson) {
415
419
  const validation = validateGalaDocument(THEME_CONTRACT_SCHEMA_ID, themeJson);
416
420
  if (!validation.valid) {
421
+ // A palette block the closed schema itself refuses (unknown or
422
+ // non-drivable key, bad profile, bad role) is a palette refusal, not a
423
+ // generic contract refusal, so the author sees one code for it.
424
+ if (
425
+ validation.diagnostics.every((diagnostic) =>
426
+ String(diagnostic.instancePointer ?? '').startsWith('/palette'),
427
+ )
428
+ ) {
429
+ throw new ThemeAssetError(
430
+ 'THEME_PALETTE_INVALID',
431
+ `theme.json palette failed ${THEME_CONTRACT_SCHEMA_ID} validation ` +
432
+ `(${validation.diagnostics.length} diagnostic(s)): ` +
433
+ JSON.stringify(validation.diagnostics),
434
+ );
435
+ }
417
436
  throw new ThemeAssetError(
418
437
  'THEME_CONTRACT_SCHEMA_INVALID',
419
438
  `theme.json failed ${THEME_CONTRACT_SCHEMA_ID} validation ` +
@@ -442,7 +461,7 @@ function assertThemeContractIntegrity(themeJson) {
442
461
  let templateRangeSatisfied;
443
462
  try {
444
463
  templateRangeSatisfied = satisfiesTemplateRange(
445
- TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION,
464
+ TEMPLATE_RENDERER_VERSION,
446
465
  /** @type {string} */ (themeJson.templateRange),
447
466
  );
448
467
  } catch (error) {
@@ -456,7 +475,7 @@ function assertThemeContractIntegrity(themeJson) {
456
475
  'THEME_CONTRACT_VERSION_MISMATCH',
457
476
  `theme.json.templateRange (${themeJson.templateRange}) does not admit ` +
458
477
  `this renderer's own published version ` +
459
- `(${TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION})`,
478
+ `(${TEMPLATE_RENDERER_VERSION})`,
460
479
  );
461
480
  }
462
481
  try {
@@ -467,6 +486,9 @@ function assertThemeContractIntegrity(themeJson) {
467
486
  /** @type {Error} */ (error).message,
468
487
  );
469
488
  }
489
+ // Template 4.1.0: every theme declares its palette bindings; a theme
490
+ // without them (any 4.0.0 package) is refused, with no fallback path.
491
+ assertThemePalette(themeJson);
470
492
  }
471
493
 
472
494
  /**
@@ -495,6 +517,9 @@ function subresourceIntegrityAttribute(bytes) {
495
517
  * @param {string} options.basePath the build input's `basePath`
496
518
  * @param {string} [options.publicBasePath] the base path emitted `<link href>`
497
519
  * values use (`route.js#derivePublicBasePath`); defaults to `basePath`
520
+ * @param {unknown} [options.appearanceTokens] `appearance.tokens` (`{}` or
521
+ * absent selects the theme's default palette); the derived palette sheet
522
+ * is written and linked after the theme's own stylesheets
498
523
  * @returns {Promise<{
499
524
  * files: {path: string, bytes: Buffer}[],
500
525
  * assets: import('../../../types/index.d.ts').ManifestAssetEntry[],
@@ -507,6 +532,7 @@ export async function loadThemeAssets({
507
532
  themeDirectory,
508
533
  basePath,
509
534
  publicBasePath = basePath,
535
+ appearanceTokens = {},
510
536
  }) {
511
537
  const { bytes: themeJsonBytes } = await readDeclaredThemeFile(
512
538
  themeDirectory,
@@ -566,6 +592,27 @@ export async function loadThemeAssets({
566
592
  );
567
593
  }
568
594
 
595
+ // Template 4.1.0: the publication palette sheet, derived on every build
596
+ // (`{}` selects the theme's default palette), content-addressed, linked
597
+ // last with SRI and manifested like every theme stylesheet.
598
+ const paletteSheet = buildPaletteStylesheet(themeJson, appearanceTokens);
599
+ const paletteRelativePath = `${PALETTE_STYLESHEET_DIRECTORY}/${paletteSheet.fileName}`;
600
+ const paletteOutputPath = projectFixedAssetPath(
601
+ basePath,
602
+ `/${paletteRelativePath}`,
603
+ );
604
+ files.push({ path: paletteOutputPath, bytes: paletteSheet.bytes });
605
+ assets.push({
606
+ path: paletteOutputPath,
607
+ mediaType: PALETTE_STYLESHEET_MEDIA_TYPE,
608
+ byteLength: String(paletteSheet.bytes.byteLength),
609
+ sha256: digestBytes(paletteSheet.bytes),
610
+ immutable: false,
611
+ });
612
+ linkTags.push(
613
+ `<link rel="stylesheet" href="${escapeHtml(`/${projectFixedAssetPath(publicBasePath, `/${paletteRelativePath}`)}`)}" integrity="${subresourceIntegrityAttribute(paletteSheet.bytes)}" crossorigin="anonymous">`,
614
+ );
615
+
569
616
  // TPL-M2 fix: partition by the already-shape-validated `stylesheets` list
570
617
  // itself, not by comparing `asset.mediaType` against the literal string
571
618
  // `'text/css'` — a theme declaring its own stylesheet rows with the same