mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user