Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/lint/src/rules/fonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,5 +281,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");
});
});
});
31 changes: 24 additions & 7 deletions packages/lint/src/rules/fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const GENERIC_FAMILIES = new Set([
"math",
"emoji",
"fangsong",
// `-apple-system` and `BlinkMacSystemFont` are the cross-browser incantation
// for the platform UI font (synonyms of `system-ui`) — they name no specific
// file, so requiring an @font-face for them is a false positive. They appear
// in nearly every default CSS reset stack.
"-apple-system",
"blinkmacsystemfont",
"inherit",
"initial",
"unset",
Expand Down Expand Up @@ -50,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>();
Expand All @@ -58,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);
Expand Down
Loading