@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/AGENTS.md +4 -4
- package/CHANGELOG.md +15 -0
- package/README.md +10 -4
- package/docs/cli.md +1 -0
- package/docs/spec-alignment.md +10 -0
- package/package.json +1 -1
- package/site/pages/docs/index.wd +4 -4
- package/site/pages/playground/index.js +6 -8
- package/src/catalog.js +2 -0
- package/src/cli.js +14 -4
- package/src/compiler/markdown.js +14 -2
- package/src/compiler/page.js +12 -12
- package/src/font-subset.js +335 -105
- package/src/statics.js +171 -15
- package/types/font-subset.d.ts +54 -6
- package/types/statics.d.ts +42 -3
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
|
|
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
|
-
*
|
|
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)
|
|
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));
|
package/src/compiler/markdown.js
CHANGED
|
@@ -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
|
+
* `{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
|
-
|
|
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
|
}
|
package/src/compiler/page.js
CHANGED
|
@@ -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
|
|
187
|
-
// incoming
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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
|
|
197
|
-
::view-transition-new(root) { animation: wd-nav-in
|
|
198
|
-
@keyframes wd-nav-out { to { opacity: 0;
|
|
199
|
-
@keyframes wd-nav-in { from { opacity: 0;
|
|
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
|
: "";
|
package/src/font-subset.js
CHANGED
|
@@ -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: `A` or `★`. */
|
|
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
|
|
146
|
-
* project (silently — a broken link is an
|
|
147
|
-
* An unreadable font file, and one the
|
|
148
|
-
* the file and ship as-is. With no
|
|
149
|
-
* pass is a no-op and
|
|
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 (
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
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 (!
|
|
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`;
|