@rathnasgala2/template 2.0.0 → 2.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 +211 -137
- package/contracts/render-policy.jcs +1 -1
- package/contracts/theme-styling-contract.jcs +1 -1
- package/package.json +5 -6
- package/src/core/errors.js +13 -13
- package/src/core/index.js +130 -75
- package/src/core/internal/appearance/base-layer.js +180 -0
- package/src/core/internal/appearance/bootstrap-script.js +11 -11
- package/src/core/internal/appearance/contract.js +59 -44
- package/src/core/internal/appearance/controller-markup.js +7 -8
- package/src/core/internal/appearance/styling-contract.js +132 -29
- package/src/core/internal/canonical-jcs.js +0 -0
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/eleventy-render.js +52 -35
- package/src/core/internal/feeds.js +15 -15
- package/src/core/internal/fs-walk.js +2 -2
- package/src/core/internal/media/avif-probe.js +4 -4
- package/src/core/internal/media/font-policy.js +2 -2
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +32 -34
- package/src/core/internal/media/pipeline.js +4 -4
- package/src/core/internal/media/png-codec.js +7 -7
- package/src/core/internal/media/resize.js +2 -2
- package/src/core/internal/media/sniff.js +4 -4
- package/src/core/internal/media/theme-svg-sanitizer.js +278 -0
- package/src/core/internal/media/webp-probe.js +3 -3
- package/src/core/internal/messages.js +8 -8
- package/src/core/internal/page-kinds.js +50 -51
- package/src/core/internal/render-policy-content.js +57 -21
- package/src/core/internal/route-labels.js +5 -6
- package/src/core/internal/route.js +58 -20
- package/src/core/internal/search-index.js +17 -16
- package/src/core/internal/semver-range.js +84 -0
- package/src/core/internal/seo.js +24 -24
- package/src/core/internal/sitemap.js +11 -12
- package/src/core/internal/skeleton.js +65 -46
- package/src/core/internal/source-inventory.js +4 -5
- package/src/core/internal/text-direction.js +1 -2
- package/src/core/internal/theme-assets.js +314 -68
- package/src/core/internal/uuid.js +4 -5
- package/src/core/manifest.js +0 -0
- package/types/index.d.ts +22 -8
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Theme package asset integration
|
|
2
|
+
* Theme package asset integration: copy the selected
|
|
3
3
|
* theme package's stylesheets (and any declared passive assets) into the
|
|
4
4
|
* candidate output directory's `<basePath>/assets/theme/`, produce their
|
|
5
5
|
* `manifestAsset` rows, and render the ordered `<link>` elements every
|
|
@@ -9,22 +9,25 @@
|
|
|
9
9
|
* `internal/print-stylesheet.js`'s own module documentation): this module
|
|
10
10
|
* trusts the theme package's own `theme.json` file-set/`cssLayers`/
|
|
11
11
|
* `stylesheets` declarations to the extent needed to *consume* them —
|
|
12
|
-
* closed stylesheet-list shape
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
12
|
+
* closed stylesheet-list shape and the declared files actually existing.
|
|
13
|
+
* The `cssLayers`/`stylesheets` pairing itself is fully closed by
|
|
14
|
+
* `urn:gala:schema:theme-contract:2.0.0`'s own `oneOf` (exactly the two
|
|
15
|
+
* admitted stylesheet lists, each with its own `const` `cssLayers`
|
|
16
|
+
* projection), which `assertThemeContractIntegrity` validates before any
|
|
17
|
+
* file is read, so this module does not re-check that projection itself.
|
|
18
|
+
* It does **not** re-implement a full CSS Syntax Module admission grammar
|
|
19
|
+
* (selector/property/at-rule closed catalogs, byte/token/rule ceilings,
|
|
20
|
+
* etc.) — that is the shared `theme-release.yml` conformance runner's job,
|
|
21
|
+
* run once per theme package release, not per publication build. Authoring
|
|
22
|
+
* real theme CSS is out of scope and forbidden here; this module only moves
|
|
23
|
+
* bytes an already-conformant theme package supplies.
|
|
21
24
|
*
|
|
22
25
|
* The theme package directory this module reads from (`options.themeDirectory`
|
|
23
26
|
* on `renderPublication`) is optional: a caller that does not yet resolve a
|
|
24
|
-
* theme package (e.g. most of this repository's own
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
27
|
+
* theme package (e.g. most of this repository's own test suite) gets the
|
|
28
|
+
* default — a `<link>` at the fixed conventional `assets/theme/print.css`
|
|
29
|
+
* path, matching `internal/print-stylesheet.js`'s pre-existing convention —
|
|
30
|
+
* so this addition is purely additive.
|
|
28
31
|
*
|
|
29
32
|
* Path-containment hardening (independent-review finding B1): every
|
|
30
33
|
* declared `theme.json` path this module reads — a `stylesheets` entry or a
|
|
@@ -50,17 +53,49 @@
|
|
|
50
53
|
* `<basePath>/assets/theme/<name>` matches the `<link href>` this module
|
|
51
54
|
* also emits. `<link href>` and the physical/manifest path are derived from
|
|
52
55
|
* the exact same joined value, so they cannot drift apart.
|
|
56
|
+
*
|
|
57
|
+
* Passive-asset admission (TPL-C2 fix): a declared `theme.json.assets[]` row
|
|
58
|
+
* is a lower-trust supply-chain input than the repository owner's own
|
|
59
|
+
* authored content, so it is held to at least the same bar as author media:
|
|
60
|
+
* its bytes are sniffed with `internal/media/sniff.js` (never trusted from
|
|
61
|
+
* the declared `mediaType` string), classified against a closed allowlist
|
|
62
|
+
* (raster formats, or SVG only after {@link sanitizeThemeSvg}'s closed
|
|
63
|
+
* admission grammar — see that module and TPL-H6's iconography decision),
|
|
64
|
+
* checked against `theme.json.budgets`'s per-file/total/count ceilings, and
|
|
65
|
+
* verified byte-for-byte against its own declared `byteLength`/`sha256`
|
|
66
|
+
* before it is ever copied into a published artifact. The manifest
|
|
67
|
+
* `mediaType` this module records is always the *sniffed* type, never the
|
|
68
|
+
* theme's own declaration.
|
|
53
69
|
*/
|
|
54
70
|
|
|
71
|
+
import { createHash } from 'node:crypto';
|
|
55
72
|
import { lstat, readFile } from 'node:fs/promises';
|
|
56
73
|
import path from 'node:path';
|
|
57
74
|
|
|
75
|
+
import { validateGalaDocument } from '@rathnasgala2/schemas';
|
|
76
|
+
|
|
58
77
|
import { ThemeAssetError } from '../errors.js';
|
|
59
78
|
import { digestBytes } from './canonical-jcs.js';
|
|
60
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
buildTemplateStylingContract,
|
|
81
|
+
TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION,
|
|
82
|
+
} from './appearance/styling-contract.js';
|
|
83
|
+
import { MEDIA_TYPE_BY_FORMAT, sniffMediaFormat } from './media/sniff.js';
|
|
84
|
+
import { sanitizeThemeSvg } from './media/theme-svg-sanitizer.js';
|
|
61
85
|
import { projectFixedAssetPath } from './route.js';
|
|
86
|
+
import { satisfiesTemplateRange } from './semver-range.js';
|
|
62
87
|
import { escapeHtml } from './skeleton.js';
|
|
63
88
|
|
|
89
|
+
/** @type {string} the `urn:gala:schema:theme-contract:2.0.0` schema id
|
|
90
|
+
* every consumed `theme.json` is validated against (TPL-H1). */
|
|
91
|
+
const THEME_CONTRACT_SCHEMA_ID = 'urn:gala:schema:theme-contract:2.0.0';
|
|
92
|
+
|
|
93
|
+
/** @type {number} the hard byte ceiling for one passive asset when a
|
|
94
|
+
* theme's own `theme.json.budgets.maximumFileBytes` is absent or larger
|
|
95
|
+
* (belt-and-suspenders; every real theme package declares a tighter budget
|
|
96
|
+
* of its own, enforced below). */
|
|
97
|
+
const DEFAULT_MAXIMUM_PASSIVE_ASSET_BYTES = 262144;
|
|
98
|
+
|
|
64
99
|
/** @type {string} the fixed, non-content-addressed output directory every
|
|
65
100
|
* theme asset (stylesheet or passive file) is copied under, relative to
|
|
66
101
|
* `basePath`. */
|
|
@@ -81,15 +116,6 @@ const STYLESHEETS_WITH_UTILITIES = Object.freeze([
|
|
|
81
116
|
'print.css',
|
|
82
117
|
]);
|
|
83
118
|
|
|
84
|
-
/** @type {Readonly<Record<string, string>>} theme stylesheet filename to its
|
|
85
|
-
* fixed `@layer` name (DEC-097 section 4). */
|
|
86
|
-
const LAYER_BY_STYLESHEET = Object.freeze({
|
|
87
|
-
'tokens.css': 'gala-tokens',
|
|
88
|
-
'components.css': 'gala-components',
|
|
89
|
-
'utilities.css': 'gala-utilities',
|
|
90
|
-
'print.css': 'gala-print',
|
|
91
|
-
});
|
|
92
|
-
|
|
93
119
|
/**
|
|
94
120
|
* @param {readonly unknown[]} candidate
|
|
95
121
|
* @returns {void}
|
|
@@ -108,38 +134,6 @@ function assertStylesheetListShape(candidate) {
|
|
|
108
134
|
}
|
|
109
135
|
}
|
|
110
136
|
|
|
111
|
-
/**
|
|
112
|
-
* @param {readonly unknown[]} stylesheets the already-shape-validated
|
|
113
|
-
* stylesheet list
|
|
114
|
-
* @param {unknown} cssLayers the candidate `theme.json.cssLayers`
|
|
115
|
-
* @returns {void}
|
|
116
|
-
*/
|
|
117
|
-
function assertCssLayersProjection(stylesheets, cssLayers) {
|
|
118
|
-
const expected = /** @type {readonly string[]} */ (stylesheets).map(
|
|
119
|
-
(filename) => LAYER_BY_STYLESHEET[/** @type {string} */ (filename)],
|
|
120
|
-
);
|
|
121
|
-
if (JSON.stringify(cssLayers) !== JSON.stringify(expected)) {
|
|
122
|
-
throw new ThemeAssetError(
|
|
123
|
-
'THEME_CSS_LAYERS_INVALID',
|
|
124
|
-
'theme.json cssLayers must byte-equal the exact ordered layer ' +
|
|
125
|
-
'projection of its own stylesheets list',
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
|
-
if (
|
|
129
|
-
!expected.every(
|
|
130
|
-
(layer, index) =>
|
|
131
|
-
layer === ORDERED_LAYERS[index] ||
|
|
132
|
-
(layer === 'gala-print' && index === expected.length - 1),
|
|
133
|
-
)
|
|
134
|
-
) {
|
|
135
|
-
throw new ThemeAssetError(
|
|
136
|
-
'THEME_CSS_LAYERS_INVALID',
|
|
137
|
-
'theme.json cssLayers must be a subsequence of the published ' +
|
|
138
|
-
'templateStylingContract.orderedLayers, in that exact relative order',
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
137
|
/** @type {string} the reason code every path-containment rejection below
|
|
144
138
|
* carries. */
|
|
145
139
|
const PATH_CONTAINMENT_CODE = 'THEME_ASSET_PATH_UNSAFE';
|
|
@@ -154,7 +148,7 @@ const PATH_CONTAINMENT_CODE = 'THEME_ASSET_PATH_UNSAFE';
|
|
|
154
148
|
* @returns {string} the same value, once it is known to be a syntactically
|
|
155
149
|
* safe relative path
|
|
156
150
|
*/
|
|
157
|
-
function assertSafeThemeRelativePath(candidate) {
|
|
151
|
+
export function assertSafeThemeRelativePath(candidate) {
|
|
158
152
|
if (typeof candidate !== 'string' || candidate.length === 0) {
|
|
159
153
|
throw new ThemeAssetError(
|
|
160
154
|
PATH_CONTAINMENT_CODE,
|
|
@@ -210,7 +204,7 @@ function assertSafeThemeRelativePath(candidate) {
|
|
|
210
204
|
* (see {@link assertSafeThemeRelativePath})
|
|
211
205
|
* @returns {Promise<string>} the absolute, verified-contained path
|
|
212
206
|
*/
|
|
213
|
-
async function assertContainedThemePath(themeDirectory, relativePath) {
|
|
207
|
+
export async function assertContainedThemePath(themeDirectory, relativePath) {
|
|
214
208
|
const resolvedRoot = path.resolve(themeDirectory);
|
|
215
209
|
let current = resolvedRoot;
|
|
216
210
|
for (const segment of relativePath.split('/')) {
|
|
@@ -283,6 +277,201 @@ function joinedThemeOutputPath(basePath, themeRelativePath) {
|
|
|
283
277
|
);
|
|
284
278
|
}
|
|
285
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The public `<link href>` for one theme file: the same joined path as
|
|
282
|
+
* {@link joinedThemeOutputPath}, but under the public base path.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} publicBasePath the public base path
|
|
285
|
+
* @param {string} themeRelativePath the file's path relative to
|
|
286
|
+
* `THEME_ASSET_DIRECTORY`
|
|
287
|
+
* @returns {string} the root-absolute href
|
|
288
|
+
*/
|
|
289
|
+
function themeHref(publicBasePath, themeRelativePath) {
|
|
290
|
+
return `/${joinedThemeOutputPath(publicBasePath, themeRelativePath)}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** @type {{maximumFileBytes: number, maximumTotalBytes: number, maximumFiles: number}} */
|
|
294
|
+
const FALLBACK_BUDGETS = Object.freeze({
|
|
295
|
+
maximumFileBytes: DEFAULT_MAXIMUM_PASSIVE_ASSET_BYTES,
|
|
296
|
+
maximumTotalBytes: DEFAULT_MAXIMUM_PASSIVE_ASSET_BYTES,
|
|
297
|
+
maximumFiles: 8,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Parse and validate `theme.json.budgets` (TPL-C2/THD-M3: the declared
|
|
302
|
+
* ceilings are load-bearing, not decorative). An absent or malformed
|
|
303
|
+
* `budgets` object falls back to {@link FALLBACK_BUDGETS} rather than
|
|
304
|
+
* failing closed outright, since a missing budgets object is a shape defect
|
|
305
|
+
* TPL-H1's schema validation already rejects for a real theme package; this
|
|
306
|
+
* fallback only matters for this repository's own minimal test fixtures.
|
|
307
|
+
*
|
|
308
|
+
* @param {unknown} candidate the parsed `theme.json.budgets` value
|
|
309
|
+
* @returns {{maximumFileBytes: number, maximumTotalBytes: number, maximumFiles: number}}
|
|
310
|
+
* the effective numeric budgets
|
|
311
|
+
*/
|
|
312
|
+
function assertThemeBudgetsShape(candidate) {
|
|
313
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
314
|
+
return FALLBACK_BUDGETS;
|
|
315
|
+
}
|
|
316
|
+
const row = /** @type {Record<string, unknown>} */ (candidate);
|
|
317
|
+
const maximumFileBytes = Number(row.maximumFileBytes);
|
|
318
|
+
const maximumTotalBytes = Number(row.maximumTotalBytes);
|
|
319
|
+
const maximumFiles = Number(row.maximumFiles);
|
|
320
|
+
if (
|
|
321
|
+
!Number.isInteger(maximumFileBytes) ||
|
|
322
|
+
maximumFileBytes <= 0 ||
|
|
323
|
+
!Number.isInteger(maximumTotalBytes) ||
|
|
324
|
+
maximumTotalBytes <= 0 ||
|
|
325
|
+
!Number.isInteger(maximumFiles) ||
|
|
326
|
+
maximumFiles <= 0
|
|
327
|
+
) {
|
|
328
|
+
throw new ThemeAssetError(
|
|
329
|
+
'THEME_ASSET_BUDGET_EXCEEDED',
|
|
330
|
+
'theme.json.budgets must declare positive integer maximumFileBytes, maximumTotalBytes and maximumFiles',
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return { maximumFileBytes, maximumTotalBytes, maximumFiles };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Verify a declared passive-asset row's own `byteLength`/`sha256` against
|
|
338
|
+
* the bytes actually read (TPL-C2: the digest chain becomes load-bearing at
|
|
339
|
+
* consume time, not merely computed and republished).
|
|
340
|
+
*
|
|
341
|
+
* @param {{path: string, byteLength?: unknown, sha256?: unknown}} asset the
|
|
342
|
+
* declared `theme.json.assets[]` row
|
|
343
|
+
* @param {Buffer} bytes the bytes actually read from disk
|
|
344
|
+
* @returns {void}
|
|
345
|
+
*/
|
|
346
|
+
function assertPassiveAssetIntegrity(asset, bytes) {
|
|
347
|
+
if (
|
|
348
|
+
asset.byteLength !== undefined &&
|
|
349
|
+
String(asset.byteLength) !== String(bytes.byteLength)
|
|
350
|
+
) {
|
|
351
|
+
throw new ThemeAssetError(
|
|
352
|
+
'THEME_ASSET_DIGEST_MISMATCH',
|
|
353
|
+
`${asset.path}: declared byteLength ${asset.byteLength} does not match ${bytes.byteLength} actual bytes`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const actualDigest = digestBytes(bytes);
|
|
357
|
+
if (asset.sha256 !== undefined && asset.sha256 !== actualDigest) {
|
|
358
|
+
throw new ThemeAssetError(
|
|
359
|
+
'THEME_ASSET_DIGEST_MISMATCH',
|
|
360
|
+
`${asset.path}: declared sha256 does not match its actual bytes (expected ${actualDigest}, got ${asset.sha256})`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Sniff, allowlist and (for SVG) sanitize one passive asset's bytes,
|
|
367
|
+
* deriving its published `mediaType` from the sniffed format, never from the
|
|
368
|
+
* theme's own declaration (TPL-C2).
|
|
369
|
+
*
|
|
370
|
+
* @param {string} relativePath the declared, already-validated theme-relative
|
|
371
|
+
* path (for error messages only)
|
|
372
|
+
* @param {Buffer} bytes the bytes actually read from disk
|
|
373
|
+
* @returns {{mediaType: string, publishedBytes: Buffer}} the manifest media
|
|
374
|
+
* type and the exact bytes to publish (sanitized, for SVG)
|
|
375
|
+
*/
|
|
376
|
+
function admitPassiveAssetBytes(relativePath, bytes) {
|
|
377
|
+
const format = sniffMediaFormat(bytes);
|
|
378
|
+
if (format === 'svg') {
|
|
379
|
+
// Author SVG is unconditionally rejected (media/sniff.js's own module
|
|
380
|
+
// documentation); a theme-declared SVG is admitted only after it
|
|
381
|
+
// survives the closed sanitization grammar below (TPL-H6's iconography
|
|
382
|
+
// decision: this is the mechanism that makes icon assets safe to ship).
|
|
383
|
+
return {
|
|
384
|
+
mediaType: 'image/svg+xml',
|
|
385
|
+
publishedBytes: sanitizeThemeSvg(bytes),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const mediaType =
|
|
389
|
+
format === 'unknown' ? undefined : MEDIA_TYPE_BY_FORMAT[format];
|
|
390
|
+
if (!mediaType) {
|
|
391
|
+
throw new ThemeAssetError(
|
|
392
|
+
'THEME_ASSET_FORMAT_INVALID',
|
|
393
|
+
`${relativePath}: bytes do not sniff as an admitted passive-asset format (png, jpeg, webp, avif, or sanitizable svg)`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return { mediaType, publishedBytes: bytes };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The full TPL-H1 fix: schema-validate the parsed `theme.json` against
|
|
401
|
+
* `urn:gala:schema:theme-contract:2.0.0` (the same validator this
|
|
402
|
+
* repository's own tests already use), then make the digest chain and
|
|
403
|
+
* version negotiation load-bearing rather than parsed-and-ignored:
|
|
404
|
+
* `contractVersion` must byte-equal this renderer's own published styling
|
|
405
|
+
* contract version, `stylingContractDigest` must byte-equal that contract's
|
|
406
|
+
* own `catalogDigest`, and `templateRange` must admit this renderer's own
|
|
407
|
+
* published version. Fails closed with a {@link ThemeAssetError} on the
|
|
408
|
+
* first violation.
|
|
409
|
+
*
|
|
410
|
+
* @param {Record<string, unknown>} themeJson the parsed `theme.json` document
|
|
411
|
+
* @returns {void}
|
|
412
|
+
*/
|
|
413
|
+
function assertThemeContractIntegrity(themeJson) {
|
|
414
|
+
const validation = validateGalaDocument(THEME_CONTRACT_SCHEMA_ID, themeJson);
|
|
415
|
+
if (!validation.valid) {
|
|
416
|
+
throw new ThemeAssetError(
|
|
417
|
+
'THEME_CONTRACT_SCHEMA_INVALID',
|
|
418
|
+
`theme.json failed urn:gala:schema:theme-contract:2.0.0 validation ` +
|
|
419
|
+
`(${validation.diagnostics.length} diagnostic(s)): ` +
|
|
420
|
+
JSON.stringify(validation.diagnostics),
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const publishedContract = buildTemplateStylingContract();
|
|
425
|
+
if (themeJson.contractVersion !== publishedContract.contractVersion) {
|
|
426
|
+
throw new ThemeAssetError(
|
|
427
|
+
'THEME_CONTRACT_VERSION_MISMATCH',
|
|
428
|
+
`theme.json.contractVersion (${themeJson.contractVersion}) does not ` +
|
|
429
|
+
`byte-equal the published styling contract version ` +
|
|
430
|
+
`(${publishedContract.contractVersion})`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (themeJson.stylingContractDigest !== publishedContract.catalogDigest) {
|
|
434
|
+
throw new ThemeAssetError(
|
|
435
|
+
'THEME_CONTRACT_VERSION_MISMATCH',
|
|
436
|
+
`theme.json.stylingContractDigest does not byte-equal the published ` +
|
|
437
|
+
`styling contract's own catalogDigest (this theme was built ` +
|
|
438
|
+
`against a different template styling contract)`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
let templateRangeSatisfied;
|
|
442
|
+
try {
|
|
443
|
+
templateRangeSatisfied = satisfiesTemplateRange(
|
|
444
|
+
TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION,
|
|
445
|
+
/** @type {string} */ (themeJson.templateRange),
|
|
446
|
+
);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
throw new ThemeAssetError(
|
|
449
|
+
'THEME_CONTRACT_VERSION_MISMATCH',
|
|
450
|
+
`theme.json.templateRange is not admitted: ${/** @type {Error} */ (error).message}`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (!templateRangeSatisfied) {
|
|
454
|
+
throw new ThemeAssetError(
|
|
455
|
+
'THEME_CONTRACT_VERSION_MISMATCH',
|
|
456
|
+
`theme.json.templateRange (${themeJson.templateRange}) does not admit ` +
|
|
457
|
+
`this renderer's own published version ` +
|
|
458
|
+
`(${TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION})`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* TPL-M1 fix: compute a Subresource Integrity attribute value
|
|
465
|
+
* (`sha256-<base64>`) from exact file bytes, so a theme stylesheet `<link>`
|
|
466
|
+
* can be verified by the browser itself.
|
|
467
|
+
*
|
|
468
|
+
* @param {Buffer} bytes exact file bytes
|
|
469
|
+
* @returns {string} an `integrity` attribute value, e.g. `sha256-AAAA...`
|
|
470
|
+
*/
|
|
471
|
+
function subresourceIntegrityAttribute(bytes) {
|
|
472
|
+
return `sha256-${createHash('sha256').update(bytes).digest('base64')}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
286
475
|
/**
|
|
287
476
|
* Load and validate a selected theme package's `theme.json`, copy its
|
|
288
477
|
* stylesheets (and any declared non-CSS passive assets) into
|
|
@@ -295,6 +484,8 @@ function joinedThemeOutputPath(basePath, themeRelativePath) {
|
|
|
295
484
|
* file set `theme.json.stylesheets`/`assets` and `package.json.files`
|
|
296
485
|
* name)
|
|
297
486
|
* @param {string} options.basePath the build input's `basePath`
|
|
487
|
+
* @param {string} [options.publicBasePath] the base path emitted `<link href>`
|
|
488
|
+
* values use (`route.js#derivePublicBasePath`); defaults to `basePath`
|
|
298
489
|
* @returns {Promise<{
|
|
299
490
|
* files: {path: string, bytes: Buffer}[],
|
|
300
491
|
* assets: import('../../../types/index.d.ts').ManifestAssetEntry[],
|
|
@@ -302,14 +493,20 @@ function joinedThemeOutputPath(basePath, themeRelativePath) {
|
|
|
302
493
|
* }>} the files to write (already `basePath`-joined), their manifest rows
|
|
303
494
|
* (same joined paths), and the ready-to-insert `<link>` markup
|
|
304
495
|
*/
|
|
305
|
-
export async function loadThemeAssets({
|
|
496
|
+
export async function loadThemeAssets({
|
|
497
|
+
themeDirectory,
|
|
498
|
+
basePath,
|
|
499
|
+
publicBasePath = basePath,
|
|
500
|
+
}) {
|
|
306
501
|
const { bytes: themeJsonBytes } = await readDeclaredThemeFile(
|
|
307
502
|
themeDirectory,
|
|
308
503
|
'theme.json',
|
|
309
504
|
);
|
|
310
|
-
/** @type {{stylesheets: unknown, cssLayers: unknown, assets?: {path: string, mediaType: string}[]}} */
|
|
505
|
+
/** @type {Record<string, unknown> & {stylesheets: unknown, cssLayers: unknown, assets?: {path: string, mediaType: string, byteLength?: string, sha256?: string}[]}} */
|
|
311
506
|
const themeJson = JSON.parse(themeJsonBytes.toString('utf8'));
|
|
312
507
|
|
|
508
|
+
assertThemeContractIntegrity(themeJson);
|
|
509
|
+
|
|
313
510
|
if (!Array.isArray(themeJson.stylesheets)) {
|
|
314
511
|
throw new ThemeAssetError(
|
|
315
512
|
'THEME_STYLESHEETS_INVALID',
|
|
@@ -317,7 +514,6 @@ export async function loadThemeAssets({ themeDirectory, basePath }) {
|
|
|
317
514
|
);
|
|
318
515
|
}
|
|
319
516
|
assertStylesheetListShape(themeJson.stylesheets);
|
|
320
|
-
assertCssLayersProjection(themeJson.stylesheets, themeJson.cssLayers);
|
|
321
517
|
|
|
322
518
|
/** @type {{path: string, bytes: Buffer}[]} */
|
|
323
519
|
const files = [];
|
|
@@ -326,11 +522,19 @@ export async function loadThemeAssets({ themeDirectory, basePath }) {
|
|
|
326
522
|
/** @type {string[]} */
|
|
327
523
|
const linkTags = [];
|
|
328
524
|
|
|
525
|
+
const declaredAssetsByPath = new Map(
|
|
526
|
+
(themeJson.assets ?? []).map((asset) => [asset.path, asset]),
|
|
527
|
+
);
|
|
329
528
|
for (const filename of /** @type {string[]} */ (themeJson.stylesheets)) {
|
|
330
529
|
const { relativePath, bytes } = await readDeclaredThemeFile(
|
|
331
530
|
themeDirectory,
|
|
332
531
|
filename,
|
|
333
532
|
);
|
|
533
|
+
// TPL-H1: the schema requires every stylesheet to also carry its own
|
|
534
|
+
// `assets[]` digest row; verify it here rather than parsing and
|
|
535
|
+
// ignoring it.
|
|
536
|
+
const declaredRow = declaredAssetsByPath.get(relativePath);
|
|
537
|
+
if (declaredRow) assertPassiveAssetIntegrity(declaredRow, bytes);
|
|
334
538
|
const outputPath = joinedThemeOutputPath(basePath, relativePath);
|
|
335
539
|
files.push({ path: outputPath, bytes });
|
|
336
540
|
assets.push({
|
|
@@ -341,26 +545,68 @@ export async function loadThemeAssets({ themeDirectory, basePath }) {
|
|
|
341
545
|
immutable: false,
|
|
342
546
|
});
|
|
343
547
|
const mediaAttribute = filename === 'print.css' ? ' media="print"' : '';
|
|
548
|
+
// TPL-M1 fix: emit Subresource Integrity so a mutated stylesheet in a
|
|
549
|
+
// published artifact is detectable by the browser itself, not only by
|
|
550
|
+
// re-running manifest verification. `digestBytes` returns a hex-prefixed
|
|
551
|
+
// `sha256:` tag for the manifest; SRI needs the same digest re-encoded as
|
|
552
|
+
// base64, computed fresh from these exact bytes.
|
|
553
|
+
const integrity = subresourceIntegrityAttribute(bytes);
|
|
344
554
|
linkTags.push(
|
|
345
|
-
`<link rel="stylesheet" href="${escapeHtml(
|
|
555
|
+
`<link rel="stylesheet" href="${escapeHtml(themeHref(publicBasePath, relativePath))}" integrity="${integrity}" crossorigin="anonymous"${mediaAttribute}>`,
|
|
346
556
|
);
|
|
347
557
|
}
|
|
348
558
|
|
|
559
|
+
// TPL-M2 fix: partition by the already-shape-validated `stylesheets` list
|
|
560
|
+
// itself, not by comparing `asset.mediaType` against the literal string
|
|
561
|
+
// `'text/css'` — a theme declaring its own stylesheet rows with the same
|
|
562
|
+
// `'text/css; charset=utf-8'` spelling this function's own manifest rows
|
|
563
|
+
// above use would otherwise be classified as a passive asset and copied a
|
|
564
|
+
// second time under the same output path.
|
|
565
|
+
const declaredStylesheetPaths = new Set(themeJson.stylesheets);
|
|
349
566
|
const passiveAssets = (themeJson.assets ?? []).filter(
|
|
350
|
-
(asset) => asset.
|
|
567
|
+
(asset) => !declaredStylesheetPaths.has(asset.path),
|
|
351
568
|
);
|
|
569
|
+
|
|
570
|
+
const budgets = assertThemeBudgetsShape(themeJson.budgets);
|
|
571
|
+
let totalPassiveBytes = 0;
|
|
572
|
+
if (passiveAssets.length > budgets.maximumFiles) {
|
|
573
|
+
throw new ThemeAssetError(
|
|
574
|
+
'THEME_ASSET_BUDGET_EXCEEDED',
|
|
575
|
+
`theme declares ${passiveAssets.length} passive assets, exceeding budgets.maximumFiles (${budgets.maximumFiles})`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
352
579
|
for (const asset of passiveAssets) {
|
|
353
580
|
const { relativePath, bytes } = await readDeclaredThemeFile(
|
|
354
581
|
themeDirectory,
|
|
355
582
|
asset.path,
|
|
356
583
|
);
|
|
584
|
+
assertPassiveAssetIntegrity(asset, bytes);
|
|
585
|
+
if (bytes.byteLength > budgets.maximumFileBytes) {
|
|
586
|
+
throw new ThemeAssetError(
|
|
587
|
+
'THEME_ASSET_BUDGET_EXCEEDED',
|
|
588
|
+
`${relativePath} is ${bytes.byteLength} bytes, exceeding budgets.maximumFileBytes (${budgets.maximumFileBytes})`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
totalPassiveBytes += bytes.byteLength;
|
|
592
|
+
if (totalPassiveBytes > budgets.maximumTotalBytes) {
|
|
593
|
+
throw new ThemeAssetError(
|
|
594
|
+
'THEME_ASSET_BUDGET_EXCEEDED',
|
|
595
|
+
`passive assets total ${totalPassiveBytes} bytes, exceeding budgets.maximumTotalBytes (${budgets.maximumTotalBytes})`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const { mediaType, publishedBytes } = admitPassiveAssetBytes(
|
|
600
|
+
relativePath,
|
|
601
|
+
bytes,
|
|
602
|
+
);
|
|
357
603
|
const outputPath = joinedThemeOutputPath(basePath, relativePath);
|
|
358
|
-
files.push({ path: outputPath, bytes });
|
|
604
|
+
files.push({ path: outputPath, bytes: publishedBytes });
|
|
359
605
|
assets.push({
|
|
360
606
|
path: outputPath,
|
|
361
|
-
mediaType
|
|
362
|
-
byteLength: String(
|
|
363
|
-
sha256: digestBytes(
|
|
607
|
+
mediaType,
|
|
608
|
+
byteLength: String(publishedBytes.byteLength),
|
|
609
|
+
sha256: digestBytes(publishedBytes),
|
|
364
610
|
immutable: false,
|
|
365
611
|
});
|
|
366
612
|
}
|
|
@@ -369,7 +615,7 @@ export async function loadThemeAssets({ themeDirectory, basePath }) {
|
|
|
369
615
|
}
|
|
370
616
|
|
|
371
617
|
/**
|
|
372
|
-
* The
|
|
618
|
+
* The default theme stylesheet markup, used only when
|
|
373
619
|
* `renderPublication` is not given a resolved `options.themeDirectory`
|
|
374
620
|
* (this repository's own tests, and any caller that has not yet resolved a
|
|
375
621
|
* theme package): one `media="print"` `<link>` at the fixed conventional
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A minimal RFC 9562 UUIDv7 generator for `artifactId`.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* artifact IDs" (DEC-097 section 6), so this value is intentionally not
|
|
2
|
+
* A minimal RFC 9562 UUIDv7 generator for `artifactId`. The `stableId`
|
|
3
|
+
* pattern requires the version nibble `7` and an `8`/`9`/`a`/`b` variant
|
|
4
|
+
* nibble; `node:crypto`'s `randomUUID()` only produces v4. Equal artifact
|
|
5
|
+
* bytes may have distinct artifact IDs, so this value is intentionally not
|
|
7
6
|
* required to be deterministic across two otherwise byte-identical builds.
|
|
8
7
|
*/
|
|
9
8
|
|
package/src/core/manifest.js
CHANGED
|
Binary file
|
package/types/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hand-maintained declaration surface for `@rathnasgala2/template
|
|
3
|
-
*
|
|
2
|
+
* Hand-maintained declaration surface for `@rathnasgala2/template`: this
|
|
3
|
+
* package is JSDoc-typed JavaScript ESM, with no TypeScript sources.
|
|
4
4
|
*
|
|
5
5
|
* Every `build-input:2.0.0` / `artifact-manifest:2.0.0` shape below is a
|
|
6
|
-
* pragmatic subset of
|
|
6
|
+
* pragmatic subset of the published schemas' exact shapes: it types every
|
|
7
7
|
* field this renderer actually reads or writes, and widens everything else
|
|
8
8
|
* to `unknown`/index signatures rather than re-authoring the full published
|
|
9
9
|
* schema as TypeScript. The `@rathnasgala2/schemas` package (JSON Schema
|
|
@@ -222,7 +222,7 @@ export interface ManifestRouteEntry {
|
|
|
222
222
|
interactionBearing: false;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
/** A `manifestAsset`-shaped entry (
|
|
225
|
+
/** A `manifestAsset`-shaped entry (the media pipeline emits one per generated file). */
|
|
226
226
|
export interface ManifestAssetEntry {
|
|
227
227
|
path: string;
|
|
228
228
|
mediaType: string;
|
|
@@ -269,7 +269,7 @@ export interface ManifestExcludedInputEntry {
|
|
|
269
269
|
/**
|
|
270
270
|
* Provenance facts the renderer cannot derive from `build-input` alone (see
|
|
271
271
|
* `src/core/manifest.js` module documentation). Supplied by the caller
|
|
272
|
-
* (eventually `publish-kernel`/`publish-action
|
|
272
|
+
* (eventually `publish-kernel`/`publish-action`).
|
|
273
273
|
*/
|
|
274
274
|
export interface RenderProvenance {
|
|
275
275
|
builder: PackageIdentity;
|
|
@@ -285,13 +285,13 @@ export interface RenderOptions {
|
|
|
285
285
|
workDirectory: string;
|
|
286
286
|
/**
|
|
287
287
|
* The caller-mounted, read-only repository source tree that
|
|
288
|
-
* `build-input`'s `resolvedFile` references (images, fonts) point into
|
|
289
|
-
*
|
|
288
|
+
* `build-input`'s `resolvedFile` references (images, fonts) point into.
|
|
289
|
+
* Must be distinct from `outputDirectory` and `workDirectory`.
|
|
290
290
|
*/
|
|
291
291
|
sourceDirectory: string;
|
|
292
292
|
/**
|
|
293
293
|
* An optional caller-mounted, read-only extracted theme package directory
|
|
294
|
-
* (
|
|
294
|
+
* (`internal/theme-assets.js`). When supplied, its `theme.json`
|
|
295
295
|
* declared stylesheets (and any declared passive assets) are copied into
|
|
296
296
|
* `assets/theme/` and linked from every generated page. Must be distinct
|
|
297
297
|
* from `outputDirectory`, `workDirectory` and `sourceDirectory`.
|
|
@@ -345,3 +345,17 @@ export declare function normalizeAuthoredMarkdown(markdownSource: string): {
|
|
|
345
345
|
|
|
346
346
|
/** A plain (not domain-separated) tagged SHA-256 digest of a body's exact UTF-8 bytes. */
|
|
347
347
|
export declare function computeBodyDigest(html: string): string;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The current published `renderPolicyIdentity`
|
|
351
|
+
* (`{name, version, digest}`) every `renderableBody.renderPolicy` must
|
|
352
|
+
* byte-equal, computed from the published `contracts/render-policy.jcs`
|
|
353
|
+
* file. A consumer that needs to construct or verify a `renderableBody`
|
|
354
|
+
* ahead of calling `renderPublication` can compute this identity without
|
|
355
|
+
* reaching into `src/core/internal/`.
|
|
356
|
+
*/
|
|
357
|
+
export declare function computeRenderPolicyIdentity(): Promise<{
|
|
358
|
+
name: string;
|
|
359
|
+
version: string;
|
|
360
|
+
digest: string;
|
|
361
|
+
}>;
|