mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(lint): allow producer-resolved google fonts (#1750)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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(/&/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({
|
||||
|
||||
Reference in New Issue
Block a user