Skip to content
Merged
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
63 changes: 59 additions & 4 deletions packages/core/src/lint/rules/fonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,24 @@ async function findByCode(html: string, code: string, isSubComposition = true) {

describe("font rules", () => {
describe("google_fonts_import", () => {
it("flags @import url with fonts.googleapis.com", async () => {
it("warns on @import url with fonts.googleapis.com without failing lint", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap');</style>
</div>`;
const findings = await findByCode(html, "google_fonts_import");
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const findings = result.findings.filter((f) => f.code === "google_fonts_import");
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("error");
expect(findings[0]!.severity).toBe("warning");
expect(result.errorCount).toBe(0);
});

it("flags <link> to fonts.googleapis.com", async () => {
it("warns on <link> to fonts.googleapis.com", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
</div>`;
const findings = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("warning");
});

it("does not flag local @font-face usage", async () => {
Expand Down Expand Up @@ -185,6 +188,58 @@ describe("font rules", () => {
expect(findings[0]!.message).toContain("geist");
});

it("does not flag a non-bundled family when a Google Fonts link loads it", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;700&display=swap">
<style>body { font-family: 'Geist', sans-serif; }</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
expect(result.errorCount).toBe(0);
});

it("parses unquoted Google Fonts link href values", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel=stylesheet href=https://fonts.googleapis.com/css2?family=Geist:wght@400;700&display=swap>
<style>body { font-family: 'Geist', sans-serif; }</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
expect(result.errorCount).toBe(0);
});

it("parses multiple Google Fonts family parameters and URL-encoded spaces", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@import url("https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=DM+Sans:ital,wght@0,400;1,700&display=swap");
h1 { font-family: 'Libre Baskerville', serif; }
body { font-family: 'DM Sans', sans-serif; }
</style>
</div>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
expect(
result.findings.filter((f) => f.code === "font_family_without_font_face"),
).toHaveLength(0);
expect(result.errorCount).toBe(0);
});

it("still flags non-bundled families not covered by the Google Fonts URL", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
<style>body { font-family: '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");
});

it("is case-insensitive when matching @font-face to font-family", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
Expand Down
76 changes: 68 additions & 8 deletions packages/core/src/lint/rules/fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,63 @@ function collectAliasedFonts(used: string[], declared: Set<string>): string[] {
return aliased;
}

function normalizeFontFamily(name: string): string | null {
const decoded = name.replace(/\+/g, " ").trim();
if (!decoded) return null;
try {
return decodeURIComponent(decoded).trim().toLowerCase() || null;
} catch {
return decoded.toLowerCase();
}
}

function extractGoogleFontFamiliesFromUrl(rawUrl: string): string[] {
const url = rawUrl.replace(/&amp;/gi, "&");
let parsed: URL;
try {
parsed = new URL(url, "https://fonts.googleapis.com");
} catch {
return [];
}

if (parsed.hostname.toLowerCase() !== "fonts.googleapis.com") return [];
const families: string[] = [];
for (const value of parsed.searchParams.getAll("family")) {
for (const familySpec of value.split("|")) {
const family = normalizeFontFamily(familySpec.split(":")[0] || "");
if (family) families.push(family);
}
}
return families;
}

function collectGoogleFontFamilies(
source: string,
styles: Array<{ content: string }>,
): Set<string> {
const families = new Set<string>();
const addUrl = (url: string) => {
for (const family of extractGoogleFontFamiliesFromUrl(url)) families.add(family);
};

const linkHrefRe =
/<link\b[^>]*\bhref\s*=\s*(?:(["'])([^"']*fonts\.googleapis\.com[^"']*)\1|([^\s>]*fonts\.googleapis\.com[^\s>]*))[^>]*>/gi;
for (const match of source.matchAll(linkHrefRe)) {
const href = match[2] || match[3];
if (href) addUrl(href);
}

const importUrlRe =
/@import\s+(?:url\(\s*)?(["']?)([^"')\s]*fonts\.googleapis\.com[^"')\s]*)\1\s*\)?/gi;
for (const style of styles) {
for (const match of style.content.matchAll(importUrlRe)) {
if (match[2]) addUrl(match[2]);
}
}

return families;
}

export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// google_fonts_import
({ styles, source, rawSource, options }) => {
Expand All @@ -100,14 +157,14 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
if (googleFontsInLink || googleFontsInImport) {
findings.push({
code: "google_fonts_import",
severity: "error",
severity: "warning",
message:
"Composition loads fonts from fonts.googleapis.com. External font requests " +
"fail in sandboxed/offline renders and add latency. Use local @font-face " +
"declarations with captured .woff2 files instead.",
"Composition loads fonts from fonts.googleapis.com. The producer resolves Google Fonts " +
"during compile/render, but raw external font requests add latency and can fail before " +
"canonicalization. Prefer mapped family names or local @font-face declarations when possible.",
fixHint:
"Replace the Google Fonts <link> or @import with @font-face { font-family: '...'; " +
"src: url('capture/assets/fonts/Font.woff2'); } pointing to captured font files.",
"For bundled fonts, remove the Google Fonts <link> or @import and keep the font-family " +
"declaration. For custom fonts, use @font-face { font-family: '...'; src: url('...woff2'); }.",
});
}
return findings;
Expand Down Expand Up @@ -137,13 +194,16 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},

// font_family_without_font_face
({ styles, rawSource, options }) => {
({ styles, source, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
const declared = extractFontFaceFamilies(styles);
const used = extractUsedFontFamilies(styles);
const googleFonts = collectGoogleFontFamilies(source, styles);

const undeclared = used.filter((name) => !declared.has(name) && !FONT_ALIAS_KEYS.has(name));
const undeclared = used.filter(
(name) => !declared.has(name) && !FONT_ALIAS_KEYS.has(name) && !googleFonts.has(name),
);
if (undeclared.length === 0) return findings;

findings.push({
Expand Down
Loading