@zvndev/darkmown 2.9.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.
package/src/statics.js CHANGED
@@ -2,14 +2,16 @@ import fs from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
4
  import zlib from "node:zlib";
5
- import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
5
+ import { BASE_SECURITY_HEADERS, REACTIVE_CSP, routeCsp } from "./headers.js";
6
6
 
7
7
  /**
8
8
  * The security headers (including the right CSP variant) for an HTML response.
9
9
  * Mirrors what production ships. Since 2.1 both variants are eval-free (the
10
10
  * reactive runtime walks a validated AST, not `new Function`), so a reactive and a
11
- * static route resolve to the same CSP — the per-route selection is kept for parity
12
- * with the `_headers` / `vercel.json` structure the same build writes. Anything not
11
+ * static route resolve to the same base CSP — the per-route selection is kept for
12
+ * parity with the `_headers` / `vercel.json` structure the same build writes, and
13
+ * it is what lets a route's own `:embed` hosts (recorded on its manifest entry as
14
+ * `assets.embedHosts`) widen `frame-src` for exactly that route. Anything not
13
15
  * found in `routes.json` (an unknown route, a 404, a missing manifest) falls back
14
16
  * to the reactive CSP, exactly like the production `_headers` catch-all block.
15
17
  * @param {string} distRoot Absolute path to the build output directory.
@@ -17,9 +19,12 @@ import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
17
19
  * @returns {Record<string, string>}
18
20
  */
19
21
  function htmlSecurityHeaders(distRoot, url) {
22
+ const entry = routeEntry(distRoot, routeOf(url));
20
23
  return {
21
24
  ...BASE_SECURITY_HEADERS,
22
- "Content-Security-Policy": routeIsReactive(distRoot, routeOf(url)) ? REACTIVE_CSP : STATIC_CSP
25
+ "Content-Security-Policy": entry
26
+ ? routeCsp(Boolean(entry.assets?.runtime), entry.assets?.embedHosts ?? [])
27
+ : REACTIVE_CSP
23
28
  };
24
29
  }
25
30
 
@@ -43,24 +48,23 @@ function routeOf(url) {
43
48
  }
44
49
 
45
50
  /**
46
- * Whether the route ships the reactive runtime, per the build's `routes.json`.
47
- * Defaults to `true` (reactive) when the route is absent or the manifest can't be
48
- * read — matching the production `_headers` catch-all, so an unknown route is
49
- * never accidentally served the stricter policy.
51
+ * The route's own manifest entry from the build's `routes.json`, or null when
52
+ * the route is absent or the manifest can't be read — in which case the caller
53
+ * falls back to the reactive CSP, matching the production `_headers` catch-all,
54
+ * so an unknown route is never accidentally served the stricter policy.
50
55
  * @param {string} distRoot
51
56
  * @param {string | null} route
52
- * @returns {boolean}
57
+ * @returns {{ route: string, assets?: { runtime?: boolean, embedHosts?: string[] } } | null}
53
58
  */
54
- function routeIsReactive(distRoot, route) {
55
- if (!route) return true;
59
+ function routeEntry(distRoot, route) {
60
+ if (!route) return null;
56
61
  try {
57
62
  const manifest = JSON.parse(fs.readFileSync(path.join(distRoot, "routes.json"), "utf8"));
58
- const entry = manifest.find((/** @type {{ route: string }} */ e) => e.route === route);
59
- if (entry) return Boolean(entry.assets?.runtime);
63
+ return manifest.find((/** @type {{ route: string }} */ e) => e.route === route) ?? null;
60
64
  } catch {
61
65
  /* no/unreadable manifest → reactive default (the catch-all policy) */
66
+ return null;
62
67
  }
63
- return true;
64
68
  }
65
69
 
66
70
  /**
@@ -256,6 +260,10 @@ export function resolvePublicFile(distRoot, url) {
256
260
  } catch {
257
261
  return null;
258
262
  }
263
+ // A decoded NUL (%00) is a valid string here but every fs call on the
264
+ // resulting path throws ERR_INVALID_ARG_VALUE out of the request handler,
265
+ // killing the server. It can never name a built file, so it is a miss.
266
+ if (cleanUrl.includes("\0")) return null;
259
267
  const base = cleanUrl.startsWith("/__wd/")
260
268
  ? cleanUrl
261
269
  : path.join(cleanUrl, path.extname(cleanUrl) ? "" : "index.html");
@@ -53,6 +53,12 @@ export function stripRuntimeComments(source: string): string;
53
53
  *
54
54
  * Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
55
55
  * so each static route emits a path glob (`/docs`, `/docs/*`) that covers both.
56
+ *
57
+ * A page whose `:embed`s frame hosts beyond the base policy (a booking widget,
58
+ * a map) carries those hosts on its manifest entry (`assets.embedHosts`), and
59
+ * its block — static or reactive — states the unioned per-route policy
60
+ * ({@link routeCsp}). A reactive route with embed hosts can no longer inherit
61
+ * the catch-all, so it always gets its own block.
56
62
  * @param {RouteManifestEntry[]} manifest
57
63
  * @returns {string}
58
64
  */
@@ -68,7 +74,10 @@ export function renderCloudflareHeaders(manifest: RouteManifestEntry[]): string;
68
74
  * @typedef {object} RouteManifestEntry
69
75
  * @property {string} route Public route path.
70
76
  * @property {string} file Source file path, relative to cwd, POSIX-separated.
71
- * @property {{ skins: string[], scripts: string[], runtime: boolean }} assets
77
+ * @property {{ skins: string[], scripts: string[], runtime: boolean, embedHosts?: string[] }} assets
78
+ * `embedHosts` is present only when the page's `:embed`s frame at least one
79
+ * source the base CSP does not already cover — absent keeps a no-embed
80
+ * site's routes.json byte-identical.
72
81
  */
73
82
  /**
74
83
  * One source route's record in the dev dependency map (`dist/.wd-dev-deps.json`),
@@ -107,6 +116,15 @@ export type BuildResult = {
107
116
  cached: number;
108
117
  unavailable: boolean;
109
118
  };
119
+ /**
120
+ * Font-subset post-pass counts (2.10.0): freshly subsetted, cache-served, and
121
+ * whether harfbuzzjs/wawoff2 could not be loaded at all.
122
+ */
123
+ fonts: {
124
+ subsetted: number;
125
+ cached: number;
126
+ unavailable: boolean;
127
+ };
110
128
  incremental?: {
111
129
  changed: string[];
112
130
  rebuilt: string[];
@@ -168,10 +186,16 @@ export type RouteManifestEntry = {
168
186
  * Source file path, relative to cwd, POSIX-separated.
169
187
  */
170
188
  file: string;
189
+ /**
190
+ * `embedHosts` is present only when the page's `:embed`s frame at least one
191
+ * source the base CSP does not already cover — absent keeps a no-embed
192
+ * site's routes.json byte-identical.
193
+ */
171
194
  assets: {
172
195
  skins: string[];
173
196
  scripts: string[];
174
197
  runtime: boolean;
198
+ embedHosts?: string[];
175
199
  };
176
200
  };
177
201
  /**
@@ -107,9 +107,10 @@ export type DirectiveEntry = {
107
107
  */
108
108
  name: string;
109
109
  /**
110
- * A single line, or an opener with a closer.
110
+ * A single line, an opener with a
111
+ * closer, or a line that may optionally open a block (`@include … @endinclude`).
111
112
  */
112
- kind: "line" | "block";
113
+ kind: "line" | "block" | "either";
113
114
  /**
114
115
  * One-line schematic (`[…]` = optional, `<…>` = slot).
115
116
  */