@hyperframes/core 0.8.127 → 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.
@@ -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