@zvndev/darkmown 2.8.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -10
- package/CHANGELOG.md +17 -0
- package/README.md +117 -16
- package/docs/spec-alignment.md +10 -0
- package/grammar/wd-directives.gbnf +1 -1
- package/package.json +11 -3
- package/site/pages/benchmarks/index.skin +67 -0
- package/site/pages/benchmarks/index.wd +126 -0
- package/site/pages/docs/index.wd +48 -3
- package/site/pages/index.wd +1 -0
- package/src/builder.js +140 -20
- package/src/catalog.js +13 -4
- package/src/cli.js +11 -1
- package/src/compiler/body.js +30 -1
- package/src/compiler/context.js +19 -1
- package/src/compiler/includes.js +44 -1
- package/src/compiler/markdown.js +35 -12
- package/src/compiler/media.js +25 -0
- package/src/compiler/page.js +25 -4
- package/src/compiler/structure.js +272 -4
- package/src/compiler.js +13 -3
- 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/image-variant-resize.js +71 -0
- package/src/image-variants.js +294 -0
- package/src/statics.js +22 -14
- package/types/builder.d.ts +34 -1
- package/types/catalog.d.ts +3 -2
- package/types/compiler/context.d.ts +41 -0
- package/types/compiler/includes.d.ts +23 -0
- package/types/compiler/page.d.ts +9 -3
- package/types/compiler/structure.d.ts +53 -1
- package/types/compiler.d.ts +5 -2
- package/types/font-subset.d.ts +140 -0
- package/types/headers.d.ts +14 -0
- package/types/image-variants.d.ts +131 -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.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The resize worker for the build-time image pre-pass (2.9.0). Never imported
|
|
3
|
+
// by the builder: the build is synchronous and sharp is not, so
|
|
4
|
+
// src/image-variants.js spawns this file as a child process with a jobs JSON
|
|
5
|
+
// file and reads back a results JSON file.
|
|
6
|
+
//
|
|
7
|
+
// sharp is an OPTIONAL dependency, so it is dynamic-imported inside a
|
|
8
|
+
// try/catch: when it cannot load (not installed, broken native binding), the
|
|
9
|
+
// worker reports `{ unavailable: true }` and exits cleanly — the parent turns
|
|
10
|
+
// that into one hint, and the build ships exactly what 2.8.0 would have.
|
|
11
|
+
// A failure on ONE image is recorded in `failed` and the rest still resize.
|
|
12
|
+
// Nothing here ever exits non-zero over an image.
|
|
13
|
+
//
|
|
14
|
+
// `WD_SHARP_MODULE` overrides the sharp specifier; tests point it at a
|
|
15
|
+
// nonexistent module to exercise the sharp-absent path without uninstalling
|
|
16
|
+
// anything. It is a test seam, not a public knob.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { pathToFileURL } from "node:url";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run every job in the jobs file and write the results file.
|
|
25
|
+
* @param {string} jobsFile JSON array of `{ source, out, width, format }`.
|
|
26
|
+
* @param {string} resultsFile Written with `{ unavailable, failed }`.
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
export async function runJobsFile(jobsFile, resultsFile) {
|
|
30
|
+
const jobs = JSON.parse(fs.readFileSync(jobsFile, "utf8"));
|
|
31
|
+
/** @type {(result: { unavailable: boolean, failed: { file: string, width: number, error: string }[] }) => void} */
|
|
32
|
+
const write = (result) => fs.writeFileSync(resultsFile, JSON.stringify(result));
|
|
33
|
+
/** @type {any} */
|
|
34
|
+
let sharp;
|
|
35
|
+
try {
|
|
36
|
+
sharp = (await import(process.env.WD_SHARP_MODULE || "sharp")).default;
|
|
37
|
+
} catch {
|
|
38
|
+
write({ unavailable: true, failed: [] });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
/** @type {{ file: string, width: number, error: string }[]} */
|
|
42
|
+
const failed = [];
|
|
43
|
+
for (const job of jobs) {
|
|
44
|
+
try {
|
|
45
|
+
// `rotate()` honors EXIF orientation before resizing: the emitted variant
|
|
46
|
+
// loses the EXIF tag with its pixels physically rotated, so it displays
|
|
47
|
+
// exactly like the tag-carrying original. `withoutEnlargement` is belt and
|
|
48
|
+
// braces — the pre-pass only ever queues widths below the intrinsic size.
|
|
49
|
+
let pipeline = sharp(job.source)
|
|
50
|
+
.rotate()
|
|
51
|
+
.resize({ width: job.width, withoutEnlargement: true });
|
|
52
|
+
if (job.format === "webp") pipeline = pipeline.webp({ quality: 82 });
|
|
53
|
+
else if (job.format === "jpeg") pipeline = pipeline.jpeg({ quality: 82, progressive: true });
|
|
54
|
+
else pipeline = pipeline.png({ compressionLevel: 9 });
|
|
55
|
+
fs.mkdirSync(path.dirname(job.out), { recursive: true });
|
|
56
|
+
await pipeline.toFile(job.out);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
failed.push({
|
|
59
|
+
file: job.source,
|
|
60
|
+
width: job.width,
|
|
61
|
+
error: err instanceof Error ? err.message : String(err)
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
write({ unavailable: false, failed });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Entry-point guard, one line so importing this module in a test registers the
|
|
69
|
+
// line as executed without running main (same pattern as the coverage gate).
|
|
70
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url)
|
|
71
|
+
runJobsFile(process.argv[2], process.argv[3]);
|