@rathnasgala2/template 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +204 -137
  2. package/contracts/render-policy.jcs +1 -1
  3. package/contracts/theme-styling-contract.jcs +1 -1
  4. package/package.json +4 -5
  5. package/src/core/errors.js +13 -13
  6. package/src/core/index.js +102 -66
  7. package/src/core/internal/appearance/base-layer.js +180 -0
  8. package/src/core/internal/appearance/bootstrap-script.js +11 -11
  9. package/src/core/internal/appearance/contract.js +59 -44
  10. package/src/core/internal/appearance/controller-markup.js +7 -8
  11. package/src/core/internal/appearance/styling-contract.js +132 -29
  12. package/src/core/internal/canonical-jcs.js +0 -0
  13. package/src/core/internal/content-security.js +0 -0
  14. package/src/core/internal/eleventy-render.js +52 -35
  15. package/src/core/internal/feeds.js +10 -13
  16. package/src/core/internal/fs-walk.js +2 -2
  17. package/src/core/internal/media/avif-probe.js +4 -4
  18. package/src/core/internal/media/font-policy.js +2 -2
  19. package/src/core/internal/media/jpeg-codec.js +0 -0
  20. package/src/core/internal/media/limits.js +32 -34
  21. package/src/core/internal/media/pipeline.js +4 -4
  22. package/src/core/internal/media/png-codec.js +7 -7
  23. package/src/core/internal/media/resize.js +2 -2
  24. package/src/core/internal/media/sniff.js +4 -4
  25. package/src/core/internal/media/theme-svg-sanitizer.js +278 -0
  26. package/src/core/internal/media/webp-probe.js +3 -3
  27. package/src/core/internal/messages.js +8 -8
  28. package/src/core/internal/page-kinds.js +46 -48
  29. package/src/core/internal/render-policy-content.js +57 -21
  30. package/src/core/internal/route-labels.js +5 -6
  31. package/src/core/internal/route.js +16 -20
  32. package/src/core/internal/search-index.js +12 -14
  33. package/src/core/internal/semver-range.js +84 -0
  34. package/src/core/internal/seo.js +21 -23
  35. package/src/core/internal/sitemap.js +6 -8
  36. package/src/core/internal/skeleton.js +42 -39
  37. package/src/core/internal/source-inventory.js +4 -5
  38. package/src/core/internal/text-direction.js +1 -2
  39. package/src/core/internal/theme-assets.js +294 -67
  40. package/src/core/internal/uuid.js +4 -5
  41. package/src/core/manifest.js +0 -0
  42. package/types/index.d.ts +22 -8
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Theme package asset integration (task packet S2-T12): copy the selected
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, `cssLayers` projection matching the
13
- * published `contracts/theme-styling-contract.jcs`'s `orderedLayers`, and
14
- * the declared files actually existing. It does **not** re-implement
15
- * DEC-097's full CSS Syntax Module admission grammar (selector/property/
16
- * at-rule closed catalogs, byte/token/rule ceilings, etc.) — that is the
17
- * shared `theme-release.yml` conformance runner's job (S2-T11), run once
18
- * per theme package release, not per publication build. Authoring real
19
- * theme CSS is S2-T13's task, forbidden here; this module only moves bytes
20
- * an already-conformant theme package supplies.
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 S2-T02 through S2-T11
25
- * tests) gets the S2-T08 default — a `<link>` at the fixed conventional
26
- * `assets/theme/print.css` path, matching `internal/print-stylesheet.js`'s
27
- * pre-existing convention — so this task's addition is purely additive.
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 { ORDERED_LAYERS } from './appearance/styling-contract.js';
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.mediaType !== 'text/css',
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: asset.mediaType,
362
- byteLength: String(bytes.byteLength),
363
- sha256: digestBytes(bytes),
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 S2-T08 default theme stylesheet markup, used only when
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`. DEC-097's
3
- * `stableId` pattern requires the version nibble `7` and an `8`/`9`/`a`/`b`
4
- * variant nibble; `node:crypto`'s `randomUUID()` only produces v4. The
5
- * decision text is explicit that "equal artifact bytes may have distinct
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
 
Binary file
package/types/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Hand-maintained declaration surface for `@rathnasgala2/template`
3
- * (DEC-094: JSDoc-typed JavaScript ESM, no TypeScript sources).
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 DEC-097 section 5/6's exact schemas: it types every
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 (S2-T05's media pipeline emits one per generated file). */
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`, S2-T15 through S2-T20).
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
- * (S2-T05). Must be distinct from `outputDirectory` and `workDirectory`.
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
- * (S2-T12: `internal/theme-assets.js`). When supplied, its `theme.json`
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
+ }>;