fix(compiler): split font-family only on top-level commas (#3067)

parseFontFamilyValue() split the family stack on every comma, so
`font-family: var(--brand-font, inherit)` became two tokens:
`var(--brand-font` and `inherit)`. The var() guard from #1655 only
skips tokens starting with `var(`, so the orphan fragment was treated
as a requested family, failed every resolution path, and aborted
fail-closed distributed renders with:

  FontFetchError: [Compiler] Unresolved fonts in fail-closed mode:
  inherit). Distributed renders require all fonts to be resolvable.

Split on top-level commas only, so a var() expression (including a
nested one) stays a single token. Quotes are tracked as well, both so
parentheses inside a quoted family name cannot skew the depth counter
and so a legal quoted comma no longer splits.

Closes #3066
This commit is contained in:
Akshay Kumar Sharma
2026-08-07 14:03:36 -07:00
committed by GitHub
parent 288bd70344
commit 172311e95e
4 changed files with 68 additions and 3 deletions
@@ -253,6 +253,17 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
expect(result).toBe(html);
});
it("does NOT throw when font-family uses a CSS var() reference with a fallback", async () => {
const html = `<!doctype html><html><head><style>
.title { font-family: var(--brand-font, inherit); }
</style></head><body><h1 class="title">hello</h1></body></html>`;
const result = await injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
fetchImpl: makeFailingFetch(),
});
expect(result).toBe(html);
});
it("resolves simple CSS var() font aliases when injecting deterministic fonts", async () => {
const html = `<!doctype html><html><head><style>
:root { --ui-font: "Inter"; --vowel-font: "Montserrat"; }
@@ -54,10 +54,44 @@ export const GENERIC_FAMILIES: ReadonlySet<string> = new Set([
* Whitespace and surrounding `"…"` / `'…'` quotes are stripped; case is
* preserved. Pass each name through `normalizeFamilyName` for case-
* insensitive comparisons.
*
* Only top-level commas split: a `var(--x, fallback)` expression stays one
* token, as does a comma inside a quoted family name.
*/
export function parseFontFamilyValue(value: string): string[] {
return value
.split(",")
const pieces: string[] = [];
let start = 0;
let depth = 0;
let quote: "'" | '"' | null = null;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
if (char === "\\") {
index += 1;
continue;
}
if (quote) {
if (char === quote) quote = null;
continue;
}
if (char === "'" || char === '"') {
quote = char;
continue;
}
if (char === "(") {
depth += 1;
continue;
}
if (char === ")") {
depth = Math.max(0, depth - 1);
continue;
}
if (char !== "," || depth !== 0) continue;
pieces.push(value.slice(start, index));
start = index + 1;
}
pieces.push(value.slice(start));
return pieces
.map((piece) => piece.trim().replace(/^['"]/, "").replace(/['"]$/, "").trim())
.filter((piece) => piece.length > 0);
}
@@ -169,6 +169,26 @@ describe("parseFontFamilyValue", () => {
expect(parseFontFamilyValue(`'My Custom Font', serif`)).toEqual(["My Custom Font", "serif"]);
});
it("keeps a comma inside a quoted family name", () => {
expect(parseFontFamilyValue(`"Display, Condensed", serif`)).toEqual([
"Display, Condensed",
"serif",
]);
});
it("keeps a var() fallback in a single token", () => {
expect(parseFontFamilyValue(`var(--brand-font, inherit), sans-serif`)).toEqual([
"var(--brand-font, inherit)",
"sans-serif",
]);
});
it("keeps a nested var() fallback in a single token", () => {
expect(
parseFontFamilyValue(`var(--brand-font, var(--fallback-font, "Inter")), sans-serif`),
).toEqual([`var(--brand-font, var(--fallback-font, "Inter"))`, "sans-serif"]);
});
it("ignores empty entries (trailing commas)", () => {
expect(parseFontFamilyValue(`Inter,,sans-serif`)).toEqual(["Inter", "sans-serif"]);
});