@rathnasgala2/template 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -137
- package/contracts/render-policy.jcs +1 -1
- package/contracts/theme-styling-contract.jcs +1 -1
- package/package.json +4 -5
- package/src/core/errors.js +13 -13
- package/src/core/index.js +102 -66
- 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 +10 -13
- 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 +46 -48
- 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 +16 -20
- package/src/core/internal/search-index.js +12 -14
- package/src/core/internal/semver-range.js +84 -0
- package/src/core/internal/seo.js +21 -23
- package/src/core/internal/sitemap.js +6 -8
- package/src/core/internal/skeleton.js +42 -39
- 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 +294 -67
- 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,188 @@ function joinedThemeOutputPath(basePath, themeRelativePath) {
|
|
|
283
277
|
);
|
|
284
278
|
}
|
|
285
279
|
|
|
280
|
+
/** @type {{maximumFileBytes: number, maximumTotalBytes: number, maximumFiles: number}} */
|
|
281
|
+
const FALLBACK_BUDGETS = Object.freeze({
|
|
282
|
+
maximumFileBytes: DEFAULT_MAXIMUM_PASSIVE_ASSET_BYTES,
|
|
283
|
+
maximumTotalBytes: DEFAULT_MAXIMUM_PASSIVE_ASSET_BYTES,
|
|
284
|
+
maximumFiles: 8,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Parse and validate `theme.json.budgets` (TPL-C2/THD-M3: the declared
|
|
289
|
+
* ceilings are load-bearing, not decorative). An absent or malformed
|
|
290
|
+
* `budgets` object falls back to {@link FALLBACK_BUDGETS} rather than
|
|
291
|
+
* failing closed outright, since a missing budgets object is a shape defect
|
|
292
|
+
* TPL-H1's schema validation already rejects for a real theme package; this
|
|
293
|
+
* fallback only matters for this repository's own minimal test fixtures.
|
|
294
|
+
*
|
|
295
|
+
* @param {unknown} candidate the parsed `theme.json.budgets` value
|
|
296
|
+
* @returns {{maximumFileBytes: number, maximumTotalBytes: number, maximumFiles: number}}
|
|
297
|
+
* the effective numeric budgets
|
|
298
|
+
*/
|
|
299
|
+
function assertThemeBudgetsShape(candidate) {
|
|
300
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
301
|
+
return FALLBACK_BUDGETS;
|
|
302
|
+
}
|
|
303
|
+
const row = /** @type {Record<string, unknown>} */ (candidate);
|
|
304
|
+
const maximumFileBytes = Number(row.maximumFileBytes);
|
|
305
|
+
const maximumTotalBytes = Number(row.maximumTotalBytes);
|
|
306
|
+
const maximumFiles = Number(row.maximumFiles);
|
|
307
|
+
if (
|
|
308
|
+
!Number.isInteger(maximumFileBytes) ||
|
|
309
|
+
maximumFileBytes <= 0 ||
|
|
310
|
+
!Number.isInteger(maximumTotalBytes) ||
|
|
311
|
+
maximumTotalBytes <= 0 ||
|
|
312
|
+
!Number.isInteger(maximumFiles) ||
|
|
313
|
+
maximumFiles <= 0
|
|
314
|
+
) {
|
|
315
|
+
throw new ThemeAssetError(
|
|
316
|
+
'THEME_ASSET_BUDGET_EXCEEDED',
|
|
317
|
+
'theme.json.budgets must declare positive integer maximumFileBytes, maximumTotalBytes and maximumFiles',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return { maximumFileBytes, maximumTotalBytes, maximumFiles };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Verify a declared passive-asset row's own `byteLength`/`sha256` against
|
|
325
|
+
* the bytes actually read (TPL-C2: the digest chain becomes load-bearing at
|
|
326
|
+
* consume time, not merely computed and republished).
|
|
327
|
+
*
|
|
328
|
+
* @param {{path: string, byteLength?: unknown, sha256?: unknown}} asset the
|
|
329
|
+
* declared `theme.json.assets[]` row
|
|
330
|
+
* @param {Buffer} bytes the bytes actually read from disk
|
|
331
|
+
* @returns {void}
|
|
332
|
+
*/
|
|
333
|
+
function assertPassiveAssetIntegrity(asset, bytes) {
|
|
334
|
+
if (
|
|
335
|
+
asset.byteLength !== undefined &&
|
|
336
|
+
String(asset.byteLength) !== String(bytes.byteLength)
|
|
337
|
+
) {
|
|
338
|
+
throw new ThemeAssetError(
|
|
339
|
+
'THEME_ASSET_DIGEST_MISMATCH',
|
|
340
|
+
`${asset.path}: declared byteLength ${asset.byteLength} does not match ${bytes.byteLength} actual bytes`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const actualDigest = digestBytes(bytes);
|
|
344
|
+
if (asset.sha256 !== undefined && asset.sha256 !== actualDigest) {
|
|
345
|
+
throw new ThemeAssetError(
|
|
346
|
+
'THEME_ASSET_DIGEST_MISMATCH',
|
|
347
|
+
`${asset.path}: declared sha256 does not match its actual bytes (expected ${actualDigest}, got ${asset.sha256})`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Sniff, allowlist and (for SVG) sanitize one passive asset's bytes,
|
|
354
|
+
* deriving its published `mediaType` from the sniffed format, never from the
|
|
355
|
+
* theme's own declaration (TPL-C2).
|
|
356
|
+
*
|
|
357
|
+
* @param {string} relativePath the declared, already-validated theme-relative
|
|
358
|
+
* path (for error messages only)
|
|
359
|
+
* @param {Buffer} bytes the bytes actually read from disk
|
|
360
|
+
* @returns {{mediaType: string, publishedBytes: Buffer}} the manifest media
|
|
361
|
+
* type and the exact bytes to publish (sanitized, for SVG)
|
|
362
|
+
*/
|
|
363
|
+
function admitPassiveAssetBytes(relativePath, bytes) {
|
|
364
|
+
const format = sniffMediaFormat(bytes);
|
|
365
|
+
if (format === 'svg') {
|
|
366
|
+
// Author SVG is unconditionally rejected (media/sniff.js's own module
|
|
367
|
+
// documentation); a theme-declared SVG is admitted only after it
|
|
368
|
+
// survives the closed sanitization grammar below (TPL-H6's iconography
|
|
369
|
+
// decision: this is the mechanism that makes icon assets safe to ship).
|
|
370
|
+
return {
|
|
371
|
+
mediaType: 'image/svg+xml',
|
|
372
|
+
publishedBytes: sanitizeThemeSvg(bytes),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const mediaType =
|
|
376
|
+
format === 'unknown' ? undefined : MEDIA_TYPE_BY_FORMAT[format];
|
|
377
|
+
if (!mediaType) {
|
|
378
|
+
throw new ThemeAssetError(
|
|
379
|
+
'THEME_ASSET_FORMAT_INVALID',
|
|
380
|
+
`${relativePath}: bytes do not sniff as an admitted passive-asset format (png, jpeg, webp, avif, or sanitizable svg)`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return { mediaType, publishedBytes: bytes };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The full TPL-H1 fix: schema-validate the parsed `theme.json` against
|
|
388
|
+
* `urn:gala:schema:theme-contract:2.0.0` (the same validator this
|
|
389
|
+
* repository's own tests already use), then make the digest chain and
|
|
390
|
+
* version negotiation load-bearing rather than parsed-and-ignored:
|
|
391
|
+
* `contractVersion` must byte-equal this renderer's own published styling
|
|
392
|
+
* contract version, `stylingContractDigest` must byte-equal that contract's
|
|
393
|
+
* own `catalogDigest`, and `templateRange` must admit this renderer's own
|
|
394
|
+
* published version. Fails closed with a {@link ThemeAssetError} on the
|
|
395
|
+
* first violation.
|
|
396
|
+
*
|
|
397
|
+
* @param {Record<string, unknown>} themeJson the parsed `theme.json` document
|
|
398
|
+
* @returns {void}
|
|
399
|
+
*/
|
|
400
|
+
function assertThemeContractIntegrity(themeJson) {
|
|
401
|
+
const validation = validateGalaDocument(THEME_CONTRACT_SCHEMA_ID, themeJson);
|
|
402
|
+
if (!validation.valid) {
|
|
403
|
+
throw new ThemeAssetError(
|
|
404
|
+
'THEME_CONTRACT_SCHEMA_INVALID',
|
|
405
|
+
`theme.json failed urn:gala:schema:theme-contract:2.0.0 validation ` +
|
|
406
|
+
`(${validation.diagnostics.length} diagnostic(s)): ` +
|
|
407
|
+
JSON.stringify(validation.diagnostics),
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const publishedContract = buildTemplateStylingContract();
|
|
412
|
+
if (themeJson.contractVersion !== publishedContract.contractVersion) {
|
|
413
|
+
throw new ThemeAssetError(
|
|
414
|
+
'THEME_CONTRACT_VERSION_MISMATCH',
|
|
415
|
+
`theme.json.contractVersion (${themeJson.contractVersion}) does not ` +
|
|
416
|
+
`byte-equal the published styling contract version ` +
|
|
417
|
+
`(${publishedContract.contractVersion})`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
if (themeJson.stylingContractDigest !== publishedContract.catalogDigest) {
|
|
421
|
+
throw new ThemeAssetError(
|
|
422
|
+
'THEME_CONTRACT_VERSION_MISMATCH',
|
|
423
|
+
`theme.json.stylingContractDigest does not byte-equal the published ` +
|
|
424
|
+
`styling contract's own catalogDigest (this theme was built ` +
|
|
425
|
+
`against a different template styling contract)`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
let templateRangeSatisfied;
|
|
429
|
+
try {
|
|
430
|
+
templateRangeSatisfied = satisfiesTemplateRange(
|
|
431
|
+
TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION,
|
|
432
|
+
/** @type {string} */ (themeJson.templateRange),
|
|
433
|
+
);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
throw new ThemeAssetError(
|
|
436
|
+
'THEME_CONTRACT_VERSION_MISMATCH',
|
|
437
|
+
`theme.json.templateRange is not admitted: ${/** @type {Error} */ (error).message}`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
if (!templateRangeSatisfied) {
|
|
441
|
+
throw new ThemeAssetError(
|
|
442
|
+
'THEME_CONTRACT_VERSION_MISMATCH',
|
|
443
|
+
`theme.json.templateRange (${themeJson.templateRange}) does not admit ` +
|
|
444
|
+
`this renderer's own published version ` +
|
|
445
|
+
`(${TEMPLATE_STYLING_CONTRACT_TEMPLATE_VERSION})`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* TPL-M1 fix: compute a Subresource Integrity attribute value
|
|
452
|
+
* (`sha256-<base64>`) from exact file bytes, so a theme stylesheet `<link>`
|
|
453
|
+
* can be verified by the browser itself.
|
|
454
|
+
*
|
|
455
|
+
* @param {Buffer} bytes exact file bytes
|
|
456
|
+
* @returns {string} an `integrity` attribute value, e.g. `sha256-AAAA...`
|
|
457
|
+
*/
|
|
458
|
+
function subresourceIntegrityAttribute(bytes) {
|
|
459
|
+
return `sha256-${createHash('sha256').update(bytes).digest('base64')}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
286
462
|
/**
|
|
287
463
|
* Load and validate a selected theme package's `theme.json`, copy its
|
|
288
464
|
* stylesheets (and any declared non-CSS passive assets) into
|
|
@@ -307,9 +483,11 @@ export async function loadThemeAssets({ themeDirectory, basePath }) {
|
|
|
307
483
|
themeDirectory,
|
|
308
484
|
'theme.json',
|
|
309
485
|
);
|
|
310
|
-
/** @type {{stylesheets: unknown, cssLayers: unknown, assets?: {path: string, mediaType: string}[]}} */
|
|
486
|
+
/** @type {Record<string, unknown> & {stylesheets: unknown, cssLayers: unknown, assets?: {path: string, mediaType: string, byteLength?: string, sha256?: string}[]}} */
|
|
311
487
|
const themeJson = JSON.parse(themeJsonBytes.toString('utf8'));
|
|
312
488
|
|
|
489
|
+
assertThemeContractIntegrity(themeJson);
|
|
490
|
+
|
|
313
491
|
if (!Array.isArray(themeJson.stylesheets)) {
|
|
314
492
|
throw new ThemeAssetError(
|
|
315
493
|
'THEME_STYLESHEETS_INVALID',
|
|
@@ -317,7 +495,6 @@ export async function loadThemeAssets({ themeDirectory, basePath }) {
|
|
|
317
495
|
);
|
|
318
496
|
}
|
|
319
497
|
assertStylesheetListShape(themeJson.stylesheets);
|
|
320
|
-
assertCssLayersProjection(themeJson.stylesheets, themeJson.cssLayers);
|
|
321
498
|
|
|
322
499
|
/** @type {{path: string, bytes: Buffer}[]} */
|
|
323
500
|
const files = [];
|
|
@@ -326,11 +503,19 @@ export async function loadThemeAssets({ themeDirectory, basePath }) {
|
|
|
326
503
|
/** @type {string[]} */
|
|
327
504
|
const linkTags = [];
|
|
328
505
|
|
|
506
|
+
const declaredAssetsByPath = new Map(
|
|
507
|
+
(themeJson.assets ?? []).map((asset) => [asset.path, asset]),
|
|
508
|
+
);
|
|
329
509
|
for (const filename of /** @type {string[]} */ (themeJson.stylesheets)) {
|
|
330
510
|
const { relativePath, bytes } = await readDeclaredThemeFile(
|
|
331
511
|
themeDirectory,
|
|
332
512
|
filename,
|
|
333
513
|
);
|
|
514
|
+
// TPL-H1: the schema requires every stylesheet to also carry its own
|
|
515
|
+
// `assets[]` digest row; verify it here rather than parsing and
|
|
516
|
+
// ignoring it.
|
|
517
|
+
const declaredRow = declaredAssetsByPath.get(relativePath);
|
|
518
|
+
if (declaredRow) assertPassiveAssetIntegrity(declaredRow, bytes);
|
|
334
519
|
const outputPath = joinedThemeOutputPath(basePath, relativePath);
|
|
335
520
|
files.push({ path: outputPath, bytes });
|
|
336
521
|
assets.push({
|
|
@@ -341,26 +526,68 @@ export async function loadThemeAssets({ themeDirectory, basePath }) {
|
|
|
341
526
|
immutable: false,
|
|
342
527
|
});
|
|
343
528
|
const mediaAttribute = filename === 'print.css' ? ' media="print"' : '';
|
|
529
|
+
// TPL-M1 fix: emit Subresource Integrity so a mutated stylesheet in a
|
|
530
|
+
// published artifact is detectable by the browser itself, not only by
|
|
531
|
+
// re-running manifest verification. `digestBytes` returns a hex-prefixed
|
|
532
|
+
// `sha256:` tag for the manifest; SRI needs the same digest re-encoded as
|
|
533
|
+
// base64, computed fresh from these exact bytes.
|
|
534
|
+
const integrity = subresourceIntegrityAttribute(bytes);
|
|
344
535
|
linkTags.push(
|
|
345
|
-
`<link rel="stylesheet" href="${escapeHtml(`/${outputPath}`)}"${mediaAttribute}>`,
|
|
536
|
+
`<link rel="stylesheet" href="${escapeHtml(`/${outputPath}`)}" integrity="${integrity}" crossorigin="anonymous"${mediaAttribute}>`,
|
|
346
537
|
);
|
|
347
538
|
}
|
|
348
539
|
|
|
540
|
+
// TPL-M2 fix: partition by the already-shape-validated `stylesheets` list
|
|
541
|
+
// itself, not by comparing `asset.mediaType` against the literal string
|
|
542
|
+
// `'text/css'` — a theme declaring its own stylesheet rows with the same
|
|
543
|
+
// `'text/css; charset=utf-8'` spelling this function's own manifest rows
|
|
544
|
+
// above use would otherwise be classified as a passive asset and copied a
|
|
545
|
+
// second time under the same output path.
|
|
546
|
+
const declaredStylesheetPaths = new Set(themeJson.stylesheets);
|
|
349
547
|
const passiveAssets = (themeJson.assets ?? []).filter(
|
|
350
|
-
(asset) => asset.
|
|
548
|
+
(asset) => !declaredStylesheetPaths.has(asset.path),
|
|
351
549
|
);
|
|
550
|
+
|
|
551
|
+
const budgets = assertThemeBudgetsShape(themeJson.budgets);
|
|
552
|
+
let totalPassiveBytes = 0;
|
|
553
|
+
if (passiveAssets.length > budgets.maximumFiles) {
|
|
554
|
+
throw new ThemeAssetError(
|
|
555
|
+
'THEME_ASSET_BUDGET_EXCEEDED',
|
|
556
|
+
`theme declares ${passiveAssets.length} passive assets, exceeding budgets.maximumFiles (${budgets.maximumFiles})`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
352
560
|
for (const asset of passiveAssets) {
|
|
353
561
|
const { relativePath, bytes } = await readDeclaredThemeFile(
|
|
354
562
|
themeDirectory,
|
|
355
563
|
asset.path,
|
|
356
564
|
);
|
|
565
|
+
assertPassiveAssetIntegrity(asset, bytes);
|
|
566
|
+
if (bytes.byteLength > budgets.maximumFileBytes) {
|
|
567
|
+
throw new ThemeAssetError(
|
|
568
|
+
'THEME_ASSET_BUDGET_EXCEEDED',
|
|
569
|
+
`${relativePath} is ${bytes.byteLength} bytes, exceeding budgets.maximumFileBytes (${budgets.maximumFileBytes})`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
totalPassiveBytes += bytes.byteLength;
|
|
573
|
+
if (totalPassiveBytes > budgets.maximumTotalBytes) {
|
|
574
|
+
throw new ThemeAssetError(
|
|
575
|
+
'THEME_ASSET_BUDGET_EXCEEDED',
|
|
576
|
+
`passive assets total ${totalPassiveBytes} bytes, exceeding budgets.maximumTotalBytes (${budgets.maximumTotalBytes})`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const { mediaType, publishedBytes } = admitPassiveAssetBytes(
|
|
581
|
+
relativePath,
|
|
582
|
+
bytes,
|
|
583
|
+
);
|
|
357
584
|
const outputPath = joinedThemeOutputPath(basePath, relativePath);
|
|
358
|
-
files.push({ path: outputPath, bytes });
|
|
585
|
+
files.push({ path: outputPath, bytes: publishedBytes });
|
|
359
586
|
assets.push({
|
|
360
587
|
path: outputPath,
|
|
361
|
-
mediaType
|
|
362
|
-
byteLength: String(
|
|
363
|
-
sha256: digestBytes(
|
|
588
|
+
mediaType,
|
|
589
|
+
byteLength: String(publishedBytes.byteLength),
|
|
590
|
+
sha256: digestBytes(publishedBytes),
|
|
364
591
|
immutable: false,
|
|
365
592
|
});
|
|
366
593
|
}
|
|
@@ -369,7 +596,7 @@ export async function loadThemeAssets({ themeDirectory, basePath }) {
|
|
|
369
596
|
}
|
|
370
597
|
|
|
371
598
|
/**
|
|
372
|
-
* The
|
|
599
|
+
* The default theme stylesheet markup, used only when
|
|
373
600
|
* `renderPublication` is not given a resolved `options.themeDirectory`
|
|
374
601
|
* (this repository's own tests, and any caller that has not yet resolved a
|
|
375
602
|
* 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
|
+
}>;
|