Skip to content
Open
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
94 changes: 93 additions & 1 deletion packages/lint/src/rules/fonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,14 +389,106 @@ describe("font rules", () => {
expect(findings).toHaveLength(0);
});

it("does not flag a var() with a quoted fallback font", async () => {
it("flags a concrete undeclared var() fallback without a close-paren artifact", 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(1);
expect(findings[0]!.message).toContain("geist");
expect(findings[0]!.message).not.toContain("geist')");
});

it("does not manufacture production close-paren family artifacts from var() fallbacks", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
h1 { font-family: var(--heading, "Montserrat Bold"), var(--body, serif); }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("montserrat bold");
expect(findings[0]!.message).not.toContain(`montserrat bold")`);
expect(findings[0]!.message).not.toContain("serif)");
});

it("does not guess scoped or cyclic custom-property values", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
:root { --heading: "Inter"; --cycle-a: var(--cycle-b); --cycle-b: var(--cycle-a); }
.card { --heading: "Geist"; }
h1 { font-family: var(--heading); }
h2 { font-family: var(--cycle-a); }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});

it("ignores CSS-wide keywords explicitly", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
body { font-family: inherit; }
h1 { font-family: revert-layer; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});

it("treats quoted generic and CSS-wide names as real families through root resolution", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
:root { --display: "system-ui"; }
body { font-family: var(--display), sans-serif; }
h1 { font-family: "inherit"; }
h2 { font-family: "serif"; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("system-ui");
expect(findings[0]!.message).toContain("inherit");
expect(findings[0]!.message).toContain("serif");
});

it("keeps quoted commas and no-space function-shaped names as families", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
h1 {
font-family: "ACME, Inc", "ACME(Display)", "var(--Display)",
var(--runtime), env(font-name), sans-serif;
}
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("acme, inc");
expect(findings[0]!.message).toContain("acme(display)");
expect(findings[0]!.message).toContain("var(--display)");
expect(findings[0]!.message).not.toContain("var(--runtime)");
expect(findings[0]!.message).not.toContain("env(font-name)");
});

