@rathnasgala2/template 2.0.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 (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +515 -0
  3. package/contracts/render-policy.jcs +1 -0
  4. package/contracts/theme-styling-contract.jcs +1 -0
  5. package/package.json +96 -0
  6. package/src/core/errors.js +136 -0
  7. package/src/core/index.js +758 -0
  8. package/src/core/internal/appearance/bootstrap-script.js +159 -0
  9. package/src/core/internal/appearance/contract.js +193 -0
  10. package/src/core/internal/appearance/controller-markup.js +80 -0
  11. package/src/core/internal/appearance/styling-contract.js +509 -0
  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 +220 -0
  15. package/src/core/internal/feeds.js +161 -0
  16. package/src/core/internal/fs-walk.js +38 -0
  17. package/src/core/internal/media/avif-probe.js +169 -0
  18. package/src/core/internal/media/font-policy.js +56 -0
  19. package/src/core/internal/media/jpeg-codec.js +0 -0
  20. package/src/core/internal/media/limits.js +129 -0
  21. package/src/core/internal/media/pipeline.js +353 -0
  22. package/src/core/internal/media/png-codec.js +406 -0
  23. package/src/core/internal/media/resize.js +147 -0
  24. package/src/core/internal/media/sniff.js +109 -0
  25. package/src/core/internal/media/webp-probe.js +139 -0
  26. package/src/core/internal/messages.js +78 -0
  27. package/src/core/internal/page-kinds.js +709 -0
  28. package/src/core/internal/provenance.js +49 -0
  29. package/src/core/internal/render-policy-content.js +295 -0
  30. package/src/core/internal/route-labels.js +68 -0
  31. package/src/core/internal/route.js +167 -0
  32. package/src/core/internal/search-index.js +119 -0
  33. package/src/core/internal/seo.js +114 -0
  34. package/src/core/internal/sitemap.js +77 -0
  35. package/src/core/internal/skeleton.js +413 -0
  36. package/src/core/internal/source-inventory.js +145 -0
  37. package/src/core/internal/text-direction.js +59 -0
  38. package/src/core/internal/theme-assets.js +387 -0
  39. package/src/core/internal/uuid.js +51 -0
  40. package/src/core/manifest.js +0 -0
  41. package/types/eleventy-plugin-syntaxhighlight.d.ts +26 -0
  42. package/types/eleventy.d.ts +38 -0
  43. package/types/index.d.ts +347 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Shared validation for the caller-supplied `options.provenance` bundle
3
+ * (see `src/core/manifest.js` module documentation for what it is and why
4
+ * `build-input` alone cannot supply it).
5
+ *
6
+ * This check must run from `renderPublication`'s `assertOptions` before any
7
+ * directory is created or Eleventy ever runs, so an incomplete provenance
8
+ * bundle fails closed exactly like an invalid `build-input` does — nothing
9
+ * is written. `src/core/manifest.js` also calls it directly as a defensive
10
+ * second check at the point it actually consumes `provenance`, since it is
11
+ * cheap and it is the module that most directly depends on the invariant.
12
+ */
13
+
14
+ import { RenderOptionsError } from '../errors.js';
15
+
16
+ /**
17
+ * Validate the caller-supplied provenance bundle is structurally present.
18
+ * This is a shallow presence check, not schema validation: the assembled
19
+ * manifest is itself schema-validated by `renderPublication` before being
20
+ * returned, which is the authoritative check on the values' final shape.
21
+ *
22
+ * @param {import('../../../types/index.d.ts').RenderProvenance} provenance
23
+ * caller-supplied provenance bundle
24
+ * @returns {void}
25
+ */
26
+ export function assertProvenance(provenance) {
27
+ if (!provenance || typeof provenance !== 'object') {
28
+ throw new RenderOptionsError('options.provenance is required');
29
+ }
30
+ const required = /** @type {const} */ ([
31
+ 'builder',
32
+ 'repositoryCoordinate',
33
+ 'workflowIdentity',
34
+ 'buildToolVersions',
35
+ ]);
36
+ for (const field of required) {
37
+ if (provenance[field] === undefined) {
38
+ throw new RenderOptionsError(`options.provenance.${field} is required`);
39
+ }
40
+ }
41
+ if (
42
+ !Array.isArray(provenance.buildToolVersions) ||
43
+ provenance.buildToolVersions.length < 9
44
+ ) {
45
+ throw new RenderOptionsError(
46
+ 'options.provenance.buildToolVersions must have at least 9 entries (build-tool-identity:2.0.0)',
47
+ );
48
+ }
49
+ }
@@ -0,0 +1,295 @@
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`).
5
+ *
6
+ * `scripts/generate-contracts.mjs` emits this exact document as compact JCS
7
+ * to `contracts/render-policy.jcs`; `src/core/internal/content-security.js`
8
+ * consumes the same constants to build the actual markdown-it, sanitize-html
9
+ * and highlighter configuration this renderer runs, and to compute the
10
+ * `renderPolicyIdentity` every `renderableBody.renderPolicy` in a
11
+ * `build-input:2.0.0` instance must byte-equal. Keeping both in one module
12
+ * makes drift between "the policy we publish" and "the policy we enforce"
13
+ * structurally impossible.
14
+ */
15
+
16
+ /** @type {string} */
17
+ export const RENDER_POLICY_NAME = 'gala-render-policy';
18
+
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`).
23
+ *
24
+ * @type {string}
25
+ */
26
+ export const RENDER_POLICY_VERSION = '2.0.0';
27
+
28
+ /** @type {string} */
29
+ export const MARKDOWN_IT_PACKAGE = 'markdown-it';
30
+ /** @type {string} */
31
+ export const MARKDOWN_IT_VERSION = '14.3.1';
32
+
33
+ /**
34
+ * The exact markdown-it construction options brief S2 section 3 requires.
35
+ *
36
+ * @type {{html: false, linkify: false, typographer: false}}
37
+ */
38
+ export const MARKDOWN_IT_OPTIONS = Object.freeze({
39
+ html: false,
40
+ linkify: false,
41
+ typographer: false,
42
+ });
43
+
44
+ /** @type {string} */
45
+ export const SANITIZER_PACKAGE = 'sanitize-html';
46
+ /** @type {string} */
47
+ export const SANITIZER_VERSION = '2.17.7';
48
+
49
+ /**
50
+ * URL schemes admitted for `a[href]`. Same-document fragments and
51
+ * repository-relative paths carry no scheme and are handled separately.
52
+ *
53
+ * @type {readonly string[]}
54
+ */
55
+ export const ALLOWED_URL_SCHEMES = Object.freeze(['https', 'http', 'mailto']);
56
+
57
+ /**
58
+ * The closed allowlist of tags this renderer ever emits from author
59
+ * markdown: "semantic text and content tags" only (brief S2 section 3).
60
+ * No form, frame, SVG, MathML, script, style, object or template element is
61
+ * ever admitted.
62
+ *
63
+ * @type {readonly string[]}
64
+ */
65
+ export const ALLOWED_TAGS = Object.freeze([
66
+ 'p',
67
+ 'br',
68
+ 'hr',
69
+ 'h1',
70
+ 'h2',
71
+ 'h3',
72
+ 'h4',
73
+ 'h5',
74
+ 'h6',
75
+ 'ul',
76
+ 'ol',
77
+ 'li',
78
+ 'blockquote',
79
+ 'pre',
80
+ 'code',
81
+ 'strong',
82
+ 'em',
83
+ 'b',
84
+ 'i',
85
+ 'u',
86
+ 's',
87
+ 'del',
88
+ 'ins',
89
+ 'sub',
90
+ 'sup',
91
+ 'mark',
92
+ 'small',
93
+ 'wbr',
94
+ 'a',
95
+ 'img',
96
+ 'table',
97
+ 'thead',
98
+ 'tbody',
99
+ 'tr',
100
+ 'th',
101
+ 'td',
102
+ 'dl',
103
+ 'dt',
104
+ 'dd',
105
+ 'figure',
106
+ 'figcaption',
107
+ 'span',
108
+ ]);
109
+
110
+ /**
111
+ * The closed per-tag attribute allowlist. `id` is admitted only on headings
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).
115
+ *
116
+ * @type {Readonly<Record<string, readonly string[]>>}
117
+ */
118
+ export const ALLOWED_ATTRIBUTES_BY_TAG = Object.freeze({
119
+ h1: Object.freeze(['id']),
120
+ h2: Object.freeze(['id']),
121
+ h3: Object.freeze(['id']),
122
+ h4: Object.freeze(['id']),
123
+ h5: Object.freeze(['id']),
124
+ h6: Object.freeze(['id']),
125
+ a: Object.freeze(['href', 'rel']),
126
+ img: Object.freeze(['src', 'alt']),
127
+ ol: Object.freeze(['start']),
128
+ pre: Object.freeze(['class']),
129
+ code: Object.freeze(['class']),
130
+ span: Object.freeze(['class']),
131
+ });
132
+
133
+ /** @type {string} */
134
+ export const HIGHLIGHTER_PACKAGE = '@11ty/eleventy-plugin-syntaxhighlight';
135
+ /** @type {string} */
136
+ export const HIGHLIGHTER_VERSION = '5.0.2';
137
+
138
+ /**
139
+ * The closed grammar-name catalog the owned highlighter accepts. A fence
140
+ * language outside this list always falls back to escaped, unhighlighted
141
+ * code (brief S2 section 3).
142
+ *
143
+ * @type {readonly string[]}
144
+ */
145
+ export const HIGHLIGHT_GRAMMARS = Object.freeze(
146
+ [
147
+ 'bash',
148
+ 'css',
149
+ 'diff',
150
+ 'java',
151
+ 'javascript',
152
+ 'json',
153
+ 'jsx',
154
+ 'markdown',
155
+ 'markup',
156
+ 'python',
157
+ 'sql',
158
+ 'tsx',
159
+ 'typescript',
160
+ 'yaml',
161
+ ].sort(),
162
+ );
163
+
164
+ /**
165
+ * The closed `span.class` token-class catalog admitted on highlighted code,
166
+ * grounded in the actual Prism.js token classes {@link HIGHLIGHT_GRAMMARS}
167
+ * can produce (Prism's documented generic token set plus this
168
+ * repository's own empirical sample across every admitted grammar). `token`
169
+ * 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").
172
+ *
173
+ * @type {readonly string[]}
174
+ */
175
+ export const ADMITTED_TOKEN_CLASSES = Object.freeze(
176
+ [
177
+ 'token',
178
+ 'atrule',
179
+ 'attr-equals',
180
+ 'attr-name',
181
+ 'attr-value',
182
+ 'bold',
183
+ 'boolean',
184
+ 'builtin',
185
+ 'cdata',
186
+ 'char',
187
+ 'class-name',
188
+ 'comment',
189
+ 'constant',
190
+ 'content',
191
+ 'coord',
192
+ 'deleted',
193
+ 'deleted-sign',
194
+ 'doctype',
195
+ 'entity',
196
+ 'for-or-select',
197
+ 'function',
198
+ 'function-variable',
199
+ 'important',
200
+ 'inserted',
201
+ 'inserted-sign',
202
+ 'interpolation',
203
+ 'interpolation-punctuation',
204
+ 'italic',
205
+ 'key',
206
+ 'keyword',
207
+ 'namespace',
208
+ 'null',
209
+ 'number',
210
+ 'operator',
211
+ 'prefix',
212
+ 'prolog',
213
+ 'property',
214
+ 'punctuation',
215
+ 'regex',
216
+ 'rule',
217
+ 'selector',
218
+ 'shebang',
219
+ 'string',
220
+ 'symbol',
221
+ 'tag',
222
+ 'template-punctuation',
223
+ 'template-string',
224
+ 'title',
225
+ 'url',
226
+ 'variable',
227
+ ].sort(),
228
+ );
229
+
230
+ /**
231
+ * The closed `pre.class`/`code.class` catalog for highlighted fences: one
232
+ * `language-<grammar>` value per {@link HIGHLIGHT_GRAMMARS} entry. An
233
+ * unknown fence language's own `language-<arbitrary>` class (emitted by
234
+ * markdown-it's own escaped fallback renderer) is not in this list and is
235
+ * stripped by the sanitizer, matching the "escaped fallback" contract.
236
+ *
237
+ * @type {readonly string[]}
238
+ */
239
+ export const ADMITTED_LANGUAGE_CLASSES = Object.freeze(
240
+ HIGHLIGHT_GRAMMARS.map((grammar) => `language-${grammar}`),
241
+ );
242
+
243
+ /**
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
248
+ * byte-identical on every route.
249
+ *
250
+ * @type {string}
251
+ */
252
+ export const CONTENT_SECURITY_POLICY_BASELINE =
253
+ "default-src 'none'; base-uri 'none'; object-src 'none'; " +
254
+ "frame-ancestors 'none'; form-action 'none'; script-src 'self'; " +
255
+ "style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'none'; " +
256
+ "media-src 'self'; manifest-src 'self'; worker-src 'none'";
257
+
258
+ /**
259
+ * Build the exact plain-object document `contracts/render-policy.jcs`
260
+ * 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).
263
+ *
264
+ * @returns {Record<string, unknown>} the render-policy document
265
+ */
266
+ export function renderPolicyDocument() {
267
+ return {
268
+ name: RENDER_POLICY_NAME,
269
+ version: RENDER_POLICY_VERSION,
270
+ contentSecurityPolicy: CONTENT_SECURITY_POLICY_BASELINE,
271
+ markdownIt: {
272
+ package: MARKDOWN_IT_PACKAGE,
273
+ version: MARKDOWN_IT_VERSION,
274
+ options: { ...MARKDOWN_IT_OPTIONS },
275
+ },
276
+ sanitizer: {
277
+ package: SANITIZER_PACKAGE,
278
+ version: SANITIZER_VERSION,
279
+ allowedTags: [...ALLOWED_TAGS],
280
+ allowedAttributes: Object.fromEntries(
281
+ Object.entries(ALLOWED_ATTRIBUTES_BY_TAG).map(([tag, attrs]) => [
282
+ tag,
283
+ [...attrs],
284
+ ]),
285
+ ),
286
+ allowedUrlSchemes: [...ALLOWED_URL_SCHEMES],
287
+ },
288
+ highlighter: {
289
+ package: HIGHLIGHTER_PACKAGE,
290
+ version: HIGHLIGHTER_VERSION,
291
+ grammars: [...HIGHLIGHT_GRAMMARS],
292
+ tokenClasses: [...ADMITTED_TOKEN_CLASSES],
293
+ },
294
+ };
295
+ }
@@ -0,0 +1,68 @@
1
+ /**
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).
5
+ *
6
+ * `contentFrontmatterNormalized.tags`/`.series` are `plainLabel` values:
7
+ * normalized Unicode text, 1..80 graphemes, with no further route-safety
8
+ * constraint (checked against
9
+ * `node_modules/@rathnasgala2/schemas/schemas/build-input.schema.json`'s
10
+ * `plainLabel` `$def` — no ASCII/slug pattern, unlike `contentFrontmatterNormalized.slug`,
11
+ * which the schema already restricts to `^[a-z0-9]+(?:-[a-z0-9]+)*$`). A tag
12
+ * or series label can therefore contain spaces, uppercase letters, or any
13
+ * non-Latin script.
14
+ *
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
17
+ * collision-resolution engine (out of scope; it only ever projects
18
+ * 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
21
+ * segment — and closes the collision risk a lossy ASCII-only slug would
22
+ * otherwise introduce (two distinct Unicode labels both losing all their
23
+ * characters to filtering) by always appending a short, deterministic content
24
+ * hash of the exact label. Two distinct labels can therefore never collide on
25
+ * the same generated route, even when their readable prefixes are identical
26
+ * or both empty.
27
+ */
28
+
29
+ import { createHash } from 'node:crypto';
30
+
31
+ /**
32
+ * @param {string} label the raw label
33
+ * @returns {string} its lowercase ASCII-only readable prefix (may be empty)
34
+ */
35
+ function asciiReadablePrefix(label) {
36
+ const lower = label.normalize('NFC').toLowerCase();
37
+ let out = '';
38
+ let lastWasSeparator = true;
39
+ for (const character of lower) {
40
+ if (/[a-z0-9]/u.test(character)) {
41
+ out += character;
42
+ lastWasSeparator = false;
43
+ } else if (!lastWasSeparator) {
44
+ out += '-';
45
+ lastWasSeparator = true;
46
+ }
47
+ }
48
+ return out.replace(/-+$/u, '').slice(0, 48);
49
+ }
50
+
51
+ /**
52
+ * Derive a deterministic, collision-free, route-safe segment for an
53
+ * arbitrary authored label (a tag, a series name, or any other `plainLabel`
54
+ * used to generate a listing page route).
55
+ *
56
+ * @param {string} label the raw authored label
57
+ * @returns {string} a lowercase ASCII route segment: an optional readable
58
+ * prefix followed by a 10-character hexadecimal content hash of the exact
59
+ * label
60
+ */
61
+ export function routeSegmentForLabel(label) {
62
+ const hash = createHash('sha256')
63
+ .update(label, 'utf8')
64
+ .digest('hex')
65
+ .slice(0, 10);
66
+ const prefix = asciiReadablePrefix(label);
67
+ return prefix.length > 0 ? `${prefix}-${hash}` : hash;
68
+ }
@@ -0,0 +1,167 @@
1
+ /**
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).
5
+ *
6
+ * 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`).
14
+ */
15
+
16
+ /**
17
+ * @typedef {'directory-index' | 'explicit-file'} RouteNormalizationProfile
18
+ */
19
+
20
+ /**
21
+ * Join a base path and a route into one absolute public route, exactly
22
+ * once, with no double slash.
23
+ *
24
+ * @param {string} basePath the build input's `basePath` (`canonicalRoute`,
25
+ * always starts with `/`)
26
+ * @param {string} route a `canonicalRoute` (always starts with `/`)
27
+ * @returns {string} the joined absolute route, starting with `/`
28
+ */
29
+ export function joinBasePathAndRoute(basePath, route) {
30
+ const baseSegment = basePath.replace(/^\/+|\/+$/g, '');
31
+ const routeSegment = route.replace(/^\/+|\/+$/g, '');
32
+ const segments = [baseSegment, routeSegment].filter(
33
+ (segment) => segment.length > 0,
34
+ );
35
+ return `/${segments.join('/')}`;
36
+ }
37
+
38
+ /**
39
+ * Project one already-joined absolute route to its candidate-directory-
40
+ * relative output file path.
41
+ *
42
+ * @param {string} joinedRoute the base-path-joined absolute route
43
+ * @param {RouteNormalizationProfile} profile the selected route
44
+ * normalization profile
45
+ * @returns {string} a repository-relative-style output path with no leading
46
+ * slash
47
+ */
48
+ export function projectRouteToFilePath(joinedRoute, profile) {
49
+ const trimmed = joinedRoute.replace(/^\/+/, '');
50
+ if (profile === 'explicit-file') {
51
+ return trimmed.length === 0 ? 'index.html' : trimmed;
52
+ }
53
+ if (trimmed.length === 0) return 'index.html';
54
+ return `${trimmed.replace(/\/+$/, '')}/index.html`;
55
+ }
56
+
57
+ /**
58
+ * Recover the public route an output file path was projected from, the
59
+ * exact inverse of {@link projectRouteToFilePath} for the `directory-index`
60
+ * profile. Used only to prove the manifest's redirect
61
+ * `backingPath`-to-`sourceRoute` equality invariant by construction.
62
+ *
63
+ * @param {string} filePath a candidate-directory-relative output path
64
+ * @param {RouteNormalizationProfile} profile the selected route
65
+ * normalization profile
66
+ * @returns {string} the absolute route that projects to `filePath`
67
+ */
68
+ export function projectFilePathToRoute(filePath, profile) {
69
+ if (profile === 'explicit-file') {
70
+ return filePath === 'index.html' ? '/' : `/${filePath}`;
71
+ }
72
+ if (filePath === 'index.html') return '/';
73
+ const withoutIndex = filePath.replace(/\/index\.html$/, '');
74
+ return `/${withoutIndex}`;
75
+ }
76
+
77
+ /**
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).
88
+ *
89
+ * @param {string} basePath the build input's `basePath` (`canonicalRoute`,
90
+ * always starts with `/`)
91
+ * @returns {string} the candidate-directory-relative error document path,
92
+ * with no leading slash
93
+ */
94
+ export function errorDocumentPath(basePath) {
95
+ const trimmed = basePath.replace(/^\/+|\/+$/g, '');
96
+ return trimmed.length === 0 ? '404.html' : `${trimmed}/404.html`;
97
+ }
98
+
99
+ /**
100
+ * Project a `basePath` and a fixed-name public asset route (a route that is
101
+ * always this one literal file, never subject to `directory-index`
102
+ * normalization: the generated feeds, sitemap and static search index) to
103
+ * its candidate-directory-relative output path. Equivalent to
104
+ * {@link projectRouteToFilePath} under the `explicit-file` profile, exposed
105
+ * under its own name so a call site never has to spell out
106
+ * `'explicit-file'` to mean "this is a literal file, not a route".
107
+ *
108
+ * @param {string} basePath the build input's `basePath`
109
+ * @param {string} route a `canonicalRoute` ending in a real file extension
110
+ * (e.g. `/feed/atom.xml`)
111
+ * @returns {string} the candidate-directory-relative output path, with no
112
+ * leading slash
113
+ */
114
+ export function projectFixedAssetPath(basePath, route) {
115
+ return projectRouteToFilePath(
116
+ joinBasePathAndRoute(basePath, route),
117
+ 'explicit-file',
118
+ );
119
+ }
120
+
121
+ const REDIRECT_TARGET_ESCAPES =
122
+ /** @type {Readonly<Record<string, string>>} */ ({
123
+ '&': '&amp;',
124
+ '<': '&lt;',
125
+ '>': '&gt;',
126
+ '"': '&quot;',
127
+ "'": '&#39;',
128
+ });
129
+
130
+ /**
131
+ * Apply the redirect document's one-pass HTML escape to a target route.
132
+ *
133
+ * @param {string} targetRoute the route the redirect points to
134
+ * @returns {string} the escaped bytes used for every `T` occurrence
135
+ */
136
+ export function escapeRedirectTarget(targetRoute) {
137
+ let escaped = '';
138
+ for (const character of targetRoute) {
139
+ escaped += REDIRECT_TARGET_ESCAPES[character] ?? character;
140
+ }
141
+ return escaped;
142
+ }
143
+
144
+ /**
145
+ * 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.
147
+ *
148
+ * @param {string} targetRoute the route this redirect document points to
149
+ * @returns {string} the complete document text
150
+ */
151
+ export function renderRedirectDocument(targetRoute) {
152
+ const escapedTarget = escapeRedirectTarget(targetRoute);
153
+ return (
154
+ '<!doctype html>\n' +
155
+ '<html lang="en">\n' +
156
+ '<head>\n' +
157
+ '<meta charset="utf-8">\n' +
158
+ `<meta http-equiv="refresh" content="0;url=${escapedTarget}">\n` +
159
+ `<link rel="canonical" href="${escapedTarget}">\n` +
160
+ '<title>Redirecting...</title>\n' +
161
+ '</head>\n' +
162
+ '<body>\n' +
163
+ `<p>This page has moved to <a href="${escapedTarget}">${escapedTarget}</a>.</p>\n` +
164
+ '</body>\n' +
165
+ '</html>\n'
166
+ );
167
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Static search index generation (task packet S2-T08; brief S2 section 3
3
+ * "Required generated outputs": "static search index").
4
+ *
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
10
+ * decision (see `internal/route-labels.js`'s route-segment grammar and the
11
+ * media pipeline scope decisions in the package README): one JSON document,
12
+ * `{schemaVersion, generatedAt, publication, documents[]}`, sorted by each
13
+ * document's own absolute URL (UTF-8 byte order, this renderer's one
14
+ * deterministic ordering convention throughout — see
15
+ * `internal/source-inventory.js#compareUtf8Bytes`).
16
+ *
17
+ * `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
20
+ * `src/core/manifest.js`'s own `manifest.generatedAt` convention, so two
21
+ * clean builds of the same `build-input` still produce byte-identical index
22
+ * bytes.
23
+ *
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.
29
+ *
30
+ * Only actual authored content (`article`/`page` records, `status:
31
+ * "published"`) is indexed — never a synthetic listing page
32
+ * (index/tag/series/archive/profile/author), which has no body text to
33
+ * excerpt and is already reachable through ordinary navigation rather than
34
+ * full-text search.
35
+ */
36
+
37
+ import { joinBasePathAndRoute } from './route.js';
38
+ import { compareUtf8Bytes } from './source-inventory.js';
39
+ import { contentLastModified, contentRoute } from './page-kinds.js';
40
+
41
+ /** @type {number} the excerpt's maximum character length — bounded for the
42
+ * same documented reason `plainText`'s own `description` field is bounded
43
+ * to 500 graphemes elsewhere in this schema family. */
44
+ const EXCERPT_MAX_LENGTH = 300;
45
+
46
+ /**
47
+ * Derive a short plain-text excerpt from an already-sanitized HTML body: a
48
+ * deliberately simple, deterministic tag strip (never a full HTML parser —
49
+ * this is a search-index summary field, not rendered output), collapsing
50
+ * whitespace and bounding the result length.
51
+ *
52
+ * @param {string} html a `renderableBody.body`-shaped, already
53
+ * render-policy-conformant HTML fragment
54
+ * @returns {string} the bounded plain-text excerpt
55
+ */
56
+ function plainTextExcerpt(html) {
57
+ const stripped = html
58
+ .replace(/<[^>]*>/g, ' ')
59
+ .replace(/\s+/g, ' ')
60
+ .trim();
61
+ return stripped.length > EXCERPT_MAX_LENGTH
62
+ ? `${stripped.slice(0, EXCERPT_MAX_LENGTH).trimEnd()}…`
63
+ : stripped;
64
+ }
65
+
66
+ /**
67
+ * Build the deterministic static search index document for one validated
68
+ * `build-input:2.0.0` instance.
69
+ *
70
+ * @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
71
+ * @returns {string} the exact UTF-8 JSON text, LF-terminated
72
+ */
73
+ export function buildSearchIndex(validatedInput) {
74
+ const { publication, content, basePath, baseUrl, buildEpoch } =
75
+ validatedInput;
76
+
77
+ /**
78
+ * @param {string} route an un-joined route
79
+ * @returns {string} the absolute `https://` URL
80
+ */
81
+ const absoluteUrl = (route) =>
82
+ new URL(joinBasePathAndRoute(basePath, route), baseUrl).toString();
83
+
84
+ const documents = content
85
+ .filter(
86
+ (record) =>
87
+ (record.frontmatter.kind === 'article' ||
88
+ record.frontmatter.kind === 'page') &&
89
+ record.frontmatter.status === 'published',
90
+ )
91
+ .map((record) => {
92
+ const { frontmatter } = record;
93
+ return {
94
+ id: frontmatter.id,
95
+ route: absoluteUrl(contentRoute(frontmatter)),
96
+ kind: frontmatter.kind,
97
+ title: frontmatter.title,
98
+ description: frontmatter.description ?? '',
99
+ excerpt: plainTextExcerpt(record.body),
100
+ language: frontmatter.language,
101
+ tags: [...frontmatter.tags].sort(),
102
+ publishedAt: frontmatter.publishedAt,
103
+ lastModified: contentLastModified(frontmatter),
104
+ };
105
+ })
106
+ .sort((a, b) => compareUtf8Bytes(a.route, b.route));
107
+
108
+ const document = {
109
+ schemaVersion: '1',
110
+ generatedAt: buildEpoch,
111
+ publication: {
112
+ title: publication.title,
113
+ canonicalBase: publication.canonicalBase,
114
+ },
115
+ documents,
116
+ };
117
+
118
+ return `${JSON.stringify(document)}\n`;
119
+ }