@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,24 +1,21 @@
1
1
  /**
2
- * RSS 2.0 and Atom 1.0 feed generation (task packet S2-T08; brief S2
3
- * section 3 "Required generated outputs": "RSS and Atom feeds").
2
+ * RSS 2.0 and Atom 1.0 feed generation.
4
3
  *
5
4
  * Both feeds are built from the exact same {@link selectPublishedArticles}
6
5
  * selection and ordering `internal/page-kinds.js`'s `index` page kind
7
6
  * paginates (most recently published first, `publishedAt` descending, tied
8
7
  * broken by ascending `id`), so a feed's item order can never drift from the
9
8
  * site's own index listing. Every URL is an absolute `https://` URL resolved
10
- * against `buildInput.baseUrl` (brief S2 section 3: "absolute URLs from the
11
- * build input's public origin"), never a relative path — a feed reader has
12
- * no notion of the publication's own base path to resolve a relative URL
13
- * against.
9
+ * against `buildInput.baseUrl` — always the build input's public origin,
10
+ * never a relative path — since a feed reader has no notion of the
11
+ * publication's own base path to resolve a relative URL against.
14
12
  *
15
- * Item count (documented scoped decision, not fixed by the brief or
16
- * DEC-097): neither source specifies a maximum feed item count. This module
17
- * bounds it at {@link FEED_ITEM_LIMIT} — a conservative, deterministic
18
- * default with no author-facing configuration surface in S2, matching this
19
- * renderer's own established style for an unspecified numeric bound (see
20
- * `internal/page-kinds.js`'s `LISTING_PAGE_SIZE` and the media pipeline
21
- * scope decisions in the package README).
13
+ * Item count: this renderer's build input carries no maximum feed item
14
+ * count. This module bounds it at {@link FEED_ITEM_LIMIT} — a conservative,
15
+ * deterministic default with no author-facing configuration surface,
16
+ * matching this renderer's own established style for an unspecified
17
+ * numeric bound (see `internal/page-kinds.js`'s `LISTING_PAGE_SIZE` and the
18
+ * media pipeline scope decisions in the package README).
22
19
  */
23
20
 
24
21
  import { escapeHtml } from './skeleton.js';
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Filesystem readdir order is not portable, so every caller that needs a
5
5
  * reproducible route/asset ordering must sort explicitly; this module never
