From e8d2da6e1873d9411cac19611f915dd762b0224a Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Wed, 20 May 2026 13:58:38 -0700 Subject: [PATCH] feat(lint): font loading + invalid capture path composition rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new composition lint rules catching failure modes that recurred across the 11-round website-to-video eval. Both ship with vitest coverage; total lint suite goes from 148 to 151 tests. **`fonts.ts` (new) — two warnings** - `google_fonts_import`: composition loads fonts from `fonts.googleapis.com` via `` or `@import url(...)`. External font requests fail in sandboxed/offline renders and add latency. Fix hint points to root-relative `capture/assets/fonts/...woff2` with a local `@font-face` declaration. - `font_family_without_font_face`: CSS uses a font-family that isn't declared with `@font-face` and isn't in the auto-bundled font set (Inter, JetBrains Mono, etc.). Text would silently fall back to system-ui — the visual fidelity loss the eval kept hitting. Fix hint points to the captured woff2 files. **`composition.ts` invalid_capture_path (new) — one error** Sub-compositions live in `compositions/` but get served with the project root as their base URL. `` works on disk but 404s in Studio and renders. Errors with a fix hint saying replace `../capture/` with root-relative `capture/`. Three vitest cases: `` triggers, multi-occurrence url()s are counted, root-relative paths stay clean. Registry source files and installed blocks are exempted. **Wiring** `hyperframeLinter.ts` runs the new fonts rules alongside the existing rule set; the composition rule was added inline so it picks up automatically. --- packages/core/src/lint/hyperframeLinter.ts | 2 + .../core/src/lint/rules/composition.test.ts | 46 ++++++ packages/core/src/lint/rules/composition.ts | 20 +++ packages/core/src/lint/rules/fonts.test.ts | 121 ++++++++++++++ packages/core/src/lint/rules/fonts.ts | 150 ++++++++++++++++++ 5 files changed, 339 insertions(+) create mode 100644 packages/core/src/lint/rules/fonts.test.ts create mode 100644 packages/core/src/lint/rules/fonts.ts diff --git a/packages/core/src/lint/hyperframeLinter.ts b/packages/core/src/lint/hyperframeLinter.ts index e50518b69..ff76579ba 100644 --- a/packages/core/src/lint/hyperframeLinter.ts +++ b/packages/core/src/lint/hyperframeLinter.ts @@ -8,6 +8,7 @@ import { captionRules } from "./rules/captions"; import { compositionRules } from "./rules/composition"; import { adapterRules } from "./rules/adapters"; import { textureRules } from "./rules/textures"; +import { fontRules } from "./rules/fonts"; const ALL_RULES = [ ...coreRules, @@ -17,6 +18,7 @@ const ALL_RULES = [ ...compositionRules, ...adapterRules, ...textureRules, + ...fontRules, ]; export function lintHyperframeHtml( diff --git a/packages/core/src/lint/rules/composition.test.ts b/packages/core/src/lint/rules/composition.test.ts index ce1e0087f..69fe23ed3 100644 --- a/packages/core/src/lint/rules/composition.test.ts +++ b/packages/core/src/lint/rules/composition.test.ts @@ -772,4 +772,50 @@ describe("composition rules", () => { expect(finding).toBeUndefined(); }); }); + + describe("invalid_capture_path", () => { + it("errors when an src uses ../capture/", () => { + const html = ` +
+ logo +
+ `; + const result = lintHyperframeHtml(html, { + filePath: "/project/compositions/scene.html", + }); + const finding = result.findings.find((f) => f.code === "invalid_capture_path"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("errors when a CSS url() uses ../capture/ (counts all occurrences)", () => { + const html = ` + +
+ `; + const result = lintHyperframeHtml(html, { + filePath: "/project/compositions/scene.html", + }); + const finding = result.findings.find((f) => f.code === "invalid_capture_path"); + expect(finding).toBeDefined(); + expect(finding?.message).toContain("2 asset path(s)"); + }); + + it("does not flag root-relative capture/ paths", () => { + const html = ` +
+ logo +
+ + `; + const result = lintHyperframeHtml(html, { + filePath: "/project/compositions/scene.html", + }); + const finding = result.findings.find((f) => f.code === "invalid_capture_path"); + expect(finding).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts index e9392ad4b..b4b9c94db 100644 --- a/packages/core/src/lint/rules/composition.ts +++ b/packages/core/src/lint/rules/composition.ts @@ -38,6 +38,26 @@ function isCompositionRootOrMount(rawTag: string): boolean { } export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ + // invalid_capture_path — catches ../capture/ in src/href attributes and scripts. + // Sub-compositions live in compositions/ but are served relative to the project + // root, so all asset paths must be root-relative ("capture/..."). + // Using "../capture/..." works on disk but breaks in Studio and renders. + ({ rawSource, options }) => { + if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return []; + // Only flag in sub-compositions and root compositions — not in registry blocks + const matches = rawSource.match(/\.\.\/capture\//g); + if (!matches || matches.length === 0) return []; + return [ + { + code: "invalid_capture_path", + severity: "error", + message: `Found ${matches.length} asset path(s) using ../capture/ — will 404 in Studio and renders.`, + fixHint: + 'Replace all "../capture/" with "capture/" throughout this file. Compositions are served with the project root as their base URL, so paths must be root-relative, not relative to the compositions/ directory.', + }, + ]; + }, + // composition_file_too_large ({ rawSource, options }) => { if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return []; diff --git a/packages/core/src/lint/rules/fonts.test.ts b/packages/core/src/lint/rules/fonts.test.ts new file mode 100644 index 000000000..af35df87f --- /dev/null +++ b/packages/core/src/lint/rules/fonts.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; +import { lintHyperframeHtml } from "../hyperframeLinter.js"; + +function findByCode(html: string, code: string, isSubComposition = true) { + const result = lintHyperframeHtml(html, { isSubComposition }); + return result.findings.filter((f) => f.code === code); +} + +describe("font rules", () => { + describe("google_fonts_import", () => { + it("flags @import url with fonts.googleapis.com", () => { + const html = `
+ +
`; + const findings = findByCode(html, "google_fonts_import"); + expect(findings).toHaveLength(1); + expect(findings[0]!.severity).toBe("warning"); + }); + + it("flags to fonts.googleapis.com", () => { + const html = `
+ +
`; + const findings = findByCode(html, "google_fonts_import"); + expect(findings).toHaveLength(1); + }); + + it("does not flag local @font-face usage", () => { + const html = `
+ +
`; + const findings = findByCode(html, "google_fonts_import"); + expect(findings).toHaveLength(0); + }); + }); + + describe("font_family_without_font_face", () => { + it("flags font-family used without @font-face", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(1); + expect(findings[0]!.message).toContain("gt walsheim"); + }); + + it("does not flag when @font-face is declared", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(0); + }); + + it("does not flag generic font families", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(0); + }); + + it("reports multiple missing families in one finding", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(1); + expect(findings[0]!.message).toContain("aeonik"); + expect(findings[0]!.message).toContain("feature deck"); + }); + + it("does not flag fonts the producer has pre-bundled", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(0); + }); + + it("still flags Google-Fonts-only fonts not pre-bundled", () => { + const html = `
+ +
`; + const findings = 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", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(0); + }); + + it("ignores font-family inside @font-face blocks", () => { + const html = `
+ +
`; + const findings = findByCode(html, "font_family_without_font_face"); + expect(findings).toHaveLength(0); + }); + }); +}); diff --git a/packages/core/src/lint/rules/fonts.ts b/packages/core/src/lint/rules/fonts.ts new file mode 100644 index 000000000..05a5e0db2 --- /dev/null +++ b/packages/core/src/lint/rules/fonts.ts @@ -0,0 +1,150 @@ +import type { LintContext, HyperframeLintFinding } from "../context"; + +const GENERIC_FAMILIES = new Set([ + "serif", + "sans-serif", + "monospace", + "cursive", + "fantasy", + "system-ui", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", + "math", + "emoji", + "fangsong", + "inherit", + "initial", + "unset", + "revert", +]); + +// Fonts pre-bundled as data URIs in the producer (deterministicFonts.ts FONT_ALIASES). +// These render correctly without @font-face — the producer injects them automatically. +// Must match the keys in packages/producer/src/services/deterministicFonts.ts exactly. +const PRODUCER_BUNDLED_FONTS = new Set([ + "inter", + "helvetica neue", + "helvetica", + "arial", + "helvetica bold", + "montserrat", + "futura", + "din alternate", + "arial black", + "outfit", + "nunito", + "oswald", + "bebas neue", + "league gothic", + "archivo black", + "space mono", + "ibm plex mono", + "jetbrains mono", + "courier new", + "courier", + "eb garamond", + "garamond", + "playfair display", + "source code pro", + "noto sans jp", + "noto sans japanese", + "roboto", + "open sans", + "lato", + "poppins", + "segoe ui", +]); + +function extractFontFaceFamilies(styles: Array<{ content: string }>): Set { + const families = new Set(); + const fontFaceRe = /@font-face\s*\{[^}]*\}/gi; + const familyRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i; + for (const style of styles) { + let match: RegExpExecArray | null; + while ((match = fontFaceRe.exec(style.content)) !== null) { + const familyMatch = match[0].match(familyRe); + if (familyMatch?.[2]) { + families.add(familyMatch[2].trim().toLowerCase()); + } + } + } + return families; +} + +function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] { + const used: string[] = []; + const seen = new Set(); + const propRe = /font-family\s*:\s*([^;}{]+)/gi; + for (const style of styles) { + const withoutFontFace = 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(); + if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) { + seen.add(name); + used.push(name); + } + } + } + } + return used; +} + +export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ + // google_fonts_import + ({ styles, source }) => { + const findings: HyperframeLintFinding[] = []; + const googleFontsInLink = /]*fonts\.googleapis\.com[^>]*>/i.test(source); + const googleFontsInImport = styles.some((s) => + /@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content), + ); + + if (googleFontsInLink || googleFontsInImport) { + findings.push({ + code: "google_fonts_import", + 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.", + fixHint: + "Replace the Google Fonts or @import with @font-face { font-family: '...'; " + + "src: url('capture/assets/fonts/Font.woff2'); } pointing to captured font files.", + }); + } + return findings; + }, + + // font_family_without_font_face + ({ styles }) => { + const findings: HyperframeLintFinding[] = []; + const declared = extractFontFaceFamilies(styles); + const used = extractUsedFontFamilies(styles); + + const undeclared = used.filter( + (name) => !declared.has(name) && !PRODUCER_BUNDLED_FONTS.has(name), + ); + if (undeclared.length === 0) return findings; + + findings.push({ + code: "font_family_without_font_face", + severity: "warning", + message: + `Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. ` + + "These are not in the auto-resolved font list, so the renderer cannot supply them automatically. " + + "Text will fall back to a generic font, producing incorrect typography in the video.", + fixHint: + "Add @font-face { font-family: '...'; src: url('capture/assets/fonts/...woff2'); } " + + "for each font family, pointing to the captured .woff2 files.", + }); + return findings; + }, +];