@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.
- package/AGENTS.md +20 -9
- package/CHANGELOG.md +13 -0
- package/README.md +104 -5
- package/docs/spec-alignment.md +7 -1
- package/grammar/wd-directives.gbnf +1 -1
- package/package.json +7 -3
- package/site/pages/benchmarks/index.skin +67 -0
- package/site/pages/benchmarks/index.wd +126 -0
- package/site/pages/docs/index.wd +47 -2
- package/site/pages/index.wd +1 -0
- package/src/builder.js +66 -13
- package/src/catalog.js +13 -4
- package/src/cli.js +5 -1
- package/src/compiler/body.js +30 -1
- package/src/compiler/context.js +13 -1
- package/src/compiler/includes.js +44 -1
- package/src/compiler/media.js +25 -0
- package/src/compiler/page.js +6 -0
- package/src/compiler/structure.js +272 -4
- package/src/errors.js +24 -0
- package/src/font-subset-worker.js +131 -0
- package/src/font-subset.js +457 -0
- package/src/grammar.js +3 -2
- package/src/headers.js +28 -1
- package/src/statics.js +22 -14
- package/types/builder.d.ts +25 -1
- package/types/catalog.d.ts +3 -2
- package/types/compiler/context.d.ts +28 -0
- package/types/compiler/includes.d.ts +23 -0
- package/types/compiler/structure.d.ts +53 -1
- package/types/font-subset.d.ts +140 -0
- package/types/headers.d.ts +14 -0
|
@@ -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: `A` or `★`. */
|
|
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
|
+
* (`★`, `★`) 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
|
|
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,
|
|
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
|
|
12
|
-
* with the `_headers` / `vercel.json` structure the same build writes
|
|
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":
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
55
|
-
if (!route) return
|
|
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
|
-
|
|
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");
|
package/types/builder.d.ts
CHANGED
|
@@ -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
|
/**
|
package/types/catalog.d.ts
CHANGED
|
@@ -107,9 +107,10 @@ export type DirectiveEntry = {
|
|
|
107
107
|
*/
|
|
108
108
|
name: string;
|
|
109
109
|
/**
|
|
110
|
-
* A single line,
|
|
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
|
*/
|