@zvndev/darkmown 2.8.0 → 2.10.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.
@@ -0,0 +1,457 @@
1
+ // ---------------------------------------------------------------------------
2
+ // BUILD-TIME FONT SUBSETTING (2.10.0).
3
+ //
4
+ // Raw font files are the most expensive asset a text-heavy site ships (the
5
+ // ToothDocs rebuild measured 1.07 MB of variable TTFs where 158 KB of subset
6
+ // woff2 served the same two families). This post-pass closes that: after the
7
+ // builder has emitted every page and stylesheet, it scans `dist/**/*.css` for
8
+ // `@font-face` rules whose `src url(...)` points at a PROJECT-LOCAL font
9
+ // (.ttf/.otf/.woff under `site/_` or `site/pages`), subsets each one to exactly
10
+ // the character set the site's emitted HTML uses (plus a documented base set),
11
+ // encodes the subset as woff2 beside the original in dist as
12
+ // `<name>-subset.woff2`, and rewrites the emitted CSS so `src:` lists the
13
+ // subset woff2 FIRST with `format("woff2")` and the original as fallback. The
14
+ // source `.skin` is never touched — the rewrite lives in dist only.
15
+ //
16
+ // Subsetting needs harfbuzzjs (the hb-subset wasm) and wawoff2, both OPTIONAL
17
+ // dependencies, so every wasm touch is quarantined behind the same seam the
18
+ // image pre-pass uses: the actual work runs in a child process
19
+ // (src/font-subset-worker.js) that loads the toolchain dynamically and reports
20
+ // `unavailable` instead of throwing when it is absent. Without the toolchain
21
+ // the build is byte-identical to 2.9.0 plus ONE hint. A font that fails to
22
+ // parse or subset is a hint naming the file; the build never fails over a font.
23
+ //
24
+ // Subsets are cached in `node_modules/.cache/darkmown-fonts/` keyed by font
25
+ // content hash + charset hash, so a second build with unchanged fonts and
26
+ // content does zero wasm work (and needs no toolchain at all when cached).
27
+ //
28
+ // The build is synchronous, the wasm API is not — that is the whole reason for
29
+ // the child process. `buildSite` stays a sync API; one `spawnSync` per build
30
+ // runs all pending subsets, and a fully cached build spawns nothing.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ import { spawnSync } from "node:child_process";
34
+ import crypto from "node:crypto";
35
+ import fs from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+ import { fileURLToPath } from "node:url";
39
+
40
+ /**
41
+ * @typedef {import("./config.js").Paths} Paths
42
+ */
43
+
44
+ /**
45
+ * One subset handed to the worker: reduce `source` to the shared charset and
46
+ * write the woff2 result to `out`, the cache path.
47
+ * @typedef {object} SubsetJob
48
+ * @property {string} source Absolute path of the original font file.
49
+ * @property {string} out Absolute cache path the subset woff2 is written to.
50
+ */
51
+
52
+ /**
53
+ * What the worker reports back. `unavailable` means the toolchain could not be
54
+ * loaded at all (harfbuzzjs/wawoff2 not installed) — every pending font then
55
+ * ships unsubsetted. `failed` carries one entry per font the toolchain itself
56
+ * rejected (corrupt payload, unsupported format).
57
+ * @typedef {object} SubsetResult
58
+ * @property {boolean} unavailable
59
+ * @property {{ file: string, error: string }[]} failed
60
+ */
61
+
62
+ /**
63
+ * The post-pass result, feeding the build summary. `unavailable` marks the
64
+ * toolchain-absent build (which is otherwise identical to not having the
65
+ * feature).
66
+ * @typedef {object} FontSubsetPlan
67
+ * @property {number} subsetted Fonts freshly subsetted this build.
68
+ * @property {number} cached Fonts served from the on-disk cache.
69
+ * @property {boolean} unavailable harfbuzzjs/wawoff2 could not be loaded.
70
+ */
71
+
72
+ /**
73
+ * A project-local font referenced by an emitted `@font-face` rule.
74
+ * @typedef {object} FontRef
75
+ * @property {string} url The url() target exactly as written in the CSS.
76
+ * @property {string} source Absolute path of the font file it resolves to.
77
+ * @property {string} subsetUrl Served URL of the subset (`<stem>-subset.woff2`).
78
+ * @property {string} subsetDist Absolute dist path the subset is written to.
79
+ * @property {string} [cacheFile] Absolute cache path, set once the hashes are known.
80
+ */
81
+
82
+ /** The formats hb-subset reads directly; woff2 inputs and remote URLs pass through. */
83
+ const SUBSETTABLE_EXTENSIONS = new Set([".ttf", ".otf", ".woff"]);
84
+
85
+ /** The cache root, relative to the project. Under node_modules, so already ignored. */
86
+ const CACHE_DIR = "node_modules/.cache/darkmown-fonts";
87
+
88
+ /** The shelf's public mount point; everything else resolves against site/pages. */
89
+ const SHELF_URL_PREFIX = "/__wd/media/";
90
+
91
+ const WORKER_PATH = path.join(
92
+ path.dirname(fileURLToPath(import.meta.url)),
93
+ "font-subset-worker.js"
94
+ );
95
+
96
+ /** An `@font-face { … }` block in emitted CSS (the skin compiler never nests one). */
97
+ const FONT_FACE_BLOCK = /@font-face\s*\{[^}]*\}/g;
98
+
99
+ /** A url() target, double-quoted, single-quoted, or bare. */
100
+ const URL_REF = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)"'\s]+))\s*\)/g;
101
+
102
+ /** A numeric character reference: `&#65;` or `&#x2605;`. */
103
+ const NUMERIC_ENTITY = /&#(x[0-9a-fA-F]+|X[0-9a-fA-F]+|\d+);/g;
104
+
105
+ // The base charset every subset carries, regardless of page content: the
106
+ // characters a page can always legitimately render (framework chrome, markdown
107
+ // typography, template copy like the store's "✓") even when this build's scan
108
+ // did not see them — for example text a colocated .js injects at runtime.
109
+ // 0020–007E printable ASCII
110
+ // 00A0–00FF Latin-1 Supplement (printable half: ·, é, ü, …)
111
+ // 2010–2027 hyphens, en/em dashes, curly quotes, ellipsis, bullets, leaders
112
+ // 2030–2033 per-mille, prime, double prime
113
+ // 2039–203A single guillemets
114
+ // 2190–2199 arrows (← ↑ → ↓ and diagonals)
115
+ // singles ★ ☆ ✓ ✕ ✗
116
+ const BASE_RANGES = [
117
+ [0x20, 0x7e],
118
+ [0xa0, 0xff],
119
+ [0x2010, 0x2027],
120
+ [0x2030, 0x2033],
121
+ [0x2039, 0x203a],
122
+ [0x2190, 0x2199]
123
+ ];
124
+ const BASE_SINGLES = [0x2605, 0x2606, 0x2713, 0x2715, 0x2717];
125
+
126
+ /** @returns {number[]} The sorted base charset (runs at module load). */
127
+ function buildBaseCodepoints() {
128
+ /** @type {number[]} */
129
+ const cps = [];
130
+ for (const [lo, hi] of BASE_RANGES) for (let cp = lo; cp <= hi; cp++) cps.push(cp);
131
+ cps.push(...BASE_SINGLES);
132
+ return cps.sort((a, b) => a - b);
133
+ }
134
+
135
+ /** The base charset, sorted ascending. */
136
+ export const BASE_CODEPOINTS = buildBaseCodepoints();
137
+
138
+ /**
139
+ * Subset every project-local font referenced by the emitted stylesheets and
140
+ * rewrite the dist CSS to lead with the subset woff2.
141
+ *
142
+ * Skips, none of them errors: remote/protocol-relative/data/relative URLs
143
+ * (silently), already-woff2 sources (silently), URLs that escape or miss the
144
+ * project (silently — a broken link is an authoring bug, not a subsetting one).
145
+ * An unreadable font file, and one the toolchain fails on, get a hint naming
146
+ * the file and ship as-is. With no `@font-face` in any emitted stylesheet the
147
+ * pass is a no-op and never even scans the HTML.
148
+ * @param {Paths} paths
149
+ * @param {{ cacheDir?: string, quiet?: boolean,
150
+ * runJobs?: (payload: { charset: number[], jobs: SubsetJob[] }) => SubsetResult }} [options]
151
+ * `quiet` suppresses the hints (dev-server rebuilds). `runJobs` is the subset
152
+ * seam — the default spawns the worker process; tests inject fakes for the
153
+ * unavailable/failure paths.
154
+ * @returns {FontSubsetPlan}
155
+ */
156
+ export function subsetFonts(paths, options = {}) {
157
+ const cacheDir = options.cacheDir ?? path.join(paths.cwd, CACHE_DIR);
158
+ const runJobs = options.runJobs ?? runSubsetJobs;
159
+ const hint = options.quiet ? () => {} : (/** @type {string} */ m) => console.warn(`hint: ${m}`);
160
+ const stats = { subsetted: 0, cached: 0, unavailable: false };
161
+ if (!fs.existsSync(paths.distRoot)) return stats;
162
+
163
+ // Pass 1: every local font any emitted stylesheet references, deduped by
164
+ // source file (several @font-face blocks, and several stylesheets, can share
165
+ // one font file — it is subsetted once and each reference is rewritten).
166
+ /** @type {Map<string, FontRef>} */
167
+ const fonts = new Map();
168
+ /** @type {string[]} Every url() occurrence that resolved, for the rewrite map. */
169
+ const occurrences = [];
170
+ /** @type {string[]} */
171
+ const cssFiles = [];
172
+ for (const file of walk(paths.distRoot)) {
173
+ if (!file.endsWith(".css")) continue;
174
+ cssFiles.push(file);
175
+ const css = fs.readFileSync(file, "utf8");
176
+ for (const url of fontUrlsInCss(css)) {
177
+ const resolved = resolveFontUrl(paths, url);
178
+ if (!resolved) continue;
179
+ occurrences.push(url);
180
+ if (!fonts.has(resolved.source)) fonts.set(resolved.source, resolved);
181
+ }
182
+ }
183
+ if (fonts.size === 0) return stats;
184
+
185
+ // Pass 2: the charset — the base set plus every code point in the emitted
186
+ // pages. The scan reads the final HTML, so non-latin content flows into the
187
+ // union automatically; over-inclusion from markup is impossible to notice
188
+ // (tags are ASCII, which the base set already carries).
189
+ /** @type {string[]} */
190
+ const texts = [];
191
+ for (const file of walk(paths.distRoot)) {
192
+ if (file.endsWith(".html")) texts.push(fs.readFileSync(file, "utf8"));
193
+ }
194
+ const codepoints = charsetFromTexts(texts);
195
+ const charsetHash = hash(codepoints.join(","));
196
+
197
+ /** @type {SubsetJob[]} */
198
+ const pending = [];
199
+ /** @type {FontRef[]} */
200
+ const waiting = [];
201
+ /** @type {string[]} Project-relative paths of fonts with uncached work, for the absent-toolchain hint. */
202
+ const needingTools = [];
203
+ /** Sources whose subset landed in dist this build (cache or fresh). */
204
+ const emitted = new Set();
205
+
206
+ /**
207
+ * Materialize one subset: copy it from the cache to its served path.
208
+ * @param {FontRef} ref
209
+ */
210
+ const emitSubset = (ref) => {
211
+ fs.mkdirSync(path.dirname(ref.subsetDist), { recursive: true });
212
+ fs.copyFileSync(/** @type {string} */ (ref.cacheFile), ref.subsetDist);
213
+ emitted.add(ref.source);
214
+ };
215
+
216
+ for (const ref of fonts.values()) {
217
+ /** @type {Uint8Array} */
218
+ let bytes;
219
+ try {
220
+ bytes = fs.readFileSync(ref.source);
221
+ } catch {
222
+ hint(
223
+ `no woff2 subset for ${relPosix(paths, ref.source)} — the file could not be read. ` +
224
+ "The original font ships as-is."
225
+ );
226
+ continue;
227
+ }
228
+ ref.cacheFile = path.join(cacheDir, `${hash(bytes)}-${charsetHash}.woff2`);
229
+ if (fs.existsSync(ref.cacheFile)) {
230
+ emitSubset(ref);
231
+ stats.cached++;
232
+ } else {
233
+ pending.push({ source: ref.source, out: ref.cacheFile });
234
+ waiting.push(ref);
235
+ needingTools.push(relPosix(paths, ref.source));
236
+ }
237
+ }
238
+
239
+ if (pending.length > 0) {
240
+ const result = runJobs({ charset: codepoints, jobs: pending });
241
+ if (result.unavailable) {
242
+ stats.unavailable = true;
243
+ hint(
244
+ `${needingTools.length} font${needingTools.length === 1 ? "" : "s"} could be subsetted ` +
245
+ `to woff2 (${needingTools.join(", ")}). Run \`npm i -D harfbuzzjs wawoff2\` ` +
246
+ "to subset fonts at build time."
247
+ );
248
+ } else {
249
+ const failed = new Map(result.failed.map((f) => [f.file, f.error]));
250
+ for (const ref of waiting) {
251
+ const error = failed.get(ref.source);
252
+ if (error !== undefined) {
253
+ hint(
254
+ `no woff2 subset for ${relPosix(paths, ref.source)} — the font could not be ` +
255
+ `subsetted (${error}). The original font ships as-is.`
256
+ );
257
+ continue;
258
+ }
259
+ emitSubset(ref);
260
+ stats.subsetted++;
261
+ }
262
+ }
263
+ }
264
+
265
+ // Pass 3: rewrite the emitted CSS. Only stylesheets that reference a
266
+ // successfully subsetted font change; everything else is untouched, and the
267
+ // source .skin files are never written.
268
+ if (emitted.size > 0) {
269
+ /** @type {Map<string, string>} url as written in the CSS → subset URL */
270
+ const subsetByUrl = new Map();
271
+ for (const url of occurrences) {
272
+ const resolved = resolveFontUrl(paths, url);
273
+ if (resolved && emitted.has(resolved.source)) subsetByUrl.set(url, resolved.subsetUrl);
274
+ }
275
+ for (const file of cssFiles) {
276
+ const css = fs.readFileSync(file, "utf8");
277
+ const rewritten = rewriteFontFaceCss(css, subsetByUrl);
278
+ if (rewritten !== css) fs.writeFileSync(file, rewritten);
279
+ }
280
+ }
281
+
282
+ return stats;
283
+ }
284
+
285
+ /**
286
+ * The default subset seam: run every pending job in ONE child process (the
287
+ * build is synchronous; the wasm API is not), communicating through temp JSON
288
+ * files rather than stdio so a large payload never hits a pipe buffer. The
289
+ * worker always writes a results file — a missing or unparsable one means the
290
+ * child itself died, which is reported as `unavailable` (the build then behaves
291
+ * as if the toolchain were absent: a hint, no subsets, no failure).
292
+ * @param {{ charset: number[], jobs: SubsetJob[] }} payload
293
+ * @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
294
+ * Injectable for tests; the real one is `spawnSync`.
295
+ * @returns {SubsetResult}
296
+ */
297
+ export function runSubsetJobs(payload, spawn = /** @type {any} */ (spawnSync)) {
298
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wd-font-subset-"));
299
+ try {
300
+ const jobsFile = path.join(dir, "jobs.json");
301
+ const resultsFile = path.join(dir, "results.json");
302
+ fs.writeFileSync(jobsFile, JSON.stringify(payload));
303
+ spawn(process.execPath, [WORKER_PATH, jobsFile, resultsFile], {
304
+ stdio: ["ignore", "ignore", "inherit"]
305
+ });
306
+ if (!fs.existsSync(resultsFile)) return { unavailable: true, failed: [] };
307
+ try {
308
+ return JSON.parse(fs.readFileSync(resultsFile, "utf8"));
309
+ } catch {
310
+ return { unavailable: true, failed: [] };
311
+ }
312
+ } finally {
313
+ fs.rmSync(dir, { recursive: true, force: true });
314
+ }
315
+ }
316
+
317
+ /**
318
+ * The subset charset for a build: the base set plus every code point appearing
319
+ * in the given texts (the emitted pages' HTML). Numeric character references
320
+ * (`&#9733;`, `&#x2605;`) are decoded first so a character the compiler emitted
321
+ * as an entity still lands in the union. Tags and attribute syntax contribute
322
+ * only ASCII, which the base set already carries, so the whole-file scan can
323
+ * only ever over-include by a handful of glyphs.
324
+ * @param {string[]} texts
325
+ * @returns {number[]} Sorted ascending, deduped.
326
+ */
327
+ export function charsetFromTexts(texts) {
328
+ /** @type {Set<number>} */
329
+ const set = new Set(BASE_CODEPOINTS);
330
+ for (const text of texts) {
331
+ const decoded = text.replace(NUMERIC_ENTITY, (_match, body) => {
332
+ const cp = /^[xX]/.test(body)
333
+ ? Number.parseInt(body.slice(1), 16)
334
+ : Number.parseInt(body, 10);
335
+ return cp <= 0x10ffff ? String.fromCodePoint(cp) : "";
336
+ });
337
+ for (const ch of decoded) set.add(/** @type {number} */ (ch.codePointAt(0)));
338
+ }
339
+ return [...set].sort((a, b) => a - b);
340
+ }
341
+
342
+ /**
343
+ * Rewrite the `@font-face` blocks of an emitted stylesheet so each subsetted
344
+ * font's `src` leads with the subset woff2, the original kept as fallback:
345
+ * src: url("/f/a-subset.woff2") format("woff2"), url("/f/a.ttf") format("truetype");
346
+ * Only the `src` declaration changes — font-display and every other descriptor
347
+ * pass through byte-identically. Idempotent: a block already carrying the
348
+ * subset reference (a previous build's rewrite of a since-unedited skin) is
349
+ * left alone.
350
+ * @param {string} css Emitted stylesheet contents.
351
+ * @param {Map<string, string>} subsetByUrl url() target as written → subset URL.
352
+ * @returns {string}
353
+ */
354
+ export function rewriteFontFaceCss(css, subsetByUrl) {
355
+ if (subsetByUrl.size === 0) return css;
356
+ return css.replace(FONT_FACE_BLOCK, (block) => {
357
+ const srcDecl = /\bsrc\s*:\s*/.exec(block);
358
+ if (!srcDecl) return block;
359
+ /** @type {string[]} */
360
+ const prefixes = [];
361
+ for (const match of block.matchAll(URL_REF)) {
362
+ const url = match[1] ?? match[2] ?? match[3];
363
+ const subset = subsetByUrl.get(url);
364
+ if (subset && !block.includes(subset) && !prefixes.some((p) => p.includes(subset))) {
365
+ prefixes.push(`url("${subset}") format("woff2"), `);
366
+ }
367
+ }
368
+ if (prefixes.length === 0) return block;
369
+ const at = srcDecl.index + srcDecl[0].length;
370
+ return `${block.slice(0, at)}${prefixes.join("")}${block.slice(at)}`;
371
+ });
372
+ }
373
+
374
+ /**
375
+ * Every url() target inside the `@font-face` blocks of a stylesheet, in order.
376
+ * @param {string} css
377
+ * @returns {string[]}
378
+ */
379
+ function fontUrlsInCss(css) {
380
+ /** @type {string[]} */
381
+ const urls = [];
382
+ for (const block of css.matchAll(FONT_FACE_BLOCK)) {
383
+ for (const match of block[0].matchAll(URL_REF)) {
384
+ urls.push(match[1] ?? match[2] ?? match[3]);
385
+ }
386
+ }
387
+ return urls;
388
+ }
389
+
390
+ /**
391
+ * Resolve a url() target from an emitted `@font-face` to the project font file
392
+ * it names, or null when it is not a subset candidate: anything not
393
+ * leading-slash (remote, protocol-relative, `data:`, relative), a non-subsettable
394
+ * extension (already-woff2 included), an un-decodable or escaping path, or a
395
+ * file that does not exist. `/__wd/media/<rel>` maps to the shelf; every other
396
+ * absolute path maps to `site/pages`.
397
+ * @param {Paths} paths
398
+ * @param {string} url The raw url() target.
399
+ * @returns {FontRef | null}
400
+ */
401
+ function resolveFontUrl(paths, url) {
402
+ if (!url.startsWith("/") || url.startsWith("//")) return null;
403
+ const clean = url.split(/[?#]/)[0];
404
+ const ext = path.posix.extname(clean).toLowerCase();
405
+ if (!SUBSETTABLE_EXTENSIONS.has(ext)) return null;
406
+ /** @type {string} */
407
+ let decoded;
408
+ try {
409
+ decoded = decodeURIComponent(clean);
410
+ } catch {
411
+ return null;
412
+ }
413
+ const fromShelf = decoded.startsWith(SHELF_URL_PREFIX);
414
+ const base = fromShelf ? paths.shelfRoot : paths.routesRoot;
415
+ const rel = fromShelf ? decoded.slice(SHELF_URL_PREFIX.length) : decoded.slice(1);
416
+ const source = path.normalize(path.join(base, rel));
417
+ if (!source.startsWith(base + path.sep)) return null;
418
+ if (!fs.existsSync(source)) return null;
419
+ const subsetUrl = `${clean.slice(0, clean.length - ext.length)}-subset.woff2`;
420
+ return { url, source, subsetUrl, subsetDist: path.join(paths.distRoot, subsetUrl.slice(1)) };
421
+ }
422
+
423
+ /**
424
+ * A short content digest for cache keys.
425
+ * @param {Uint8Array | string} data
426
+ * @returns {string}
427
+ */
428
+ function hash(data) {
429
+ return crypto.createHash("sha256").update(data).digest("hex").slice(0, 24);
430
+ }
431
+
432
+ /**
433
+ * A project-relative, POSIX-separated path for hint messages.
434
+ * @param {Paths} paths
435
+ * @param {string} file Absolute path.
436
+ * @returns {string}
437
+ */
438
+ function relPosix(paths, file) {
439
+ return path.relative(paths.cwd, file).replaceAll(path.sep, "/");
440
+ }
441
+
442
+ /**
443
+ * All files under `dir`, recursively. Mirrors the builder's own walker; kept
444
+ * local so this module does not import the builder (which imports this).
445
+ * @param {string} dir
446
+ * @returns {string[]}
447
+ */
448
+ function walk(dir) {
449
+ /** @type {string[]} */
450
+ const files = [];
451
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
452
+ const abs = path.join(dir, entry.name);
453
+ if (entry.isDirectory()) files.push(...walk(abs));
454
+ else files.push(abs);
455
+ }
456
+ return files;
457
+ }
package/src/grammar.js CHANGED
@@ -87,11 +87,12 @@ export function generateGrammar() {
87
87
  lines.push("");
88
88
  // A `.wd` line is an opener, a BLOCK CLOSER (or mid-block marker), or an
89
89
  // interpolation. The closers were missing, so constrained decoding could open
90
- // an `@loop` it was then unable to close.
90
+ // an `@loop` it was then unable to close. `@endinclude` closes the block form
91
+ // of `@include` (children slot); the opener line itself stays `dir-include`.
91
92
  lines.push("root ::= directive | closer | binding");
92
93
  lines.push(`directive ::= ${dirRules.join(" | ")}`);
93
94
  lines.push(
94
- 'closer ::= "@endloop" | "@empty" | ":endif" | ":endform" | ":endcarousel" | ":::"' +
95
+ 'closer ::= "@endloop" | "@empty" | "@endinclude" | ":endif" | ":endform" | ":endcarousel" | ":::"' +
95
96
  ' | ":else if" sp condition ( sp joiner sp condition )* | ":else if" sp path | ":else"'
96
97
  );
97
98
  lines.push("");
package/src/headers.js CHANGED
@@ -30,7 +30,10 @@
30
30
  * - `data:` favicon and remote images — `img-src 'self' data: https:`.
31
31
  * - `:video`/`:audio` allow relative or `http(s)` sources — `media-src 'self' https:`.
32
32
  * - `:embed` rewrites to exactly the YouTube no-cookie and Vimeo player origins,
33
- * so `frame-src` pre-authorizes those two hosts (and nothing else).
33
+ * so `frame-src` pre-authorizes those two hosts; a generic `:embed` host
34
+ * (a booking widget, a map) is unioned in PER ROUTE at build time (see
35
+ * {@link routeCsp}), recorded in routes.json, so only pages that embed a
36
+ * host authorize it.
34
37
  * - A native `:form action="/api/…"` POSTs same-origin, so `form-action 'self'`
35
38
  * authorizes it (and blocks a form exfiltrating to a third-party origin). Apps
36
39
  * posting to a remote backend widen this alongside `connect-src`.
@@ -119,6 +122,30 @@ export const REACTIVE_CSP = [SCRIPT_SRC, ...COMMON_CSP_DIRECTIVES].join("; ");
119
122
  */
120
123
  export const STATIC_CSP = [SCRIPT_SRC, ...COMMON_CSP_DIRECTIVES].join("; ");
121
124
 
125
+ /**
126
+ * The CSP for one route: its base policy (`REACTIVE_CSP`/`STATIC_CSP`) with the
127
+ * page's `:embed` frame hosts unioned into `frame-src`. The compiler records the
128
+ * FINAL iframe src of every `:embed` (after the YouTube/Vimeo rewrite), so a
129
+ * generic embed — a booking widget, a map — is authorized on exactly the pages
130
+ * that carry it instead of being blocked by the shipped policy. YouTube/Vimeo
131
+ * embeds dedupe against the base list, so a page whose embeds are all covered
132
+ * already produces a byte-identical policy, as does a page with no embeds.
133
+ * @param {boolean} reactive Whether the route ships the reactive runtime.
134
+ * @param {Iterable<string>} [embedHosts] Origins (`https://app.example.com`) or
135
+ * `'self'` for a same-origin embed, collected at compile time.
136
+ * @returns {string}
137
+ */
138
+ export function routeCsp(reactive, embedHosts = []) {
139
+ const base = reactive ? REACTIVE_CSP : STATIC_CSP;
140
+ const hosts = [...new Set(embedHosts)].sort();
141
+ if (hosts.length === 0) return base;
142
+ return base.replace(/frame-src ([^;]*)/, (directive, sources) => {
143
+ const existing = sources.split(/\s+/);
144
+ const added = hosts.filter((host) => !existing.includes(host));
145
+ return added.length === 0 ? directive : `frame-src ${sources} ${added.join(" ")}`;
146
+ });
147
+ }
148
+
122
149
  /**
123
150
  * Baseline security headers applied to every HTML response, independent of the
124
151
  * CSP. CSP is added per-route (static vs reactive) by the helpers below.
@@ -0,0 +1,71 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The resize worker for the build-time image pre-pass (2.9.0). Never imported
3
+ // by the builder: the build is synchronous and sharp is not, so
4
+ // src/image-variants.js spawns this file as a child process with a jobs JSON
5
+ // file and reads back a results JSON file.
6
+ //
7
+ // sharp is an OPTIONAL dependency, so it is dynamic-imported inside a
8
+ // try/catch: when it cannot load (not installed, broken native binding), the
9
+ // worker reports `{ unavailable: true }` and exits cleanly — the parent turns
10
+ // that into one hint, and the build ships exactly what 2.8.0 would have.
11
+ // A failure on ONE image is recorded in `failed` and the rest still resize.
12
+ // Nothing here ever exits non-zero over an image.
13
+ //
14
+ // `WD_SHARP_MODULE` overrides the sharp specifier; tests point it at a
15
+ // nonexistent module to exercise the sharp-absent path without uninstalling
16
+ // anything. It is a test seam, not a public knob.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { pathToFileURL } from "node:url";
22
+
23
+ /**
24
+ * Run every job in the jobs file and write the results file.
25
+ * @param {string} jobsFile JSON array of `{ source, out, width, format }`.
26
+ * @param {string} resultsFile Written with `{ unavailable, failed }`.
27
+ * @returns {Promise<void>}
28
+ */
29
+ export async function runJobsFile(jobsFile, resultsFile) {
30
+ const jobs = JSON.parse(fs.readFileSync(jobsFile, "utf8"));
31
+ /** @type {(result: { unavailable: boolean, failed: { file: string, width: number, error: string }[] }) => void} */
32
+ const write = (result) => fs.writeFileSync(resultsFile, JSON.stringify(result));
33
+ /** @type {any} */
34
+ let sharp;
35
+ try {
36
+ sharp = (await import(process.env.WD_SHARP_MODULE || "sharp")).default;
37
+ } catch {
38
+ write({ unavailable: true, failed: [] });
39
+ return;
40
+ }
41
+ /** @type {{ file: string, width: number, error: string }[]} */
42
+ const failed = [];
43
+ for (const job of jobs) {
44
+ try {
45
+ // `rotate()` honors EXIF orientation before resizing: the emitted variant
46
+ // loses the EXIF tag with its pixels physically rotated, so it displays
47
+ // exactly like the tag-carrying original. `withoutEnlargement` is belt and
48
+ // braces — the pre-pass only ever queues widths below the intrinsic size.
49
+ let pipeline = sharp(job.source)
50
+ .rotate()
51
+ .resize({ width: job.width, withoutEnlargement: true });
52
+ if (job.format === "webp") pipeline = pipeline.webp({ quality: 82 });
53
+ else if (job.format === "jpeg") pipeline = pipeline.jpeg({ quality: 82, progressive: true });
54
+ else pipeline = pipeline.png({ compressionLevel: 9 });
55
+ fs.mkdirSync(path.dirname(job.out), { recursive: true });
56
+ await pipeline.toFile(job.out);
57
+ } catch (err) {
58
+ failed.push({
59
+ file: job.source,
60
+ width: job.width,
61
+ error: err instanceof Error ? err.message : String(err)
62
+ });
63
+ }
64
+ }
65
+ write({ unavailable: false, failed });
66
+ }
67
+
68
+ // Entry-point guard, one line so importing this module in a test registers the
69
+ // line as executed without running main (same pattern as the coverage gate).
70
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url)
71
+ runJobsFile(process.argv[2], process.argv[3]);