@hyperframes/core 0.8.126 → 0.8.128
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/dist/fonts/authoredGoogleFonts.d.ts +3 -0
- package/dist/fonts/authoredGoogleFonts.d.ts.map +1 -0
- package/dist/fonts/authoredGoogleFonts.js +84 -0
- package/dist/fonts/authoredGoogleFonts.js.map +1 -0
- package/dist/fonts/deterministicFonts.d.ts +148 -0
- package/dist/fonts/deterministicFonts.d.ts.map +1 -0
- package/dist/fonts/deterministicFonts.js +1479 -0
- package/dist/fonts/deterministicFonts.js.map +1 -0
- package/dist/fonts/fontCompression.d.ts +8 -0
- package/dist/fonts/fontCompression.d.ts.map +1 -0
- package/dist/fonts/fontCompression.js +91 -0
- package/dist/fonts/fontCompression.js.map +1 -0
- package/dist/fonts/fontData.generated.d.ts +7 -0
- package/dist/fonts/fontData.generated.d.ts.map +1 -0
- package/dist/fonts/fontData.generated.js +50 -0
- package/dist/fonts/fontData.generated.js.map +1 -0
- package/package.json +33 -5
|
@@ -0,0 +1,1479 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { FONT_ALIAS_MAP, resolveAliasDisplayName } from "./aliases.js";
|
|
6
|
+
import { locateSystemFontVariants, SYSTEM_FONT_SIZE_LIMIT } from "./systemFontLocator.js";
|
|
7
|
+
import { parseHTML } from "linkedom";
|
|
8
|
+
import postcss from "postcss";
|
|
9
|
+
import { EMBEDDED_FONT_DATA } from "./fontData.generated.js";
|
|
10
|
+
import { fontToDataUri } from "./fontCompression.js";
|
|
11
|
+
import { authoredGoogleFontStylesheets, withPageText } from "./authoredGoogleFonts.js";
|
|
12
|
+
/**
|
|
13
|
+
* Family names that resolve to a host-OS font (or a CSS generic that the
|
|
14
|
+
* browser substitutes with a host-OS font). Exported so plan-time validators
|
|
15
|
+
* can reject them as primary families in distributed renders.
|
|
16
|
+
*
|
|
17
|
+
* Lower-cased — call `normalizeFamilyName` on declared values before lookup.
|
|
18
|
+
*/
|
|
19
|
+
export const GENERIC_FAMILIES = new Set([
|
|
20
|
+
"sans-serif",
|
|
21
|
+
"serif",
|
|
22
|
+
"monospace",
|
|
23
|
+
"cursive",
|
|
24
|
+
"fantasy",
|
|
25
|
+
"system-ui",
|
|
26
|
+
"ui-sans-serif",
|
|
27
|
+
"ui-serif",
|
|
28
|
+
"ui-monospace",
|
|
29
|
+
"emoji",
|
|
30
|
+
"math",
|
|
31
|
+
"fangsong",
|
|
32
|
+
"-apple-system",
|
|
33
|
+
"blinkmacsystemfont",
|
|
34
|
+
]);
|
|
35
|
+
// Keywords valid as a whole font-family value; they name no font.
|
|
36
|
+
const CSS_WIDE_KEYWORDS = new Set([
|
|
37
|
+
"inherit",
|
|
38
|
+
"initial",
|
|
39
|
+
"unset",
|
|
40
|
+
"revert",
|
|
41
|
+
"revert-layer",
|
|
42
|
+
]);
|
|
43
|
+
/**
|
|
44
|
+
* Parse a single `font-family` value (e.g. `"Inter", -apple-system,
|
|
45
|
+
* sans-serif`) into a list of unquoted family names in declaration order.
|
|
46
|
+
* Whitespace and surrounding `"…"` / `'…'` quotes are stripped; case is
|
|
47
|
+
* preserved. Pass each name through `normalizeFamilyName` for case-
|
|
48
|
+
* insensitive comparisons.
|
|
49
|
+
*
|
|
50
|
+
* Only top-level commas split: a `var(--x, fallback)` expression stays one
|
|
51
|
+
* token, as does a comma inside a quoted family name.
|
|
52
|
+
*/
|
|
53
|
+
// fallow-ignore-next-line complexity
|
|
54
|
+
export function parseFontFamilyValue(value) {
|
|
55
|
+
const pieces = [];
|
|
56
|
+
let start = 0;
|
|
57
|
+
let depth = 0;
|
|
58
|
+
let quote = null;
|
|
59
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
60
|
+
const char = value[index];
|
|
61
|
+
if (char === "\\") {
|
|
62
|
+
index += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (quote) {
|
|
66
|
+
if (char === quote)
|
|
67
|
+
quote = null;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (char === "'" || char === '"') {
|
|
71
|
+
quote = char;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (char === "(") {
|
|
75
|
+
depth += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (char === ")") {
|
|
79
|
+
depth = Math.max(0, depth - 1);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (char !== "," || depth !== 0)
|
|
83
|
+
continue;
|
|
84
|
+
pieces.push(value.slice(start, index));
|
|
85
|
+
start = index + 1;
|
|
86
|
+
}
|
|
87
|
+
pieces.push(value.slice(start));
|
|
88
|
+
return pieces
|
|
89
|
+
.map((piece) => piece.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim())
|
|
90
|
+
.filter((piece) => piece.length > 0);
|
|
91
|
+
}
|
|
92
|
+
function systemPrimaryReplacement(value, deterministicPrimary, customProperties) {
|
|
93
|
+
const variable = primaryCssVariable(value);
|
|
94
|
+
if (variable) {
|
|
95
|
+
if (customProperties.get(variable.name) || variable.fallback === null)
|
|
96
|
+
return null;
|
|
97
|
+
// Rewrite the fallback in place so a property defined at runtime still wins.
|
|
98
|
+
const fallback = systemPrimaryReplacement(variable.fallback, deterministicPrimary, customProperties);
|
|
99
|
+
return fallback ? `var(${variable.name}, ${fallback})${variable.rest}` : null;
|
|
100
|
+
}
|
|
101
|
+
const families = parseFontFamilyValue(value);
|
|
102
|
+
if (families.length === 0)
|
|
103
|
+
return null;
|
|
104
|
+
if (!GENERIC_FAMILIES.has(normalizeFamilyName(families[0])))
|
|
105
|
+
return null;
|
|
106
|
+
return `${deterministicPrimary}, ${value.trim()}`;
|
|
107
|
+
}
|
|
108
|
+
function parseCssRoot(css) {
|
|
109
|
+
try {
|
|
110
|
+
return postcss.parse(css);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function isFontFaceDeclaration(decl) {
|
|
117
|
+
const parent = decl.parent;
|
|
118
|
+
return parent?.type === "atrule" && parent.name.toLowerCase() === "font-face";
|
|
119
|
+
}
|
|
120
|
+
function normalizeCssDeclarations(root, replacePrimary) {
|
|
121
|
+
let changed = false;
|
|
122
|
+
root.walkDecls((decl) => {
|
|
123
|
+
if (decl.prop.startsWith("--")) {
|
|
124
|
+
const replacement = replacePrimary(decl.value);
|
|
125
|
+
if (!replacement)
|
|
126
|
+
return;
|
|
127
|
+
decl.value = replacement;
|
|
128
|
+
changed = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (decl.prop.toLowerCase() !== "font-family")
|
|
132
|
+
return;
|
|
133
|
+
if (isFontFaceDeclaration(decl)) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const replacement = replacePrimary(decl.value);
|
|
137
|
+
if (!replacement)
|
|
138
|
+
return;
|
|
139
|
+
decl.value = replacement;
|
|
140
|
+
changed = true;
|
|
141
|
+
});
|
|
142
|
+
return changed;
|
|
143
|
+
}
|
|
144
|
+
function normalizeCssFontFamilyDeclarations(css, replacePrimary) {
|
|
145
|
+
const root = parseCssRoot(css);
|
|
146
|
+
if (!root)
|
|
147
|
+
return css;
|
|
148
|
+
const changed = normalizeCssDeclarations(root, replacePrimary);
|
|
149
|
+
return changed ? root.toString() : css;
|
|
150
|
+
}
|
|
151
|
+
function normalizeInlineStyleAttribute(style, replacePrimary) {
|
|
152
|
+
const root = parseCssRoot(`*{${style}}`);
|
|
153
|
+
if (!root)
|
|
154
|
+
return style;
|
|
155
|
+
const rule = root.first;
|
|
156
|
+
if (rule?.type !== "rule")
|
|
157
|
+
return style;
|
|
158
|
+
const before = rule.toString();
|
|
159
|
+
normalizeCssDeclarations(root, replacePrimary);
|
|
160
|
+
if (rule.toString() === before)
|
|
161
|
+
return style;
|
|
162
|
+
const serialized = (rule.nodes ?? []).map((node) => node.toString()).join("; ");
|
|
163
|
+
return serialized.endsWith(";") ? serialized : `${serialized};`;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Import/generated HTML often uses host UI stacks such as
|
|
167
|
+
* `-apple-system, BlinkMacSystemFont, sans-serif` as a primary family. That is
|
|
168
|
+
* fine on the author's machine but not in distributed render workers, where
|
|
169
|
+
* host fonts differ by OS. Promote a bundled deterministic family to the
|
|
170
|
+
* primary slot while preserving the original stack as fallbacks.
|
|
171
|
+
*/
|
|
172
|
+
export function normalizeSystemFontPrimaryFamilies(html, deterministicPrimary = "Inter") {
|
|
173
|
+
const { document } = parseHTML(html);
|
|
174
|
+
const customProperties = collectFontFamilyCustomProperties(html);
|
|
175
|
+
const replacePrimary = (value) => systemPrimaryReplacement(value, deterministicPrimary, customProperties);
|
|
176
|
+
let changed = false;
|
|
177
|
+
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
|
178
|
+
const current = styleEl.textContent ?? "";
|
|
179
|
+
const next = normalizeCssFontFamilyDeclarations(current, replacePrimary);
|
|
180
|
+
if (next === current)
|
|
181
|
+
continue;
|
|
182
|
+
styleEl.textContent = next;
|
|
183
|
+
changed = true;
|
|
184
|
+
}
|
|
185
|
+
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
|
186
|
+
const current = el.getAttribute("style") ?? "";
|
|
187
|
+
const next = normalizeInlineStyleAttribute(current, replacePrimary);
|
|
188
|
+
if (next === current)
|
|
189
|
+
continue;
|
|
190
|
+
el.setAttribute("style", next);
|
|
191
|
+
changed = true;
|
|
192
|
+
}
|
|
193
|
+
for (const el of Array.from(document.querySelectorAll("[data-font-family]"))) {
|
|
194
|
+
const current = el.getAttribute("data-font-family") ?? "";
|
|
195
|
+
const next = replacePrimary(current);
|
|
196
|
+
if (!next)
|
|
197
|
+
continue;
|
|
198
|
+
el.setAttribute("data-font-family", next);
|
|
199
|
+
changed = true;
|
|
200
|
+
}
|
|
201
|
+
return changed ? document.toString() : html;
|
|
202
|
+
}
|
|
203
|
+
function collectCssCustomProperties(css, customProperties) {
|
|
204
|
+
const root = parseCssRoot(css);
|
|
205
|
+
if (!root)
|
|
206
|
+
return;
|
|
207
|
+
root.walkDecls((decl) => {
|
|
208
|
+
if (!decl.prop.startsWith("--"))
|
|
209
|
+
return;
|
|
210
|
+
customProperties.set(decl.prop, decl.value);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function* iterateCssRootFontFamilyDeclarations(root) {
|
|
214
|
+
const declarations = [];
|
|
215
|
+
root.walkDecls((decl) => {
|
|
216
|
+
if (decl.prop.toLowerCase() !== "font-family")
|
|
217
|
+
return;
|
|
218
|
+
if (isFontFaceDeclaration(decl))
|
|
219
|
+
return;
|
|
220
|
+
const declaration = decl.value;
|
|
221
|
+
declarations.push({
|
|
222
|
+
surface: "font-family",
|
|
223
|
+
declaration,
|
|
224
|
+
families: parseFontFamilyValue(declaration),
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
yield* declarations;
|
|
228
|
+
}
|
|
229
|
+
function* iterateCssFontFamilyDeclarations(css) {
|
|
230
|
+
const root = parseCssRoot(css);
|
|
231
|
+
if (!root)
|
|
232
|
+
return;
|
|
233
|
+
yield* iterateCssRootFontFamilyDeclarations(root);
|
|
234
|
+
}
|
|
235
|
+
function* iterateInlineStyleFontFamilyDeclarations(style) {
|
|
236
|
+
const root = parseCssRoot(`*{${style}}`);
|
|
237
|
+
if (!root)
|
|
238
|
+
return;
|
|
239
|
+
yield* iterateCssRootFontFamilyDeclarations(root);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Collect simple CSS custom-property font aliases from style blocks and inline
|
|
243
|
+
* styles. CSS cascade is richer than this map, but for compiler-generated
|
|
244
|
+
* imports the common shape is `--font: Inter, sans-serif` paired with
|
|
245
|
+
* `font-family: var(--font)`.
|
|
246
|
+
*/
|
|
247
|
+
export function collectFontFamilyCustomProperties(html) {
|
|
248
|
+
const { document } = parseHTML(html);
|
|
249
|
+
const customProperties = new Map();
|
|
250
|
+
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
|
251
|
+
collectCssCustomProperties(styleEl.textContent ?? "", customProperties);
|
|
252
|
+
}
|
|
253
|
+
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
|
254
|
+
collectCssCustomProperties(`*{${el.getAttribute("style") ?? ""}}`, customProperties);
|
|
255
|
+
}
|
|
256
|
+
return customProperties;
|
|
257
|
+
}
|
|
258
|
+
function primaryCssVariable(value) {
|
|
259
|
+
const trimmed = value.trim();
|
|
260
|
+
if (!trimmed.toLowerCase().startsWith("var("))
|
|
261
|
+
return null;
|
|
262
|
+
let depth = 0;
|
|
263
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
264
|
+
const char = trimmed[index];
|
|
265
|
+
if (char === "(") {
|
|
266
|
+
depth += 1;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (char !== ")")
|
|
270
|
+
continue;
|
|
271
|
+
depth -= 1;
|
|
272
|
+
if (depth !== 0)
|
|
273
|
+
continue;
|
|
274
|
+
const inner = trimmed.slice(4, index).trim();
|
|
275
|
+
const commaIndex = inner.indexOf(",");
|
|
276
|
+
const name = (commaIndex === -1 ? inner : inner.slice(0, commaIndex)).trim();
|
|
277
|
+
if (!/^--[A-Za-z0-9_-]+$/.test(name))
|
|
278
|
+
return null;
|
|
279
|
+
return {
|
|
280
|
+
name,
|
|
281
|
+
fallback: commaIndex === -1 ? null : inner.slice(commaIndex + 1),
|
|
282
|
+
rest: trimmed.slice(index + 1),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
// Deep enough for real alias chains; stops `--a: var(--b); --b: var(--a)` cycles.
|
|
288
|
+
const MAX_CSS_VARIABLE_DEPTH = 8;
|
|
289
|
+
function resolveDeclaredFamilies(declaration, customProperties, optional = false, depth = 0) {
|
|
290
|
+
const tag = (families) => families.map((family) => ({ family, optional }));
|
|
291
|
+
const families = parseFontFamilyValue(declaration);
|
|
292
|
+
const variable = depth < MAX_CSS_VARIABLE_DEPTH ? primaryCssVariable(declaration) : null;
|
|
293
|
+
if (!variable)
|
|
294
|
+
return tag(families);
|
|
295
|
+
const resolved = customProperties.get(variable.name);
|
|
296
|
+
// An undefined property uses the var() fallback, as the browser does.
|
|
297
|
+
const source = resolved || variable.fallback;
|
|
298
|
+
if (source === null)
|
|
299
|
+
return tag(families);
|
|
300
|
+
const primary = resolveDeclaredFamilies(source, customProperties, !resolved || depth > 0, depth + 1);
|
|
301
|
+
// An empty substitution makes the declaration invalid, so the browser inherits instead.
|
|
302
|
+
if (primary.length === 0)
|
|
303
|
+
return [];
|
|
304
|
+
return [...primary, ...tag(families.slice(1))];
|
|
305
|
+
}
|
|
306
|
+
export function resolveFontFamilyDeclarationFamilies(declaration, customProperties) {
|
|
307
|
+
return resolveDeclaredFamilies(declaration, customProperties).map(({ family }) => family);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Iterate every font-family declaration in a compiled HTML document. Yields
|
|
311
|
+
* each declaration's surface (CSS property vs HTML attribute), raw value,
|
|
312
|
+
* and the parsed family list. Used by both the @font-face injector and the
|
|
313
|
+
* plan-time validator so they read the same surface area.
|
|
314
|
+
*/
|
|
315
|
+
export function* iterateFontFamilyDeclarations(html) {
|
|
316
|
+
const { document } = parseHTML(html);
|
|
317
|
+
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
|
318
|
+
yield* iterateCssFontFamilyDeclarations(styleEl.textContent ?? "");
|
|
319
|
+
}
|
|
320
|
+
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
|
321
|
+
yield* iterateInlineStyleFontFamilyDeclarations(el.getAttribute("style") ?? "");
|
|
322
|
+
}
|
|
323
|
+
for (const el of Array.from(document.querySelectorAll("[data-font-family]"))) {
|
|
324
|
+
const declaration = el.getAttribute("data-font-family") ?? "";
|
|
325
|
+
yield { surface: "data-font-family", declaration, families: parseFontFamilyValue(declaration) };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const CANONICAL_FONTS = {
|
|
329
|
+
inter: {
|
|
330
|
+
packageName: "@fontsource/inter",
|
|
331
|
+
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
|
332
|
+
},
|
|
333
|
+
montserrat: {
|
|
334
|
+
packageName: "@fontsource/montserrat",
|
|
335
|
+
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
|
336
|
+
},
|
|
337
|
+
outfit: {
|
|
338
|
+
packageName: "@fontsource/outfit",
|
|
339
|
+
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
|
340
|
+
},
|
|
341
|
+
nunito: {
|
|
342
|
+
packageName: "@fontsource/nunito",
|
|
343
|
+
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
|
344
|
+
},
|
|
345
|
+
oswald: {
|
|
346
|
+
packageName: "@fontsource/oswald",
|
|
347
|
+
faces: [{ weight: "400" }, { weight: "700" }],
|
|
348
|
+
},
|
|
349
|
+
"league-gothic": {
|
|
350
|
+
packageName: "@fontsource/league-gothic",
|
|
351
|
+
faces: [{ weight: "400" }],
|
|
352
|
+
},
|
|
353
|
+
"archivo-black": {
|
|
354
|
+
packageName: "@fontsource/archivo-black",
|
|
355
|
+
faces: [{ weight: "400" }],
|
|
356
|
+
},
|
|
357
|
+
"space-mono": {
|
|
358
|
+
packageName: "@fontsource/space-mono",
|
|
359
|
+
faces: [{ weight: "400" }, { weight: "700" }],
|
|
360
|
+
},
|
|
361
|
+
"ibm-plex-mono": {
|
|
362
|
+
packageName: "@fontsource/ibm-plex-mono",
|
|
363
|
+
faces: [{ weight: "400" }, { weight: "700" }],
|
|
364
|
+
},
|
|
365
|
+
"jetbrains-mono": {
|
|
366
|
+
packageName: "@fontsource/jetbrains-mono",
|
|
367
|
+
faces: [{ weight: "400" }, { weight: "700" }],
|
|
368
|
+
},
|
|
369
|
+
"eb-garamond": {
|
|
370
|
+
packageName: "@fontsource/eb-garamond",
|
|
371
|
+
faces: [{ weight: "400" }, { weight: "700" }],
|
|
372
|
+
},
|
|
373
|
+
"playfair-display": {
|
|
374
|
+
packageName: "@fontsource/playfair-display",
|
|
375
|
+
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
|
376
|
+
},
|
|
377
|
+
"source-code-pro": {
|
|
378
|
+
packageName: "@fontsource/source-code-pro",
|
|
379
|
+
faces: [{ weight: "400" }, { weight: "700" }],
|
|
380
|
+
},
|
|
381
|
+
"noto-sans-jp": {
|
|
382
|
+
packageName: "@fontsource/noto-sans-jp",
|
|
383
|
+
faces: [{ weight: "400" }, { weight: "700" }],
|
|
384
|
+
},
|
|
385
|
+
roboto: {
|
|
386
|
+
packageName: "@fontsource/roboto",
|
|
387
|
+
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
|
388
|
+
},
|
|
389
|
+
"open-sans": {
|
|
390
|
+
packageName: "@fontsource/open-sans",
|
|
391
|
+
faces: [{ weight: "400" }, { weight: "700" }],
|
|
392
|
+
},
|
|
393
|
+
lato: {
|
|
394
|
+
packageName: "@fontsource/lato",
|
|
395
|
+
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
|
396
|
+
},
|
|
397
|
+
poppins: {
|
|
398
|
+
packageName: "@fontsource/poppins",
|
|
399
|
+
faces: [{ weight: "400" }, { weight: "700" }, { weight: "900" }],
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
// FONT_ALIASES derives from the shared alias map; every FONT_ALIAS_MAP value is a CANONICAL_FONTS key.
|
|
403
|
+
export const FONT_ALIASES = FONT_ALIAS_MAP;
|
|
404
|
+
export { FONT_ALIAS_KEYS } from "./aliases.js";
|
|
405
|
+
export { fontToDataUri } from "./fontCompression.js";
|
|
406
|
+
function normalizeFamilyName(family) {
|
|
407
|
+
return family
|
|
408
|
+
.trim()
|
|
409
|
+
.replace(/^['"]|['"]$/g, "")
|
|
410
|
+
.trim()
|
|
411
|
+
.toLowerCase();
|
|
412
|
+
}
|
|
413
|
+
function fontDataUri(packageName, weight, style = "normal") {
|
|
414
|
+
const key = `${packageName}:${weight}:${style}`;
|
|
415
|
+
const uri = EMBEDDED_FONT_DATA.get(key);
|
|
416
|
+
if (!uri) {
|
|
417
|
+
throw new Error(`No embedded font data for ${key}. Regenerate with: tsx scripts/generate-font-data.ts`);
|
|
418
|
+
}
|
|
419
|
+
return uri;
|
|
420
|
+
}
|
|
421
|
+
function extractExistingFontFaces(html) {
|
|
422
|
+
const families = new Set();
|
|
423
|
+
const opening = /@font-face\s*\{/gi;
|
|
424
|
+
const family = /font-family\s*:/gi;
|
|
425
|
+
while (opening.exec(html)) {
|
|
426
|
+
family.lastIndex = opening.lastIndex;
|
|
427
|
+
let declaration = family.exec(html);
|
|
428
|
+
// The original matcher requires at least one character before ';'.
|
|
429
|
+
while (declaration && html[family.lastIndex] === ";") {
|
|
430
|
+
declaration = family.exec(html);
|
|
431
|
+
}
|
|
432
|
+
// If this opener has no complete family/semicolon/closer suffix, no later
|
|
433
|
+
// opener can have one. Never retry the same unmatched suffix.
|
|
434
|
+
if (!declaration)
|
|
435
|
+
break;
|
|
436
|
+
const valueStart = family.lastIndex;
|
|
437
|
+
const semicolon = html.indexOf(";", valueStart);
|
|
438
|
+
if (semicolon < 0)
|
|
439
|
+
break;
|
|
440
|
+
const end = html.indexOf("}", semicolon + 1);
|
|
441
|
+
if (end < 0)
|
|
442
|
+
break;
|
|
443
|
+
const normalized = normalizeFamilyName(html.slice(valueStart, semicolon));
|
|
444
|
+
if (normalized)
|
|
445
|
+
families.add(normalized);
|
|
446
|
+
opening.lastIndex = end + 1;
|
|
447
|
+
}
|
|
448
|
+
return families;
|
|
449
|
+
}
|
|
450
|
+
function isFetchableFamilyName(normalized) {
|
|
451
|
+
if (!normalized || normalized.startsWith("var("))
|
|
452
|
+
return false;
|
|
453
|
+
return !GENERIC_FAMILIES.has(normalized) && !CSS_WIDE_KEYWORDS.has(normalized);
|
|
454
|
+
}
|
|
455
|
+
function extractRequestedFontFamilies(html) {
|
|
456
|
+
const requested = new Map();
|
|
457
|
+
const customProperties = collectFontFamilyCustomProperties(html);
|
|
458
|
+
for (const { declaration } of iterateFontFamilyDeclarations(html)) {
|
|
459
|
+
for (const { family, optional } of resolveDeclaredFamilies(declaration, customProperties)) {
|
|
460
|
+
const normalized = family.toLowerCase();
|
|
461
|
+
if (!isFetchableFamilyName(normalized))
|
|
462
|
+
continue;
|
|
463
|
+
const seen = requested.get(normalized);
|
|
464
|
+
requested.set(normalized, {
|
|
465
|
+
family: seen?.family ?? family,
|
|
466
|
+
optional: (seen?.optional ?? true) && optional,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return requested;
|
|
471
|
+
}
|
|
472
|
+
export function fontFormatHint(src) {
|
|
473
|
+
return src.startsWith("data:font/collection;") ? "collection" : "woff2";
|
|
474
|
+
}
|
|
475
|
+
// generate-font-data.ts embeds Fontsource's -latin- subset for every family.
|
|
476
|
+
const BUNDLED_SUBSET_UNICODE_RANGE = "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, " +
|
|
477
|
+
"U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD";
|
|
478
|
+
function isBundledSubsetRange(unicodeRange) {
|
|
479
|
+
const normalize = (range) => range.toLowerCase().replace(/\s+/g, "");
|
|
480
|
+
return (unicodeRange !== undefined &&
|
|
481
|
+
normalize(unicodeRange) === normalize(BUNDLED_SUBSET_UNICODE_RANGE));
|
|
482
|
+
}
|
|
483
|
+
function buildFontFaceRule(familyName, src, weight, style, unicodeRange) {
|
|
484
|
+
return [
|
|
485
|
+
"@font-face {",
|
|
486
|
+
` font-family: "${familyName}";`,
|
|
487
|
+
` src: url("${src}") format("${fontFormatHint(src)}");`,
|
|
488
|
+
` font-style: ${style};`,
|
|
489
|
+
` font-weight: ${weight};`,
|
|
490
|
+
" font-display: block;",
|
|
491
|
+
// Preserve the subset's unicode-range so the browser selects the right
|
|
492
|
+
// per-codepoint subset (matching Google Fonts' own CSS semantics).
|
|
493
|
+
...(unicodeRange ? [` unicode-range: ${unicodeRange};`] : []),
|
|
494
|
+
"}",
|
|
495
|
+
].join("\n");
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Google serves several canonical families as a variable font: every static
|
|
499
|
+
* weight resolves to the same woff2. Faces sharing a source can be emitted as
|
|
500
|
+
* one weight-range rule instead of embedding that blob once per weight.
|
|
501
|
+
*
|
|
502
|
+
* Without `text=` the response is ordered weight-major, subset-minor, so those
|
|
503
|
+
* faces are not adjacent — group by source rather than scanning neighbours.
|
|
504
|
+
* Insertion order keeps the emitted CSS deterministic.
|
|
505
|
+
*/
|
|
506
|
+
function normalizeWeightKey(weight) {
|
|
507
|
+
const numeric = Number(weight);
|
|
508
|
+
return Number.isFinite(numeric) ? String(numeric) : weight.trim().toLowerCase();
|
|
509
|
+
}
|
|
510
|
+
function coverageKey(weight, style) {
|
|
511
|
+
return `${normalizeWeightKey(weight)}:${style}`;
|
|
512
|
+
}
|
|
513
|
+
function groupFacesBySource(faces) {
|
|
514
|
+
const groups = new Map();
|
|
515
|
+
for (const face of faces) {
|
|
516
|
+
const key = [face.dataUri, face.style, face.unicodeRange ?? ""].join("\u0000");
|
|
517
|
+
const existing = groups.get(key);
|
|
518
|
+
if (existing)
|
|
519
|
+
existing.push(face);
|
|
520
|
+
else
|
|
521
|
+
groups.set(key, [face]);
|
|
522
|
+
}
|
|
523
|
+
return [...groups.values()];
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* A weight range must not span a weight the embedded bundle already serves, or
|
|
527
|
+
* the later rule would win for that weight and shadow the bundled face.
|
|
528
|
+
*/
|
|
529
|
+
function spansCoveredWeight(from, to, coveredWeights) {
|
|
530
|
+
const start = Number(from.weight);
|
|
531
|
+
const end = Number(to.weight);
|
|
532
|
+
if (!Number.isFinite(start) || !Number.isFinite(end))
|
|
533
|
+
return true;
|
|
534
|
+
const low = Math.min(start, end);
|
|
535
|
+
const high = Math.max(start, end);
|
|
536
|
+
for (const covered of coveredWeights) {
|
|
537
|
+
const [weight, style] = covered.split(":");
|
|
538
|
+
if (style !== from.style)
|
|
539
|
+
continue;
|
|
540
|
+
const value = Number(weight);
|
|
541
|
+
if (Number.isFinite(value) && value > low && value < high)
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Split one source group into ascending runs, breaking wherever the embedded
|
|
548
|
+
* bundle already covers a weight inside the span. A weight that is not a plain
|
|
549
|
+
* number (a variable `100 900` range, say) cannot be ordered, so it stays on
|
|
550
|
+
* its own.
|
|
551
|
+
*/
|
|
552
|
+
function partitionWeightRuns(faces, coveredWeights) {
|
|
553
|
+
const runs = [];
|
|
554
|
+
const sortable = faces.filter((face) => Number.isFinite(Number(face.weight)));
|
|
555
|
+
const unsortable = faces.filter((face) => !Number.isFinite(Number(face.weight)));
|
|
556
|
+
let current = [];
|
|
557
|
+
for (const face of [...sortable].sort((a, b) => Number(a.weight) - Number(b.weight))) {
|
|
558
|
+
const previous = current[current.length - 1];
|
|
559
|
+
if (previous && spansCoveredWeight(previous, face, coveredWeights)) {
|
|
560
|
+
runs.push(current);
|
|
561
|
+
current = [];
|
|
562
|
+
}
|
|
563
|
+
current.push(face);
|
|
564
|
+
}
|
|
565
|
+
if (current.length > 0)
|
|
566
|
+
runs.push(current);
|
|
567
|
+
for (const face of unsortable)
|
|
568
|
+
runs.push([face]);
|
|
569
|
+
return runs;
|
|
570
|
+
}
|
|
571
|
+
// Path 1: pre-bundled fonts via FONT_ALIASES — emit embedded faces,
|
|
572
|
+
// then fetch from Google Fonts to fill missing weights and character subsets.
|
|
573
|
+
async function bundledFamilyFaceRules(canonical, normalizedFamily, emitFamily, optional, options, fontText) {
|
|
574
|
+
const rules = [];
|
|
575
|
+
const coveredWeights = new Set();
|
|
576
|
+
const bundledRules = [];
|
|
577
|
+
for (const face of canonical.faces) {
|
|
578
|
+
const style = face.style || "normal";
|
|
579
|
+
const src = fontDataUri(canonical.packageName, face.weight, style);
|
|
580
|
+
bundledRules.push(buildFontFaceRule(emitFamily, src, face.weight, style, BUNDLED_SUBSET_UNICODE_RANGE));
|
|
581
|
+
coveredWeights.add(coverageKey(face.weight, style));
|
|
582
|
+
}
|
|
583
|
+
// Fetch all weights from Google Fonts and add any that aren't
|
|
584
|
+
// already covered by the embedded bundle. This ensures that
|
|
585
|
+
// compositions requesting e.g. wght@200 get that weight even
|
|
586
|
+
// if the bundle only ships 400/700/900. Query the CANONICAL
|
|
587
|
+
// family, not the authored one: for a cross-typeface alias
|
|
588
|
+
// (helvetica → inter) the authored name is a different typeface,
|
|
589
|
+
// so supplementing from it would mix two typefaces under one
|
|
590
|
+
// font-family. The faces are still emitted under
|
|
591
|
+
// `emitFamily` so the authored CSS keeps matching.
|
|
592
|
+
const canonicalFamily = resolveAliasDisplayName(normalizedFamily);
|
|
593
|
+
const googleFaces = canonicalFamily
|
|
594
|
+
? await fetchFamilyFaces(canonicalFamily, optional, options, fontText)
|
|
595
|
+
: [];
|
|
596
|
+
// Bundled weights only cover Latin. Keep other subsets (including
|
|
597
|
+
// text= responses without a range), even for weights already embedded.
|
|
598
|
+
const supplementary = googleFaces.filter((face) => !coveredWeights.has(coverageKey(face.weight, face.style)) ||
|
|
599
|
+
!isBundledSubsetRange(face.unicodeRange));
|
|
600
|
+
const runs = groupFacesBySource(supplementary).flatMap((group) => partitionWeightRuns(group, coveredWeights));
|
|
601
|
+
// Overlapping `unicode-range` rules resolve last-defined-first, so a run
|
|
602
|
+
// is emitted where its first face appeared in the response rather than
|
|
603
|
+
// grouped by source. Collapsing must not reorder the faces.
|
|
604
|
+
const firstAppearance = (run) => Math.min(...run.map((face) => supplementary.indexOf(face)));
|
|
605
|
+
for (const run of [...runs].sort((a, b) => firstAppearance(a) - firstAppearance(b))) {
|
|
606
|
+
const first = run[0];
|
|
607
|
+
const last = run[run.length - 1];
|
|
608
|
+
if (!first || !last)
|
|
609
|
+
continue;
|
|
610
|
+
const weight = run.length > 1 ? `${first.weight} ${last.weight}` : first.weight;
|
|
611
|
+
rules.push(buildFontFaceRule(emitFamily, first.dataUri, weight, first.style, first.unicodeRange));
|
|
612
|
+
}
|
|
613
|
+
// Broader or text-subset responses can overlap Latin. Emit the bundle
|
|
614
|
+
// last so existing Latin glyphs keep their deterministic bundled source.
|
|
615
|
+
rules.push(...bundledRules);
|
|
616
|
+
return rules;
|
|
617
|
+
}
|
|
618
|
+
// Path 2: fetch from Google Fonts (with local cache)
|
|
619
|
+
async function googleFamilyFaceRules(lookupFamily, emitFamily, optional, options, fontText) {
|
|
620
|
+
const googleFaces = await fetchFamilyFaces(lookupFamily, optional, options, fontText);
|
|
621
|
+
if (googleFaces.length === 0)
|
|
622
|
+
return null;
|
|
623
|
+
return googleFaces.map((face) => buildFontFaceRule(emitFamily, face.dataUri, face.weight, face.style, face.unicodeRange));
|
|
624
|
+
}
|
|
625
|
+
// Path 3: locate font on the local filesystem, compress, and embed.
|
|
626
|
+
async function systemFamilyFaceRules(lookupFamily, emitFamily, log) {
|
|
627
|
+
const variants = locateSystemFontVariants(lookupFamily);
|
|
628
|
+
if (variants.length === 0)
|
|
629
|
+
return null;
|
|
630
|
+
const rules = [];
|
|
631
|
+
let totalBytes = 0;
|
|
632
|
+
for (const variant of variants) {
|
|
633
|
+
const fontBuffer = readFileSync(variant.path);
|
|
634
|
+
totalBytes += fontBuffer.length;
|
|
635
|
+
const dataUri = await fontToDataUri(fontBuffer, variant.format);
|
|
636
|
+
rules.push(buildFontFaceRule(emitFamily, dataUri, variant.weight, variant.style));
|
|
637
|
+
}
|
|
638
|
+
if (totalBytes > SYSTEM_FONT_SIZE_LIMIT) {
|
|
639
|
+
log.warn(`[Compiler] System font "${lookupFamily}" is large (${(totalBytes / 1024 / 1024).toFixed(1)} MB total across ${variants.length} variant(s)) — embedding anyway. Consider font subsetting for production.`);
|
|
640
|
+
}
|
|
641
|
+
log.info(`[Compiler] Embedded system font "${lookupFamily}" — ${variants.length} variant(s), ${(totalBytes / 1024).toFixed(0)} KB total`);
|
|
642
|
+
return rules;
|
|
643
|
+
}
|
|
644
|
+
/** Faces of `lookupFamily` named `emitFamily`, or `null` when no path resolves it. */
|
|
645
|
+
async function resolveFamilyFaceRules(normalizedFamily, lookupFamily, emitFamily, optional, options, fontText) {
|
|
646
|
+
const canonicalKey = FONT_ALIASES[normalizedFamily];
|
|
647
|
+
if (canonicalKey) {
|
|
648
|
+
const canonical = CANONICAL_FONTS[canonicalKey];
|
|
649
|
+
if (!canonical)
|
|
650
|
+
return [];
|
|
651
|
+
return bundledFamilyFaceRules(canonical, normalizedFamily, emitFamily, optional, options, fontText);
|
|
652
|
+
}
|
|
653
|
+
return ((await googleFamilyFaceRules(lookupFamily, emitFamily, optional, options, fontText)) ??
|
|
654
|
+
(options.allowSystemFontCapture && !pageNamedThisFile(lookupFamily, options)
|
|
655
|
+
? await systemFamilyFaceRules(lookupFamily, emitFamily, options.log)
|
|
656
|
+
: null));
|
|
657
|
+
}
|
|
658
|
+
// css2 repeats `family=Name:axes`; the legacy css API packs `family=Name:400|Other`.
|
|
659
|
+
function googleFontsUrlFamilies(rawUrl) {
|
|
660
|
+
let url;
|
|
661
|
+
try {
|
|
662
|
+
// A relative href resolves onto the placeholder host and is rejected below.
|
|
663
|
+
url = new URL(rawUrl.trim(), "https://document.invalid/");
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return [];
|
|
667
|
+
}
|
|
668
|
+
if (url.hostname !== "fonts.googleapis.com")
|
|
669
|
+
return [];
|
|
670
|
+
if (url.pathname !== "/css" && url.pathname !== "/css2")
|
|
671
|
+
return [];
|
|
672
|
+
return url.searchParams
|
|
673
|
+
.getAll("family")
|
|
674
|
+
.flatMap((value) => value.split("|"))
|
|
675
|
+
.map((entry) => (entry.split(":", 1)[0] ?? "").trim())
|
|
676
|
+
.filter((family) => family.length > 0);
|
|
677
|
+
}
|
|
678
|
+
function importRuleUrl(params) {
|
|
679
|
+
const trimmed = params.trim();
|
|
680
|
+
if (trimmed.toLowerCase().startsWith("url(")) {
|
|
681
|
+
const close = trimmed.indexOf(")");
|
|
682
|
+
if (close < 0 || !isUnconditionalMedia(trimmed.slice(close + 1)))
|
|
683
|
+
return null;
|
|
684
|
+
return trimmed.slice(4, close).trim().replace(/^['"]/, "").replace(/['"]$/, "");
|
|
685
|
+
}
|
|
686
|
+
const quote = trimmed[0];
|
|
687
|
+
if (quote !== '"' && quote !== "'")
|
|
688
|
+
return null;
|
|
689
|
+
const end = trimmed.indexOf(quote, 1);
|
|
690
|
+
return end > 0 && isUnconditionalMedia(trimmed.slice(end + 1)) ? trimmed.slice(1, end) : null;
|
|
691
|
+
}
|
|
692
|
+
function fontFaceRuleFamily(rule) {
|
|
693
|
+
let family;
|
|
694
|
+
rule.walkDecls((decl) => {
|
|
695
|
+
if (decl.prop.toLowerCase() === "font-family")
|
|
696
|
+
family = parseFontFamilyValue(decl.value)[0];
|
|
697
|
+
});
|
|
698
|
+
return family;
|
|
699
|
+
}
|
|
700
|
+
function isUnconditionalMedia(media) {
|
|
701
|
+
const normalized = (media ?? "").trim().toLowerCase();
|
|
702
|
+
return normalized === "" || normalized === "all";
|
|
703
|
+
}
|
|
704
|
+
function isUnconditionalStylesheet(element) {
|
|
705
|
+
const type = (element.getAttribute("type") ?? "").trim().toLowerCase();
|
|
706
|
+
return (isUnconditionalMedia(element.getAttribute("media")) &&
|
|
707
|
+
(type === "" || type === "text/css") &&
|
|
708
|
+
!(element.getAttribute("title") ?? "").trim() &&
|
|
709
|
+
!element.hasAttribute("disabled"));
|
|
710
|
+
}
|
|
711
|
+
function declareStyleFontFamilies(styleEl, register) {
|
|
712
|
+
if (!isUnconditionalStylesheet(styleEl))
|
|
713
|
+
return;
|
|
714
|
+
const root = parseCssRoot(styleEl.textContent ?? "");
|
|
715
|
+
if (!root)
|
|
716
|
+
return;
|
|
717
|
+
root.walkAtRules((atRule) => {
|
|
718
|
+
if (atRule.parent?.type !== "root")
|
|
719
|
+
return;
|
|
720
|
+
const name = atRule.name.toLowerCase();
|
|
721
|
+
if (name === "import") {
|
|
722
|
+
const url = importRuleUrl(atRule.params);
|
|
723
|
+
if (url)
|
|
724
|
+
for (const family of googleFontsUrlFamilies(url))
|
|
725
|
+
register(family);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (name !== "font-face")
|
|
729
|
+
return;
|
|
730
|
+
const family = fontFaceRuleFamily(atRule);
|
|
731
|
+
if (family)
|
|
732
|
+
register(family, atRule);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
function declareLinkedFontFamilies(link, register) {
|
|
736
|
+
const rel = (link.getAttribute("rel") ?? "").toLowerCase().split(/\s+/);
|
|
737
|
+
if (!rel.includes("stylesheet") || rel.includes("alternate") || !isUnconditionalStylesheet(link))
|
|
738
|
+
return;
|
|
739
|
+
for (const family of googleFontsUrlFamilies(link.getAttribute("href") ?? ""))
|
|
740
|
+
register(family);
|
|
741
|
+
}
|
|
742
|
+
function collectDeclaredFontFamilies(html) {
|
|
743
|
+
const { document } = parseHTML(html);
|
|
744
|
+
const declared = new Map();
|
|
745
|
+
const register = (family, fontFaceRule) => {
|
|
746
|
+
const key = normalizeFamilyName(family);
|
|
747
|
+
if (!key || GENERIC_FAMILIES.has(key))
|
|
748
|
+
return;
|
|
749
|
+
let entry = declared.get(key);
|
|
750
|
+
if (!entry) {
|
|
751
|
+
entry = { family, fontFaceRules: [] };
|
|
752
|
+
declared.set(key, entry);
|
|
753
|
+
}
|
|
754
|
+
if (fontFaceRule)
|
|
755
|
+
entry.fontFaceRules.push(fontFaceRule);
|
|
756
|
+
};
|
|
757
|
+
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
|
|
758
|
+
declareStyleFontFamilies(styleEl, register);
|
|
759
|
+
}
|
|
760
|
+
for (const link of Array.from(document.querySelectorAll("link[href]"))) {
|
|
761
|
+
declareLinkedFontFamilies(link, register);
|
|
762
|
+
}
|
|
763
|
+
return declared;
|
|
764
|
+
}
|
|
765
|
+
// `+` too: fetchGoogleFont already reads it as the URL-encoded space.
|
|
766
|
+
function familySpellingKey(family) {
|
|
767
|
+
return family.toLowerCase().replace(/[\s_+-]+/g, "");
|
|
768
|
+
}
|
|
769
|
+
function findDeclaredFamilyAlias(authoredFamily, declaredFamilies) {
|
|
770
|
+
// Declared verbatim yet unresolved: substituting a near-spelling would be a guess.
|
|
771
|
+
if (declaredFamilies.has(normalizeFamilyName(authoredFamily)))
|
|
772
|
+
return null;
|
|
773
|
+
const key = familySpellingKey(authoredFamily);
|
|
774
|
+
if (!key)
|
|
775
|
+
return null;
|
|
776
|
+
let match = null;
|
|
777
|
+
for (const entry of declaredFamilies.values()) {
|
|
778
|
+
if (familySpellingKey(entry.family) !== key)
|
|
779
|
+
continue;
|
|
780
|
+
// Two declared families collapse to the same key; picking one is a guess.
|
|
781
|
+
if (match)
|
|
782
|
+
return null;
|
|
783
|
+
match = entry;
|
|
784
|
+
}
|
|
785
|
+
return match;
|
|
786
|
+
}
|
|
787
|
+
// Emits under the authored name instead of rewriting usages, as FONT_ALIASES does,
|
|
788
|
+
// so script-assigned and var()-indirected usages match too.
|
|
789
|
+
async function resolveDeclaredFamilyAlias(authoredFamily, declaredFamilies, optional, options, fontText) {
|
|
790
|
+
const declared = findDeclaredFamilyAlias(authoredFamily, declaredFamilies);
|
|
791
|
+
if (!declared)
|
|
792
|
+
return null;
|
|
793
|
+
let rules;
|
|
794
|
+
if (declared.fontFaceRules.length > 0) {
|
|
795
|
+
// The document's own faces win, as they would had the family been spelled as declared.
|
|
796
|
+
rules = declared.fontFaceRules.map((rule) => {
|
|
797
|
+
const renamed = rule.clone();
|
|
798
|
+
renamed.walkDecls((decl) => {
|
|
799
|
+
if (decl.prop.toLowerCase() === "font-family")
|
|
800
|
+
decl.value = `"${authoredFamily}"`;
|
|
801
|
+
});
|
|
802
|
+
return renamed.toString();
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
rules = await resolveFamilyFaceRules(normalizeFamilyName(declared.family), declared.family, authoredFamily, optional, options, fontText);
|
|
807
|
+
}
|
|
808
|
+
if (!rules)
|
|
809
|
+
return null;
|
|
810
|
+
options.log.warn(`[Compiler] font-family "${authoredFamily}" is not a known family; resolved it to "${declared.family}", ` +
|
|
811
|
+
`which this document declares. Correct the authored font-family to "${declared.family}".`);
|
|
812
|
+
return rules;
|
|
813
|
+
}
|
|
814
|
+
async function buildFontFaceCss(requestedFamilies, options, declaredFamilies, fontText) {
|
|
815
|
+
const rules = [];
|
|
816
|
+
const unresolved = [];
|
|
817
|
+
// Required families fetch first, so optional ones cannot spend the shared fetch budget before them.
|
|
818
|
+
const requiredFirst = [...requestedFamilies].sort(([, a], [, b]) => Number(a.optional) - Number(b.optional));
|
|
819
|
+
for (const [normalizedFamily, { family: originalCaseFamily, optional }] of requiredFirst) {
|
|
820
|
+
const familyRules = (await resolveFamilyFaceRules(normalizedFamily, originalCaseFamily, originalCaseFamily, optional, options, fontText)) ??
|
|
821
|
+
// Last, so a family that already resolves keeps exactly the faces it had.
|
|
822
|
+
(await resolveDeclaredFamilyAlias(originalCaseFamily, declaredFamilies(), optional, options, fontText));
|
|
823
|
+
if (familyRules) {
|
|
824
|
+
rules.push(...familyRules);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
// No path resolved
|
|
828
|
+
unresolved.push(originalCaseFamily);
|
|
829
|
+
}
|
|
830
|
+
return {
|
|
831
|
+
css: rules.join("\n\n").trim(),
|
|
832
|
+
unresolved: unresolved.sort(),
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function warnUnresolvedFonts(unresolved, log) {
|
|
836
|
+
const mapped = Object.entries(FONT_ALIASES)
|
|
837
|
+
.reduce((acc, [alias, canonical]) => {
|
|
838
|
+
const display = alias === canonical ? alias : `${alias} → ${canonical}`;
|
|
839
|
+
if (!acc.includes(display))
|
|
840
|
+
acc.push(display);
|
|
841
|
+
return acc;
|
|
842
|
+
}, [])
|
|
843
|
+
.sort();
|
|
844
|
+
log.warn(`[Compiler] No deterministic font mapping for: ${unresolved.join(", ")}\n` +
|
|
845
|
+
` Mapped fonts: ${mapped.join(", ")}\n` +
|
|
846
|
+
` To fix, pick one:\n` +
|
|
847
|
+
` 1. Use a mapped font name instead (see list above)\n` +
|
|
848
|
+
` 2. Add a @font-face block in your HTML with a local or hosted font file\n` +
|
|
849
|
+
` 3. Install the font locally on the render machine (Docker: add to Dockerfile)\n` +
|
|
850
|
+
` 4. Add an alias to FONT_ALIAS_MAP in packages/core/src/fonts/aliases.ts (for contributors)\n` +
|
|
851
|
+
` Docs: https://hyperframes.heygen.com/docs/fonts`);
|
|
852
|
+
}
|
|
853
|
+
// ---------------------------------------------------------------------------
|
|
854
|
+
// Google Fonts on-demand fetch + local cache
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
let lambdaFontCacheRoot;
|
|
857
|
+
// On AWS Lambda `$HOME` resolves to a `/home/sbx_*` tree that's read-only;
|
|
858
|
+
// only `/tmp` is writable. Create one private, unguessable cache directory per
|
|
859
|
+
// warm process and reuse it across invocations. Honor HYPERFRAMES_FONT_CACHE_DIR
|
|
860
|
+
// as an explicit override for any environment.
|
|
861
|
+
function resolveFontCacheRoot() {
|
|
862
|
+
if (process.env.HYPERFRAMES_FONT_CACHE_DIR) {
|
|
863
|
+
return process.env.HYPERFRAMES_FONT_CACHE_DIR;
|
|
864
|
+
}
|
|
865
|
+
if (process.env.AWS_LAMBDA_FUNCTION_NAME) {
|
|
866
|
+
lambdaFontCacheRoot ??= mkdtempSync(join(tmpdir(), "hyperframes-fonts-"));
|
|
867
|
+
return lambdaFontCacheRoot;
|
|
868
|
+
}
|
|
869
|
+
return join(homedir(), ".cache", "hyperframes", "fonts");
|
|
870
|
+
}
|
|
871
|
+
// Chrome UA triggers woff2 responses from Google Fonts CSS API
|
|
872
|
+
const WOFF2_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
873
|
+
function fontSlug(familyName) {
|
|
874
|
+
return familyName
|
|
875
|
+
.toLowerCase()
|
|
876
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
877
|
+
.replace(/^-|-$/g, "");
|
|
878
|
+
}
|
|
879
|
+
let ephemeralFontCacheRoot;
|
|
880
|
+
function fontCacheDir(slug) {
|
|
881
|
+
const dir = join(resolveFontCacheRoot(), slug);
|
|
882
|
+
if (!existsSync(dir)) {
|
|
883
|
+
try {
|
|
884
|
+
mkdirSync(dir, { recursive: true });
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
const firstFallback = ephemeralFontCacheRoot === undefined;
|
|
888
|
+
ephemeralFontCacheRoot ??= mkdtempSync(join(tmpdir(), "hyperframes-fonts-"));
|
|
889
|
+
const fallback = join(ephemeralFontCacheRoot, slug);
|
|
890
|
+
mkdirSync(fallback, { recursive: true });
|
|
891
|
+
if (firstFallback) {
|
|
892
|
+
STDERR_LOGGER.warn(`Font cache directory is unwritable (${dir}). ` +
|
|
893
|
+
`Using temporary fallback — fonts will re-download each run. ` +
|
|
894
|
+
`Fix with: chmod 755 ${resolveFontCacheRoot()}`);
|
|
895
|
+
}
|
|
896
|
+
return fallback;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return dir;
|
|
900
|
+
}
|
|
901
|
+
// A short, stable discriminator for a single subset's woff2. Google Fonts'
|
|
902
|
+
// css2 API returns one @font-face per (weight × unicode-range subset) — e.g.
|
|
903
|
+
// `vietnamese`, `latin-ext`, and `latin` faces for the SAME weight, each with
|
|
904
|
+
// a distinct woff2 URL and glyph set. Keying the cache by weight+style alone
|
|
905
|
+
// collides every subset onto one filename, so only the first subset in the
|
|
906
|
+
// CSS gets downloaded and the rest read it back. Derive the cache key from the
|
|
907
|
+
// (subset-unique, version-stable) woff2 URL so each subset is cached on its own.
|
|
908
|
+
function subsetToken(woff2Url) {
|
|
909
|
+
return createHash("sha1").update(woff2Url).digest("hex").slice(0, 12);
|
|
910
|
+
}
|
|
911
|
+
function cachedWoff2Path(slug, weight, style, subset) {
|
|
912
|
+
return join(fontCacheDir(slug), `${weight}-${style}-${subset}.woff2`);
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Typed codes let distributed workflow adapters distinguish deterministic
|
|
916
|
+
* resolution failures from temporary upstream unavailability.
|
|
917
|
+
*/
|
|
918
|
+
export const FONT_FETCH_FAILED = "FONT_FETCH_FAILED";
|
|
919
|
+
export const FONT_FETCH_UNAVAILABLE = "FONT_FETCH_UNAVAILABLE";
|
|
920
|
+
/**
|
|
921
|
+
* Typed error thrown by {@link injectDeterministicFontFaces} when
|
|
922
|
+
* `failClosedFontFetch === true` and deterministic font resolution fails.
|
|
923
|
+
* The default (swallow + warn) preserves the in-process behavior.
|
|
924
|
+
*/
|
|
925
|
+
export class FontFetchError extends Error {
|
|
926
|
+
code;
|
|
927
|
+
familyName;
|
|
928
|
+
url;
|
|
929
|
+
cause;
|
|
930
|
+
/** Unlike `message`, holds no URLs: safe for callers that must not echo resolver output. */
|
|
931
|
+
unresolvedFamilies;
|
|
932
|
+
constructor(familyName, url, message, cause, code = FONT_FETCH_FAILED, unresolvedFamilies = []) {
|
|
933
|
+
super(message);
|
|
934
|
+
this.name = "FontFetchError";
|
|
935
|
+
this.code = code;
|
|
936
|
+
this.familyName = familyName;
|
|
937
|
+
this.url = url;
|
|
938
|
+
this.cause = cause;
|
|
939
|
+
this.unresolvedFamilies = unresolvedFamilies;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Retryable font-fetch failure. Distributed adapters map this code to an
|
|
944
|
+
* unavailable response so the workflow can retry the plan activity.
|
|
945
|
+
*/
|
|
946
|
+
export class FontFetchUnavailableError extends FontFetchError {
|
|
947
|
+
constructor(familyName, url, message, cause) {
|
|
948
|
+
super(familyName, url, message, cause, FONT_FETCH_UNAVAILABLE);
|
|
949
|
+
this.name = "FontFetchUnavailableError";
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const DEFAULT_FONT_FETCH_RETRY_POLICY = {
|
|
953
|
+
maxAttempts: 2,
|
|
954
|
+
attemptTimeoutMs: 8_000,
|
|
955
|
+
maxElapsedMs: 20_000,
|
|
956
|
+
baseDelayMs: 250,
|
|
957
|
+
};
|
|
958
|
+
/**
|
|
959
|
+
* Build a typed FontFetchError describing why a Google Fonts request failed.
|
|
960
|
+
* Centralizes the message wording so all four call sites (CSS/woff2 ×
|
|
961
|
+
* HTTP-error/exception) stay phrased identically.
|
|
962
|
+
*/
|
|
963
|
+
function fontFetchError(familyName, url, what, cause, unavailable = false) {
|
|
964
|
+
const reason = "status" in cause
|
|
965
|
+
? `returned HTTP ${cause.status}`
|
|
966
|
+
: `failed: ${cause.error instanceof Error ? cause.error.message : String(cause.error)}`;
|
|
967
|
+
const message = `[deterministicFonts] ${what} fetch for ${JSON.stringify(familyName)} ${reason}. ` +
|
|
968
|
+
`Distributed renders require deterministic fonts; system-font fallback would produce ` +
|
|
969
|
+
`non-byte-identical output.`;
|
|
970
|
+
const errorCause = "error" in cause ? cause.error : undefined;
|
|
971
|
+
return unavailable
|
|
972
|
+
? new FontFetchUnavailableError(familyName, url, message, errorCause)
|
|
973
|
+
: new FontFetchError(familyName, url, message, errorCause);
|
|
974
|
+
}
|
|
975
|
+
function isRetryableFontFetchStatus(status) {
|
|
976
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
977
|
+
}
|
|
978
|
+
function retryAfterMs(response) {
|
|
979
|
+
const value = response.headers.get("retry-after");
|
|
980
|
+
if (value === null)
|
|
981
|
+
return null;
|
|
982
|
+
const seconds = Number(value);
|
|
983
|
+
if (Number.isFinite(seconds))
|
|
984
|
+
return Math.max(0, seconds * 1_000);
|
|
985
|
+
const date = Date.parse(value);
|
|
986
|
+
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
|
|
987
|
+
}
|
|
988
|
+
function callerAbortReason(signal) {
|
|
989
|
+
return signal.reason ?? new DOMException("Font fetch cancelled", "AbortError");
|
|
990
|
+
}
|
|
991
|
+
function throwIfCallerAborted(signal) {
|
|
992
|
+
if (signal?.aborted)
|
|
993
|
+
throw callerAbortReason(signal);
|
|
994
|
+
}
|
|
995
|
+
async function waitForFontFetchRetry(delayMs, signal) {
|
|
996
|
+
throwIfCallerAborted(signal);
|
|
997
|
+
if (delayMs <= 0)
|
|
998
|
+
return;
|
|
999
|
+
await new Promise((resolve, reject) => {
|
|
1000
|
+
const timeout = setTimeout(() => {
|
|
1001
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1002
|
+
resolve();
|
|
1003
|
+
}, delayMs);
|
|
1004
|
+
const onAbort = () => {
|
|
1005
|
+
clearTimeout(timeout);
|
|
1006
|
+
reject(signal ? callerAbortReason(signal) : new DOMException("Cancelled", "AbortError"));
|
|
1007
|
+
};
|
|
1008
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1009
|
+
if (signal?.aborted)
|
|
1010
|
+
onAbort();
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
function retryDelayMs(response, attempt, baseDelayMs) {
|
|
1014
|
+
const requestedDelay = response ? retryAfterMs(response) : null;
|
|
1015
|
+
if (requestedDelay !== null)
|
|
1016
|
+
return requestedDelay;
|
|
1017
|
+
const ceiling = baseDelayMs * 2 ** attempt;
|
|
1018
|
+
return Math.floor(Math.random() * (ceiling + 1));
|
|
1019
|
+
}
|
|
1020
|
+
function cancelResponseBody(response) {
|
|
1021
|
+
try {
|
|
1022
|
+
void response.body?.cancel().catch(() => {
|
|
1023
|
+
// Best-effort connection cleanup must not replace the original status.
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
// Best-effort connection cleanup must not replace the original status.
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
async function runFontFetchAttempt(url, init, readBody, options, remainingMs) {
|
|
1031
|
+
const timeoutSignal = AbortSignal.timeout(Math.max(1, Math.min(options.retryPolicy.attemptTimeoutMs, remainingMs)));
|
|
1032
|
+
const signal = options.abortSignal
|
|
1033
|
+
? AbortSignal.any([options.abortSignal, timeoutSignal])
|
|
1034
|
+
: timeoutSignal;
|
|
1035
|
+
let response;
|
|
1036
|
+
try {
|
|
1037
|
+
response = await options.fetchImpl(url, { ...init, signal });
|
|
1038
|
+
if (!isRetryableFontFetchStatus(response.status)) {
|
|
1039
|
+
if (!response.ok)
|
|
1040
|
+
return { completed: true, result: { ok: false, response } };
|
|
1041
|
+
const body = await readBody(response);
|
|
1042
|
+
return { completed: true, result: { ok: true, response, body } };
|
|
1043
|
+
}
|
|
1044
|
+
cancelResponseBody(response);
|
|
1045
|
+
return { completed: false, response, cause: { status: response.status } };
|
|
1046
|
+
}
|
|
1047
|
+
catch (error) {
|
|
1048
|
+
throwIfCallerAborted(options.abortSignal);
|
|
1049
|
+
return { completed: false, response, cause: { error } };
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
async function fetchFontResource(url, init, readBody, familyName, what, options) {
|
|
1053
|
+
if (!options.failClosedFontFetch) {
|
|
1054
|
+
const response = await options.fetchImpl(url, { ...init, signal: options.abortSignal });
|
|
1055
|
+
if (!response.ok)
|
|
1056
|
+
return { ok: false, response };
|
|
1057
|
+
return { ok: true, response, body: await readBody(response) };
|
|
1058
|
+
}
|
|
1059
|
+
let lastCause = {
|
|
1060
|
+
error: new DOMException("Font fetch budget exhausted", "TimeoutError"),
|
|
1061
|
+
};
|
|
1062
|
+
for (let attempt = 0; attempt < options.retryPolicy.maxAttempts; attempt += 1) {
|
|
1063
|
+
throwIfCallerAborted(options.abortSignal);
|
|
1064
|
+
const remainingMs = options.retryDeadlineMs - Date.now();
|
|
1065
|
+
if (remainingMs <= 0)
|
|
1066
|
+
break;
|
|
1067
|
+
const attemptResult = await runFontFetchAttempt(url, init, readBody, options, remainingMs);
|
|
1068
|
+
if (attemptResult.completed)
|
|
1069
|
+
return attemptResult.result;
|
|
1070
|
+
lastCause = attemptResult.cause;
|
|
1071
|
+
if (attempt + 1 >= options.retryPolicy.maxAttempts)
|
|
1072
|
+
break;
|
|
1073
|
+
const delayMs = retryDelayMs(attemptResult.response, attempt, options.retryPolicy.baseDelayMs);
|
|
1074
|
+
if (delayMs >= options.retryDeadlineMs - Date.now())
|
|
1075
|
+
break;
|
|
1076
|
+
await waitForFontFetchRetry(delayMs, options.abortSignal);
|
|
1077
|
+
}
|
|
1078
|
+
throw fontFetchError(familyName, url, what, lastCause, true);
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Ensure one subset's woff2 is cached on disk (downloading if absent) and
|
|
1082
|
+
* return it as a `data:` URI. Returns `null` when the woff2 isn't served
|
|
1083
|
+
* (4xx) so the caller skips that face. Throws {@link FontFetchError} on
|
|
1084
|
+
* transient (5xx / network) failures when `failClosedFontFetch` is set.
|
|
1085
|
+
*/
|
|
1086
|
+
async function ensureWoff2DataUri(cachePath, woff2Url, familyName, weight, style, options) {
|
|
1087
|
+
try {
|
|
1088
|
+
return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
|
|
1089
|
+
}
|
|
1090
|
+
catch {
|
|
1091
|
+
// Not cached yet — fall through to fetch.
|
|
1092
|
+
}
|
|
1093
|
+
const woff2What = `Google Fonts woff2 (${weight}/${style})`;
|
|
1094
|
+
try {
|
|
1095
|
+
const fontResult = await fetchFontResource(woff2Url, undefined, (response) => response.arrayBuffer(), familyName, woff2What, options);
|
|
1096
|
+
if (!fontResult.ok)
|
|
1097
|
+
return null;
|
|
1098
|
+
// wx = O_CREAT|O_EXCL: atomic create, rejects symlinks, fails with
|
|
1099
|
+
// EEXIST if a concurrent call cached it between our read and write.
|
|
1100
|
+
writeFileSync(cachePath, Buffer.from(fontResult.body), { flag: "wx", mode: 0o644 });
|
|
1101
|
+
}
|
|
1102
|
+
catch (err) {
|
|
1103
|
+
throwIfCallerAborted(options.abortSignal);
|
|
1104
|
+
if (err instanceof FontFetchError)
|
|
1105
|
+
throw err;
|
|
1106
|
+
if (err.code === "EEXIST") {
|
|
1107
|
+
// Concurrent call wrote it — read their result below.
|
|
1108
|
+
}
|
|
1109
|
+
else if (options.failClosedFontFetch) {
|
|
1110
|
+
throw fontFetchError(familyName, woff2Url, woff2What, { error: err });
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
|
|
1117
|
+
}
|
|
1118
|
+
// Per-process cache for Google Fonts CSS lookups, keyed by request URL —
|
|
1119
|
+
// repeat compiles of the same family reuse one network round trip. A
|
|
1120
|
+
// rejected lookup evicts itself so a later call still retries.
|
|
1121
|
+
const googleFontCssCache = new Map();
|
|
1122
|
+
/** Test-only reset — the cache is otherwise process-lifetime, shared across calls. */
|
|
1123
|
+
export function _clearGoogleFontCssCacheForTests() {
|
|
1124
|
+
googleFontCssCache.clear();
|
|
1125
|
+
}
|
|
1126
|
+
/** Rejects with `signal`'s own abort reason without cancelling `promise` itself. */
|
|
1127
|
+
function raceAgainstAbort(promise, signal) {
|
|
1128
|
+
if (!signal)
|
|
1129
|
+
return promise;
|
|
1130
|
+
if (signal.aborted)
|
|
1131
|
+
return Promise.reject(callerAbortReason(signal));
|
|
1132
|
+
return new Promise((resolve, reject) => {
|
|
1133
|
+
const onAbort = () => reject(callerAbortReason(signal));
|
|
1134
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1135
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
function fetchGoogleFontCss(url, familyName, options) {
|
|
1139
|
+
// Retries decide the outcome, so only callers with the same mode and attempt count share an entry.
|
|
1140
|
+
const mode = options.failClosedFontFetch ? `closed${options.retryPolicy.maxAttempts}` : "open";
|
|
1141
|
+
const key = `${mode}:${url}`;
|
|
1142
|
+
let shared = googleFontCssCache.get(key);
|
|
1143
|
+
if (!shared) {
|
|
1144
|
+
// The shared fetch must not carry any one caller's abortSignal, or that
|
|
1145
|
+
// caller cancelling would fail the lookup for every other waiter.
|
|
1146
|
+
shared = fetchFontResource(url, { headers: { "User-Agent": WOFF2_USER_AGENT } }, (response) => response.text(), familyName, "Google Fonts CSS", { ...options, abortSignal: undefined }).then((result) => {
|
|
1147
|
+
if (result.ok)
|
|
1148
|
+
return { ok: true, body: result.body };
|
|
1149
|
+
// A transient status must not stick for the process lifetime.
|
|
1150
|
+
if (isRetryableFontFetchStatus(result.response.status))
|
|
1151
|
+
googleFontCssCache.delete(key);
|
|
1152
|
+
return { ok: false };
|
|
1153
|
+
});
|
|
1154
|
+
googleFontCssCache.set(key, shared);
|
|
1155
|
+
shared.catch(() => googleFontCssCache.delete(key));
|
|
1156
|
+
}
|
|
1157
|
+
return raceAgainstAbort(shared, options.abortSignal);
|
|
1158
|
+
}
|
|
1159
|
+
function pageNamedThisFile(familyName, options) {
|
|
1160
|
+
return options.authoredStylesheets.has(normalizeFamilyName(familyName.replace(/\+/g, " ")));
|
|
1161
|
+
}
|
|
1162
|
+
function declaredFaceFamily(block) {
|
|
1163
|
+
const found = /font-family:\s*['"]([^'"]+)['"]/i.exec(block);
|
|
1164
|
+
return found?.[1];
|
|
1165
|
+
}
|
|
1166
|
+
// fallow-ignore-next-line complexity
|
|
1167
|
+
async function fetchGoogleFont(familyName, options, fontText) {
|
|
1168
|
+
// Agents sometimes copy the `family=` value from a Google Fonts URL into
|
|
1169
|
+
// CSS, where `+` remains a literal character instead of being decoded as a
|
|
1170
|
+
// space. Resolve that URL-style spelling through the canonical Google family
|
|
1171
|
+
// while preserving `familyName` for the emitted @font-face alias so the
|
|
1172
|
+
// authored CSS still matches it.
|
|
1173
|
+
const googleFamilyName = familyName.replace(/\+/g, " ");
|
|
1174
|
+
const encodedFamily = encodeURIComponent(googleFamilyName);
|
|
1175
|
+
const textParam = fontText ? `&text=${encodeURIComponent(fontText)}` : "";
|
|
1176
|
+
// Bundled families need the full supplement, including italics a page's
|
|
1177
|
+
// link may omit. Other families need the authored URL to preserve axes
|
|
1178
|
+
// such as optical size instead of replacing them with a weight-only face.
|
|
1179
|
+
const normalizedFamily = normalizeFamilyName(googleFamilyName);
|
|
1180
|
+
const authoredStylesheet = FONT_ALIASES[normalizedFamily]
|
|
1181
|
+
? undefined
|
|
1182
|
+
: options.authoredStylesheets.get(normalizedFamily);
|
|
1183
|
+
// `text=` asks Google for only the characters on the page. A CJK family
|
|
1184
|
+
// without it is a hundred files, and the compile's font budget is 20s.
|
|
1185
|
+
const urls = authoredStylesheet
|
|
1186
|
+
? authoredStylesheet.map((url) => withPageText(url, fontText))
|
|
1187
|
+
: [
|
|
1188
|
+
`https://fonts.googleapis.com/css2?family=${encodedFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700${textParam}`,
|
|
1189
|
+
];
|
|
1190
|
+
const faces = [];
|
|
1191
|
+
for (const url of urls) {
|
|
1192
|
+
faces.push(...(await fetchGoogleFontStylesheet(googleFamilyName, url, options)));
|
|
1193
|
+
}
|
|
1194
|
+
return faces;
|
|
1195
|
+
}
|
|
1196
|
+
async function readGoogleFontStylesheet(familyName, url, options) {
|
|
1197
|
+
try {
|
|
1198
|
+
const cssResult = await fetchGoogleFontCss(url, familyName, options);
|
|
1199
|
+
if (!cssResult.ok) {
|
|
1200
|
+
// Missing families are deterministic; transient failures follow the
|
|
1201
|
+
// caller's retry and fail-closed policy.
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
return cssResult.body;
|
|
1205
|
+
}
|
|
1206
|
+
catch (err) {
|
|
1207
|
+
// Rethrow typed error untouched. Network / DNS / fetch-throws are
|
|
1208
|
+
// non-deterministic infrastructure failures — wrapped when failClosed
|
|
1209
|
+
// is on, swallowed otherwise.
|
|
1210
|
+
throwIfCallerAborted(options.abortSignal);
|
|
1211
|
+
if (err instanceof FontFetchError)
|
|
1212
|
+
throw err;
|
|
1213
|
+
if (options.failClosedFontFetch) {
|
|
1214
|
+
throw fontFetchError(familyName, url, "Google Fonts CSS", { error: err });
|
|
1215
|
+
}
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
function parseGoogleFontSource(match, familyName) {
|
|
1220
|
+
const declared = declaredFaceFamily(match[0]);
|
|
1221
|
+
if (declared && normalizeFamilyName(declared) !== normalizeFamilyName(familyName))
|
|
1222
|
+
return null;
|
|
1223
|
+
const url = match[3];
|
|
1224
|
+
if (!url)
|
|
1225
|
+
return null;
|
|
1226
|
+
return {
|
|
1227
|
+
style: match[1] || "normal",
|
|
1228
|
+
weight: (match[2] || "400").replace(/\s+/g, " "),
|
|
1229
|
+
url,
|
|
1230
|
+
unicodeRange: match[4]?.trim() || undefined,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
async function fetchGoogleFontStylesheet(familyName, url, options) {
|
|
1234
|
+
const slug = fontSlug(familyName);
|
|
1235
|
+
const cssText = await readGoogleFontStylesheet(familyName, url, options);
|
|
1236
|
+
if (!cssText)
|
|
1237
|
+
return [];
|
|
1238
|
+
// Parse @font-face blocks from the CSS response. The optional trailing
|
|
1239
|
+
// capture grabs each face's `unicode-range` (Google emits it after `src`)
|
|
1240
|
+
// so the injected face only claims the codepoints the subset actually
|
|
1241
|
+
// covers — without it the face would advertise full coverage it lacks.
|
|
1242
|
+
// A variable face is `font-weight: 400 900`: one file for every weight in
|
|
1243
|
+
// the span. Keeping only the first number leaves 600/700/800 on the network.
|
|
1244
|
+
const faceRegex = /@font-face\s*\{[^}]*font-style:\s*(normal|italic|oblique(?:\s+-?\d+(?:\.\d+)?deg){0,2})\s*;[^}]*font-weight:\s*(\d+(?:\s+\d+)?)[^}]*src:\s*url\(([^)]+)\)\s*format\(['"]woff2['"]\)(?:[^}]*?unicode-range:\s*([^;}]+))?[^}]*\}/gi;
|
|
1245
|
+
const faces = [];
|
|
1246
|
+
for (const match of cssText.matchAll(faceRegex)) {
|
|
1247
|
+
const source = parseGoogleFontSource(match, familyName);
|
|
1248
|
+
if (!source)
|
|
1249
|
+
continue;
|
|
1250
|
+
const { weight, style, unicodeRange, url: woff2Url } = source;
|
|
1251
|
+
const cachePath = cachedWoff2Path(slug, weight, style, subsetToken(woff2Url));
|
|
1252
|
+
const dataUri = await ensureWoff2DataUri(cachePath, woff2Url, familyName, weight, style, options);
|
|
1253
|
+
if (dataUri)
|
|
1254
|
+
faces.push({ weight, style, dataUri, unicodeRange });
|
|
1255
|
+
}
|
|
1256
|
+
if (faces.length > 0) {
|
|
1257
|
+
options.log.info(`[Compiler] Fetched ${faces.length} font face(s) for "${familyName}" from Google Fonts (cached to ${fontCacheDir(slug)})`);
|
|
1258
|
+
}
|
|
1259
|
+
return faces;
|
|
1260
|
+
}
|
|
1261
|
+
/** An optional family gets one retry on a transient failure, then renders its fallback with a warning. */
|
|
1262
|
+
async function fetchFamilyFaces(familyName, optional, options, fontText) {
|
|
1263
|
+
if (!optional)
|
|
1264
|
+
return fetchGoogleFont(familyName, options, fontText);
|
|
1265
|
+
const maxAttempts = Math.min(options.retryPolicy.maxAttempts, 2);
|
|
1266
|
+
try {
|
|
1267
|
+
return await fetchGoogleFont(familyName, { ...options, retryPolicy: { ...options.retryPolicy, maxAttempts } }, fontText);
|
|
1268
|
+
}
|
|
1269
|
+
catch (err) {
|
|
1270
|
+
if (!(err instanceof FontFetchUnavailableError))
|
|
1271
|
+
throw err;
|
|
1272
|
+
options.log.warn(`[Compiler] Optional font "${familyName}" is unavailable, rendering its fallback: ${err.message}`);
|
|
1273
|
+
return [];
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
const STDERR_LOGGER = {
|
|
1277
|
+
warn: (message) => console.warn(`[WARN] ${message}`),
|
|
1278
|
+
info: (message) => console.error(`[INFO] ${message}`),
|
|
1279
|
+
};
|
|
1280
|
+
// Keep the complete CSS request under the broadly supported ~2 KB URL limit.
|
|
1281
|
+
// Using unique source/decoded characters plus deterministic case variants covers
|
|
1282
|
+
// static text, strings authored in scripts, and CSS case transforms while
|
|
1283
|
+
// collapsing repeated prose and base64 assets to a tiny set.
|
|
1284
|
+
const GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH = 1_700;
|
|
1285
|
+
const SMALL_TO_FULL_KANA = new Map([
|
|
1286
|
+
["ぁ", "あ"],
|
|
1287
|
+
["ぃ", "い"],
|
|
1288
|
+
["ぅ", "う"],
|
|
1289
|
+
["ぇ", "え"],
|
|
1290
|
+
["ぉ", "お"],
|
|
1291
|
+
["っ", "つ"],
|
|
1292
|
+
["ゃ", "や"],
|
|
1293
|
+
["ゅ", "ゆ"],
|
|
1294
|
+
["ょ", "よ"],
|
|
1295
|
+
["ゎ", "わ"],
|
|
1296
|
+
["ァ", "ア"],
|
|
1297
|
+
["ィ", "イ"],
|
|
1298
|
+
["ゥ", "ウ"],
|
|
1299
|
+
["ェ", "エ"],
|
|
1300
|
+
["ォ", "オ"],
|
|
1301
|
+
["ッ", "ツ"],
|
|
1302
|
+
["ャ", "ヤ"],
|
|
1303
|
+
["ュ", "ユ"],
|
|
1304
|
+
["ョ", "ヨ"],
|
|
1305
|
+
["ヮ", "ワ"],
|
|
1306
|
+
["ヵ", "カ"],
|
|
1307
|
+
["ヶ", "ケ"],
|
|
1308
|
+
]);
|
|
1309
|
+
function collectLangAttributes(document) {
|
|
1310
|
+
const locales = new Set();
|
|
1311
|
+
for (const element of document.querySelectorAll("[lang]")) {
|
|
1312
|
+
const lang = element.getAttribute("lang");
|
|
1313
|
+
if (!lang)
|
|
1314
|
+
continue;
|
|
1315
|
+
const primary = lang.split("-")[0].toLowerCase();
|
|
1316
|
+
try {
|
|
1317
|
+
Intl.getCanonicalLocales(primary);
|
|
1318
|
+
locales.add(primary);
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
// Invalid BCP-47 tag (e.g. lang="en_US", lang="x") — skip silently.
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return locales;
|
|
1325
|
+
}
|
|
1326
|
+
function addCaseClosure(out, character, locales) {
|
|
1327
|
+
out.add(character);
|
|
1328
|
+
for (const variant of `${character.toUpperCase()}${character.toLowerCase()}`) {
|
|
1329
|
+
out.add(variant);
|
|
1330
|
+
}
|
|
1331
|
+
for (const locale of locales) {
|
|
1332
|
+
for (const variant of `${character.toLocaleUpperCase(locale)}${character.toLocaleLowerCase(locale)}`) {
|
|
1333
|
+
out.add(variant);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
function addFullwidthVariants(chars) {
|
|
1338
|
+
for (const character of [...chars]) {
|
|
1339
|
+
const code = character.codePointAt(0) ?? 0;
|
|
1340
|
+
if (code >= 0x0021 && code <= 0x007e) {
|
|
1341
|
+
chars.add(String.fromCodePoint(code + 0xfee0));
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
function addFullSizeKanaVariants(chars) {
|
|
1346
|
+
for (const character of [...chars]) {
|
|
1347
|
+
const full = SMALL_TO_FULL_KANA.get(character);
|
|
1348
|
+
if (full)
|
|
1349
|
+
chars.add(full);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const FULL_WIDTH_KEYWORD_RE = /\bfull-width\b/;
|
|
1353
|
+
const FULL_SIZE_KANA_KEYWORD_RE = /\bfull-size-kana\b/;
|
|
1354
|
+
const DECLARATION_BOUNDARY_RE = /[;{}]/;
|
|
1355
|
+
function skipWhitespace(s, pos) {
|
|
1356
|
+
while (pos < s.length && " \t\n\r\f\v".includes(s[pos]))
|
|
1357
|
+
pos += 1;
|
|
1358
|
+
return pos;
|
|
1359
|
+
}
|
|
1360
|
+
function findDeclarationEnd(s, pos) {
|
|
1361
|
+
const match = DECLARATION_BOUNDARY_RE.exec(s.slice(pos));
|
|
1362
|
+
return match ? pos + match.index : s.length;
|
|
1363
|
+
}
|
|
1364
|
+
// Linear indexOf/slice scan: a `text-transform\s*:[^;{}]*\bkw\b` regex backtracks
|
|
1365
|
+
// O(n²) on input with many `text-transform:` runs (js/polynomial-redos).
|
|
1366
|
+
function hasTextTransformKeyword(html, keyword) {
|
|
1367
|
+
const haystack = html.toLowerCase();
|
|
1368
|
+
const property = "text-transform";
|
|
1369
|
+
let from = 0;
|
|
1370
|
+
for (;;) {
|
|
1371
|
+
const at = haystack.indexOf(property, from);
|
|
1372
|
+
if (at === -1)
|
|
1373
|
+
return false;
|
|
1374
|
+
const afterProp = skipWhitespace(haystack, at + property.length);
|
|
1375
|
+
if (haystack[afterProp] !== ":") {
|
|
1376
|
+
from = at + property.length;
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
const end = findDeclarationEnd(haystack, afterProp + 1);
|
|
1380
|
+
if (keyword.test(haystack.slice(afterProp + 1, end)))
|
|
1381
|
+
return true;
|
|
1382
|
+
from = end;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
function extractGoogleFontsText(html) {
|
|
1386
|
+
const { document } = parseHTML(html);
|
|
1387
|
+
const decodedBodyText = document.body?.textContent ?? "";
|
|
1388
|
+
const locales = collectLangAttributes(document);
|
|
1389
|
+
// Intentional over-approximation: raw html includes base64, scripts, and
|
|
1390
|
+
// class names, but they collapse in the Set and the budget gate catches bloat.
|
|
1391
|
+
const uniqueCharacters = new Set();
|
|
1392
|
+
for (const character of new Set([...Array.from(html), ...Array.from(decodedBodyText)])) {
|
|
1393
|
+
addCaseClosure(uniqueCharacters, character, locales);
|
|
1394
|
+
}
|
|
1395
|
+
if (hasTextTransformKeyword(html, FULL_WIDTH_KEYWORD_RE))
|
|
1396
|
+
addFullwidthVariants(uniqueCharacters);
|
|
1397
|
+
if (hasTextTransformKeyword(html, FULL_SIZE_KANA_KEYWORD_RE))
|
|
1398
|
+
addFullSizeKanaVariants(uniqueCharacters);
|
|
1399
|
+
const fontText = [...uniqueCharacters].join("");
|
|
1400
|
+
return encodeURIComponent(fontText).length <= GOOGLE_FONTS_TEXT_MAX_ENCODED_LENGTH
|
|
1401
|
+
? fontText
|
|
1402
|
+
: undefined;
|
|
1403
|
+
}
|
|
1404
|
+
function resolveFontFetchRetryPolicy(configured) {
|
|
1405
|
+
return {
|
|
1406
|
+
maxAttempts: Math.max(1, Math.floor(configured?.maxAttempts ?? DEFAULT_FONT_FETCH_RETRY_POLICY.maxAttempts)),
|
|
1407
|
+
attemptTimeoutMs: Math.max(1, configured?.attemptTimeoutMs ?? DEFAULT_FONT_FETCH_RETRY_POLICY.attemptTimeoutMs),
|
|
1408
|
+
maxElapsedMs: Math.max(1, configured?.maxElapsedMs ?? DEFAULT_FONT_FETCH_RETRY_POLICY.maxElapsedMs),
|
|
1409
|
+
baseDelayMs: Math.max(0, configured?.baseDelayMs ?? DEFAULT_FONT_FETCH_RETRY_POLICY.baseDelayMs),
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Last in document order: a stylesheet declared after the deterministic faces would win the
|
|
1414
|
+
* cascade for the same family whenever it had loaded, so early frames could differ between renders.
|
|
1415
|
+
*/
|
|
1416
|
+
function placeAfterEveryStylesheet(document, head, styleEl) {
|
|
1417
|
+
const stylesheets = document.querySelectorAll("style, link[rel~=stylesheet]");
|
|
1418
|
+
const lastStylesheet = stylesheets[stylesheets.length - 1];
|
|
1419
|
+
if (lastStylesheet)
|
|
1420
|
+
lastStylesheet.after(styleEl);
|
|
1421
|
+
else
|
|
1422
|
+
head.appendChild(styleEl);
|
|
1423
|
+
}
|
|
1424
|
+
export async function injectDeterministicFontFaces(html, options = {}) {
|
|
1425
|
+
const failClosedFontFetch = options.failClosedFontFetch === true;
|
|
1426
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1427
|
+
const allowSystemFontCapture = options.allowSystemFontCapture !== false;
|
|
1428
|
+
const retryPolicy = resolveFontFetchRetryPolicy(options.fontFetchRetryPolicy);
|
|
1429
|
+
const fetchOptions = {
|
|
1430
|
+
log: options.logger ?? STDERR_LOGGER,
|
|
1431
|
+
failClosedFontFetch,
|
|
1432
|
+
fetchImpl,
|
|
1433
|
+
allowSystemFontCapture,
|
|
1434
|
+
abortSignal: options.abortSignal,
|
|
1435
|
+
retryPolicy,
|
|
1436
|
+
retryDeadlineMs: Date.now() + retryPolicy.maxElapsedMs,
|
|
1437
|
+
authoredStylesheets: authoredGoogleFontStylesheets(html),
|
|
1438
|
+
};
|
|
1439
|
+
const existingFaces = extractExistingFontFaces(html);
|
|
1440
|
+
const requestedFamilies = extractRequestedFontFamilies(html);
|
|
1441
|
+
const pendingFamilies = new Map();
|
|
1442
|
+
for (const [normalizedFamily, requested] of requestedFamilies) {
|
|
1443
|
+
if (!existingFaces.has(normalizedFamily)) {
|
|
1444
|
+
pendingFamilies.set(normalizedFamily, requested);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
if (pendingFamilies.size === 0) {
|
|
1448
|
+
return html;
|
|
1449
|
+
}
|
|
1450
|
+
let declaredFamilies;
|
|
1451
|
+
const { css, unresolved } = await buildFontFaceCss(pendingFamilies, fetchOptions, () => (declaredFamilies ??= collectDeclaredFontFamilies(html)), extractGoogleFontsText(html));
|
|
1452
|
+
// An optional family that no source serves, or that stayed unavailable, is tolerated.
|
|
1453
|
+
const required = unresolved.filter((family) => !pendingFamilies.get(family.toLowerCase())?.optional);
|
|
1454
|
+
if (required.length > 0 && options.failClosedFontFetch) {
|
|
1455
|
+
throw new FontFetchError(required.join(", "), "", `[Compiler] Unresolved fonts in fail-closed mode: ${required.join(", ")}. ` +
|
|
1456
|
+
`Distributed renders require all fonts to be resolvable.`, undefined, FONT_FETCH_FAILED, unresolved);
|
|
1457
|
+
}
|
|
1458
|
+
if (!css) {
|
|
1459
|
+
if (unresolved.length > 0) {
|
|
1460
|
+
warnUnresolvedFonts(unresolved, fetchOptions.log);
|
|
1461
|
+
}
|
|
1462
|
+
return html;
|
|
1463
|
+
}
|
|
1464
|
+
const { document } = parseHTML(html);
|
|
1465
|
+
const head = document.querySelector("head");
|
|
1466
|
+
if (!head) {
|
|
1467
|
+
return html;
|
|
1468
|
+
}
|
|
1469
|
+
const styleEl = document.createElement("style");
|
|
1470
|
+
styleEl.setAttribute("data-hyperframes-deterministic-fonts", "true");
|
|
1471
|
+
styleEl.textContent = css;
|
|
1472
|
+
placeAfterEveryStylesheet(document, head, styleEl);
|
|
1473
|
+
fetchOptions.log.info(`[Compiler] Injected deterministic @font-face rules for ${pendingFamilies.size - unresolved.length} requested font families`);
|
|
1474
|
+
if (unresolved.length > 0) {
|
|
1475
|
+
warnUnresolvedFonts(unresolved, fetchOptions.log);
|
|
1476
|
+
}
|
|
1477
|
+
return document.toString();
|
|
1478
|
+
}
|
|
1479
|
+
//# sourceMappingURL=deterministicFonts.js.map
|