@zvndev/darkmown 2.9.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/AGENTS.md +23 -12
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +105 -6
  4. package/SECURITY.md +1 -0
  5. package/docs/cli.md +1 -1
  6. package/docs/spec-alignment.md +16 -2
  7. package/grammar/wd-directives.gbnf +1 -1
  8. package/package.json +8 -4
  9. package/site/pages/benchmarks/index.skin +67 -0
  10. package/site/pages/benchmarks/index.wd +126 -0
  11. package/site/pages/docs/index.wd +47 -2
  12. package/site/pages/index.wd +1 -0
  13. package/site/pages/playground/index.js +58 -54
  14. package/src/api-runner.js +80 -8
  15. package/src/builder.js +70 -15
  16. package/src/catalog.js +13 -4
  17. package/src/cli.js +18 -6
  18. package/src/compiler/body.js +30 -1
  19. package/src/compiler/context.js +13 -1
  20. package/src/compiler/includes.js +44 -1
  21. package/src/compiler/media.js +25 -0
  22. package/src/compiler/page.js +6 -0
  23. package/src/compiler/state.js +75 -0
  24. package/src/compiler/structure.js +272 -4
  25. package/src/errors.js +74 -3
  26. package/src/font-subset-worker.js +131 -0
  27. package/src/font-subset.js +475 -0
  28. package/src/grammar.js +3 -2
  29. package/src/headers.js +28 -1
  30. package/src/image-variant-resize.js +4 -1
  31. package/src/image-variants.js +9 -4
  32. package/src/router.js +16 -0
  33. package/src/skin.js +111 -45
  34. package/src/statics.js +22 -14
  35. package/src/templates/dashboard/README.md +3 -1
  36. package/src/templates/landing/site/pages/index.skin +7 -0
  37. package/src/templates/landing/site/pages/index.wd +10 -3
  38. package/src/templates/store/README.md +5 -2
  39. package/src/templates/store/site/_/card.wd +19 -0
  40. package/src/templates/store/site/_/products.json +6 -6
  41. package/src/templates/store/site/pages/index.skin +17 -2
  42. package/src/templates/store/site/pages/index.wd +3 -12
  43. package/types/api-runner.d.ts +20 -4
  44. package/types/builder.d.ts +25 -1
  45. package/types/catalog.d.ts +3 -2
  46. package/types/compiler/context.d.ts +28 -0
  47. package/types/compiler/includes.d.ts +23 -0
  48. package/types/compiler/structure.d.ts +53 -1
  49. package/types/errors.d.ts +6 -4
  50. package/types/font-subset.d.ts +144 -0
  51. package/types/headers.d.ts +14 -0
  52. package/types/image-variants.d.ts +7 -3
  53. package/types/router.d.ts +5 -0
  54. package/types/skin.d.ts +9 -4
