@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.
Files changed (42) hide show
  1. package/README.md +211 -137
  2. package/contracts/render-policy.jcs +1 -1
  3. package/contracts/theme-styling-contract.jcs +1 -1
  4. package/package.json +5 -6
  5. package/src/core/errors.js +13 -13
  6. package/src/core/index.js +130 -75
  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 +15 -15
  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 +50 -51
  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 +58 -20
  32. package/src/core/internal/search-index.js +17 -16
  33. package/src/core/internal/semver-range.js +84 -0
  34. package/src/core/internal/seo.js +24 -24
  35. package/src/core/internal/sitemap.js +11 -12
  36. package/src/core/internal/skeleton.js +65 -46
  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 +314 -68
  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,7 +1,7 @@
1
1
  /**
2
- * The single source of truth for `contracts/render-policy.jcs`'s content
3
- * (brief S2 section 3 "Markdown, sanitizer, highlighter" and "Per-artifact
4
- * CSP baseline"; DEC-097 section 5 `renderPolicyIdentity`).
2
+ * The single source of truth for `contracts/render-policy.jcs`'s content:
3
+ * the markdown, sanitizer, highlighter and per-artifact CSP baseline, and
4
+ * the `renderPolicyIdentity` derived from them.
5
5
  *
6
6
  * `scripts/generate-contracts.mjs` emits this exact document as compact JCS
7
7
  * to `contracts/render-policy.jcs`; `src/core/internal/content-security.js`
@@ -17,9 +17,9 @@
17
17
  export const RENDER_POLICY_NAME = 'gala-render-policy';
18
18
 
19
19
  /**
20
- * Byte-equal to the template lock's `contractVersion` (DEC-097 section 5),
21
- * which every S2 `build-input:2.0.0` fixture carries as the literal string
22
- * `"2.0.0"` (matches `manifest.js`'s `buildInputContractVersion`).
20
+ * Byte-equal to the template lock's `contractVersion`, which every
21
+ * `build-input:2.0.0` fixture in this repository carries as the literal
22
+ * string `"2.0.0"` (matches `manifest.js`'s `buildInputContractVersion`).
23
23
  *
24
24
  * @type {string}
25
25
  */
@@ -31,7 +31,7 @@ export const MARKDOWN_IT_PACKAGE = 'markdown-it';
31
31
  export const MARKDOWN_IT_VERSION = '14.3.1';
32
32
 
33
33
  /**
34
- * The exact markdown-it construction options brief S2 section 3 requires.
34
+ * The exact markdown-it construction options this renderer requires.
35
35
  *
36
36
  * @type {{html: false, linkify: false, typographer: false}}
37
37
  */
@@ -56,7 +56,7 @@ export const ALLOWED_URL_SCHEMES = Object.freeze(['https', 'http', 'mailto']);
56
56
 
57
57
  /**
58
58
  * The closed allowlist of tags this renderer ever emits from author
59
- * markdown: "semantic text and content tags" only (brief S2 section 3).
59
+ * markdown: semantic text and content tags only.
60
60
  * No form, frame, SVG, MathML, script, style, object or template element is
61
61
  * ever admitted.
62
62
  *
@@ -110,8 +110,7 @@ export const ALLOWED_TAGS = Object.freeze([
110
110
  /**
111
111
  * The closed per-tag attribute allowlist. `id` is admitted only on headings
112
112
  * (this renderer's own generated heading IDs); `style`, every `on*` handler,
113
- * `srcdoc` and every other undeclared attribute is always stripped (brief S2
114
- * section 3).
113
+ * `srcdoc` and every other undeclared attribute is always stripped.
115
114
  *
116
115
  * @type {Readonly<Record<string, readonly string[]>>}
117
116
  */
@@ -138,7 +137,7 @@ export const HIGHLIGHTER_VERSION = '5.0.2';
138
137
  /**
139
138
  * The closed grammar-name catalog the owned highlighter accepts. A fence
140
139
  * language outside this list always falls back to escaped, unhighlighted
141
- * code (brief S2 section 3).
140
+ * code.
142
141
  *
143
142
  * @type {readonly string[]}
144
143
  */
