fix(lint): stop font_family_without_font_face flagging system-ui stacks and var() (#1796)

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.
This commit is contained in:
Miguel Ángel
2026-06-30 11:31:36 -07:00
committed by GitHub
parent 3a2f052889
commit 11432ea8ce
2 changed files with 52 additions and 7 deletions
+34
View File
@@ -291,5 +291,39 @@ describe("font rules", () => {
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag the -apple-system / BlinkMacSystemFont system-ui stack", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag a var() font-family indirection it cannot resolve", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>:root { --heading: 'Inter'; } h1 { font-family: var(--heading); }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag a var() with a quoted fallback font", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>h1 { font-family: var(--heading, 'Geist'), sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("still flags a real undeclared font sitting next to a system stack", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Aeonik', -apple-system, BlinkMacSystemFont, sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("aeonik");
expect(findings[0]!.message).not.toContain("apple-system");
});
});
});
+18 -7
View File
@@ -56,6 +56,22 @@ function extractFontFaceFamilies(styles: Array<{ content: string }>): Set<string
return families;
}
// Normalize one comma-separated font-family entry to a lowercase family name,
// or null if it carries no resolvable name. `var(--heading)` (or any function
// token) is an indirection the linter cannot statically resolve, so the literal
// `var(...)` is not a font name and flagging it is a false positive. Comma-split
// fallbacks like `var(--x, 'Inter')` also leave a dangling `)` on the fallback
// part, so skip anything bearing parentheses.
function normalizeUsedFontName(part: string): string | null {
const name = part
.trim()
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
if (!name || name.includes("(") || name.includes(")")) return null;
return name;
}
function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
const used: string[] = [];
const seen = new Set<string>();
@@ -64,13 +80,8 @@ function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
const withoutFontFace = stripCssComments(style.content).replace(/@font-face\s*\{[^}]*\}/gi, "");
let match: RegExpExecArray | null;
while ((match = propRe.exec(withoutFontFace)) !== null) {
const stack = match[1]!;
for (const part of stack.split(",")) {
const name = part
.trim()
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
for (const part of match[1]!.split(",")) {
const name = normalizeUsedFontName(part);
if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) {
seen.add(name);
used.push(name);