fix(lint): allow producer-resolved google fonts (#1750)

This commit is contained in:
Miguel Ángel
2026-06-26 17:47:34 -04:00
committed by GitHub
parent 70a03f788b
commit aacce27a33
2 changed files with 127 additions and 12 deletions
+59 -4
View File
@@ -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 () => {
@@ -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>
+68 -8
View File
@@ -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 }) => {
@@ -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;
@@ -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({