@@ -0,0 +1,131 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The subset worker for the build-time font post-pass (2.10.0). Never imported
3
+ // by the builder: the build is synchronous and the wasm toolchain is not, so
4
+ // src/font-subset.js spawns this file as a child process with a jobs JSON file
5
+ // and reads back a results JSON file.
6
+ //
7
+ // harfbuzzjs (the hb-subset wasm) and wawoff2 are OPTIONAL dependencies, so
8
+ // both are loaded inside a try/catch: when either cannot load, the worker
9
+ // reports `{ unavailable: true }` and exits cleanly — the parent turns that
10
+ // into one hint, and the build ships exactly what 2.9.0 would have. A failure
11
+ // on ONE font is recorded in `failed` and the rest still subset. Nothing here
12
+ // ever exits non-zero over a font.
13
+ //
14
+ // The harfbuzzjs package ships the subset build as a bare wasm with no JS glue
15
+ // (its exports map only exposes the shaping API), so we instantiate
16
+ // `harfbuzzjs/dist/harfbuzz-subset.wasm` directly — it imports nothing and is
17
+ // initialized with one `_initialize()` call. Default hb-subset behavior keeps
18
+ // the layout features of the retained glyphs (kern/liga/calt via GSUB/GPOS
19
+ // subsetting) and the variation tables (fvar/gvar), so variable axes survive.
20
+ //
21
+ // `WD_HARFBUZZ_WASM` and `WD_WAWOFF2_MODULE` override the two tool specifiers;
22
+ // tests point them at nonexistent paths to exercise the toolchain-absent path
23
+ // without uninstalling anything. They are test seams, not public knobs.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ import fs from "node:fs";
27
+ import { createRequire } from "node:module";
28
+ import path from "node:path";
29
+ import { pathToFileURL } from "node:url";
30
+
31
+ /** hb_memory_mode_writable: the blob may keep referencing the malloc'd bytes. */
32
+ const HB_MEMORY_MODE_WRITABLE = 2;
33
+
34
+ /**
35
+ * Load the optional toolchain, or null when either half is absent. The wasm
36
+ * path resolves through the harfbuzzjs package's exports map, so a consumer's
37
+ * `npm i -D harfbuzzjs` beside the installed framework is found the same way
38
+ * sharp is for the image pre-pass.
39
+ * @returns {Promise<{ hb: any, compress: (input: Uint8Array) => Promise<Uint8Array> } | null>}
40
+ */
41
+ export async function loadFontTools() {
42
+ try {
43
+ const require = createRequire(import.meta.url);
44
+ const wasmPath =
45
+ process.env.WD_HARFBUZZ_WASM ?? require.resolve("harfbuzzjs/dist/harfbuzz-subset.wasm");
46
+ const { instance } = await WebAssembly.instantiate(await fs.promises.readFile(wasmPath));
47
+ const hb = /** @type {any} */ (instance.exports);
48
+ hb._initialize();
49
+ const compress = require(process.env.WD_WAWOFF2_MODULE || "wawoff2").compress;
50
+ return { hb, compress };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Subset one font to `codepoints`, returning the subset as TTF bytes (the
58
+ * caller encodes to woff2). Throws when harfbuzz cannot parse or subset the
59
+ * file. Every wasm handle is released on both paths; the heap view is taken
60
+ * fresh at each use because a malloc can grow (and detach) the memory.
61
+ * @param {any} hb The hb-subset wasm exports.
62
+ * @param {Uint8Array} fontBytes The original font file.
63
+ * @param {number[]} codepoints The charset to keep.
64
+ * @returns {Uint8Array}
65
+ */
66
+ export function subsetFont(hb, fontBytes, codepoints) {
67
+ const input = hb.hb_subset_input_create_or_fail();
68
+ if (!input) throw new Error("harfbuzz could not create a subset input");
69
+ const unicodes = hb.hb_subset_input_unicode_set(input);
70
+ for (const cp of codepoints) hb.hb_set_add(unicodes, cp);
71
+ const fontPtr = hb.malloc(fontBytes.byteLength);
72
+ new Uint8Array(hb.memory.buffer).set(fontBytes, fontPtr);
73
+ const blob = hb.hb_blob_create(fontPtr, fontBytes.byteLength, HB_MEMORY_MODE_WRITABLE, 0, 0);
74
+ const face = hb.hb_face_create(blob, 0);
75
+ hb.hb_blob_destroy(blob);
76
+ const subsetFace = hb.hb_subset_or_fail(face, input);
77
+ if (!subsetFace) {
78
+ hb.hb_face_destroy(face);
79
+ hb.hb_subset_input_destroy(input);
80
+ hb.free(fontPtr);
81
+ throw new Error("harfbuzz could not parse or subset the font");
82
+ }
83
+ const subsetBlob = hb.hb_face_reference_blob(subsetFace);
84
+ const length = hb.hb_blob_get_length(subsetBlob);
85
+ const dataPtr = hb.hb_blob_get_data(subsetBlob, 0);
86
+ const bytes = new Uint8Array(hb.memory.buffer, dataPtr, length).slice();
87
+ hb.hb_blob_destroy(subsetBlob);
88
+ hb.hb_face_destroy(subsetFace);
89
+ hb.hb_face_destroy(face);
90
+ hb.hb_subset_input_destroy(input);
91
+ hb.free(fontPtr);
92
+ return bytes;
93
+ }
94
+
95
+ /**
96
+ * Run every job in the jobs file and write the results file.
97
+ * @param {string} jobsFile JSON `{ charset: number[], jobs: [{ source, out }] }`.
98
+ * @param {string} resultsFile Written with `{ unavailable, failed }`.
99
+ * @returns {Promise<void>}
100
+ */
101
+ export async function runJobsFile(jobsFile, resultsFile) {
102
+ const payload = JSON.parse(fs.readFileSync(jobsFile, "utf8"));
103
+ /** @type {(result: { unavailable: boolean, failed: { file: string, error: string }[] }) => void} */
104
+ const write = (result) => fs.writeFileSync(resultsFile, JSON.stringify(result));
105
+ const tools = await loadFontTools();
106
+ if (!tools) {
107
+ write({ unavailable: true, failed: [] });
108
+ return;
109
+ }
110
+ /** @type {{ file: string, error: string }[]} */
111
+ const failed = [];
112
+ for (const job of payload.jobs) {
113
+ try {
114
+ const subset = subsetFont(tools.hb, fs.readFileSync(job.source), payload.charset);
115
+ const woff2 = await tools.compress(subset);
116
+ fs.mkdirSync(path.dirname(job.out), { recursive: true });
117
+ fs.writeFileSync(job.out, woff2);
118
+ } catch (err) {
119
+ failed.push({
120
+ file: job.source,
121
+ error: err instanceof Error ? err.message : String(err)
122
+ });
123
+ }
124
+ }
125
+ write({ unavailable: false, failed });
126
+ }
127
+
128
+ // Entry-point guard, one line so importing this module in a test registers the
129
+ // line as executed without running main (same pattern as the coverage gate).
130
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url)
131
+ runJobsFile(process.argv[2], process.argv[3]);
@@ -0,0 +1,475 @@
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
+ import { fsReader } from "./compiler/fs-reader.js";
40
+ import { isAllowedInclude } from "./compiler/includes.js";
41
+
42
+ /**
43
+ * @typedef {import("./config.js").Paths} Paths
44
+ */
45
+
46
+ /**
47
+ * One subset handed to the worker: reduce `source` to the shared charset and
48
+ * write the woff2 result to `out`, the cache path.
49
+ * @typedef {object} SubsetJob
50
+ * @property {string} source Absolute path of the original font file.
51
+ * @property {string} out Absolute cache path the subset woff2 is written to.
52
+ */
53
+
54
+ /**
55
+ * What the worker reports back. `unavailable` means the toolchain could not be
56
+ * loaded at all (harfbuzzjs/wawoff2 not installed) — every pending font then
57
+ * ships unsubsetted. `failed` carries one entry per font the toolchain itself
58
+ * rejected (corrupt payload, unsupported format).
59
+ * @typedef {object} SubsetResult
60
+ * @property {boolean} unavailable
61
+ * @property {{ file: string, error: string }[]} failed
62
+ */
63
+
64
+ /**
65
+ * The post-pass result, feeding the build summary. `unavailable` marks the
66
+ * toolchain-absent build (which is otherwise identical to not having the
67
+ * feature).
68
+ * @typedef {object} FontSubsetPlan
69
+ * @property {number} subsetted Fonts freshly subsetted this build.
70
+ * @property {number} cached Fonts served from the on-disk cache.
71
+ * @property {boolean} unavailable harfbuzzjs/wawoff2 could not be loaded.
72
+ */
73
+
74
+ /**
75
+ * A project-local font referenced by an emitted `@font-face` rule.
76
+ * @typedef {object} FontRef
77
+ * @property {string} url The url() target exactly as written in the CSS.
78
+ * @property {string} source Absolute path of the font file it resolves to.
79
+ * @property {string} subsetUrl Served URL of the subset (`<stem>-subset.woff2`).
80
+ * @property {string} subsetDist Absolute dist path the subset is written to.
81
+ * @property {string} [cacheFile] Absolute cache path, set once the hashes are known.
82
+ */
83
+
84
+ /** The formats hb-subset reads directly; woff2 inputs and remote URLs pass through. */
85
+ const SUBSETTABLE_EXTENSIONS = new Set([".ttf", ".otf", ".woff"]);
86
+
87
+ /** The cache root, relative to the project. Under node_modules, so already ignored. */
88
+ const CACHE_DIR = "node_modules/.cache/darkmown-fonts";
89
+
90
+ /** The shelf's public mount point; everything else resolves against site/pages. */
91
+ const SHELF_URL_PREFIX = "/__wd/media/";
92
+
93
+ const WORKER_PATH = path.join(
94
+ path.dirname(fileURLToPath(import.meta.url)),
95
+ "font-subset-worker.js"
96
+ );
97
+
98
+ /** An `@font-face { … }` block in emitted CSS (the skin compiler never nests one). */
99
+ const FONT_FACE_BLOCK = /@font-face\s*\{[^}]*\}/g;
100
+
101
+ /** A url() target, double-quoted, single-quoted, or bare. */
102
+ const URL_REF = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)"'\s]+))\s*\)/g;
103
+
104
+ /** A numeric character reference: `&#65;` or `&#x2605;`. */
105
+ const NUMERIC_ENTITY = /&#(x[0-9a-fA-F]+|X[0-9a-fA-F]+|\d+);/g;
106
+
107
+ // The base charset every subset carries, regardless of page content: the
108
+ // characters a page can always legitimately render (framework chrome, markdown
109
+ // typography, template copy like the store's "✓") even when this build's scan
110
+ // did not see them — for example text a colocated .js injects at runtime.
111
+ // 0020–007E printable ASCII
112
+ // 00A0–00FF Latin-1 Supplement (printable half: ·, é, ü, …)
113
+ // 2010–2027 hyphens, en/em dashes, curly quotes, ellipsis, bullets, leaders
114
+ // 2030–2033 per-mille, prime, double prime
115
+ // 2039–203A single guillemets
116
+ // 2190–2199 arrows (← ↑ → ↓ and diagonals)
117
+ // singles ★ ☆ ✓ ✕ ✗
118
+ const BASE_RANGES = [
119
+ [0x20, 0x7e],
120
+ [0xa0, 0xff],
121
+ [0x2010, 0x2027],
122
+ [0x2030, 0x2033],
123
+ [0x2039, 0x203a],
124
+ [0x2190, 0x2199]
125
+ ];
126
+ const BASE_SINGLES = [0x2605, 0x2606, 0x2713, 0x2715, 0x2717];
127
+
128
+ /** @returns {number[]} The sorted base charset (runs at module load). */
129
+ function buildBaseCodepoints() {
130
+ /** @type {number[]} */
131
+ const cps = [];
132
+ for (const [lo, hi] of BASE_RANGES) for (let cp = lo; cp <= hi; cp++) cps.push(cp);
133
+ cps.push(...BASE_SINGLES);
134
+ return cps.sort((a, b) => a - b);
135
+ }
136
+
137
+ /** The base charset, sorted ascending. */
138
+ export const BASE_CODEPOINTS = buildBaseCodepoints();
139
+
140
+ /**
141
+ * Subset every project-local font referenced by the emitted stylesheets and
142
+ * rewrite the dist CSS to lead with the subset woff2.
143
+ *
144
+ * Skips, none of them errors: remote/protocol-relative/data/relative URLs
145
+ * (silently), already-woff2 sources (silently), URLs that escape or miss the
146
+ * project (silently — a broken link is an authoring bug, not a subsetting one).
147
+ * An unreadable font file, and one the toolchain fails on, get a hint naming
148
+ * the file and ship as-is. With no `@font-face` in any emitted stylesheet the
149
+ * pass is a no-op and never even scans the HTML.
150
+ * @param {Paths} paths
151
+ * @param {{ cacheDir?: string, quiet?: boolean,
152
+ * runJobs?: (payload: { charset: number[], jobs: SubsetJob[] }) => SubsetResult }} [options]
153
+ * `quiet` suppresses the hints (dev-server rebuilds). `runJobs` is the subset
154
+ * seam — the default spawns the worker process; tests inject fakes for the
155
+ * unavailable/failure paths.
156
+ * @returns {FontSubsetPlan}
157
+ */
158
+ export function subsetFonts(paths, options = {}) {
159
+ const cacheDir = options.cacheDir ?? path.join(paths.cwd, CACHE_DIR);
160
+ const runJobs = options.runJobs ?? runSubsetJobs;
161
+ const hint = options.quiet ? () => {} : (/** @type {string} */ m) => console.warn(`hint: ${m}`);
162
+ const stats = { subsetted: 0, cached: 0, unavailable: false };
163
+ if (!fs.existsSync(paths.distRoot)) return stats;
164
+
165
+ // Pass 1: every local font any emitted stylesheet references, deduped by
166
+ // source file (several @font-face blocks, and several stylesheets, can share
167
+ // one font file — it is subsetted once and each reference is rewritten).
168
+ /** @type {Map<string, FontRef>} */
169
+ const fonts = new Map();
170
+ /** @type {string[]} Every url() occurrence that resolved, for the rewrite map. */
171
+ const occurrences = [];
172
+ /** @type {string[]} */
173
+ const cssFiles = [];
174
+ for (const file of walk(paths.distRoot)) {
175
+ if (!file.endsWith(".css")) continue;
176
+ cssFiles.push(file);
177
+ const css = fs.readFileSync(file, "utf8");
178
+ for (const url of fontUrlsInCss(css)) {
179
+ const resolved = resolveFontUrl(paths, url);
180
+ if (!resolved) continue;
181
+ occurrences.push(url);
182
+ if (!fonts.has(resolved.source)) fonts.set(resolved.source, resolved);
183
+ }
184
+ }
185
+ if (fonts.size === 0) return stats;
186
+
187
+ // Pass 2: the charset — the base set plus every code point in the emitted
188
+ // pages. The scan reads the final HTML, so non-latin content flows into the
189
+ // union automatically; over-inclusion from markup is impossible to notice
190
+ // (tags are ASCII, which the base set already carries).
191
+ /** @type {string[]} */
192
+ const texts = [];
193
+ for (const file of walk(paths.distRoot)) {
194
+ if (file.endsWith(".html")) texts.push(fs.readFileSync(file, "utf8"));
195
+ }
196
+ const codepoints = charsetFromTexts(texts);
197
+ const charsetHash = hash(codepoints.join(","));
198
+
199
+ /** @type {SubsetJob[]} */
200
+ const pending = [];
201
+ /** @type {FontRef[]} */
202
+ const waiting = [];
203
+ /** @type {string[]} Project-relative paths of fonts with uncached work, for the absent-toolchain hint. */
204
+ const needingTools = [];
205
+ /** Sources whose subset landed in dist this build (cache or fresh). */
206
+ const emitted = new Set();
207
+
208
+ /**
209
+ * Materialize one subset: copy it from the cache to its served path.
210
+ * @param {FontRef} ref
211
+ */
212
+ const emitSubset = (ref) => {
213
+ fs.mkdirSync(path.dirname(ref.subsetDist), { recursive: true });
214
+ fs.copyFileSync(/** @type {string} */ (ref.cacheFile), ref.subsetDist);
215
+ emitted.add(ref.source);
216
+ };
217
+
218
+ for (const ref of fonts.values()) {
219
+ /** @type {Uint8Array} */
220
+ let bytes;
221
+ try {
222
+ bytes = fs.readFileSync(ref.source);
223
+ } catch {
224
+ hint(
225
+ `no woff2 subset for ${relPosix(paths, ref.source)} — the file could not be read. ` +
226
+ "The original font ships as-is."
227
+ );
228
+ continue;
229
+ }
230
+ ref.cacheFile = path.join(cacheDir, `${hash(bytes)}-${charsetHash}.woff2`);
231
+ if (fs.existsSync(ref.cacheFile)) {
232
+ emitSubset(ref);
233
+ stats.cached++;
234
+ } else {
235
+ pending.push({ source: ref.source, out: ref.cacheFile });
236
+ waiting.push(ref);
237
+ needingTools.push(relPosix(paths, ref.source));
238
+ }
239
+ }
240
+
241
+ if (pending.length > 0) {
242
+ const result = runJobs({ charset: codepoints, jobs: pending });
243
+ if (result.unavailable) {
244
+ stats.unavailable = true;
245
+ hint(
246
+ `${needingTools.length} font${needingTools.length === 1 ? "" : "s"} could be subsetted ` +
247
+ `to woff2 (${needingTools.join(", ")}). Run \`npm i -D harfbuzzjs wawoff2\` ` +
248
+ "to subset fonts at build time."
249
+ );
250
+ } else {
251
+ const failed = new Map(result.failed.map((f) => [f.file, f.error]));
252
+ for (const ref of waiting) {
253
+ const error = failed.get(ref.source);
254
+ if (error !== undefined) {
255
+ hint(
256
+ `no woff2 subset for ${relPosix(paths, ref.source)} — the font could not be ` +
257
+ `subsetted (${error}). The original font ships as-is.`
258
+ );
259
+ continue;
260
+ }
261
+ emitSubset(ref);
262
+ stats.subsetted++;
263
+ }
264
+ }
265
+ }
266
+
267
+ // Pass 3: rewrite the emitted CSS. Only stylesheets that reference a
268
+ // successfully subsetted font change; everything else is untouched, and the
269
+ // source .skin files are never written.
270
+ if (emitted.size > 0) {
271
+ /** @type {Map<string, string>} url as written in the CSS → subset URL */
272
+ const subsetByUrl = new Map();
273
+ for (const url of occurrences) {
274
+ const resolved = resolveFontUrl(paths, url);
275
+ if (resolved && emitted.has(resolved.source)) subsetByUrl.set(url, resolved.subsetUrl);
276
+ }
277
+ for (const file of cssFiles) {
278
+ const css = fs.readFileSync(file, "utf8");
279
+ const rewritten = rewriteFontFaceCss(css, subsetByUrl);
280
+ if (rewritten !== css) fs.writeFileSync(file, rewritten);
281
+ }
282
+ }
283
+
284
+ return stats;
285
+ }
286
+
287
+ /**
288
+ * The default subset seam: run every pending job in ONE child process (the
289
+ * build is synchronous; the wasm API is not), communicating through temp JSON
290
+ * files rather than stdio so a large payload never hits a pipe buffer. The
291
+ * worker always writes a results file — a missing or unparsable one means the
292
+ * child itself died (crashed, or killed by the spawn timeout), which is
293
+ * reported as `unavailable` (the build then behaves as if the toolchain were
294
+ * absent: a hint, no subsets, no failure).
295
+ * @param {{ charset: number[], jobs: SubsetJob[] }} payload
296
+ * @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
297
+ * Injectable for tests; the real one is `spawnSync`.
298
+ * @param {number} [timeoutMs] The worker is killed after this long (default
299
+ * two minutes), so a hung toolchain call cannot stall a build forever. The
300
+ * kill lands in the same `unavailable` path as any other dead child.
301
+ * @returns {SubsetResult}
302
+ */
303
+ export function runSubsetJobs(payload, spawn = /** @type {any} */ (spawnSync), timeoutMs = 120000) {
304
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wd-font-subset-"));
305
+ try {
306
+ const jobsFile = path.join(dir, "jobs.json");
307
+ const resultsFile = path.join(dir, "results.json");
308
+ fs.writeFileSync(jobsFile, JSON.stringify(payload));
309
+ spawn(process.execPath, [WORKER_PATH, jobsFile, resultsFile], {
310
+ stdio: ["ignore", "ignore", "inherit"],
311
+ timeout: timeoutMs
312
+ });
313
+ if (!fs.existsSync(resultsFile)) return { unavailable: true, failed: [] };
314
+ try {
315
+ return JSON.parse(fs.readFileSync(resultsFile, "utf8"));
316
+ } catch {
317
+ return { unavailable: true, failed: [] };
318
+ }
319
+ } finally {
320
+ fs.rmSync(dir, { recursive: true, force: true });
321
+ }
322
+ }
323
+
324
+ /**
325
+ * The subset charset for a build: the base set plus every code point appearing
326
+ * in the given texts (the emitted pages' HTML). Numeric character references
327
+ * (`&#9733;`, `&#x2605;`) are decoded first so a character the compiler emitted
328
+ * as an entity still lands in the union. Tags and attribute syntax contribute
329
+ * only ASCII, which the base set already carries, so the whole-file scan can
330
+ * only ever over-include by a handful of glyphs.
331
+ * @param {string[]} texts
332
+ * @returns {number[]} Sorted ascending, deduped.
333
+ */
334
+ export function charsetFromTexts(texts) {
335
+ /** @type {Set<number>} */
336
+ const set = new Set(BASE_CODEPOINTS);
337
+ for (const text of texts) {
338
+ const decoded = text.replace(NUMERIC_ENTITY, (_match, body) => {
339
+ const cp = /^[xX]/.test(body)
340
+ ? Number.parseInt(body.slice(1), 16)
341
+ : Number.parseInt(body, 10);
342
+ return cp <= 0x10ffff ? String.fromCodePoint(cp) : "";
343
+ });
344
+ for (const ch of decoded) set.add(/** @type {number} */ (ch.codePointAt(0)));
345
+ }
346
+ return [...set].sort((a, b) => a - b);
347
+ }
348
+
349
+ /**
350
+ * Rewrite the `@font-face` blocks of an emitted stylesheet so each subsetted
351
+ * font's `src` leads with the subset woff2, the original kept as fallback:
352
+ * src: url("/f/a-subset.woff2") format("woff2"), url("/f/a.ttf") format("truetype");
353
+ * Only the `src` declaration changes — font-display and every other descriptor
354
+ * pass through byte-identically. Idempotent: a block already carrying the
355
+ * subset reference (a previous build's rewrite of a since-unedited skin) is
356
+ * left alone.
357
+ * @param {string} css Emitted stylesheet contents.
358
+ * @param {Map<string, string>} subsetByUrl url() target as written → subset URL.
359
+ * @returns {string}
360
+ */
361
+ export function rewriteFontFaceCss(css, subsetByUrl) {
362
+ if (subsetByUrl.size === 0) return css;
363
+ return css.replace(FONT_FACE_BLOCK, (block) => {
364
+ const srcDecl = /\bsrc\s*:\s*/.exec(block);
365
+ if (!srcDecl) return block;
366
+ /** @type {string[]} */
367
+ const prefixes = [];
368
+ for (const match of block.matchAll(URL_REF)) {
369
+ const url = match[1] ?? match[2] ?? match[3];
370
+ const subset = subsetByUrl.get(url);
371
+ if (subset && !block.includes(subset) && !prefixes.some((p) => p.includes(subset))) {
372
+ prefixes.push(`url("${subset}") format("woff2"), `);
373
+ }
374
+ }
375
+ if (prefixes.length === 0) return block;
376
+ const at = srcDecl.index + srcDecl[0].length;
377
+ return `${block.slice(0, at)}${prefixes.join("")}${block.slice(at)}`;
378
+ });
379
+ }
380
+
381
+ /**
382
+ * Every url() target inside the `@font-face` blocks of a stylesheet, in order.
383
+ * @param {string} css
384
+ * @returns {string[]}
385
+ */
386
+ function fontUrlsInCss(css) {
387
+ /** @type {string[]} */
388
+ const urls = [];
389
+ for (const block of css.matchAll(FONT_FACE_BLOCK)) {
390
+ for (const match of block[0].matchAll(URL_REF)) {
391
+ urls.push(match[1] ?? match[2] ?? match[3]);
392
+ }
393
+ }
394
+ return urls;
395
+ }
396
+
397
+ /**
398
+ * Resolve a url() target from an emitted `@font-face` to the project font file
399
+ * it names, or null when it is not a subset candidate: anything not
400
+ * leading-slash (remote, protocol-relative, `data:`, relative), a non-subsettable
401
+ * extension (already-woff2 included), an un-decodable or escaping path, a
402
+ * symlink (whose target could live anywhere on disk, whatever the link's own
403
+ * path says), or a file that does not exist. `/__wd/media/<rel>` maps to the
404
+ * shelf; every other absolute path maps to `site/pages`.
405
+ * @param {Paths} paths
406
+ * @param {string} url The raw url() target.
407
+ * @returns {FontRef | null}
408
+ */
409
+ function resolveFontUrl(paths, url) {
410
+ if (!url.startsWith("/") || url.startsWith("//")) return null;
411
+ const clean = url.split(/[?#]/)[0];
412
+ const ext = path.posix.extname(clean).toLowerCase();
413
+ if (!SUBSETTABLE_EXTENSIONS.has(ext)) return null;
414
+ /** @type {string} */
415
+ let decoded;
416
+ try {
417
+ decoded = decodeURIComponent(clean);
418
+ } catch {
419
+ return null;
420
+ }
421
+ const fromShelf = decoded.startsWith(SHELF_URL_PREFIX);
422
+ const base = fromShelf ? paths.shelfRoot : paths.routesRoot;
423
+ const rel = fromShelf ? decoded.slice(SHELF_URL_PREFIX.length) : decoded.slice(1);
424
+ const source = path.normalize(path.join(base, rel));
425
+ if (!source.startsWith(base + path.sep)) return null;
426
+ if (!fs.existsSync(source)) return null;
427
+ // Symlinks are never subsetted, matching the asset-copy convention
428
+ // (`emitPageAssets` skips them entirely, and so does the image pre-pass):
429
+ // the lexical containment check above reads the path as WRITTEN, so a link
430
+ // sitting inside the project could point anywhere on disk and the target's
431
+ // bytes would be read and published as a subset. An ancestor directory can
432
+ // be a symlink with the file itself plain, so the canonical (realpath)
433
+ // containment check `@include` uses runs too — a font whose real path
434
+ // escapes site/pages and site/_ is simply "not project-local" here.
435
+ if (fs.lstatSync(source).isSymbolicLink()) return null;
436
+ if (!isAllowedInclude(source, paths, fsReader())) return null;
437
+ const subsetUrl = `${clean.slice(0, clean.length - ext.length)}-subset.woff2`;
438
+ return { url, source, subsetUrl, subsetDist: path.join(paths.distRoot, subsetUrl.slice(1)) };
439
+ }
440
+
441
+ /**
442
+ * A short content digest for cache keys.
443
+ * @param {Uint8Array | string} data
444
+ * @returns {string}
445
+ */
446
+ function hash(data) {
447
+ return crypto.createHash("sha256").update(data).digest("hex").slice(0, 24);
448
+ }
449
+
450
+ /**
451
+ * A project-relative, POSIX-separated path for hint messages.
452
+ * @param {Paths} paths
453
+ * @param {string} file Absolute path.
454
+ * @returns {string}
455
+ */
456
+ function relPosix(paths, file) {
457
+ return path.relative(paths.cwd, file).replaceAll(path.sep, "/");
458
+ }
459
+
460
+ /**
461
+ * All files under `dir`, recursively. Mirrors the builder's own walker; kept
462
+ * local so this module does not import the builder (which imports this).
463
+ * @param {string} dir
464
+ * @returns {string[]}
465
+ */
466
+ function walk(dir) {
467
+ /** @type {string[]} */
468
+ const files = [];
469
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
470
+ const abs = path.join(dir, entry.name);
471
+ if (entry.isDirectory()) files.push(...walk(abs));
472
+ else files.push(abs);
473
+ }
474
+ return files;
475
+ }
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.
@@ -46,7 +46,10 @@ export async function runJobsFile(jobsFile, resultsFile) {
46
46
  // loses the EXIF tag with its pixels physically rotated, so it displays
47
47
  // exactly like the tag-carrying original. `withoutEnlargement` is belt and
48
48
  // braces — the pre-pass only ever queues widths below the intrinsic size.
49
- let pipeline = sharp(job.source)
49
+ // `limitInputPixels` caps the decompression-bomb surface: 100 MP is above
50
+ // any real photo, and an oversized input fails as a per-job error (a hint
51
+ // naming the file), never a build failure.
52
+ let pipeline = sharp(job.source, { limitInputPixels: 1e8 })
50
53
  .rotate()
51
54
  .resize({ width: job.width, withoutEnlargement: true });
52
55
  if (job.format === "webp") pipeline = pipeline.webp({ quality: 82 });