@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 +16 -4
- package/package.json +2 -2
- package/src/core/errors.js +5 -1
- package/src/core/index.js +19 -1
- package/src/core/internal/appearance/base-layer.js +2 -2
- package/src/core/internal/appearance/bootstrap-script.js +27 -4
- package/src/core/internal/appearance/contract.js +76 -0
- package/src/core/internal/appearance/controller-markup.js +6 -2
- package/src/core/internal/appearance/palette-sheet.js +217 -0
- package/src/core/internal/components.js +4 -2
- package/src/core/internal/eleventy-render.js +12 -6
- package/src/core/internal/first-body-image.js +91 -0
- package/src/core/internal/package-version.js +11 -0
- package/src/core/internal/page-kinds.js +19 -13
- package/src/core/internal/source-inventory.js +8 -7
- package/src/core/internal/theme-assets.js +52 -5
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.
|
|
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.
|
|
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.
|
|
690
|
-
regenerated, together with the SBOM, from the authoritative published 3.
|
|
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.
|
|
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.
|
|
80
|
+
"@rathnasgala2/schemas": "3.10.0"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@cyclonedx/cyclonedx-npm": "6.0.1",
|
package/src/core/errors.js
CHANGED
|
@@ -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 =
|
|
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-
|
|
463
|
-
.g-card-placeholder .g-icon { width: 2.25rem; height: 2.25rem; opacity: .
|
|
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 (
|
|
103
|
+
if (isAllowed(value)) return value;
|
|
82
104
|
} catch (error) {
|
|
83
|
-
// Storage blocked: fall back to
|
|
105
|
+
// Storage blocked: fall back to the author's default below.
|
|
84
106
|
}
|
|
85
|
-
return
|
|
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
|
-
* @
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
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
|
-
* -
|
|
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}="${
|
|
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
|
-
|
|
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
|
+
* (``), `./`-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').
|
|
503
|
+
* @param {import('../../../types/index.d.ts').ContentBuildRecord} record
|
|
503
504
|
* @returns {{socialImageRef?: {path: string, sourceDigest: string}, socialImageAlt?: string}}
|
|
504
505
|
*/
|
|
505
|
-
const socialOfContent = (
|
|
506
|
-
fm.
|
|
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
|
-
:
|
|
511
|
+
: media
|
|
509
512
|
? {
|
|
510
|
-
socialImageRef:
|
|
513
|
+
socialImageRef: media.file,
|
|
511
514
|
socialImageAlt:
|
|
512
|
-
|
|
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
|
|
1008
|
-
:
|
|
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(
|
|
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
|
|
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:
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
`(${
|
|
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
|