6
- * returns raw enumeration order (brief S2 section 3: "no
7
- * filesystem-enumeration-order dependence").
6
+ * returns raw enumeration order, so this renderer has no
7
+ * filesystem-enumeration-order dependence.
8
8
  */
9
9
 
10
10
  import { readdir } from 'node:fs/promises';
@@ -7,17 +7,17 @@
7
7
  * enough to confirm an `ftyp` box with major brand `avif`, locate the
8
8
  * `meta` box, and read the primary item's `ispe` (image spatial extents)
9
9
  * property for declared width/height, but it does not run a full AV1
10
- * bitstream decode. DEC-097's closed `gala-theme-binary-assets-v2` profile
10
+ * bitstream decode. This renderer's closed passive-theme-asset decoder
11
11
  * requires a complete single-frame AV1 decode for *passive theme assets*;
12
12
  * an equivalent from-scratch AV1 decoder for *author media* is out of
13
- * proportion to this task's scope, flagged to the orchestrator as a
13
+ * proportion to this module's scope, flagged to the orchestrator as a
14
14
  * follow-up. AVIF is therefore admitted as a *source* reference
15
15
  * (recorded, digest-verified, dimension-bounded) with no generated
16
16
  * responsive derivative; its original bytes pass through as the sole asset
17
17
  * entry, exactly like WebP.
18
18
  *
19
- * The box walk is bounded: at most 4,096 boxes are visited (matching
20
- * DEC-097's own "4,096 container structures" isolation bound for the binary
19
+ * The box walk is bounded: at most 4,096 boxes are visited (matching this
20
+ * renderer's own container-structure isolation bound for the binary
21
21
  * decoder family) and every box's declared size is checked against the
22
22
  * remaining buffer length before it is used to slice, so a malformed or
23
23
  * adversarial box table cannot cause an out-of-bounds read or an unbounded
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Font policy for `appearance.fontAssets` (brief S2 section 3: "Custom fonts
2
+ * Font policy for `appearance.fontAssets`: custom fonts
3
3
  * require licensing, glyph and language coverage plus fallback evidence, and
4
- * no remote font is ever retrieved").
4
+ * no remote font is ever retrieved.
5
5
  *
6
6
  * `build-input:2.0.0`'s `resolvedFile` shape (the only shape
7
7
  * `appearance.fontAssets` entries use) is `{path, sourceDigest}` — a
@@ -1,37 +1,34 @@
1
1
  import { MediaPipelineError } from '../../errors.js';
2
2
 
3
3
  /**
4
- * Media pipeline resource ceilings (brief S2 section 3, S2-T05: "bounded
5
- * decoding worker", "decompression and resource limits").
4
+ * Media pipeline resource ceilings: a bounded decoding worker with fixed
5
+ * decompression and resource limits, so a hostile or malformed image/font
6
+ * can never trigger unbounded allocation or a decode bomb.
6
7
  *
7
- * The raster-specific numbers below are DEC-097's own closed
8
- * `gala-theme-binary-assets-v2` bounds (decision document section titled
9
- * "Binary theme assets"): non-animated raster, width/height each `1..8192`,
10
- * at most 16,777,216 pixels, at most 67,108,864 decoded RGBA8 buffer bytes,
11
- * source raster at most 16,777,216 bytes, source font at most 4,194,304
12
- * bytes. DEC-097 states those bounds for the closed theme *passive-asset*
13
- * decoder profile; the brief's author-media pipeline (S2-T05) gives no
14
- * separate numeric profile of its own, so this renderer reuses the same
15
- * numbers for the author-media decode-bound worker rather than inventing an
16
- * unrelated set — the physical decode-bomb risk (integer overflow, unbounded
17
- * allocation) is identical for both call sites. This is an explicit,
18
- * documented implementer decision, not a value taken from author-media text.
8
+ * The raster-specific numbers below are this renderer's closed binary-asset
9
+ * bounds, shared between the theme passive-asset decoder profile and the
10
+ * author-media pipeline (which has no separate numeric profile of its own):
11
+ * non-animated raster, width/height each `1..8192`, at most 16,777,216
12
+ * pixels, at most 67,108,864 decoded RGBA8 buffer bytes, source raster at
13
+ * most 16,777,216 bytes, source font at most 4,194,304 bytes. Reusing the
14
+ * same numbers for both call sites, rather than inventing an unrelated set,
15
+ * is a deliberate choice: the physical decode-bomb risk (integer overflow,
16
+ * unbounded allocation) is identical for both.
19
17
  *
20
- * DEC-097's separate "document-22 operational release" MVP launch gate also
21
- * fixes a smaller general per-source-file ceiling (10,485,760 bytes) that
22
- * applies to *every* repository source file regardless of kind, with "the
23
- * smaller declared per-file/package budget still wins." Applied here: the
18
+ * A separate, smaller general per-source-file ceiling (10,485,760 bytes)
19
+ * applies to *every* repository source file regardless of kind, and the
20
+ * smaller declared per-file/package budget always wins. Applied here: the
24
21
  * effective per-image source-byte ceiling is `min(16,777,216, 10,485,760)`.
25
22
  * The font ceiling (4,194,304) is already smaller than the general cap, so
26
23
  * it is unaffected.
27
24
  *
28
25
  * `MAX_IMAGES_PER_PUBLICATION` and `MAX_TOTAL_MEDIA_BYTES_PER_PUBLICATION`
29
- * are not fixed anywhere in the brief or DEC-097; there is no cited
30
- * per-publication *media-specific* aggregate. These two values are this
31
- * renderer's own conservative, documented ceilings (well under DEC-097's
32
- * unrelated 50,000-file / 1,073,741,824-byte whole-artifact ceiling), so a
33
- * publication cannot exhaust the media pipeline's bounded decode budget by
34
- * sheer reference count even though each individual file is itself bounded.
26
+ * have no externally fixed per-publication *media-specific* aggregate to
27
+ * conform to. These two values are this renderer's own conservative,
28
+ * documented ceilings (well under this renderer's separate, unrelated
29
+ * 50,000-file / 1,073,741,824-byte whole-artifact ceiling), so a publication
30
+ * cannot exhaust the media pipeline's bounded decode budget by sheer
31
+ * reference count even though each individual file is itself bounded.
35
32
  */
36
33
 
37
34
  /** Maximum admitted raster width or height, in pixels, inclusive. */
@@ -44,15 +41,15 @@ export const MAX_IMAGE_PIXELS = 16_777_216;
44
41
  export const MAX_DECODED_RGBA_BYTES = 67_108_864;
45
42
 
46
43
  /**
47
- * Maximum admitted raster source byte length: the smaller of DEC-097's
48
- * raster-specific cap (16,777,216) and its general per-source-file cap
49
- * (10,485,760).
44
+ * Maximum admitted raster source byte length: the smaller of this
45
+ * renderer's raster-specific cap (16,777,216) and its general
46
+ * per-source-file cap (10,485,760).
50
47
  */
51
48
  export const MAX_IMAGE_SOURCE_BYTES = 10_485_760;
52
49
 
53
50
  /**
54
- * Maximum admitted font source byte length (DEC-097's binary font cap,
55
- * already smaller than the general per-source-file cap).
51
+ * Maximum admitted font source byte length (this renderer's binary font
52
+ * cap, already smaller than the general per-source-file cap).
56
53
  */
57
54
  export const MAX_FONT_SOURCE_BYTES = 4_194_304;
58
55
 
@@ -74,8 +71,9 @@ export const MAX_FONTS_PER_PUBLICATION = 8;
74
71
  /**
75
72
  * Maximum aggregate decoded-plus-derivative byte budget the media pipeline
76
73
  * will spend for one publication (implementer-chosen; see module
77
- * documentation). Comfortably under DEC-097's unrelated 1,073,741,824-byte
78
- * whole-artifact ceiling, which this constant does not replace or relax.
74
+ * documentation). Comfortably under this renderer's separate, unrelated
75
+ * 1,073,741,824-byte whole-artifact ceiling, which this constant does not
76
+ * replace or relax.
79
77
  */
80
78
  export const MAX_TOTAL_MEDIA_BYTES_PER_PUBLICATION = 268_435_456;
81
79
 
@@ -116,9 +114,9 @@ export function assertDimensionsWithinCeiling(
116
114
 
117
115
  /**
118
116
  * Non-resetting wall-clock budget for one file's bounded decode, in
119
- * milliseconds. DEC-097's own closed binary-asset decoder uses a ten-second
120
- * non-resetting deadline; this renderer reuses that figure for the same
121
- * documented reason as the size/pixel ceilings above.
117
+ * milliseconds. This renderer's closed binary-asset decoder uses a
118
+ * ten-second non-resetting deadline, for the same documented reason as the
119
+ * size/pixel ceilings above.
122
120
  */
123
121
  export const DECODE_DEADLINE_MS = 10_000;
124
122
 
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Media pipeline orchestration (brief S2 section 3, S2-T05).
2
+ * Media pipeline orchestration.
3
3
  *
4
4
  * `build-input:2.0.0` never carries raw asset bytes for a `resolvedFile`
5
- * reference — only its repository-relative `path` and a `sourceDigest`
6
- * (DEC-097 section 6: "`build` exposes this decoded set as a read-only
7
- * source mount"). This pipeline therefore reads actual bytes from
5
+ * reference — only its repository-relative `path` and a `sourceDigest`:
6
+ * `build` exposes this decoded set as a read-only source mount. This
7
+ * pipeline therefore reads actual bytes from
8
8
  * `options.sourceDirectory`, the caller-mounted read-only source tree, and
9
9
  * fails closed (`MEDIA_SOURCE_DIGEST_MISMATCH`) if what it reads there does
10
10
  * not hash to the digest `build-input` declared — the same fail-closed
@@ -6,11 +6,11 @@
6
6
  * (grayscale+alpha) and 6 (truecolor+alpha), bit depth 8 only, no
7
7
  * interlacing. Any other bit depth, interlace method, or PNG structure this
8
8
  * module does not recognize is a `MEDIA_FORMAT_INVALID` rejection — never a
9
- * best-effort guess — matching the fail-closed posture the brief and
10
- * DEC-097 both require of this pipeline. This is a deliberately smaller
11
- * surface than DEC-097's own closed `gala-theme-binary-assets-v2` PNG
12
- * grammar (which the theme packages use for passive presentation assets);
13
- * author media only needs a decode path sufficient to bound, re-derive
9
+ * best-effort guess — matching the fail-closed posture required throughout
10
+ * this pipeline. This is a deliberately smaller surface than this
11
+ * renderer's own closed passive-theme-asset PNG grammar (which the theme
12
+ * packages use for passive presentation assets); author media only needs a
13
+ * decode path sufficient to bound, re-derive
14
14
  * orientation-free pixels from, and re-encode ordinary photographic/graphic
15
15
  * uploads.
16
16
  *
@@ -371,8 +371,8 @@ function crc32(buffer) {
371
371
  * Deterministically encode an RGBA8 raster as a minimal truecolor+alpha PNG:
372
372
  * one `IHDR`, one "filter type 0 (none)" `IDAT` stream at a fixed zlib
373
373
  * level/strategy, one `IEND`. No ancillary chunk (no metadata) is ever
374
- * emitted, matching the brief's "strip nonessential metadata" requirement
375
- * for every output this renderer produces.
374
+ * emitted, stripping nonessential metadata
375
+ * from every output this renderer produces.
376
376
  *
377
377
  * @param {{width: number, height: number, rgba: Buffer}} raster the raster
378
378
  * to encode
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * IEEE 754 double-precision arithmetic is fully deterministic — the same
6
6
  * inputs always produce the same bit pattern on any conforming engine — so
7
- * this box filter (unlike a lossy codec's own pixel decode, which DEC-097
8
- * notes is implementation-dependent) is safe to use directly for the
7
+ * this box filter (unlike a lossy codec's own pixel decode, which is
8
+ * implementation-dependent) is safe to use directly for the
9
9
  * two-clean-build byte-equality requirement: the same source raster and the
10
10
  * same target width always produce the same resized raster, and therefore,
11
11
  * fed through this pipeline's own deterministic encoders, the same
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Media-type sniffing from decoded bytes, never from a file extension or a
3
- * caller-declared media type (brief S2 section 3: "Establish media type from
4
- * decoded bytes, not extension").
3
+ * caller-declared media type: media type is established from
4
+ * decoded bytes, not extension.
5
5
  *
6
- * SVG is deliberately excluded from this sniffer's admitted output set: "SVG
7
- * is never processed as author media" (brief S2 section 3). Any byte
6
+ * SVG is deliberately excluded from this sniffer's admitted output set: SVG
7
+ * is never processed as author media. Any byte
8
8
  * sequence recognizable as SVG/XML is classified `'svg'` here specifically
9
9
  * so the caller can reject it with a distinct, explicit
10
10
  * `MEDIA_SVG_REJECTED` reason rather than falling through to a generic
@@ -0,0 +1,278 @@
1
+ /**
2
+ * The closed admission grammar a theme-declared passive SVG asset must pass
3
+ * before it is copied into a published artifact (TPL-C2, prerequisite for
4
+ * TPL-H6's iconography decision).
5
+ *
6
+ * Author-supplied SVG is unconditionally rejected by the media pipeline
7
+ * (`internal/media/sniff.js`'s `'svg'` classification, `MEDIA_SVG_REJECTED`
8
+ * in `internal/media/pipeline.js`) — SVG is active content (it admits
9
+ * `<script>`, event-handler attributes and external references) and the
10
+ * render-policy's own residual-active-content scan explicitly forbids it in
11
+ * markdown-derived HTML. A theme package is a lower-trust supply-chain input
12
+ * than the repository owner's own authored content, so it cannot be held to
13
+ * a looser standard: this module is the one path an SVG byte sequence may
14
+ * take to reach a published artifact, and it applies its own closed
15
+ * tag/attribute/scheme allowlist — independent of, and strictly narrower
16
+ * than, `sanitize-html`'s author-content configuration in
17
+ * `internal/content-security.js` — followed by the same class of fail-closed
18
+ * residual-scan backstop that pipeline uses.
19
+ *
20
+ * Scope: this grammar targets exactly one use case — a small, static,
21
+ * decorative icon or mark referenced from theme CSS (`::before`/`::after`
22
+ * `content: url(...)`, `background-image`, or an `<img>`/`<use>` a future
23
+ * theme ships). It is deliberately conservative: no `<script>`, no `<style>`
24
+ * (inline CSS can itself carry `url()` external references), no
25
+ * `<foreignObject>` (arbitrary embedded HTML), no `<image>` (a raster/SVG
26
+ * reference of its own, which would need this same admission recursively),
27
+ * no event-handler attribute of any kind, and no attribute whose value is a
28
+ * URL reference other than a same-document `#fragment` (so `<use href="#id">`
29
+ * referencing a `<symbol>` defined earlier in the same file works; anything
30
+ * that could reach off-document content does not).
31
+ */
32
+
33
+ import sanitizeHtml from 'sanitize-html';
34
+
35
+ import { ThemeAssetError } from '../../errors.js';
36
+
37
+ /** @type {string} the reason code every rejection in this module carries. */
38
+ export const THEME_SVG_REJECTED_CODE = 'THEME_ASSET_SVG_REJECTED';
39
+
40
+ /**
41
+ * The closed SVG element allowlist: structural/grouping and shape elements,
42
+ * gradients/clipping used for flat icon artwork, and `<title>`/`<desc>` for
43
+ * accessible names. No scripting, styling, foreign-content or
44
+ * external-media element is admitted.
45
+ *
46
+ * @type {readonly string[]}
47
+ */
48
+ const ALLOWED_SVG_TAGS = Object.freeze([
49
+ 'svg',
50
+ 'g',
51
+ 'defs',
52
+ 'symbol',
53
+ 'use',
54
+ 'title',
55
+ 'desc',
56
+ 'path',
57
+ 'rect',
58
+ 'circle',
59
+ 'ellipse',
60
+ 'line',
61
+ 'polyline',
62
+ 'polygon',
63
+ 'clipPath',
64
+ 'mask',
65
+ 'linearGradient',
66
+ 'radialGradient',
67
+ 'stop',
68
+ ]);
69
+
70
+ /** @type {readonly string[]} presentation/geometry attributes admitted on
71
+ * every allowed tag (a closed union, not per-tag — {@link assertNoResidualActiveSvgContent}
72
+ * is the fail-closed backstop for anything this allowlist under-restricts). */
73
+ const COMMON_SVG_ATTRIBUTES = Object.freeze([
74
+ 'id',
75
+ 'class',
76
+ 'role',
77
+ 'aria-hidden',
78
+ 'aria-label',
79
+ 'focusable',
80
+ 'viewBox',
81
+ 'width',
82
+ 'height',
83
+ 'x',
84
+ 'y',
85
+ 'x1',
86
+ 'y1',
87
+ 'x2',
88
+ 'y2',
89
+ 'cx',
90
+ 'cy',
91
+ 'r',
92
+ 'rx',
93
+ 'ry',
94
+ 'd',
95
+ 'points',
96
+ 'transform',
97
+ 'fill',
98
+ 'fill-rule',
99
+ 'fill-opacity',
100
+ 'stroke',
101
+ 'stroke-width',
102
+ 'stroke-linecap',
103
+ 'stroke-linejoin',
104
+ 'stroke-dasharray',
105
+ 'stroke-opacity',
106
+ 'opacity',
107
+ 'clip-path',
108
+ 'clip-rule',
109
+ 'mask',
110
+ 'offset',
111
+ 'stop-color',
112
+ 'stop-opacity',
113
+ 'gradientUnits',
114
+ 'gradientTransform',
115
+ 'preserveAspectRatio',
116
+ 'xmlns',
117
+ ]);
118
+
119
+ /** @type {import('sanitize-html').IOptions} */
120
+ const SVG_SANITIZE_OPTIONS = {
121
+ allowedTags: [...ALLOWED_SVG_TAGS],
122
+ allowedAttributes: Object.fromEntries(
123
+ ALLOWED_SVG_TAGS.map((tag) => [
124
+ tag,
125
+ tag === 'use'
126
+ ? [...COMMON_SVG_ATTRIBUTES, 'href', 'xlink:href']
127
+ : [...COMMON_SVG_ATTRIBUTES],
128
+ ]),
129
+ ),
130
+ // `href`/`xlink:href` are only ever admitted on `use` above, and only a
131
+ // same-document fragment reference is a legal value (enforced below by
132
+ // {@link assertNoResidualActiveSvgContent}, since sanitize-html's own
133
+ // scheme allowlist does not distinguish "no scheme" from "fragment-only").
134
+ allowedSchemesByTag: { use: [] },
135
+ allowProtocolRelative: false,
136
+ nonTextTags: [
137
+ 'script',
138
+ 'style',
139
+ 'foreignObject',
140
+ 'image',
141
+ 'animate',
142
+ 'animateTransform',
143
+ 'animateMotion',
144
+ 'set',
145
+ 'iframe',
146
+ ],
147
+ disallowedTagsMode: 'discard',
148
+ parser: { xmlMode: true, decodeEntities: true },
149
+ };
150
+
151
+ /** @type {readonly RegExp[]} fail-closed backstop patterns; a hit here is
152
+ * this module's own configuration defect, never expected of admitted
153
+ * content — mirrors `content-security.js`'s
154
+ * `RESIDUAL_ACTIVE_CONTENT_PATTERNS` backstop pattern. */
155
+ const RESIDUAL_ACTIVE_SVG_PATTERNS = Object.freeze([
156
+ /<script[\s>/]/i,
157
+ /<style[\s>/]/i,
158
+ /<foreignobject[\s>/]/i,
159
+ /<image[\s>/]/i,
160
+ /<iframe[\s>/]/i,
161
+ /\son[a-z]+\s*=/i,
162
+ /url\(\s*['"]?(?:https?:)?\/\//i,
163
+ /url\(\s*['"]?data:/i,
164
+ ]);
165
+
166
+ /**
167
+ * @param {string} sanitized the sanitize-html output
168
+ * @returns {void}
169
+ */
170
+ function assertNoResidualActiveSvgContent(sanitized) {
171
+ for (const pattern of RESIDUAL_ACTIVE_SVG_PATTERNS) {
172
+ if (pattern.test(sanitized)) {
173
+ throw new ThemeAssetError(
174
+ THEME_SVG_REJECTED_CODE,
175
+ `sanitized theme SVG still matches a forbidden pattern (${pattern})`,
176
+ );
177
+ }
178
+ }
179
+ // Every href/xlink:href that survived sanitization must be a same-document
180
+ // fragment reference; sanitize-html's own scheme allowlist admits a
181
+ // schemeless value (including a bare fragment) without distinguishing it
182
+ // from, say, a schemeless relative path, so this is checked explicitly.
183
+ for (const match of sanitized.matchAll(
184
+ /(?:href|xlink:href)\s*=\s*"([^"]*)"/gi,
185
+ )) {
186
+ if (!match[1].startsWith('#')) {
187
+ throw new ThemeAssetError(
188
+ THEME_SVG_REJECTED_CODE,
189
+ `theme SVG href must be a same-document fragment reference, got ${JSON.stringify(match[1])}`,
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Reject, rather than silently strip, any of the specific constructs the
197
+ * fix requires an SVG sanitizer to police (no script, no event handlers, no
198
+ * external references): a theme package is untrusted supply-chain input, so
199
+ * a file that *contains* one of these is treated as a rejected asset, not
200
+ * quietly laundered into a smaller, safe file the theme author never
201
+ * reviewed. The allowlist pass below is defense-in-depth on top of this,
202
+ * narrowing an already-clean file to the admitted tag/attribute vocabulary.
203
+ *
204
+ * @param {string} source the raw SVG text
205
+ * @returns {void}
206
+ */
207
+ function assertNoForbiddenSvgConstructs(source) {
208
+ if (/<!doctype/i.test(source) || /<!entity/i.test(source)) {
209
+ throw new ThemeAssetError(
210
+ THEME_SVG_REJECTED_CODE,
211
+ 'theme SVG must not declare a DOCTYPE or an XML entity',
212
+ );
213
+ }
214
+ if (
215
+ /<\s*(script|style|foreignobject|image|iframe|animate\w*|set)[\s>/]/i.test(
216
+ source,
217
+ )
218
+ ) {
219
+ throw new ThemeAssetError(
220
+ THEME_SVG_REJECTED_CODE,
221
+ 'theme SVG must not contain a <script>, <style>, <foreignObject>, ' +
222
+ '<image>, <iframe>, animation or <set> element',
223
+ );
224
+ }
225
+ if (/\son[a-z]+\s*=/i.test(source)) {
226
+ throw new ThemeAssetError(
227
+ THEME_SVG_REJECTED_CODE,
228
+ 'theme SVG must not carry an event-handler attribute',
229
+ );
230
+ }
231
+ for (const match of source.matchAll(
232
+ /(?:href|xlink:href)\s*=\s*(['"])(.*?)\1/gis,
233
+ )) {
234
+ if (!match[2].startsWith('#')) {
235
+ throw new ThemeAssetError(
236
+ THEME_SVG_REJECTED_CODE,
237
+ `theme SVG href must be a same-document fragment reference, got ${JSON.stringify(match[2])}`,
238
+ );
239
+ }
240
+ }
241
+ if (/url\(\s*['"]?(?:https?:)?\/\//i.test(source)) {
242
+ throw new ThemeAssetError(
243
+ THEME_SVG_REJECTED_CODE,
244
+ 'theme SVG must not reference an external url()',
245
+ );
246
+ }
247
+ if (/url\(\s*['"]?data:/i.test(source)) {
248
+ throw new ThemeAssetError(
249
+ THEME_SVG_REJECTED_CODE,
250
+ 'theme SVG must not embed a data: url()',
251
+ );
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Sanitize one theme-declared SVG passive asset's exact bytes against the
257
+ * closed grammar above, failing closed on anything it does not admit.
258
+ *
259
+ * @param {Buffer} bytes the file's raw bytes (already sniffed as `'svg'` by
260
+ * {@link ../media/sniff.js})
261
+ * @returns {Buffer} the sanitized UTF-8 SVG bytes, safe to publish
262
+ */
263
+ export function sanitizeThemeSvg(bytes) {
264
+ const source = bytes.toString('utf8');
265
+ assertNoForbiddenSvgConstructs(source);
266
+ const sanitized = sanitizeHtml(source, SVG_SANITIZE_OPTIONS);
267
+ if (sanitized.trim().length === 0 || !/<svg[\s>]/i.test(sanitized)) {
268
+ throw new ThemeAssetError(
269
+ THEME_SVG_REJECTED_CODE,
270
+ 'theme SVG did not survive sanitization as a root <svg> element',
271
+ );
272
+ }
273
+ // Fail-closed backstop over the allowlist pass above — a hit here is this
274
+ // module's own configuration defect, never expected of admitted content
275
+ // (the explicit rejection checks above should already have caught it).
276
+ assertNoResidualActiveSvgContent(sanitized);
277
+ return Buffer.from(sanitized, 'utf8');
278
+ }
@@ -5,11 +5,11 @@
5
5
  * the `RIFF`/`WEBP` container framing and the `VP8 `/`VP8L` chunk header
6
6
  * closely enough to reject a malformed or oversized file and to read the
7
7
  * format's own declared width/height, but it does not run a full VP8/VP8L
8
- * entropy decoder — DEC-097's own closed `gala-theme-binary-assets-v2`
9
- * profile requires a complete bounded VP8/VP8L bitstream decode for
8
+ * entropy decoder — this renderer's closed passive-theme-asset decoder
9
+ * requires a complete bounded VP8/VP8L bitstream decode for
10
10
  * *passive theme assets*; hand-authoring an equivalent, independently
11
11
  * verifiable decoder for *author media* was judged out of proportion to
12
- * this task's scope and is flagged to the orchestrator as a follow-up
12
+ * this module's scope and is flagged to the orchestrator as a follow-up
13
13
  * rather than attempted partially. What this module still enforces, fully:
14
14
  * exact container framing, a single recognized image chunk (any `VP8X`,
15
15
  * animation, or a second image chunk rejects), and the shared
@@ -1,17 +1,17 @@
1
1
  /**
2
- * Core-owned chrome message catalog (brief S2 section 3: the semantic
3
- * skeleton and navigation renderer; task packet S2-T06: "all user-visible
4
- * chrome strings coming from a message catalog (not inline literals) so
5
- * localization can find them").
2
+ * Core-owned chrome message catalog: every user-visible chrome string comes
3
+ * from a message catalog, not an inline literal, so localization can find
4
+ * them.
6
5
  *
7
6
  * Every user-visible string this package's own generated chrome (skip link,
8
7
  * navigation labels, breadcrumb labels, pagination controls, generated
9
- * listing-page headings, the error page kind, the S2-T07 Light/Dark/System
8
+ * listing-page headings, the error page kind, the Light/Dark/System
10
9
  * appearance control's label and its three option labels) emits is looked
11
10
  * up here by key
12
- * rather than written inline at its call site. S2 authors exactly one
13
- * catalog, `en` (the only language the golden fixture's own core chrome is
14
- * required to exercise); per-content localization of *authored* content is
11
+ * rather than written inline at its call site. This renderer authors
12
+ * exactly one catalog, `en` (the only language the golden fixture's own
13
+ * core chrome is required to exercise); per-content localization of
14
+ * *authored* content is
15
15
  * `publication`/`content` data, not core chrome, and full chrome
16
16
  * localization (additional catalogs, a locale-selection policy) is future
17
17
  * work this module is shaped to admit without changing any call site: every