@@ -167,8 +166,8 @@ export const HIGHLIGHT_GRAMMARS = Object.freeze(
167
166
  * can produce (Prism's documented generic token set plus this
168
167
  * repository's own empirical sample across every admitted grammar). `token`
169
168
  * itself is always the base class; every other admitted value is a
170
- * secondary token-class (brief S2 section 3: "`span` elements carrying
171
- * `token` and admitted token-class values").
169
+ * secondary token-class: `span` elements carry `token` plus admitted
170
+ * token-class values.
172
171
  *
173
172
  * @type {readonly string[]}
174
173
  */
@@ -241,15 +240,51 @@ export const ADMITTED_LANGUAGE_CLASSES = Object.freeze(
241
240
  );
242
241
 
243
242
  /**
244
- * The exact per-artifact CSP baseline (brief S2 section 3). Directive order
245
- * is byte-exact as written in the brief; every directive's value list is
246
- * already sorted bytewise and duplicate-free because S2 materializes no
247
- * module package, configuration, output or runtime, so this string is
243
+ * The CSP specification explicitly ignores `frame-ancestors`
244
+ * (and `report-uri`/`sandbox`, neither used here) when a policy is
245
+ * delivered via `<meta http-equiv>` — browsers drop the directive and log a
246
+ * console warning on every page load. `frame-ancestors` is therefore
247
+ * split out as the one header-only directive: clickjacking protection for a
248
+ * published artifact is a hosting-layer obligation (a response header, most
249
+ * commonly `Content-Security-Policy` or `X-Frame-Options`), recorded here so
250
+ * every consuming adapter/deployment knows to add it, rather than a directive
251
+ * that silently does nothing inside the `<meta>` tag every page already
252
+ * carries.
253
+ *
254
+ * @type {readonly string[]}
255
+ */
256
+ export const CSP_HEADER_ONLY_DIRECTIVES = Object.freeze([
257
+ "frame-ancestors 'none'",
258
+ ]);
259
+
260
+ /**
261
+ * The exact per-artifact CSP baseline actually deliverable inside a
262
+ * `<meta http-equiv="Content-Security-Policy">` tag: {@link CSP_HEADER_ONLY_DIRECTIVES}
263
+ * is excluded. Directive order is byte-exact, minus
264
+ * the excluded directive; every directive's value list is already sorted
265
+ * bytewise and duplicate-free because this renderer materializes no module
266
+ * package, configuration, output or runtime, so this string is
248
267
  * byte-identical on every route.
249
268
  *
250
269
  * @type {string}
251
270
  */
252
- export const CONTENT_SECURITY_POLICY_BASELINE =
271
+ export const CONTENT_SECURITY_POLICY_META_BASELINE =
272
+ "default-src 'none'; base-uri 'none'; object-src 'none'; " +
273
+ "form-action 'none'; script-src 'self'; " +
274
+ "style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'none'; " +
275
+ "media-src 'self'; manifest-src 'self'; worker-src 'none'";
276
+
277
+ /**
278
+ * The complete CSP baseline this renderer originally specified as one
279
+ * string — the meta-safe directives plus the header-only directive, in
280
+ * this fixed byte-exact order — kept only as the render-policy identity's own
281
+ * digest input (`renderPolicyDocument`'s `contentSecurityPolicy` field): a
282
+ * hosting adapter that delivers this policy as a response header, rather
283
+ * than a `<meta>` tag, may deliver this complete string unmodified.
284
+ *
285
+ * @type {string}
286
+ */
287
+ export const CONTENT_SECURITY_POLICY_COMPLETE =
253
288
  "default-src 'none'; base-uri 'none'; object-src 'none'; " +
254
289
  "frame-ancestors 'none'; form-action 'none'; script-src 'self'; " +
255
290
  "style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'none'; " +
@@ -258,8 +293,7 @@ export const CONTENT_SECURITY_POLICY_BASELINE =
258
293
  /**
259
294
  * Build the exact plain-object document `contracts/render-policy.jcs`
260
295
  * publishes: `name`, `version` byte-equal to the template lock's
261
- * `contractVersion`, a closed sanitizer/parser catalog, and no self-digest
262
- * (DEC-097 section 5).
296
+ * `contractVersion`, a closed sanitizer/parser catalog, and no self-digest.
263
297
  *
264
298
  * @returns {Record<string, unknown>} the render-policy document
265
299
  */
@@ -267,7 +301,9 @@ export function renderPolicyDocument() {
267
301
  return {
268
302
  name: RENDER_POLICY_NAME,
269
303
  version: RENDER_POLICY_VERSION,
270
- contentSecurityPolicy: CONTENT_SECURITY_POLICY_BASELINE,
304
+ contentSecurityPolicy: CONTENT_SECURITY_POLICY_COMPLETE,
305
+ contentSecurityPolicyMeta: CONTENT_SECURITY_POLICY_META_BASELINE,
306
+ contentSecurityPolicyHeaderOnlyDirectives: [...CSP_HEADER_ONLY_DIRECTIVES],
271
307
  markdownIt: {
272
308
  package: MARKDOWN_IT_PACKAGE,
273
309
  version: MARKDOWN_IT_VERSION,
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Deterministic route-segment derivation for generated listing pages (tag,
3
- * series, archive) from an authored `plainLabel` (task packet S2-T06:
4
- * navigation renderer/page kinds).
3
+ * series, archive) from an authored `plainLabel`.
5
4
  *
6
5
  * `contentFrontmatterNormalized.tags`/`.series` are `plainLabel` values:
7
6
  * normalized Unicode text, 1..80 graphemes, with no further route-safety
@@ -12,12 +11,12 @@
12
11
  * or series label can therefore contain spaces, uppercase letters, or any
13
12
  * non-Latin script.
14
13
  *
15
- * `route.js` already documents that this renderer's route projection is
16
- * deliberately not DEC-097 section 8's full Unicode-17 percent-encoding and
14
+ * `route.js` already documents that this renderer's route projection
15
+ * deliberately excludes a full Unicode percent-encoding and
17
16
  * collision-resolution engine (out of scope; it only ever projects
18
17
  * already-schema-validated `canonicalRoute` values, never derives a route
19
- * from an arbitrary label). This module makes the same scoped choice for the
20
- * one new case S2-T06 introduces — turning an arbitrary label into a route
18
+ * from an arbitrary label). This module makes the same scoped choice for
19
+ * the one new case it introduces — turning an arbitrary label into a route
21
20
  * segment — and closes the collision risk a lossy ASCII-only slug would
22
21
  * otherwise introduce (two distinct Unicode labels both losing all their
23
22
  * characters to filtering) by always appending a short, deterministic content
@@ -1,16 +1,14 @@
1
1
  /**
2
2
  * Route-to-output-path projection and the byte-exact generated redirect
3
- * document (brief S2 section 3 "Required generated outputs"; DEC-097
4
- * section 6 artifact-manifest redirect subsection).
3
+ * document.
5
4
  *
6
5
  * This module implements the `directory-index` and `explicit-file` cases of
7
- * DEC-097 section 7's `gala-portable-v2` route projection only for routes
8
- * that are already valid `canonicalRoute` strings (schema-enforced before
9
- * this module ever runs). The full Unicode-17 percent-encoding/collision
10
- * engine DEC-097 section 8 describes is out of S2-T03's scope: it governs
11
- * arbitrary repository-relative source paths, which this renderer never
12
- * derives routes from (every route it projects comes from an
13
- * already-schema-validated `canonicalRoute` field in `build-input`).
6
+ * route projection only for routes that are already valid `canonicalRoute`
7
+ * strings (schema-enforced before this module ever runs). A full Unicode
8
+ * percent-encoding/collision engine for arbitrary repository-relative
9
+ * source paths is out of scope here: this renderer never derives routes
10
+ * from source paths — every route it projects comes from an
11
+ * already-schema-validated `canonicalRoute` field in `build-input`.
14
12
  */
15
13
 
16
14
  /**
@@ -35,6 +33,48 @@ export function joinBasePathAndRoute(basePath, route) {
35
33
  return `/${segments.join('/')}`;
36
34
  }
37
35
 
36
+ /**
37
+ * Derive the public base path every emitted URL must carry from the build
38
+ * input: the path component of `baseUrl` (for example `/g9/` for a GitHub
39
+ * project site published at `https://owner.github.io/g9/`) joined with the
40
+ * build input's own `basePath`. Only emitted URLs (hrefs, `src`, canonical,
41
+ * feed, sitemap and search-index URLs) use this value; candidate-directory
42
+ * output file paths keep using `basePath` alone, because the artifact is
43
+ * deployed at the root of the hosting site.
44
+ *
45
+ * @param {string} baseUrl the build input's validated `baseUrl`
46
+ * @param {string} basePath the build input's `basePath`
47
+ * @returns {string} the public base path with a leading slash and no double
48
+ * slashes. When `baseUrl` carries a path it also has a trailing slash
49
+ * (`/g9/`); with an origin-only `baseUrl` the result is exactly
50
+ * `basePath`'s legacy joined form (`/` or `/blog`), so origin-only output
51
+ * is byte-identical to earlier releases.
52
+ */
53
+ export function derivePublicBasePath(baseUrl, basePath) {
54
+ const urlPath = new URL(baseUrl).pathname;
55
+ const joined = joinBasePathAndRoute(
56
+ joinBasePathAndRoute(urlPath, basePath),
57
+ '/',
58
+ );
59
+ return joined !== '/' && urlPath !== '/' ? `${joined}/` : joined;
60
+ }
61
+
62
+ /**
63
+ * Join a public base path (see {@link derivePublicBasePath}) and a route
64
+ * into one emitted URL path. The site root route is the public base path
65
+ * itself (`/site/` when `baseUrl` carries a path); every other route is
66
+ * joined exactly like {@link joinBasePathAndRoute}.
67
+ *
68
+ * @param {string} publicBasePath the public base path
69
+ * @param {string} route a `canonicalRoute`
70
+ * @returns {string} the emitted absolute URL path
71
+ */
72
+ export function joinPublicRoute(publicBasePath, route) {
73
+ return route === '/'
74
+ ? publicBasePath
75
+ : joinBasePathAndRoute(publicBasePath, route);
76
+ }
77
+
38
78
  /**
39
79
  * Project one already-joined absolute route to its candidate-directory-
40
80
  * relative output file path.
@@ -75,16 +115,14 @@ export function projectFilePathToRoute(filePath, profile) {
75
115
  }
76
116
 
77
117
  /**
78
- * Project a `basePath` to the one fixed generated error-document output path
79
- * DEC-097's Spaces website-configuration subsection defines: `errorDocumentKey`
80
- * is exactly `404.html` when `basePath` is `/`; otherwise the base path with
81
- * its one leading and terminal slash removed, followed by `/404.html`. This
82
- * is deliberately independent of the selected `RouteNormalizationProfile`
83
- * (`directory-index` vs `explicit-file`): the generated error document is
84
- * always this one literal filename, never route-normalized like an ordinary
85
- * content route (brief S2 section 3: "one ordinary generated `404.html` at
86
- * the selected base path"; DEC-097's Spaces/Pages/local-directory adapters
87
- * all bind their provider error-document configuration to this exact key).
118
+ * Project a `basePath` to the one fixed generated error-document output
119
+ * path: exactly `404.html` when `basePath` is `/`; otherwise the base path
120
+ * with its one leading and terminal slash removed, followed by
121
+ * `/404.html`. This is deliberately independent of the selected
122
+ * `RouteNormalizationProfile` (`directory-index` vs `explicit-file`): the
123
+ * generated error document is always this one literal filename, never
124
+ * route-normalized like an ordinary content route — this fixed key is what
125
+ * each hosting adapter's provider error-document configuration binds to.
88
126
  *
89
127
  * @param {string} basePath the build input's `basePath` (`canonicalRoute`,
90
128
  * always starts with `/`)
@@ -143,7 +181,7 @@ export function escapeRedirectTarget(targetRoute) {
143
181
 
144
182
  /**
145
183
  * Render the exact UTF-8, no-BOM, LF-terminated generated redirect document
146
- * DEC-097 section 6 and brief S2 section 3 specify byte-for-byte.
184
+ * this renderer must produce byte-for-byte.
147
185
  *
148
186
  * @param {string} targetRoute the route this redirect document points to
149
187
  * @returns {string} the complete document text
@@ -1,12 +1,10 @@
1
1
  /**
2
- * Static search index generation (task packet S2-T08; brief S2 section 3
3
- * "Required generated outputs": "static search index").
2
+ * Static search index generation.
4
3
  *
5
- * Scope note (documented, not silently assumed): neither the brief text this
6
- * task read nor DEC-097 fixes an exact search-index document shape (checked
7
- * with a full-text grep of both for "search" — no hit beyond this brief
8
- * phrase). This module therefore documents its own deterministic shape here,
9
- * matching this renderer's established style for an unfixed structural
4
+ * Scope note (documented, not silently assumed): this renderer has no
5
+ * externally fixed search-index document shape to conform to. This module
6
+ * therefore documents its own deterministic shape here, matching this
7
+ * renderer's established style for an unfixed structural
10
8
  * decision (see `internal/route-labels.js`'s route-segment grammar and the
11
9
  * media pipeline scope decisions in the package README): one JSON document,
12
10
  * `{schemaVersion, generatedAt, publication, documents[]}`, sorted by each
@@ -15,17 +13,17 @@
15
13
  * `internal/source-inventory.js#compareUtf8Bytes`).
16
14
  *
17
15
  * `generatedAt` is `buildInput.buildEpoch` — a build-input *field*, sourced
18
- * deterministically from the selected Git commit's own committer timestamp
19
- * (DEC-097 section 5), not read from the wall clock at build time — matching
16
+ * deterministically from the selected Git commit's own committer timestamp,
17
+ * not read from the wall clock at build time — matching
20
18
  * `src/core/manifest.js`'s own `manifest.generatedAt` convention, so two
21
19
  * clean builds of the same `build-input` still produce byte-identical index
22
20
  * bytes.
23
21
  *
24
- * No runtime search JavaScript accompanies this document (brief S2 section
25
- * 1: "reads completely with JavaScript disabled"; acceptance test C: "the
26
- * appearance controller is the only browser bootstrap" — this renderer's one
27
- * admitted client-side script in all of S2). This module's only output is
28
- * the index document's bytes; nothing wires a `<script>` to it.
22
+ * No runtime search JavaScript accompanies this document: this renderer
23
+ * must read completely with JavaScript disabled, and the appearance
24
+ * controller is the only client-side script it ever admits. This module's
25
+ * only output is the index document's bytes; nothing wires a `<script>` to
26
+ * it.
29
27
  *
30
28
  * Only actual authored content (`article`/`page` records, `status:
31
29
  * "published"`) is indexed — never a synthetic listing page
@@ -34,7 +32,7 @@
34
32
  * full-text search.
35
33
  */
36
34
 
37
- import { joinBasePathAndRoute } from './route.js';
35
+ import { derivePublicBasePath, joinPublicRoute } from './route.js';
38
36
  import { compareUtf8Bytes } from './source-inventory.js';
39
37
  import { contentLastModified, contentRoute } from './page-kinds.js';
40
38
 
@@ -79,7 +77,10 @@ export function buildSearchIndex(validatedInput) {
79
77
  * @returns {string} the absolute `https://` URL
80
78
  */
81
79
  const absoluteUrl = (route) =>
82
- new URL(joinBasePathAndRoute(basePath, route), baseUrl).toString();
80
+ new URL(
81
+ joinPublicRoute(derivePublicBasePath(baseUrl, basePath), route),
82
+ baseUrl,
83
+ ).toString();
83
84
 
84
85
  const documents = content
85
86
  .filter(
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A closed subset of npm-compatible SemVer range satisfaction (TPL-H1), used
3
+ * only to check a theme package's own declared `theme.json.templateRange`
4
+ * against this renderer's published version. This is deliberately not a
5
+ * general-purpose SemVer range engine: `@rathnasgala2/schemas`'
6
+ * `theme-contract.schema.json` `templateRange` field (`$defs/semverRange`)
7
+ * admits an open-ended npm range grammar, but every legitimate value this
8
+ * closed-contract ecosystem actually emits is either an exact version or a
9
+ * caret range — the same two forms `package.json` dependency pins in this
10
+ * very repository use — so this module supports exactly those two forms and
11
+ * fails closed (rejects) on anything else, exactly like every other closed
12
+ * catalog in this repository (`nthExpressionProfile`, the pseudo-class
13
+ * catalog, `functionalSelectorArguments`, ...).
14
+ */
15
+
16
+ const EXACT_VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
17
+ const CARET_RANGE_PATTERN = /^\^(\d+)\.(\d+)\.(\d+)$/;
18
+
19
+ /**
20
+ * @param {string} version a canonical `major.minor.patch` SemVer string
21
+ * @returns {[number, number, number]} the parsed components
22
+ */
23
+ function parseExactVersion(version) {
24
+ const match = EXACT_VERSION_PATTERN.exec(version);
25
+ if (!match) {
26
+ throw new Error(`not a canonical major.minor.patch version: ${version}`);
27
+ }
28
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
29
+ }
30
+
31
+ /**
32
+ * @param {[number, number, number]} a
33
+ * @param {[number, number, number]} b
34
+ * @returns {number} negative, zero or positive per standard comparator
35
+ * contract
36
+ */
37
+ function compareVersions(a, b) {
38
+ for (let index = 0; index < 3; index += 1) {
39
+ if (a[index] !== b[index]) return a[index] - b[index];
40
+ }
41
+ return 0;
42
+ }
43
+
44
+ /**
45
+ * Whether `version` satisfies `range`, supporting only an exact
46
+ * `major.minor.patch` range (equality) or a caret range `^major.minor.patch`
47
+ * (matches `>=major.minor.patch <(major+1).0.0`, or, for a `0.x.y` caret
48
+ * range, npm's own tightened `0.x` semantics: `>=0.minor.patch <0.(minor+1).0`).
49
+ *
50
+ * @param {string} version this renderer's own exact published version
51
+ * @param {string} range the theme's declared `templateRange`
52
+ * @returns {boolean} whether `version` satisfies `range`
53
+ * @throws {Error} when `range` is not one of the two closed forms this
54
+ * module admits
55
+ */
56
+ export function satisfiesTemplateRange(version, range) {
57
+ const parsedVersion = parseExactVersion(version);
58
+ const exactMatch = EXACT_VERSION_PATTERN.exec(range);
59
+ if (exactMatch) {
60
+ return compareVersions(parsedVersion, parseExactVersion(range)) === 0;
61
+ }
62
+ const caretMatch = CARET_RANGE_PATTERN.exec(range);
63
+ if (caretMatch) {
64
+ const lower = /** @type {[number, number, number]} */ ([
65
+ Number(caretMatch[1]),
66
+ Number(caretMatch[2]),
67
+ Number(caretMatch[3]),
68
+ ]);
69
+ if (compareVersions(parsedVersion, lower) < 0) return false;
70
+ const [major, minor] = lower;
71
+ if (major > 0) {
72
+ return parsedVersion[0] === major;
73
+ }
74
+ if (minor > 0) {
75
+ return parsedVersion[0] === 0 && parsedVersion[1] === minor;
76
+ }
77
+ // ^0.0.z: npm pins this to the exact patch version.
78
+ return compareVersions(parsedVersion, lower) === 0;
79
+ }
80
+ throw new Error(
81
+ `templateRange must be an exact "major.minor.patch" version or a ` +
82
+ `"^major.minor.patch" caret range, got ${JSON.stringify(range)}`,
83
+ );
84
+ }
@@ -1,28 +1,26 @@
1
1
  /**
2
- * SEO/localization metadata assembly (task packet S2-T08; brief S2 section 3
3
- * "Required generated outputs": "canonical share and copy affordances,
4
- * localization metadata, SEO metadata").
2
+ * SEO/localization metadata assembly: canonical share and copy affordances,
3
+ * localization metadata, SEO metadata.
5
4
  *
6
5
  * This module resolves the per-page facts `internal/page-kinds.js` already
7
6
  * decided (`description`, `ogType`, `socialImageRef`, `robotsContent`) into
8
7
  * the concrete absolute-URL strings a page's `<head>` needs. It is kept
9
8
  * separate from `page-kinds.js` because resolving a `socialImageRef` into an
10
9
  * absolute derivative URL needs two facts only `src/core/index.js` has at
11
- * that point: the S2-T05 media pipeline's own finished `assets` list (so the
12
- * exact derivative file extension a given source image produced is known —
10
+ * that point: the media pipeline's own finished `assets` list (so the exact
11
+ * derivative file extension a given source image produced is known —
13
12
  * `page-kinds.js` never decodes an image) and `buildInput.baseUrl` (the
14
13
  * origin every absolute URL this renderer emits is resolved against).
15
14
  *
16
- * S2-T12 basePath fix (LOCAL-7 follow-up): every derivative-asset URL this
17
- * module resolves is now `basePath`-joined through `internal/route.js`'s
18
- * shared {@link import('./route.js').joinBasePathAndRoute} before being
19
- * resolved against `baseUrl`, exactly like every other renderer-derived
20
- * route (`internal/page-kinds.js`, `internal/print-stylesheet.js`). Before
21
- * this fix, {@link resolveSocialImageUrl} joined a bare root-absolute
15
+ * basePath fix: every derivative-asset URL this module resolves is
16
+ * `basePath`-joined through `internal/route.js`'s shared
17
+ * {@link import('./route.js').joinBasePathAndRoute} before being resolved
18
+ * against `baseUrl`, exactly like every other renderer-derived route
19
+ * (`internal/page-kinds.js`, `internal/print-stylesheet.js`). Without this,
20
+ * {@link resolveSocialImageUrl} would join a bare root-absolute
22
21
  * `/${derivativePath}` against `baseUrl`, which 404s under a non-root
23
- * `basePath` publication (`internal/theme-assets.js` is the S2-T12 module
24
- * that fixes the equivalent theme/print stylesheet and appearance-script
25
- * hrefs).
22
+ * `basePath` publication (`internal/theme-assets.js` applies the equivalent
23
+ * fix for the theme/print stylesheet and appearance-script hrefs).
26
24
  *
27
25
  * hreflang note (documented scope decision, not a silent omission):
28
26
  * `build-input:2.0.0` has no translation/locale-variant linkage between
@@ -31,11 +29,11 @@
31
29
  * `$defs` entry named anything like `translation`/`localizedContent`/
32
30
  * `alternateLanguage` exists anywhere in the schema; `authorNormalized`'s own
33
31
  * `localized` array carries only a translated author bio, never a route). A
34
- * page's own `<html lang>` already carries its resolved language (S2-T06),
35
- * which is the entire localization signal this build-input shape declares.
36
- * `<link rel="alternate" hreflang="...">` is therefore correctly *empty* for
37
- * S2 — brief S2 section 3's "hreflang/localization metadata... where the
38
- * input declares translations" is satisfied vacuously, since the input never
32
+ * page's own `<html lang>` already carries its resolved language, which is
33
+ * the entire localization signal this build-input shape declares.
34
+ * `<link rel="alternate" hreflang="...">` is therefore correctly *empty*
35
+ * here — the requirement to emit hreflang/localization metadata where the
36
+ * input declares translations is satisfied vacuously, since the input never
39
37
  * declares one. This module has no `hreflang` export for that reason; a
40
38
  * later build-input major version that adds translation linkage is the
41
39
  * natural place to add one, at which point every page-kind call site below
@@ -52,14 +50,14 @@ import { joinBasePathAndRoute } from './route.js';
52
50
  * know this path shape, so a change to either stays a one-place fix).
53
51
  *
54
52
  * @param {readonly {path: string, mediaType: string}[]} mediaAssets the
55
- * S2-T05 media pipeline's own finished `assets` list
53
+ * media pipeline's own finished `assets` list
56
54
  * @param {{path: string, sourceDigest: string} | undefined} reference a
57
55
  * `resolvedFile`-shaped reference (`publication.defaultImage`,
58
56
  * `author.avatar`, `frontmatter.socialImage`, `frontmatter.hero.file`)
59
57
  * @returns {string | undefined} the derivative asset's own output-relative
60
58
  * path (never the raw source path), or `undefined` when no reference was
61
59
  * supplied or the media pipeline produced no matching derivative (an image
62
- * this renderer's own S2-T05 pipeline was never asked to process, e.g. a
60
+ * this renderer's own media pipeline was never asked to process, e.g. a
63
61
  * test fixture exercising SEO metadata without the media pipeline)
64
62
  */
65
63
  export function resolveOriginalDerivativePath(mediaAssets, reference) {
@@ -77,11 +75,13 @@ export function resolveOriginalDerivativePath(mediaAssets, reference) {
77
75
  *
78
76
  * @param {object} options resolution options
79
77
  * @param {readonly {path: string, mediaType: string}[]} options.mediaAssets
80
- * the S2-T05 media pipeline's own finished `assets` list
78
+ * the media pipeline's own finished `assets` list
81
79
  * @param {{path: string, sourceDigest: string} | undefined} options.reference
82
80
  * the candidate `resolvedFile` reference
83
- * @param {string} options.baseUrl the build input's origin-only `baseUrl`
84
- * @param {string} options.basePath the build input's `basePath` (S2-T12: the
81
+ * @param {string} options.baseUrl the build input's `baseUrl`
82
+ * @param {string} options.basePath the public base path
83
+ * (`route.js#derivePublicBasePath`, the `baseUrl` path joined with the
84
+ * build input's `basePath`; the
85
85
  * derivative path is joined with this before being resolved against
86
86
  * `baseUrl`, so a non-root publication's social-image URL is correct)
87
87
  * @returns {string | undefined} the absolute derivative image URL, or
@@ -1,26 +1,24 @@
1
1
  /**
2
- * Sitemap generation (task packet S2-T08; brief S2 section 3 "Required
3
- * generated outputs": "sitemap"; task packet instructions: "published routes
4
- * only, deterministic order, `lastmod` from content timestamps not build
5
- * time").
2
+ * Sitemap generation: published routes only, deterministic order, with
3
+ * `lastmod` derived from content timestamps rather than build time.
6
4
  *
7
5
  * Every entry comes from `internal/page-kinds.js`'s own
8
6
  * {@link import('./page-kinds.js').GeneratedPage} list — the single source
9
7
  * of truth for which page kinds exist and what each one's own
10
8
  * `robotsContent`/`lastModified` is — filtered to exclude any page this
11
- * renderer itself marked `noindex` (task packet: "robots handling for
12
- * unlisted content"; `status: "unlisted"` content is exactly the case
9
+ * renderer itself marked `noindex` (robots handling for unlisted content:
10
+ * `status: "unlisted"` content is exactly the case
13
11
  * `internal/page-kinds.js` sets `robotsContent` for). The generated `404`
14
12
  * error page, the feeds, the sitemap document itself and the static search
15
13
  * index are never `GeneratedPage` entries, so they are structurally excluded
16
14
  * without a second exclusion list. A `manifestRedirect` route is likewise
17
15
  * never in this list; a redirect's fixed byte-for-byte document already
18
- * carries its own `<link rel="canonical">` to the real content page (brief
19
- * S2 section 3), which is what a crawler should index instead.
16
+ * carries its own `<link rel="canonical">` to the real content page, which
17
+ * is what a crawler should index instead.
20
18
  */
21
19
 
22
20
  import { escapeHtml } from './skeleton.js';
23
- import { joinBasePathAndRoute } from './route.js';
21
+ import { joinPublicRoute } from './route.js';
24
22
  import { compareUtf8Bytes } from './source-inventory.js';
25
23
 
26
24
  /**
@@ -39,8 +37,9 @@ function isNoindex(page) {
39
37
  * @param {object} options build options
40
38
  * @param {readonly import('./page-kinds.js').GeneratedPage[]} options.generatedPages
41
39
  * every page `internal/page-kinds.js` produced
42
- * @param {string} options.basePath the build input's `basePath`
43
- * @param {string} options.baseUrl the build input's origin-only `baseUrl`
40
+ * @param {string} options.basePath the public base path
41
+ * (`route.js#derivePublicBasePath`)
42
+ * @param {string} options.baseUrl the build input's `baseUrl`
44
43
  * @returns {string} the exact UTF-8 `sitemap.xml` text
45
44
  */
46
45
  export function buildSitemap({ generatedPages, basePath, baseUrl }) {
@@ -49,7 +48,7 @@ export function buildSitemap({ generatedPages, basePath, baseUrl }) {
49
48
  * @returns {string} the absolute `https://` URL
50
49
  */
51
50
  const absoluteUrl = (route) =>
52
- new URL(joinBasePathAndRoute(basePath, route), baseUrl).toString();
51
+ new URL(joinPublicRoute(basePath, route), baseUrl).toString();
53
52
 
54
53
  const entries = generatedPages
55
54
  .filter((page) => !isNoindex(page))