@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.
- package/LICENSE +201 -0
- package/README.md +515 -0
- package/contracts/render-policy.jcs +1 -0
- package/contracts/theme-styling-contract.jcs +1 -0
- package/package.json +96 -0
- package/src/core/errors.js +136 -0
- package/src/core/index.js +758 -0
- package/src/core/internal/appearance/bootstrap-script.js +159 -0
- package/src/core/internal/appearance/contract.js +193 -0
- package/src/core/internal/appearance/controller-markup.js +80 -0
- package/src/core/internal/appearance/styling-contract.js +509 -0
- package/src/core/internal/canonical-jcs.js +0 -0
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/eleventy-render.js +220 -0
- package/src/core/internal/feeds.js +161 -0
- package/src/core/internal/fs-walk.js +38 -0
- package/src/core/internal/media/avif-probe.js +169 -0
- package/src/core/internal/media/font-policy.js +56 -0
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +129 -0
- package/src/core/internal/media/pipeline.js +353 -0
- package/src/core/internal/media/png-codec.js +406 -0
- package/src/core/internal/media/resize.js +147 -0
- package/src/core/internal/media/sniff.js +109 -0
- package/src/core/internal/media/webp-probe.js +139 -0
- package/src/core/internal/messages.js +78 -0
- package/src/core/internal/page-kinds.js +709 -0
- package/src/core/internal/provenance.js +49 -0
- package/src/core/internal/render-policy-content.js +295 -0
- package/src/core/internal/route-labels.js +68 -0
- package/src/core/internal/route.js +167 -0
- package/src/core/internal/search-index.js +119 -0
- package/src/core/internal/seo.js +114 -0
- package/src/core/internal/sitemap.js +77 -0
- package/src/core/internal/skeleton.js +413 -0
- package/src/core/internal/source-inventory.js +145 -0
- package/src/core/internal/text-direction.js +59 -0
- package/src/core/internal/theme-assets.js +387 -0
- package/src/core/internal/uuid.js +51 -0
- package/src/core/manifest.js +0 -0
- package/types/eleventy-plugin-syntaxhighlight.d.ts +26 -0
- package/types/eleventy.d.ts +38 -0
- 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
|
+
'&': '&',
|
|
124
|
+
'<': '<',
|
|
125
|
+
'>': '>',
|
|
126
|
+
'"': '"',
|
|
127
|
+
"'": ''',
|
|
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
|
+
}
|