it("malformed CSS invalidates only matching static aliases", async () => {
const differentName = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>:root { --heading: "Geist"; }</style>
<style>.broken { --other: "Outfit";</style>
<style>h1 { font-family: var(--heading); }</style>
</div>`;
const retainedFindings = await findByCode(differentName, "font_family_without_font_face");
expect(retainedFindings).toHaveLength(1);
expect(retainedFindings[0]!.message).toContain("geist");

const sameName = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>:root { --heading: "Geist"; }</style>
<style>.broken { --heading: "Outfit";</style>
<style>h1 { font-family: var(--heading); }</style>
</div>`;
const unresolvedFindings = await findByCode(sameName, "font_family_without_font_face");
expect(unresolvedFindings).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>
Expand Down
109 changes: 88 additions & 21 deletions packages/lint/src/rules/fonts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import {
collectStaticRootFontFamilyCustomProperties,
FONT_ALIAS_KEYS,
GOOGLE_FONT_FAMILY_ALIAS_KEYS,
isCssWideFontFamilyKeyword,
resolveAliasDisplayName,
resolveFontFamilyDeclarationCandidates,
type FontFamilyCustomPropertyDeclaration,
type FontFamilyValueToken,
} from "@hyperframes/parsers/composition";
import postcss, { type AtRule, type Declaration, type Rule, type Root } from "postcss";
import type { LintContext, HyperframeLintFinding } from "../context";
import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition";

Expand All @@ -26,10 +32,6 @@ const GENERIC_FAMILIES = new Set([
// (e.g. `-apple-system, system-ui, sans-serif`).
"-apple-system",
"blinkmacsystemfont",
"inherit",
"initial",
"unset",
"revert",
]);

// A CSS comment can contain a `}` (e.g. `@font-face { /* 400 } regular */
Expand Down Expand Up @@ -60,38 +62,103 @@ 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
function normalizeUsedFontName(candidate: FontFamilyValueToken): string | null {
const name = candidate.value
.trim()
.replace(/\s*!important\s*$/i, "")
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
if (!name || name.includes("(") || name.includes(")")) return null;
if (
!name ||
candidate.kind === "function" ||
(!candidate.quoted && (isCssWideFontFamilyKeyword(name) || GENERIC_FAMILIES.has(name)))
) {
return null;
}
return name;
}

function parseCssRoot(css: string): Root | null {
try {
return postcss.parse(css);
} catch {
return null;
}
}

function isFontFaceDeclaration(decl: Declaration): boolean {
const parent = decl.parent;
return parent?.type === "atrule" && (parent as AtRule).name.toLowerCase() === "font-face";
}

function collectLintCustomProperties(
styles: Array<{ content: string }>,
parsedRoots: Array<Root | null>,
): Map<string, string> {
const declarations: FontFamilyCustomPropertyDeclaration[] = [];
for (let index = 0; index < styles.length; index += 1) {
const root = parsedRoots[index];
if (!root) {
for (const match of styles[index]!.content.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) {
declarations.push({
name: match[1]!,
value: "",
staticRoot: false,
});
}
continue;
}
root.walkDecls((decl) => {
if (!decl.prop.startsWith("--")) return;
const parent = decl.parent;
declarations.push({
name: decl.prop,
value: decl.value,
staticRoot:
parent?.type === "rule" &&
(parent as Rule).selector.trim() === ":root" &&
parent.parent?.type === "root",
important: decl.important,
});
});
}
return collectStaticRootFontFamilyCustomProperties(declarations);
}

function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
const used: string[] = [];
const seen = new Set<string>();
const parsedRoots = styles.map((style) => parseCssRoot(style.content));
const roots = parsedRoots.filter((root) => root !== null);
const customProperties = collectLintCustomProperties(styles, parsedRoots);

const addDeclaration = (declaration: string) => {
for (const candidate of resolveFontFamilyDeclarationCandidates(declaration, customProperties)) {
const name = normalizeUsedFontName(candidate);
if (name && !seen.has(name)) {
seen.add(name);
used.push(name);
}
}
};

for (const root of roots) {
root.walkDecls((decl) => {
if (decl.prop.toLowerCase() !== "font-family" || isFontFaceDeclaration(decl)) return;
addDeclaration(decl.value);
});
}

// Preserve best-effort lint coverage for malformed stylesheets PostCSS
// cannot parse. The shared value parser still prevents function commas from
// manufacturing stray `)` family names.
const propRe = /font-family\s*:\s*([^;}{]+)/gi;
for (const style of styles) {
if (parseCssRoot(style.content)) continue;
const withoutFontFace = stripCssComments(style.content).replace(/@font-face\s*\{[^}]*\}/gi, "");
let match: RegExpExecArray | null;
while ((match = propRe.exec(withoutFontFace)) !== null) {
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);
}
}
addDeclaration(match[1]!);
}
}
return used;
Expand Down
11 changes: 11 additions & 0 deletions packages/parsers/src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ export {
CANONICAL_FONT_DISPLAY_NAMES,
resolveAliasDisplayName,
} from "./fontAliases.js";
export {
CSS_WIDE_FONT_FAMILY_KEYWORDS,
collectStaticRootFontFamilyCustomProperties,
isCssWideFontFamilyKeyword,
parseFontFamilyValue,
parseFontFamilyValueTokens,
resolveFontFamilyDeclarationCandidates,
resolveFontFamilyDeclarationFamilies,
type FontFamilyCustomPropertyDeclaration,
type FontFamilyValueToken,
} from "./fontFamilyValue.js";
export { decodeUrlPathVariants } from "./utils/urlPath.js";
export {
parseCompositionVariables,
Expand Down
Loading
Loading