@zvndev/darkmown 2.11.0 → 2.12.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.
package/src/cli.js CHANGED
@@ -490,11 +490,13 @@ function startDevServer({ cwd, log, warn, error }) {
490
490
  * Start the static preview server for an existing `dist/`. Throws `CliError`
491
491
  * when there is nothing built to serve.
492
492
  *
493
- * Preview mirrors production more closely than dev in two ways: project
493
+ * Preview mirrors production more closely than dev in three ways: project
494
494
  * `api/*.js` functions run on `/api/*` (same runner and `(request) => Response`
495
495
  * contract as dev — a project without `api/` falls straight through to static),
496
- * and compressible responses are brotli/gzip-negotiated off Accept-Encoding so
497
- * local weight checks see transfer sizes, not raw bytes.
496
+ * compressible responses are brotli/gzip-negotiated off Accept-Encoding so
497
+ * local weight checks see transfer sizes, not raw bytes, and static files carry
498
+ * validators (ETag/Last-Modified/Cache-Control) with conditional-request 304s,
499
+ * so a reload revalidates instead of re-downloading every asset.
498
500
  * @param {{ cwd: string, log: typeof console.log, error: typeof console.error }} io
499
501
  * @returns {Promise<{ command: "serve", server: import("node:http").Server, close: () => Promise<void> }>}
500
502
  */
@@ -518,7 +520,15 @@ function startPreviewServer({ cwd, log, error }) {
518
520
  // but never echoed — a stack trace leaks paths and internals to the client.
519
521
  handleApiRequest({ apiDir, req, res, acceptEncoding, discloseErrors: false })
520
522
  .then((handled) => {
521
- if (!handled) serve(distRoot, req.url || "/", res, { acceptEncoding });
523
+ if (!handled) {
524
+ serve(distRoot, req.url || "/", res, {
525
+ acceptEncoding,
526
+ validators: {
527
+ ifNoneMatch: req.headers["if-none-match"],
528
+ ifModifiedSince: req.headers["if-modified-since"]
529
+ }
530
+ });
531
+ }
522
532
  })
523
533
  .catch((err) => {
524
534
  error(err instanceof Error && err.stack ? err.stack : String(err));
@@ -788,6 +788,13 @@ function bindingPlugin(mdInstance) {
788
788
  * wrapper container. The block must directly follow the element (no space), and
789
789
  * `{ … }` interpolation is unaffected (its content starts with a name, not `.`/`#`).
790
790
  * Issue #18.
791
+ *
792
+ * One image-only extension: `sizes="…"` (quoted, any content). The srcset
793
+ * variant wiring emits no `sizes` of its own — inventing one guesses the
794
+ * layout — so the AUTHOR supplies it here when the image is not full-width:
795
+ * `![hero](/h.jpg){sizes="(max-width: 640px) 100vw, 640px"}`. It passes through
796
+ * with or without a srcset, and markdown-it escapes the value on render. On a
797
+ * non-image target the token is ignored like any other unrecognized part.
791
798
  * @param {MarkdownIt} mdInstance
792
799
  * @returns {void}
793
800
  */
@@ -799,13 +806,18 @@ function attrsPlugin(mdInstance) {
799
806
  for (let i = 0; i < children.length; i++) {
800
807
  const tok = children[i];
801
808
  if (tok.type !== "text") continue;
802
- const m = /^\{([.#][^}]*)\}/.exec(tok.content);
809
+ const m = /^\{((?:[.#]|sizes=")[^}]*)\}/.exec(tok.content);
803
810
  if (!m) continue;
804
811
  const target = attrTarget(children, i);
805
812
  if (!target) continue;
806
- for (const part of m[1].split(/\s+/).filter(Boolean)) {
813
+ // `sizes` is one quoted token that may itself hold spaces, so it must
814
+ // be matched whole before the whitespace split. Anything else that is
815
+ // not `.class` / `#id` keeps its long-standing behavior: skipped.
816
+ for (const part of m[1].match(/sizes="[^"]*"|\S+/g) ?? []) {
807
817
  if (part[0] === ".") target.attrJoin("class", part.slice(1));
808
818
  else if (part[0] === "#") target.attrSet("id", part.slice(1));
819
+ else if (target.type === "image" && part.startsWith('sizes="'))
820
+ target.attrSet("sizes", part.slice(7, -1));
809
821
  }
810
822
  tok.content = tok.content.slice(m[0].length);
811
823
  }
@@ -183,24 +183,24 @@ export function compilePage(file, context, options = {}) {
183
183
  // (graceful, transition-free fallback) and only applies to same-origin
184
184
  // navigations where both the outgoing and incoming page opt in.
185
185
  //
186
- // The default UA animation cross-fades the root: outgoing opacity 1→0 while
187
- // incoming 0→1, both at once. Mid-navigation that leaves the two pages
188
- // superimposed at ~50% opacity — headings ghost over headings. We override it
189
- // with a directional fade+slide (old lifts up and out, new rises up and in) so
190
- // the pages move past each other instead of stacking. Short + eased = peppy.
186
+ // The default is a quick out-then-in: the outgoing snapshot fades to 0 in
187
+ // 90ms, the incoming one fades in after an 80ms delay. A simultaneous
188
+ // cross-fade was measured on a real site and read as flicker: the incoming
189
+ // snapshot stacks over the outgoing one, so image-heavy pages double-expose
190
+ // (outgoing opaque, incoming ghosting through). Sequencing keeps at most a
191
+ // ~10ms overlap near background opacity, so nothing ever ghosts. The delay
192
+ // fills `both`, keeping the incoming snapshot invisible until its turn.
191
193
  const wantTransitions =
192
194
  compiled.meta.transitions === true || compiled.meta.transitions === "true";
193
195
  const transitions = wantTransitions
194
196
  ? `\n <style>
195
197
  @view-transition { navigation: auto; }
196
- ::view-transition-old(root) { animation: wd-nav-out 200ms cubic-bezier(0.4, 0, 1, 1) both; }
197
- ::view-transition-new(root) { animation: wd-nav-in 200ms cubic-bezier(0, 0, 0.2, 1) both; }
198
- @keyframes wd-nav-out { to { opacity: 0; transform: translateY(-1rem); } }
199
- @keyframes wd-nav-in { from { opacity: 0; transform: translateY(1rem); } }
198
+ ::view-transition-old(root) { animation: wd-nav-out 90ms cubic-bezier(0.4, 0, 0.2, 1) both; }
199
+ ::view-transition-new(root) { animation: wd-nav-in 130ms cubic-bezier(0.4, 0, 0.2, 1) 80ms both; }
200
+ @keyframes wd-nav-out { to { opacity: 0; } }
201
+ @keyframes wd-nav-in { from { opacity: 0; } }
200
202
  @media (prefers-reduced-motion: reduce) {
201
- ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 1ms; }
202
- @keyframes wd-nav-out { to { opacity: 0; } }
203
- @keyframes wd-nav-in { from { opacity: 0; } }
203
+ ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 1ms; animation-delay: 0ms; }
204
204
  }
205
205
  </style>`
206
206
  : "";
@@ -28,6 +28,18 @@
28
28
  // The build is synchronous, the wasm API is not — that is the whole reason for
29
29
  // the child process. `buildSite` stays a sync API; one `spawnSync` per build
30
30
  // runs all pending subsets, and a fully cached build spawns nothing.
31
+ //
32
+ // FONT PRELOADS (2.12.0). The same pass ends by injecting
33
+ // `<link rel="preload" as="font" crossorigin>` into every emitted page for the
34
+ // fonts that page actually loads (found through its own linked stylesheets), so
35
+ // the font fetch starts with the CSS instead of after it — the visible
36
+ // swap-and-reflow on every load. A page preloads exactly the URL its rewritten
37
+ // CSS leads with: the subset woff2 when subsetting ran, the original project
38
+ // font when the toolchain is absent, the font was skipped, or the source was
39
+ // already woff2. Remote srcs are never preloaded, a page whose stylesheets
40
+ // declare no project fonts is left byte-identical, and re-running the pass over
41
+ // the same dist (the incremental build's re-emitted HTML) never duplicates a
42
+ // link.
31
43
  // ---------------------------------------------------------------------------
32
44
 
33
45
  import { spawnSync } from "node:child_process";
@@ -81,9 +93,37 @@ import { isAllowedInclude } from "./compiler/includes.js";
81
93
  * @property {string} [cacheFile] Absolute cache path, set once the hashes are known.
82
94
  */
83
95
 
96
+ /**
97
+ * A project-local font an emitted stylesheet can preload. For a subsettable
98
+ * font this is the FontRef itself; an already-woff2 original carries a null
99
+ * `subsetUrl` (there is nothing to subset — it preloads as-is).
100
+ * @typedef {object} PreloadRef
101
+ * @property {string} url The url() target exactly as written in the CSS.
102
+ * @property {string} source Absolute path of the font file it resolves to.
103
+ * @property {string | null} subsetUrl Served URL of the subset, when subsettable.
104
+ */
105
+
106
+ /**
107
+ * One preload link to inject into a page.
108
+ * @typedef {object} PreloadFont
109
+ * @property {string} href The URL the page's CSS leads with (subset or original).
110
+ * @property {string} type The MIME type for the href's extension.
111
+ */
112
+
84
113
  /** The formats hb-subset reads directly; woff2 inputs and remote URLs pass through. */
85
114
  const SUBSETTABLE_EXTENSIONS = new Set([".ttf", ".otf", ".woff"]);
86
115
 
116
+ /** Already-encoded woff2: never subsetted, but preloaded as-is. */
117
+ const WOFF2_ONLY = new Set([".woff2"]);
118
+
119
+ /** The preload `type` for each preloadeable font extension. */
120
+ const FONT_MIME = /** @type {Record<string, string>} */ ({
121
+ ".woff2": "font/woff2",
122
+ ".woff": "font/woff",
123
+ ".ttf": "font/ttf",
124
+ ".otf": "font/otf"
125
+ });
126
+
87
127
  /** The cache root, relative to the project. Under node_modules, so already ignored. */
88
128
  const CACHE_DIR = "node_modules/.cache/darkmown-fonts";
89
129
 
@@ -101,6 +141,9 @@ const FONT_FACE_BLOCK = /@font-face\s*\{[^}]*\}/g;
101
141
  /** A url() target, double-quoted, single-quoted, or bare. */
102
142
  const URL_REF = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)"'\s]+))\s*\)/g;
103
143
 
144
+ /** A whole `<link …>` tag in emitted HTML. */
145
+ const LINK_TAG = /<link\b[^>]*>/g;
146
+
104
147
  /** A numeric character reference: `&#65;` or `&#x2605;`. */
105
148
  const NUMERIC_ENTITY = /&#(x[0-9a-fA-F]+|X[0-9a-fA-F]+|\d+);/g;
106
149
 
@@ -139,14 +182,17 @@ export const BASE_CODEPOINTS = buildBaseCodepoints();
139
182
 
140
183
  /**
141
184
  * Subset every project-local font referenced by the emitted stylesheets and
142
- * rewrite the dist CSS to lead with the subset woff2.
185
+ * rewrite the dist CSS to lead with the subset woff2. Then inject one font
186
+ * `<link rel="preload" as="font" crossorigin>` per project font into every
187
+ * emitted page that loads it through its own linked stylesheets.
143
188
  *
144
189
  * 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.
190
+ * (silently), already-woff2 sources (silently — not subsetted, but preloaded
191
+ * as-is), URLs that escape or miss the project (silently — a broken link is an
192
+ * authoring bug, not a subsetting one). An unreadable font file, and one the
193
+ * toolchain fails on, get a hint naming the file and ship as-is. With no
194
+ * project-font reference in any emitted stylesheet the pass is a no-op and
195
+ * never even scans the HTML.
150
196
  * @param {Paths} paths
151
197
  * @param {{ cacheDir?: string, quiet?: boolean,
152
198
  * runJobs?: (payload: { charset: number[], jobs: SubsetJob[] }) => SubsetResult }} [options]
@@ -165,122 +211,144 @@ export function subsetFonts(paths, options = {}) {
165
211
  // Pass 1: every local font any emitted stylesheet references, deduped by
166
212
  // source file (several @font-face blocks, and several stylesheets, can share
167
213
  // one font file — it is subsetted once and each reference is rewritten).
214
+ // `cssRefs` keeps the per-stylesheet list for the preload injection (pass 4):
215
+ // subsettable refs plus already-woff2 originals.
168
216
  /** @type {Map<string, FontRef>} */
169
217
  const fonts = new Map();
170
218
  /** @type {string[]} Every url() occurrence that resolved, for the rewrite map. */
171
219
  const occurrences = [];
172
220
  /** @type {string[]} */
173
221
  const cssFiles = [];
222
+ /** @type {Map<string, PreloadRef[]>} Stylesheet → its project fonts, in order. */
223
+ const cssRefs = new Map();
174
224
  for (const file of walk(paths.distRoot)) {
175
225
  if (!file.endsWith(".css")) continue;
176
226
  cssFiles.push(file);
177
227
  const css = fs.readFileSync(file, "utf8");
228
+ /** @type {PreloadRef[]} */
229
+ const refs = [];
178
230
  for (const url of fontUrlsInCss(css)) {
179
231
  const resolved = resolveFontUrl(paths, url);
180
- if (!resolved) continue;
181
- occurrences.push(url);
182
- if (!fonts.has(resolved.source)) fonts.set(resolved.source, resolved);
232
+ if (resolved) {
233
+ occurrences.push(url);
234
+ if (!fonts.has(resolved.source)) fonts.set(resolved.source, resolved);
235
+ refs.push(resolved);
236
+ continue;
237
+ }
238
+ const woff2 = resolveWoff2FontUrl(paths, url);
239
+ if (woff2) refs.push(woff2);
183
240
  }
241
+ if (refs.length > 0) cssRefs.set(file, refs);
184
242
  }
185
- if (fonts.size === 0) return stats;
243
+ if (fonts.size === 0 && cssRefs.size === 0) return stats;
186
244
 
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
245
  /** Sources whose subset landed in dist this build (cache or fresh). */
206
246
  const emitted = new Set();
207
247
 
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;
248
+ if (fonts.size > 0) {
249
+ // Pass 2: the charset — the base set plus every code point in the emitted
250
+ // pages. The scan reads the final HTML, so non-latin content flows into the
251
+ // union automatically; over-inclusion from markup is impossible to notice
252
+ // (tags are ASCII, which the base set already carries).
253
+ /** @type {string[]} */
254
+ const texts = [];
255
+ for (const file of walk(paths.distRoot)) {
256
+ if (file.endsWith(".html")) texts.push(fs.readFileSync(file, "utf8"));
229
257
  }
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));
258
+ const codepoints = charsetFromTexts(texts);
259
+ const charsetHash = hash(codepoints.join(","));
260
+
261
+ /** @type {SubsetJob[]} */
262
+ const pending = [];
263
+ /** @type {FontRef[]} */
264
+ const waiting = [];
265
+ /** @type {string[]} Project-relative paths of fonts with uncached work, for the absent-toolchain hint. */
266
+ const needingTools = [];
267
+
268
+ /**
269
+ * Materialize one subset: copy it from the cache to its served path.
270
+ * @param {FontRef} ref
271
+ */
272
+ const emitSubset = (ref) => {
273
+ fs.mkdirSync(path.dirname(ref.subsetDist), { recursive: true });
274
+ fs.copyFileSync(/** @type {string} */ (ref.cacheFile), ref.subsetDist);
275
+ emitted.add(ref.source);
276
+ };
277
+
278
+ for (const ref of fonts.values()) {
279
+ /** @type {Uint8Array} */
280
+ let bytes;
281
+ try {
282
+ bytes = fs.readFileSync(ref.source);
283
+ } catch {
284
+ hint(
285
+ `no woff2 subset for ${relPosix(paths, ref.source)} — the file could not be read. ` +
286
+ "The original font ships as-is."
287
+ );
288
+ continue;
289
+ }
290
+ ref.cacheFile = path.join(cacheDir, `${hash(bytes)}-${charsetHash}.woff2`);
291
+ if (fs.existsSync(ref.cacheFile)) {
292
+ emitSubset(ref);
293
+ stats.cached++;
294
+ } else {
295
+ pending.push({ source: ref.source, out: ref.cacheFile });
296
+ waiting.push(ref);
297
+ needingTools.push(relPosix(paths, ref.source));
298
+ }
238
299
  }
239
- }
240
300
 
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;
301
+ if (pending.length > 0) {
302
+ const result = runJobs({ charset: codepoints, jobs: pending });
303
+ if (result.unavailable) {
304
+ stats.unavailable = true;
305
+ hint(
306
+ `${needingTools.length} font${needingTools.length === 1 ? "" : "s"} could be subsetted ` +
307
+ `to woff2 (${needingTools.join(", ")}). Run \`npm i -D harfbuzzjs wawoff2\` ` +
308
+ "to subset fonts at build time."
309
+ );
310
+ } else {
311
+ const failed = new Map(result.failed.map((f) => [f.file, f.error]));
312
+ for (const ref of waiting) {
313
+ const error = failed.get(ref.source);
314
+ if (error !== undefined) {
315
+ hint(
316
+ `no woff2 subset for ${relPosix(paths, ref.source)} — the font could not be ` +
317
+ `subsetted (${error}). The original font ships as-is.`
318
+ );
319
+ continue;
320
+ }
321
+ emitSubset(ref);
322
+ stats.subsetted++;
260
323
  }
261
- emitSubset(ref);
262
- stats.subsetted++;
263
324
  }
264
325
  }
265
- }
266
326
 
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);
327
+ // Pass 3: rewrite the emitted CSS. Only stylesheets that reference a
328
+ // successfully subsetted font change; everything else is untouched, and the
329
+ // source .skin files are never written.
330
+ if (emitted.size > 0) {
331
+ /** @type {Map<string, string>} url as written in the CSS → subset URL */
332
+ const subsetByUrl = new Map();
333
+ for (const url of occurrences) {
334
+ const resolved = resolveFontUrl(paths, url);
335
+ if (resolved && emitted.has(resolved.source)) subsetByUrl.set(url, resolved.subsetUrl);
336
+ }
337
+ for (const file of cssFiles) {
338
+ const css = fs.readFileSync(file, "utf8");
339
+ const rewritten = rewriteFontFaceCss(css, subsetByUrl);
340
+ if (rewritten !== css) fs.writeFileSync(file, rewritten);
341
+ }
281
342
  }
282
343
  }
283
344
 
345
+ // Pass 4: preload links. Every emitted page gets one
346
+ // `<link rel="preload" as="font" crossorigin>` per project font its OWN
347
+ // linked stylesheets declare, right after the last stylesheet link — the URL
348
+ // the page's CSS leads with (the subset woff2 when it exists, the original
349
+ // otherwise). Pages loading no project fonts stay byte-identical.
350
+ injectFontPreloads(paths, cssRefs, emitted);
351
+
284
352
  return stats;
285
353
  }
286
354
 
@@ -394,23 +462,193 @@ function fontUrlsInCss(css) {
394
462
  return urls;
395
463
  }
396
464
 
465
+ /**
466
+ * Pass 4: inject font preload links into every emitted page. Each page
467
+ * preloads exactly the fonts its own linked stylesheets declare — the subset
468
+ * woff2 when subsetting emitted one, the original project font URL otherwise
469
+ * (toolchain absent, font skipped, or source already woff2) — and nothing
470
+ * else: a page whose stylesheets carry no project font stays byte-identical.
471
+ * @param {Paths} paths
472
+ * @param {Map<string, PreloadRef[]>} cssRefs Stylesheet → its project fonts.
473
+ * @param {Set<string>} emitted Sources whose subset landed in dist this build.
474
+ * @returns {void}
475
+ */
476
+ function injectFontPreloads(paths, cssRefs, emitted) {
477
+ // The preload list per stylesheet: the URL its rewritten CSS leads with,
478
+ // deduped (a font referenced twice, and the subset reference a previous
479
+ // build's rewrite added beside the original, collapse to one link).
480
+ /** @type {Map<string, PreloadFont[]>} */
481
+ const perCss = new Map();
482
+ for (const [cssFile, refs] of cssRefs) {
483
+ /** @type {PreloadFont[]} */
484
+ const list = [];
485
+ /** @type {Set<string>} */
486
+ const seen = new Set();
487
+ for (const ref of refs) {
488
+ const href = ref.subsetUrl && emitted.has(ref.source) ? ref.subsetUrl : ref.url;
489
+ if (seen.has(href)) continue;
490
+ seen.add(href);
491
+ list.push({ href, type: fontMimeType(href) });
492
+ }
493
+ perCss.set(cssFile, list);
494
+ }
495
+
496
+ for (const file of walk(paths.distRoot)) {
497
+ if (!file.endsWith(".html")) continue;
498
+ const html = fs.readFileSync(file, "utf8");
499
+ /** @type {PreloadFont[]} */
500
+ const preloads = [];
501
+ /** @type {Set<string>} */
502
+ const seen = new Set();
503
+ for (const href of stylesheetHrefs(html)) {
504
+ const cssFile = resolveDistCss(paths.distRoot, href);
505
+ if (!cssFile) continue;
506
+ for (const font of perCss.get(cssFile) ?? []) {
507
+ if (seen.has(font.href)) continue;
508
+ seen.add(font.href);
509
+ preloads.push(font);
510
+ }
511
+ }
512
+ const rewritten = injectPreloadLinks(html, preloads);
513
+ if (rewritten !== html) fs.writeFileSync(file, rewritten);
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Inject one `<link rel="preload" href="…" as="font" type="…" crossorigin>`
519
+ * per font, immediately after the LAST stylesheet link in the head (after the
520
+ * favicon link when the page has no stylesheet link, and not at all when it
521
+ * has neither). `crossorigin` is mandatory: font fetches are always CORS-mode,
522
+ * even same-origin, so a preload without it downloads the font twice.
523
+ * Idempotent — a preload already present (an earlier pass over the same dist)
524
+ * is never duplicated.
525
+ * @param {string} html The emitted page.
526
+ * @param {PreloadFont[]} preloads The fonts to preload, in order.
527
+ * @returns {string} The page, unchanged when there is nothing new to inject.
528
+ */
529
+ export function injectPreloadLinks(html, preloads) {
530
+ const fresh = preloads.filter((font) => !html.includes(`rel="preload" href="${font.href}"`));
531
+ if (fresh.length === 0) return html;
532
+ const tags = fresh.map(
533
+ (font) => ` <link rel="preload" href="${font.href}" as="font" type="${font.type}" crossorigin>`
534
+ );
535
+ let anchorEnd = -1;
536
+ for (const match of html.matchAll(LINK_TAG)) {
537
+ if (/\brel="stylesheet"/.test(match[0])) anchorEnd = match.index + match[0].length;
538
+ }
539
+ if (anchorEnd === -1) {
540
+ for (const match of html.matchAll(LINK_TAG)) {
541
+ if (/\brel="icon"/.test(match[0])) {
542
+ anchorEnd = match.index + match[0].length;
543
+ break;
544
+ }
545
+ }
546
+ }
547
+ if (anchorEnd === -1) return html;
548
+ return `${html.slice(0, anchorEnd)}\n${tags.join("\n")}${html.slice(anchorEnd)}`;
549
+ }
550
+
551
+ /**
552
+ * The hrefs of every `<link rel="stylesheet">` in a page, in document order.
553
+ * @param {string} html
554
+ * @returns {string[]}
555
+ */
556
+ function stylesheetHrefs(html) {
557
+ /** @type {string[]} */
558
+ const hrefs = [];
559
+ for (const match of html.matchAll(LINK_TAG)) {
560
+ if (!/\brel="stylesheet"/.test(match[0])) continue;
561
+ const href = /\bhref="([^"]*)"/.exec(match[0]);
562
+ if (href) hrefs.push(href[1]);
563
+ }
564
+ return hrefs;
565
+ }
566
+
567
+ /**
568
+ * Resolve a stylesheet href from an emitted page to its dist file, or null
569
+ * when it cannot name one (remote, protocol-relative, an un-decodable or
570
+ * escaping path, or simply not a .css file). The file need not exist — the
571
+ * caller's map lookup is the existence check.
572
+ * @param {string} distRoot
573
+ * @param {string} href
574
+ * @returns {string | null}
575
+ */
576
+ function resolveDistCss(distRoot, href) {
577
+ if (!href.startsWith("/") || href.startsWith("//")) return null;
578
+ const clean = href.split(/[?#]/)[0];
579
+ if (!clean.endsWith(".css")) return null;
580
+ /** @type {string} */
581
+ let decoded;
582
+ try {
583
+ decoded = decodeURIComponent(clean);
584
+ } catch {
585
+ return null;
586
+ }
587
+ const file = path.normalize(path.join(distRoot, decoded));
588
+ if (!file.startsWith(distRoot + path.sep)) return null;
589
+ return file;
590
+ }
591
+
592
+ /**
593
+ * The preload `type` for a font URL, from its extension.
594
+ * @param {string} href
595
+ * @returns {string}
596
+ */
597
+ function fontMimeType(href) {
598
+ const ext = path.posix.extname(href.split(/[?#]/)[0]).toLowerCase();
599
+ return FONT_MIME[ext] ?? "font/woff2";
600
+ }
601
+
397
602
  /**
398
603
  * Resolve a url() target from an emitted `@font-face` to the project font file
399
604
  * it names, or null when it is not a subset candidate: anything not
400
605
  * leading-slash (remote, protocol-relative, `data:`, relative), a non-subsettable
401
606
  * extension (already-woff2 included), an un-decodable or escaping path, a
402
607
  * 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`.
608
+ * path says), or a file that does not exist.
405
609
  * @param {Paths} paths
406
610
  * @param {string} url The raw url() target.
407
611
  * @returns {FontRef | null}
408
612
  */
409
613
  function resolveFontUrl(paths, url) {
614
+ return resolveProjectFont(paths, url, SUBSETTABLE_EXTENSIONS);
615
+ }
616
+
617
+ /**
618
+ * The preload-only variant: an already-woff2 project font is never subsetted,
619
+ * but it is still a font the page loads locally, so it earns a preload of its
620
+ * own URL. Same project-local rules as resolveFontUrl.
621
+ * @param {Paths} paths
622
+ * @param {string} url The raw url() target.
623
+ * @returns {PreloadRef | null}
624
+ */
625
+ function resolveWoff2FontUrl(paths, url) {
626
+ const ref = resolveProjectFont(paths, url, WOFF2_ONLY);
627
+ return ref ? { url: ref.url, source: ref.source, subsetUrl: null } : null;
628
+ }
629
+
630
+ /**
631
+ * The shared project-local resolution behind resolveFontUrl and
632
+ * resolveWoff2FontUrl. `/__wd/media/<rel>` maps to the shelf; every other
633
+ * absolute path maps to `site/pages`. A symlink is never project-local,
634
+ * matching the asset-copy convention (`emitPageAssets` skips them entirely,
635
+ * and so does the image pre-pass): the lexical containment check reads the
636
+ * path as WRITTEN, so a link sitting inside the project could point anywhere
637
+ * on disk and the target's bytes would be read and published as a subset. An
638
+ * ancestor directory can be a symlink with the file itself plain, so the
639
+ * canonical (realpath) containment check `@include` uses runs too — a font
640
+ * whose real path escapes site/pages and site/_ is simply "not project-local"
641
+ * here.
642
+ * @param {Paths} paths
643
+ * @param {string} url The raw url() target.
644
+ * @param {Set<string>} extensions The extensions accepted by the caller.
645
+ * @returns {FontRef | null}
646
+ */
647
+ function resolveProjectFont(paths, url, extensions) {
410
648
  if (!url.startsWith("/") || url.startsWith("//")) return null;
411
649
  const clean = url.split(/[?#]/)[0];
412
650
  const ext = path.posix.extname(clean).toLowerCase();
413
- if (!SUBSETTABLE_EXTENSIONS.has(ext)) return null;
651
+ if (!extensions.has(ext)) return null;
414
652
  /** @type {string} */
415
653
  let decoded;
416
654
  try {
@@ -424,14 +662,6 @@ function resolveFontUrl(paths, url) {
424
662
  const source = path.normalize(path.join(base, rel));
425
663
  if (!source.startsWith(base + path.sep)) return null;
426
664
  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
665
  if (fs.lstatSync(source).isSymbolicLink()) return null;
436
666
  if (!isAllowedInclude(source, paths, fsReader())) return null;
437
667
  const subsetUrl = `${clean.slice(0, clean.length - ext.length)}-subset.woff2`;