Skip to content

Commit 236ceff

Browse files
committed
fix(lint): stop font_family_without_font_face flagging system-ui stacks and var()
The font_family_without_font_face rule raised a blocking error on two legitimate, very common cases: 1. The `-apple-system, BlinkMacSystemFont` system-ui stack. These two tokens are the cross-browser incantation for the platform UI font (synonyms of `system-ui`); they name no font file, so demanding an @font-face for them is wrong. They appear in almost every CSS reset. 2. `font-family: var(--x)` indirection. The shared family extractor took the literal `var(--heading)` as a font name. The linter cannot statically resolve a custom property, so it must not flag it. Fix at the root: add the two system-ui synonyms to GENERIC_FAMILIES, and skip any parenthesised (function) token in the shared family extractor so both this rule and system_font_will_alias stop misreading var(). A real undeclared font sitting in the same stack is still flagged.
1 parent 812a5d4 commit 236ceff

2 files changed

Lines changed: 58 additions & 7 deletions

File tree

packages/lint/src/rules/fonts.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,5 +281,39 @@ describe("font rules", () => {
281281
const findings = await findByCode(html, "font_family_without_font_face");
282282
expect(findings).toHaveLength(0);
283283
});
284+
285+
it("does not flag the -apple-system / BlinkMacSystemFont system-ui stack", async () => {
286+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
287+
<style>body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }</style>
288+
</div>`;
289+
const findings = await findByCode(html, "font_family_without_font_face");
290+
expect(findings).toHaveLength(0);
291+
});
292+
293+
it("does not flag a var() font-family indirection it cannot resolve", async () => {
294+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
295+
<style>:root { --heading: 'Inter'; } h1 { font-family: var(--heading); }</style>
296+
</div>`;
297+
const findings = await findByCode(html, "font_family_without_font_face");
298+
expect(findings).toHaveLength(0);
299+
});
300+
301+
it("does not flag a var() with a quoted fallback font", async () => {
302+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
303+
<style>h1 { font-family: var(--heading, 'Geist'), sans-serif; }</style>
304+
</div>`;
305+
const findings = await findByCode(html, "font_family_without_font_face");
306+
expect(findings).toHaveLength(0);
307+
});
308+
309+
it("still flags a real undeclared font sitting next to a system stack", async () => {
310+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
311+
<style>body { font-family: 'Aeonik', -apple-system, BlinkMacSystemFont, sans-serif; }</style>
312+
</div>`;
313+
const findings = await findByCode(html, "font_family_without_font_face");
314+
expect(findings).toHaveLength(1);
315+
expect(findings[0]!.message).toContain("aeonik");
316+
expect(findings[0]!.message).not.toContain("apple-system");
317+
});
284318
});
285319
});

packages/lint/src/rules/fonts.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const GENERIC_FAMILIES = new Set([
1616
"math",
1717
"emoji",
1818
"fangsong",
19+
// `-apple-system` and `BlinkMacSystemFont` are the cross-browser incantation
20+
// for the platform UI font (synonyms of `system-ui`) — they name no specific
21+
// file, so requiring an @font-face for them is a false positive. They appear
22+
// in nearly every default CSS reset stack.
23+
"-apple-system",
24+
"blinkmacsystemfont",
1925
"inherit",
2026
"initial",
2127
"unset",
@@ -50,6 +56,22 @@ function extractFontFaceFamilies(styles: Array<{ content: string }>): Set<string
5056
return families;
5157
}
5258

59+
// Normalize one comma-separated font-family entry to a lowercase family name,
60+
// or null if it carries no resolvable name. `var(--heading)` (or any function
61+
// token) is an indirection the linter cannot statically resolve, so the literal
62+
// `var(...)` is not a font name and flagging it is a false positive. Comma-split
63+
// fallbacks like `var(--x, 'Inter')` also leave a dangling `)` on the fallback
64+
// part, so skip anything bearing parentheses.
65+
function normalizeUsedFontName(part: string): string | null {
66+
const name = part
67+
.trim()
68+
.replace(/^['"]|['"]$/g, "")
69+
.trim()
70+
.toLowerCase();
71+
if (!name || name.includes("(") || name.includes(")")) return null;
72+
return name;
73+
}
74+
5375
function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
5476
const used: string[] = [];
5577
const seen = new Set<string>();
@@ -58,13 +80,8 @@ function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
5880
const withoutFontFace = stripCssComments(style.content).replace(/@font-face\s*\{[^}]*\}/gi, "");
5981
let match: RegExpExecArray | null;
6082
while ((match = propRe.exec(withoutFontFace)) !== null) {
61-
const stack = match[1]!;
62-
for (const part of stack.split(",")) {
63-
const name = part
64-
.trim()
65-
.replace(/^['"]|['"]$/g, "")
66-
.trim()
67-
.toLowerCase();
83+
for (const part of match[1]!.split(",")) {
84+
const name = normalizeUsedFontName(part);
6885
if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) {
6986
seen.add(name);
7087
used.push(name);

0 commit comments

Comments
 (0)