fix(producer): renders use the font named in a var() fallback (#4619)

* fix(producer): renders use the font named in a var() fallback

When a font-family starts with var(--x, A, B) and --x is not defined, the
render now reads A, B the way the browser does. A system font at the head of
that fallback gets Inter placed in front of it, the same rule a direct
system-font stack already follows. The rewrite stays inside the var()
fallback, so a defined --x still wins. A defined property whose value is
itself a var() is followed too. The distributed plan check reads the same
answer.

Fonts named only in such a fallback are best-effort: they are embedded when
they resolve, and one that does not resolve is logged instead of failing a
distributed render. Fonts named directly, or through a defined property,
still fail closed as before.

CSS-wide keywords such as inherit are no longer treated as font names to
fetch.

Compiled the way a distributed render compiles them, 28 registry items now
embed a font they name this way, and none newly fails.

* refactor(producer): fold var() fallback resolution into one branch

* fix(producer): fonts behind a var() chain or empty fallback add no new render failures

A family reached through a chain of defined var()s is optional like a
fallback-only one: fetched fail-closed, skipped only when no source serves
it. An empty var() fallback makes the declaration invalid, so the browser
inherits and the plan check no longer rejects the families after it.

* test(producer): a family named after a leading var() stays required

* fix(producer): a transient error on an optional font warns and renders the fallback

* chore(catalog): record the font requests var() fallbacks now make

* fix(producer): required fonts fetch before optional ones can spend the time budget
This commit is contained in:
Miguel Ángel
2026-09-28 03:30:21 +00:00
committed by GitHub
parent 4094c7be4c
commit 04d56d06aa
10 changed files with 721 additions and 80 deletions
@@ -14,7 +14,8 @@
* The tests inject `fetchImpl` so no real network call happens.
*/
import { beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import { defaultLogger } from "../logger.js";
import {
_clearGoogleFontCssCacheForTests,
FONT_FETCH_FAILED,
@@ -22,6 +23,7 @@ import {
FontFetchError,
FontFetchUnavailableError,
injectDeterministicFontFaces,
normalizeSystemFontPrimaryFamilies,
} from "./deterministicFonts.js";
// The Google Fonts CSS cache is process-lifetime and keyed by URL; several
@@ -343,3 +345,301 @@ describe("FontFetchError", () => {
expect(err).toBeInstanceOf(Error);
});
});
describe("fail-closed fonts named only in an undefined var() fallback", () => {
const styled = (css: string) =>
`<!doctype html><html><head><style>${css}</style></head><body><p>hello</p></body></html>`;
it("injects what resolves from a Tailwind v4 stack and skips the rest", async () => {
const html = normalizeSystemFontPrimaryFamilies(
styled(
`body { font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); }`,
),
);
const result = await injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: makeHttp400Fetch(),
});
expect(result).toContain(`font-family: "Inter";`);
});
it("still throws for an unresolved family in a direct list", async () => {
const caught = await rejectedError(
injectDeterministicFontFaces(styled(`body { font-family: "Acme Brand Sans", sans-serif; }`), {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: makeHttp400Fetch(),
}),
);
expect(caught).toBeInstanceOf(FontFetchError);
expect((caught as FontFetchError).familyName).toContain("Acme Brand Sans");
});
it("tries a fallback-only family and tolerates it not being found", async () => {
const html = styled(`body { font-family: var(--brand, "Acme Brand Sans", sans-serif); }`);
let requests = 0;
const notFound = makeHttp400Fetch();
const fetchImpl = (async (...args: Parameters<typeof fetch>) => {
requests += 1;
return notFound(...args);
}) as unknown as typeof fetch;
const result = await injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
});
expect(result).toBe(html);
expect(requests).toBeGreaterThan(0);
});
it.each([
[`:root { --a: var(--b); --b: "Acme Brand Sans"; } body { font-family: var(--a), serif; }`],
[`:root { --b: "Acme Brand Sans"; } body { font-family: var(--unset, var(--b)), serif; }`],
])("tolerates a family behind a chain of var()s not being found: %s", async (css) => {
const html = styled(css);
const result = await injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: makeHttp400Fetch(),
});
expect(result).toBe(html);
});
it("keeps a family named after a leading var() required", async () => {
const caught = await rejectedError(
injectDeterministicFontFaces(
styled(`body { font-family: var(--unset, Inter), "Acme Brand Sans", serif; }`),
{ failClosedFontFetch: true, allowSystemFontCapture: false, fetchImpl: makeHttp400Fetch() },
),
);
expect((caught as FontFetchError).familyName).toContain("Acme Brand Sans");
});
it("keeps a family required behind one defined var()", async () => {
const caught = await rejectedError(
injectDeterministicFontFaces(
styled(`:root { --a: "Acme Brand Sans"; } body { font-family: var(--a), serif; }`),
{ failClosedFontFetch: true, allowSystemFontCapture: false, fetchImpl: makeHttp400Fetch() },
),
);
expect(caught).toBeInstanceOf(FontFetchError);
});
describe("a transient failure on a fallback-only family", () => {
const FALLBACK_ONLY = styled(
`body { font-family: var(--brand, "Acme Brand Sans", sans-serif); }`,
);
let warnings: string[] = [];
let warnSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
warnings = [];
warnSpy = spyOn(defaultLogger, "warn").mockImplementation((message: string) => {
warnings.push(message);
});
});
afterEach(() => warnSpy.mockRestore());
const counting = (inner: typeof fetch) => {
const counter = { requests: 0 };
const fetchImpl = (async (...args: Parameters<typeof fetch>) => {
counter.requests += 1;
return inner(...args);
}) as unknown as typeof fetch;
return { counter, fetchImpl };
};
const hangingFetch = (async (_input: unknown, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
})) as unknown as typeof fetch;
const optionalWarnings = () => warnings.filter((w) => w.includes("Optional font"));
it.each([
["HTTP 503", makeHttp503Fetch],
["a network failure", makeFailingFetch],
])("retries once on %s, then renders the fallback and warns", async (_label, makeFetch) => {
const { counter, fetchImpl } = counting(makeFetch());
const result = await injectDeterministicFontFaces(FALLBACK_ONLY, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
fontFetchRetryPolicy: { baseDelayMs: 0, maxAttempts: 3 },
});
expect(result).toBe(FALLBACK_ONLY);
expect(counter.requests).toBe(2);
expect(optionalWarnings()).toHaveLength(1);
expect(optionalWarnings()[0]).toContain(`"Acme Brand Sans"`);
});
it("fetches a required family before hanging optional ones spend the budget", async () => {
const requested: string[] = [];
const notFound = makeHttp400Fetch();
const fetchImpl = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
requested.push(String(input));
return String(input).includes("Required")
? notFound(input, init)
: hangingFetch(input, init);
}) as unknown as typeof fetch;
const caught = await rejectedError(
injectDeterministicFontFaces(
styled(
`.a { font-family: var(--a, "Optional One"), serif; } .b { font-family: var(--b, "Optional Two"), serif; } .c { font-family: "Required Face", serif; }`,
),
{
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
fontFetchRetryPolicy: { baseDelayMs: 0, attemptTimeoutMs: 100, maxElapsedMs: 300 },
},
),
);
expect((caught as FontFetchError).code).toBe(FONT_FETCH_FAILED);
expect(requested.some((url) => url.includes("Required"))).toBe(true);
});
it("keeps the bundled Inter faces and warns when its top-up fetch fails", async () => {
const html = styled(`body { font-family: var(--brand, Inter, sans-serif); }`);
const result = await injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: makeHttp503Fetch(),
fontFetchRetryPolicy: { baseDelayMs: 0 },
});
expect(result).toContain(`font-family: "Inter";`);
expect(optionalWarnings()).toHaveLength(1);
expect(optionalWarnings()[0]).toContain(`"Inter"`);
});
it("gives up on a hanging fetch within the per-attempt bound and warns", async () => {
const { counter, fetchImpl } = counting(hangingFetch);
const started = Date.now();
const result = await injectDeterministicFontFaces(FALLBACK_ONLY, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
fontFetchRetryPolicy: { baseDelayMs: 0, attemptTimeoutMs: 50, maxAttempts: 3 },
});
expect(result).toBe(FALLBACK_ONLY);
expect(counter.requests).toBe(2);
expect(Date.now() - started).toBeLessThan(1_000);
expect(optionalWarnings()[0]).toContain(`"Acme Brand Sans"`);
});
it("injects the face when the retry succeeds, without a warning", async () => {
const served = makeGoogleFontFetch([]);
const unavailable = makeHttp503Fetch();
let calls = 0;
const fetchImpl = (async (...args: Parameters<typeof fetch>) =>
(calls += 1) === 1 ? unavailable(...args) : served(...args)) as unknown as typeof fetch;
const result = await injectDeterministicFontFaces(FALLBACK_ONLY, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
fontFetchRetryPolicy: { baseDelayMs: 0 },
});
expect(result).toContain("data-hyperframes-deterministic-fonts");
expect(result).toContain(`font-family: "Acme Brand Sans"`);
expect(optionalWarnings()).toHaveLength(0);
});
it("still aborts when the caller cancels", async () => {
const controller = new AbortController();
const reason = new Error("caller cancelled");
setTimeout(() => controller.abort(reason), 20);
const caught = await rejectedError(
injectDeterministicFontFaces(FALLBACK_ONLY, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: hangingFetch,
abortSignal: controller.signal,
fontFetchRetryPolicy: { baseDelayMs: 0, attemptTimeoutMs: 5_000 },
}),
);
expect(caught).toBe(reason);
expect(optionalWarnings()).toHaveLength(0);
});
it("keeps a required family fail-closed after all its attempts", async () => {
const { counter, fetchImpl } = counting(makeHttp503Fetch());
const caught = await rejectedError(
injectDeterministicFontFaces(
styled(`body { font-family: "Acme Brand Sans", sans-serif; }`),
{
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
fontFetchRetryPolicy: { baseDelayMs: 0, maxAttempts: 3 },
},
),
);
expect(caught).toBeInstanceOf(FontFetchUnavailableError);
expect(counter.requests).toBe(3);
expect(optionalWarnings()).toHaveLength(0);
});
it("does not hand a required family the optional retry count for the same request", async () => {
const { counter, fetchImpl } = counting(makeHttp503Fetch());
const fontFetchRetryPolicy = { baseDelayMs: 0, maxAttempts: 3 };
const [optionalResult, requiredError] = await Promise.all([
injectDeterministicFontFaces(FALLBACK_ONLY, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
fontFetchRetryPolicy,
}),
rejectedError(
injectDeterministicFontFaces(
styled(
`body { font-family: var(--brand, "Acme Brand Sans", sans-serif); } p { font-family: "Acme Brand Sans"; }`,
),
{
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl,
fontFetchRetryPolicy,
},
),
),
]);
expect(optionalResult).toBe(FALLBACK_ONLY);
expect(requiredError).toBeInstanceOf(FontFetchUnavailableError);
expect(counter.requests).toBe(5);
});
it("keeps a family fail-closed when it is also named directly", async () => {
const caught = await rejectedError(
injectDeterministicFontFaces(
styled(
`.a { font-family: var(--brand, "Acme Brand Sans", sans-serif); } .b { font-family: "Acme Brand Sans", serif; }`,
),
{
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: makeHttp503Fetch(),
fontFetchRetryPolicy: { baseDelayMs: 0 },
},
),
);
expect(caught).toBeInstanceOf(FontFetchUnavailableError);
});
});
it.each([
[
`.a { font-family: var(--brand, "Acme Brand Sans", sans-serif); } .b { font-family: "Acme Brand Sans", serif; }`,
],
[
`.b { font-family: "Acme Brand Sans", serif; } .a { font-family: var(--brand, "Acme Brand Sans", sans-serif); }`,
],
])("keeps a family required when it is also named directly: %s", async (css) => {
const caught = await rejectedError(
injectDeterministicFontFaces(styled(css), {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: makeHttp400Fetch(),
}),
);
expect(caught).toBeInstanceOf(FontFetchError);
expect((caught as FontFetchError).familyName).toContain("Acme Brand Sans");
});
});
@@ -7,6 +7,8 @@ import {
FONT_ALIAS_KEYS,
_clearGoogleFontCssCacheForTests,
injectDeterministicFontFaces,
normalizeSystemFontPrimaryFamilies,
resolveFontFamilyDeclarationFamilies,
} from "./deterministicFonts.js";
describe("existing font-face recognition", () => {
@@ -202,3 +204,117 @@ describe("FONT_ALIASES cross-platform coverage", () => {
expect(FONT_ALIAS_KEYS.size).toBe(Object.keys(FONT_ALIASES).length);
});
});
const styled = (css: string) => `<html><head><style>${css}</style></head><body>Hello</body></html>`;
describe("var() font-family fallbacks", () => {
const noProperties = new Map<string, string>();
it("injects the fallback family of an undefined custom property", async () => {
const html = styled(`body { font-family: var(--font-body, Inter, system-ui, sans-serif); }`);
const result = await injectDeterministicFontFaces(html, { allowSystemFontCapture: false });
expect(result).toContain(`font-family: "Inter";`);
});
it("uses the fallback list when the custom property is undefined", () => {
expect(
resolveFontFamilyDeclarationFamilies(
"var(--font-body, Inter, system-ui, sans-serif), serif",
noProperties,
),
).toEqual(["Inter", "system-ui", "sans-serif", "serif"]);
});
it("prefers a defined custom property over the fallback", () => {
expect(
resolveFontFamilyDeclarationFamilies(
"var(--font-body, Inter), serif",
new Map([["--font-body", '"Montserrat", sans-serif']]),
),
).toEqual(["Montserrat", "sans-serif", "serif"]);
});
it("resolves a nested var() fallback", () => {
expect(resolveFontFamilyDeclarationFamilies("var(--a, var(--b, Inter))", noProperties)).toEqual(
["Inter"],
);
expect(
resolveFontFamilyDeclarationFamilies(
"var(--a, var(--b, Inter))",
new Map([["--b", "Oswald"]]),
),
).toEqual(["Oswald"]);
});
it("follows a defined property whose value is itself a var()", () => {
expect(
resolveFontFamilyDeclarationFamilies(
"var(--a), serif",
new Map([
["--a", "var(--b, system-ui)"],
["--b", "Oswald"],
]),
),
).toEqual(["Oswald", "serif"]);
expect(
resolveFontFamilyDeclarationFamilies(
"var(--a)",
new Map([["--a", "var(--undefined, Inter, system-ui)"]]),
),
).toEqual(["Inter", "system-ui"]);
});
it("stops on a custom property cycle", () => {
const cycle = new Map([
["--a", "var(--b)"],
["--b", "var(--a)"],
]);
expect(resolveFontFamilyDeclarationFamilies("var(--a), serif", cycle)).toEqual([
expect.stringMatching(/^var\(/),
"serif",
]);
});
it.each(["inherit", "var(--font-body, inherit)"])(
"treats %s as a keyword, not a font to fetch",
async (value) => {
const html = styled(`body { font-family: ${value}; }`);
const fetchImpl = Object.assign(async () => new Response("", { status: 404 }), {
preconnect: fetch.preconnect,
});
expect(
await injectDeterministicFontFaces(html, {
fetchImpl,
allowSystemFontCapture: false,
failClosedFontFetch: true,
}),
).toBe(html);
},
);
it("requests no family for an undefined var() without a fallback", async () => {
const html = styled(`body { font-family: var(--font-body); }`);
expect(await injectDeterministicFontFaces(html, { allowSystemFontCapture: false })).toBe(html);
});
});
describe("system-font primaries behind var()", () => {
it.each([
[
`var(--font-display, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif)`,
`var(--font-display, Inter, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif)`,
],
[`var(--a, var(--b, system-ui)), serif`, `var(--a, var(--b, Inter, system-ui)), serif`],
])("puts Inter first in the fallback of undefined %s", (value, expected) => {
expect(normalizeSystemFontPrimaryFamilies(styled(`body { font-family: ${value}; }`))).toBe(
styled(`body { font-family: ${expected}; }`),
);
});
it("leaves a var() alone when its property is defined", () => {
const html = styled(
`:root { --font-display: "Montserrat"; } body { font-family: var(--font-display, -apple-system, sans-serif); }`,
);
expect(normalizeSystemFontPrimaryFamilies(html)).toBe(html);
});
});
@@ -49,6 +49,15 @@ export const GENERIC_FAMILIES: ReadonlySet<string> = new Set([
"blinkmacsystemfont",
]);
// Keywords valid as a whole font-family value; they name no font.
const CSS_WIDE_KEYWORDS: ReadonlySet<string> = new Set([
"inherit",
"initial",
"unset",
"revert",
"revert-layer",
]);
/**
* Parse a single `font-family` value (e.g. `"Inter", -apple-system,
* sans-serif`) into a list of unquoted family names in declaration order.
@@ -97,7 +106,22 @@ export function parseFontFamilyValue(value: string): string[] {
.filter((piece) => piece.length > 0);
}
function systemPrimaryReplacement(value: string, deterministicPrimary: string): string | null {
function systemPrimaryReplacement(
value: string,
deterministicPrimary: string,
customProperties: ReadonlyMap<string, string>,
): string | null {
const variable = primaryCssVariable(value);
if (variable) {
if (customProperties.get(variable.name) || variable.fallback === null) return null;
// Rewrite the fallback in place so a property defined at runtime still wins.
const fallback = systemPrimaryReplacement(
variable.fallback,
deterministicPrimary,
customProperties,
);
return fallback ? `var(${variable.name}, ${fallback})${variable.rest}` : null;
}
const families = parseFontFamilyValue(value);
if (families.length === 0) return null;
if (!GENERIC_FAMILIES.has(normalizeFamilyName(families[0]!))) return null;
@@ -117,11 +141,13 @@ function isFontFaceDeclaration(decl: Declaration): boolean {
return parent?.type === "atrule" && (parent as AtRule).name.toLowerCase() === "font-face";
}
function normalizeCssDeclarations(root: postcss.Root, deterministicPrimary: string): boolean {
type PrimaryReplacer = (value: string) => string | null;
function normalizeCssDeclarations(root: postcss.Root, replacePrimary: PrimaryReplacer): boolean {
let changed = false;
root.walkDecls((decl) => {
if (decl.prop.startsWith("--")) {
const replacement = systemPrimaryReplacement(decl.value, deterministicPrimary);
const replacement = replacePrimary(decl.value);
if (!replacement) return;
decl.value = replacement;
changed = true;
@@ -132,7 +158,7 @@ function normalizeCssDeclarations(root: postcss.Root, deterministicPrimary: stri
if (isFontFaceDeclaration(decl)) {
return;
}
const replacement = systemPrimaryReplacement(decl.value, deterministicPrimary);
const replacement = replacePrimary(decl.value);
if (!replacement) return;
decl.value = replacement;
changed = true;
@@ -141,20 +167,20 @@ function normalizeCssDeclarations(root: postcss.Root, deterministicPrimary: stri
return changed;
}
function normalizeCssFontFamilyDeclarations(css: string, deterministicPrimary: string): string {
function normalizeCssFontFamilyDeclarations(css: string, replacePrimary: PrimaryReplacer): string {
const root = parseCssRoot(css);
if (!root) return css;
const changed = normalizeCssDeclarations(root, deterministicPrimary);
const changed = normalizeCssDeclarations(root, replacePrimary);
return changed ? root.toString() : css;
}
function normalizeInlineStyleAttribute(style: string, deterministicPrimary: string): string {
function normalizeInlineStyleAttribute(style: string, replacePrimary: PrimaryReplacer): string {
const root = parseCssRoot(`*{${style}}`);
if (!root) return style;
const rule = root.first;
if (rule?.type !== "rule") return style;
const before = rule.toString();
normalizeCssDeclarations(root, deterministicPrimary);
normalizeCssDeclarations(root, replacePrimary);
if (rule.toString() === before) return style;
const serialized = ((rule as Rule).nodes ?? []).map((node) => node.toString()).join("; ");
return serialized.endsWith(";") ? serialized : `${serialized};`;
@@ -172,11 +198,14 @@ export function normalizeSystemFontPrimaryFamilies(
deterministicPrimary = "Inter",
): string {
const { document } = parseHTML(html);
const customProperties = collectFontFamilyCustomProperties(html);
const replacePrimary: PrimaryReplacer = (value) =>
systemPrimaryReplacement(value, deterministicPrimary, customProperties);
let changed = false;
for (const styleEl of Array.from(document.querySelectorAll("style"))) {
const current = styleEl.textContent ?? "";
const next = normalizeCssFontFamilyDeclarations(current, deterministicPrimary);
const next = normalizeCssFontFamilyDeclarations(current, replacePrimary);
if (next === current) continue;
styleEl.textContent = next;
changed = true;
@@ -184,7 +213,7 @@ export function normalizeSystemFontPrimaryFamilies(
for (const el of Array.from(document.querySelectorAll("[style]"))) {
const current = el.getAttribute("style") ?? "";
const next = normalizeInlineStyleAttribute(current, deterministicPrimary);
const next = normalizeInlineStyleAttribute(current, replacePrimary);
if (next === current) continue;
el.setAttribute("style", next);
changed = true;
@@ -192,7 +221,7 @@ export function normalizeSystemFontPrimaryFamilies(
for (const el of Array.from(document.querySelectorAll("[data-font-family]"))) {
const current = el.getAttribute("data-font-family") ?? "";
const next = systemPrimaryReplacement(current, deterministicPrimary);
const next = replacePrimary(current);
if (!next) continue;
el.setAttribute("data-font-family", next);
changed = true;
@@ -270,7 +299,9 @@ export function collectFontFamilyCustomProperties(html: string): Map<string, str
return customProperties;
}
function primaryCssVariableName(value: string): string | null {
function primaryCssVariable(
value: string,
): { name: string; fallback: string | null; rest: string } | null {
const trimmed = value.trim();
if (!trimmed.toLowerCase().startsWith("var(")) return null;
@@ -285,27 +316,60 @@ function primaryCssVariableName(value: string): string | null {
depth -= 1;
if (depth !== 0) continue;
const varExpression = trimmed.slice(0, index + 1);
const inner = varExpression.slice(4, -1).trim();
const inner = trimmed.slice(4, index).trim();
const commaIndex = inner.indexOf(",");
const variableName = (commaIndex === -1 ? inner : inner.slice(0, commaIndex)).trim();
return /^--[A-Za-z0-9_-]+$/.test(variableName) ? variableName : null;
const name = (commaIndex === -1 ? inner : inner.slice(0, commaIndex)).trim();
if (!/^--[A-Za-z0-9_-]+$/.test(name)) return null;
return {
name,
fallback: commaIndex === -1 ? null : inner.slice(commaIndex + 1),
rest: trimmed.slice(index + 1),
};
}
return null;
}
/**
* `optional`: reached only through a var() fallback or a chain of var()s; not-found is skipped
* and a transient failure warns instead of throwing.
*/
type ResolvedFamily = { family: string; optional: boolean };
// Deep enough for real alias chains; stops `--a: var(--b); --b: var(--a)` cycles.
const MAX_CSS_VARIABLE_DEPTH = 8;
function resolveDeclaredFamilies(
declaration: string,
customProperties: ReadonlyMap<string, string>,
optional = false,
depth = 0,
): ResolvedFamily[] {
const tag = (families: string[]) => families.map((family) => ({ family, optional }));
const families = parseFontFamilyValue(declaration);
const variable = depth < MAX_CSS_VARIABLE_DEPTH ? primaryCssVariable(declaration) : null;
if (!variable) return tag(families);
const resolved = customProperties.get(variable.name);
// An undefined property uses the var() fallback, as the browser does.
const source = resolved || variable.fallback;
if (source === null) return tag(families);
const primary = resolveDeclaredFamilies(
source,
customProperties,
!resolved || depth > 0,
depth + 1,
);
// An empty substitution makes the declaration invalid, so the browser inherits instead.
if (primary.length === 0) return [];
return [...primary, ...tag(families.slice(1))];
}
export function resolveFontFamilyDeclarationFamilies(
declaration: string,
customProperties: ReadonlyMap<string, string>,
): string[] {
const families = parseFontFamilyValue(declaration);
const variableName = primaryCssVariableName(declaration);
if (!variableName) return families;
const resolved = customProperties.get(variableName);
if (!resolved) return families;
return [...parseFontFamilyValue(resolved), ...families.slice(1)];
return resolveDeclaredFamilies(declaration, customProperties).map(({ family }) => family);
}
/**
@@ -463,18 +527,23 @@ function extractExistingFontFaces(html: string): Set<string> {
return families;
}
function extractRequestedFontFamilies(html: string): Map<string, string> {
const requested = new Map<string, string>();
function isFetchableFamilyName(normalized: string): boolean {
if (!normalized || normalized.startsWith("var(")) return false;
return !GENERIC_FAMILIES.has(normalized) && !CSS_WIDE_KEYWORDS.has(normalized);
}
function extractRequestedFontFamilies(html: string): Map<string, ResolvedFamily> {
const requested = new Map<string, ResolvedFamily>();
const customProperties = collectFontFamilyCustomProperties(html);
for (const { declaration } of iterateFontFamilyDeclarations(html)) {
for (const originalCase of resolveFontFamilyDeclarationFamilies(
declaration,
customProperties,
)) {
const normalized = originalCase.toLowerCase();
if (!normalized || GENERIC_FAMILIES.has(normalized)) continue;
if (normalized.startsWith("var(")) continue;
if (!requested.has(normalized)) requested.set(normalized, originalCase);
for (const { family, optional } of resolveDeclaredFamilies(declaration, customProperties)) {
const normalized = family.toLowerCase();
if (!isFetchableFamilyName(normalized)) continue;
const seen = requested.get(normalized);
requested.set(normalized, {
family: seen?.family ?? family,
optional: (seen?.optional ?? true) && optional,
});
}
}
return requested;
@@ -599,7 +668,7 @@ function partitionWeightRuns(
}
async function buildFontFaceCss(
requestedFamilies: Map<string, string>,
requestedFamilies: Map<string, ResolvedFamily>,
options: InternalFontFetchOptions,
fontText?: string,
): Promise<{
@@ -609,7 +678,11 @@ async function buildFontFaceCss(
const rules: string[] = [];
const unresolved: string[] = [];
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
// Required families fetch first, so optional ones cannot spend the shared fetch budget before them.
const requiredFirst = [...requestedFamilies].sort(
([, a], [, b]) => Number(a.optional) - Number(b.optional),
);
for (const [normalizedFamily, { family: originalCaseFamily, optional }] of requiredFirst) {
// Path 1: pre-bundled fonts via FONT_ALIASES — emit embedded faces,
// then fetch from Google Fonts to fill missing weights and character subsets.
const canonicalKey = FONT_ALIASES[normalizedFamily];
@@ -645,7 +718,7 @@ async function buildFontFaceCss(
// `originalCaseFamily` so the authored CSS keeps matching.
const canonicalFamily = resolveAliasDisplayName(normalizedFamily);
const googleFaces = canonicalFamily
? await fetchGoogleFont(canonicalFamily, options, fontText)
? await fetchFamilyFaces(canonicalFamily, optional, options, fontText)
: [];
// Bundled weights only cover Latin. Keep other subsets (including
@@ -685,7 +758,7 @@ async function buildFontFaceCss(
}
// Path 2: fetch from Google Fonts (with local cache)
const googleFaces = await fetchGoogleFont(originalCaseFamily, options, fontText);
const googleFaces = await fetchFamilyFaces(originalCaseFamily, optional, options, fontText);
if (googleFaces.length > 0) {
for (const face of googleFaces) {
rules.push(
@@ -1148,8 +1221,9 @@ function fetchGoogleFontCss(
familyName: string,
options: InternalFontFetchOptions,
): Promise<{ ok: true; body: string } | { ok: false }> {
// Fail-closed callers retry and throw where lenient ones don't, so they never share an entry.
const key = `${options.failClosedFontFetch ? "closed" : "open"}:${url}`;
// Retries decide the outcome, so only callers with the same mode and attempt count share an entry.
const mode = options.failClosedFontFetch ? `closed${options.retryPolicy.maxAttempts}` : "open";
const key = `${mode}:${url}`;
let shared = googleFontCssCache.get(key);
if (!shared) {
// The shared fetch must not carry any one caller's abortSignal, or that
@@ -1306,6 +1380,30 @@ async function fetchGoogleFontStylesheet(
return faces;
}
/** An optional family gets one retry on a transient failure, then renders its fallback with a warning. */
async function fetchFamilyFaces(
familyName: string,
optional: boolean,
options: InternalFontFetchOptions,
fontText?: string,
): Promise<GoogleFontFace[]> {
if (!optional) return fetchGoogleFont(familyName, options, fontText);
const maxAttempts = Math.min(options.retryPolicy.maxAttempts, 2);
try {
return await fetchGoogleFont(
familyName,
{ ...options, retryPolicy: { ...options.retryPolicy, maxAttempts } },
fontText,
);
} catch (err) {
if (!(err instanceof FontFetchUnavailableError)) throw err;
defaultLogger.warn(
`[Compiler] Optional font "${familyName}" is unavailable, rendering its fallback: ${err.message}`,
);
return [];
}
}
// ---------------------------------------------------------------------------
/**
@@ -1313,7 +1411,7 @@ async function fetchGoogleFontStylesheet(
*/
export interface InjectDeterministicFontFacesOptions {
/**
* When `true`, exhausted transient fetch failures throw
* When `true`, exhausted transient fetch failures of a required family throw
* {@link FontFetchUnavailableError} with code `FONT_FETCH_UNAVAILABLE`;
* deterministic resolution failures retain `FONT_FETCH_FAILED`.
*
@@ -1535,11 +1633,11 @@ export async function injectDeterministicFontFaces(
const existingFaces = extractExistingFontFaces(html);
const requestedFamilies = extractRequestedFontFamilies(html);
const pendingFamilies = new Map<string, string>();
const pendingFamilies = new Map<string, ResolvedFamily>();
for (const [normalizedFamily, originalCaseFamily] of requestedFamilies) {
for (const [normalizedFamily, requested] of requestedFamilies) {
if (!existingFaces.has(normalizedFamily)) {
pendingFamilies.set(normalizedFamily, originalCaseFamily);
pendingFamilies.set(normalizedFamily, requested);
}
}
@@ -1552,11 +1650,15 @@ export async function injectDeterministicFontFaces(
fetchOptions,
extractGoogleFontsText(html),
);
if (unresolved.length > 0 && options.failClosedFontFetch) {
// An optional family that no source serves, or that stayed unavailable, is tolerated.
const required = unresolved.filter(
(family) => !pendingFamilies.get(family.toLowerCase())?.optional,
);
if (required.length > 0 && options.failClosedFontFetch) {
throw new FontFetchError(
unresolved.join(", "),
required.join(", "),
"",
`[Compiler] Unresolved fonts in fail-closed mode: ${unresolved.join(", ")}. ` +
`[Compiler] Unresolved fonts in fail-closed mode: ${required.join(", ")}. ` +
`Distributed renders require all fonts to be resolvable.`,
);
}
@@ -1089,6 +1089,40 @@ describe("system-primary font normalization", () => {
expect(rootStyle).toContain("--inline-system-font: Inter, system-ui, sans-serif");
expect(rootStyle).toContain("font-family: Inter, sans-serif");
});
it("promotes Inter inside the fallback of an undefined var() before plan validation", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-var-system-font-"));
writeFileSync(
join(projectDir, "index.html"),
`<!doctype html>
<html>
<head>
<style>
body {
font-family: var(
--font-display,
-apple-system,
BlinkMacSystemFont,
"Helvetica Neue",
Arial,
sans-serif
);
}
</style>
</head>
<body>
<div data-composition-id="root" data-width="640" data-height="360" data-duration="1">Hello</div>
</body>
</html>`,
);
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
expect(() => validateNoSystemFonts(compiled.html)).not.toThrow();
expect(compiled.html.replace(/\s+/g, "")).toContain(
'font-family:var(--font-display,Inter,-apple-system,BlinkMacSystemFont,"HelveticaNeue",Arial,sans-serif)',
);
});
});
describe("local font embedding", () => {
@@ -310,6 +310,18 @@ describe("validateNoSystemFonts", () => {
expect((caught as Error).message).toContain(`"-apple-system"`);
});
it("checks the fallback of an undefined CSS var() primary", () => {
const offending = `<style>body { font-family: var(--ui-font, -apple-system, sans-serif); }</style>`;
expect(() => validateNoSystemFonts(offending)).toThrow(`"-apple-system"`);
const ok = `<style>body { font-family: var(--ui-font, "Inter", sans-serif); }</style>`;
expect(() => validateNoSystemFonts(ok)).not.toThrow();
});
it.each(["serif", "system-ui"])("accepts var(--x,), %s, which the browser inherits", (rest) => {
const html = `<style>body { font-family: var(--x,), ${rest}; }</style>`;
expect(() => validateNoSystemFonts(html)).not.toThrow();
});
it("accepts CSS var() primary aliases that resolve to deterministic fonts", () => {
const ok = `<style>
:root { --ui-font: "Inter", -apple-system, sans-serif; }
@@ -126,8 +126,8 @@ export function validateNoSystemFonts(compiledHtml: string): void {
const families = resolveFontFamilyDeclarationFamilies(declaration, customProperties);
if (families.length === 0) continue;
const primaryRaw = families[0]!;
// Unresolved var() primaries are left to the browser; resolved custom
// properties are checked above so common `--font: system-ui` aliases fail.
// A var() primary is checked as its resolved value or, when undefined, its
// fallback, so `--font: system-ui` and `var(--font, system-ui)` both fail.
if (!GENERIC_FAMILIES.has(primaryRaw.toLowerCase())) continue;
throw new PlanValidationError(
SYSTEM_FONT_USED,
@@ -0,0 +1,77 @@
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z0slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4tk10Sw&skey=e5dfd0c3910c7ec9&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z0slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4tk10Sw&skey=e5dfd0c3910c7ec9&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+a, U+20-23, U+25-7d, U+2014;
}
+31 -31
View File
@@ -4,16 +4,16 @@
"contentType": "application/javascript; charset=utf-8",
"file": "c174bfce53a729418d57a8ad.bin"
},
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23%5B%5D()6zZqQ'75%25_%7C%2B%5C%3F%5E%26%E2%80%94%60": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "21363c70fe0f5ada0387fc10.bin"
},
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23%5B%5DzZ(5)6%257%3F_%7C%2B%5C'%5E%26qQ%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "41acfa6f109d3a5946f742d9.bin"
},
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%2B%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%5C%3F'%5E%26qQ%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "d46936f54ebde49ebb44d3a9.bin"
},
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%2B%5C%3F'%5E%26qQ%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
@@ -29,11 +29,6 @@
"contentType": "text/css; charset=utf-8",
"file": "73f0f6259c8c64e613d401f4.bin"
},
"https://fonts.googleapis.com/css2?family=JetBrains%20Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%2B%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%5C%3F'%5E%26qQ%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "c01c545a99d9d5e629e4e793.bin"
},
"https://fonts.googleapis.com/css2?family=JetBrains%20Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%2B%5C%3F'%5E%26qQ%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
@@ -49,11 +44,6 @@
"contentType": "text/css; charset=utf-8",
"file": "4319419b25fdd00a2b54650a.bin"
},
"https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%2B%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%5C%3F'%5E%26qQ%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "30d4b72c02fd3924be31f70d.bin"
},
"https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%2B%5C%3F'%5E%26qQ%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
@@ -81,23 +71,13 @@
},
"https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVTSescuDWy1jyf3vHcK8kEZ40pF3vP1c8fSehoADPa66Ndc0EPCSTXV5jdaqElUTCu7zs2_4YkMfaJpMnuEXM-XlC3ezPKhu4pxCYeJrnQUpTmNYlvFTSaZ50Q-rwkpCHw&skey=a0a0114a1dcab3ac&v=v51": {
"status": 200,
"contentType": "font/woff2",
"contentType": "text/html; charset=utf-8",
"file": "788a403350b85d98aec587a3.bin"
},
"https://fonts.gstatic.com/l/font?kit=tDbp2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO-LfjGbsVNLG7DGdF6OZ1Pcwgknk-6tfcOETEf8ZjwZ1qeKZyczFtihI9cRfe3jgtqPvZ4GM8lUiwZSvOUprKTX7OrGHXbmC0dn1P1cMJ_TofiX3A1d0GPVKlYpbZwlpRwMOH5ilHuu3bpJuIwk3XNMxaDDqyh-HEXqRJEO_FrwSUc&skey=74ed26de24979d51&v=v24": {
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z0slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4tk10Sw&skey=e5dfd0c3910c7ec9&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "728492d30bf60f21ae04abd1.bin"
},
"https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC-7jDdIsxmG1S4kI2wqibvTm6_HSTI8hUSo9elLPWZU1qfAlh9usvmC0NXcrzdX6ttGYmH5rbj0fJiV60wlxpOoMENiID7wMbqOVLCL3tb6MF4FLv3jdS78Cl59EfJNtbKnRvDeJbp1veZfg&skey=48ad01c60053c2ae&v=v24": {
"status": 200,
"contentType": "font/woff2",
"file": "c356c27cca02fa3bfd0c2bef.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z_slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4tk10S_6TqEzHcXFbzA&skey=e5dfd0c3910c7ec9&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "ae40a5f8f9f604f443e21213.bin"
"file": "f664c5b9216de5d0d4ece5a0.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z1slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXRK6VTJsLDadXye5CxHf1GTkcRElUQpEyoQb0y5KS9g&skey=e5dfd0c3910c7ec9&v=v20": {
"status": 200,
@@ -106,9 +86,19 @@
},
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z3slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXRK6VTJsLDadXye5CxHf1GTkcRElUQpEyoQb0y5CRgWX4g&skey=e5dfd0c3910c7ec9&v=v20": {
"status": 200,
"contentType": "text/html; charset=utf-8",
"contentType": "font/woff2",
"file": "2427868cae6e8cf32d2e8d8b.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z_slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4tk10S_6TqEzHcXFbzA&skey=e5dfd0c3910c7ec9&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "ae40a5f8f9f604f443e21213.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD9d41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzsWX6bxniqpYhQ&skey=c491285d6722e4fa&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "42e5ad0b3cee71d5f01953d8.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_941mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNHJHZM71bLvr7Yd3qY5i5ZYVORl8JGl0onESgWadJEXw&skey=c491285d6722e4fa&v=v20": {
"status": 200,
"contentType": "text/html; charset=utf-8",
@@ -119,9 +109,19 @@
"contentType": "font/woff2",
"file": "e59d60b150022666efbc6da6.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD9d41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzsWX6bxniqpYhQ&skey=c491285d6722e4fa&v=v20": {
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "42e5ad0b3cee71d5f01953d8.bin"
"file": "47d5160342b9998840916131.bin"
},
"https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC-7jDdIsxmG1S4kI2wqibvTm6_HSTI8hUSo9elLPWZU1qfAlh9usvmC0NXcrzdX6ttGYmH5rbj0fJiV60wlxpOoMENiID7wMbqOVLCL3tb6MF4FLv3jdS78Cl59EfJNtbKnRvDeJbp1veZfg&skey=48ad01c60053c2ae&v=v24": {
"status": 200,
"contentType": "font/woff2",
"file": "c356c27cca02fa3bfd0c2bef.bin"
},
"https://fonts.gstatic.com/l/font?kit=tDbp2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO-LfjGbsVNLG7DGdF6OZ1Pcwgknk-6tfcOETEf8ZjwZ1qeKZyczFtihI9cRfe3jgtqPvZ4GM8lUiwZSvOUprKTX7OrGHXbmC0dn1P1cMJ_TofiX3A1d0GPVKlYpbZwlpRwMOH5ilHuu3bpJuIwk3XNMxaDDqyh-HEXqRJEO_FrwSUc&skey=74ed26de24979d51&v=v24": {
"status": 200,
"contentType": "font/woff2",
"file": "728492d30bf60f21ae04abd1.bin"
}
}