Skip to content

Commit 2c9544f

Browse files
committed
feat(lint): font loading + invalid capture path composition rules
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 `<link>` 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. `<img src="../capture/...">` works on disk but 404s in Studio and renders. Errors with a fix hint saying replace `../capture/` with root-relative `capture/`. Three vitest cases: `<img>` 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.
1 parent 65c5209 commit 2c9544f

5 files changed

Lines changed: 339 additions & 0 deletions

File tree

packages/core/src/lint/hyperframeLinter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { captionRules } from "./rules/captions";
88
import { compositionRules } from "./rules/composition";
99
import { adapterRules } from "./rules/adapters";
1010
import { textureRules } from "./rules/textures";
11+
import { fontRules } from "./rules/fonts";
1112

1213
const ALL_RULES = [
1314
...coreRules,
@@ -17,6 +18,7 @@ const ALL_RULES = [
1718
...compositionRules,
1819
...adapterRules,
1920
...textureRules,
21+
...fontRules,
2022
];
2123

2224
export function lintHyperframeHtml(

packages/core/src/lint/rules/composition.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,4 +772,50 @@ describe("composition rules", () => {
772772
expect(finding).toBeUndefined();
773773
});
774774
});
775+
776+
describe("invalid_capture_path", () => {
777+
it("errors when an <img> src uses ../capture/", () => {
778+
const html = `<html><body>
779+
<div data-composition-id="x">
780+
<img src="../capture/assets/logo.svg" alt="logo">
781+
</div>
782+
</body></html>`;
783+
const result = lintHyperframeHtml(html, {
784+
filePath: "/project/compositions/scene.html",
785+
});
786+
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
787+
expect(finding).toBeDefined();
788+
expect(finding?.severity).toBe("error");
789+
});
790+
791+
it("errors when a CSS url() uses ../capture/ (counts all occurrences)", () => {
792+
const html = `<html><body>
793+
<style>
794+
@font-face { font-family: 'Brand'; src: url('../capture/assets/fonts/Brand.woff2'); }
795+
.hero { background-image: url('../capture/assets/hero.png'); }
796+
</style>
797+
<div data-composition-id="x"></div>
798+
</body></html>`;
799+
const result = lintHyperframeHtml(html, {
800+
filePath: "/project/compositions/scene.html",
801+
});
802+
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
803+
expect(finding).toBeDefined();
804+
expect(finding?.message).toContain("2 asset path(s)");
805+
});
806+
807+
it("does not flag root-relative capture/ paths", () => {
808+
const html = `<html><body>
809+
<div data-composition-id="x">
810+
<img src="capture/assets/logo.svg" alt="logo">
811+
</div>
812+
<style>.hero { background-image: url('capture/assets/hero.png'); }</style>
813+
</body></html>`;
814+
const result = lintHyperframeHtml(html, {
815+
filePath: "/project/compositions/scene.html",
816+
});
817+
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
818+
expect(finding).toBeUndefined();
819+
});
820+
});
775821
});

packages/core/src/lint/rules/composition.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@ function isCompositionRootOrMount(rawTag: string): boolean {
3838
}
3939

4040
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
41+
// invalid_capture_path — catches ../capture/ in src/href attributes and scripts.
42+
// Sub-compositions live in compositions/ but are served relative to the project
43+
// root, so all asset paths must be root-relative ("capture/...").
44+
// Using "../capture/..." works on disk but breaks in Studio and renders.
45+
({ rawSource, options }) => {
46+
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
47+
// Only flag in sub-compositions and root compositions — not in registry blocks
48+
const matches = rawSource.match(/\.\.\/capture\//g);
49+
if (!matches || matches.length === 0) return [];
50+
return [
51+
{
52+
code: "invalid_capture_path",
53+
severity: "error",
54+
message: `Found ${matches.length} asset path(s) using ../capture/ — will 404 in Studio and renders.`,
55+
fixHint:
56+
'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.',
57+
},
58+
];
59+
},
60+
4161
// composition_file_too_large
4262
({ rawSource, options }) => {
4363
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, it, expect } from "vitest";
2+
import { lintHyperframeHtml } from "../hyperframeLinter.js";
3+
4+
function findByCode(html: string, code: string, isSubComposition = true) {
5+
const result = lintHyperframeHtml(html, { isSubComposition });
6+
return result.findings.filter((f) => f.code === code);
7+
}
8+
9+
describe("font rules", () => {
10+
describe("google_fonts_import", () => {
11+
it("flags @import url with fonts.googleapis.com", () => {
12+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
13+
<style>@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap');</style>
14+
</div>`;
15+
const findings = findByCode(html, "google_fonts_import");
16+
expect(findings).toHaveLength(1);
17+
expect(findings[0]!.severity).toBe("warning");
18+
});
19+
20+
it("flags <link> to fonts.googleapis.com", () => {
21+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
22+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
23+
</div>`;
24+
const findings = findByCode(html, "google_fonts_import");
25+
expect(findings).toHaveLength(1);
26+
});
27+
28+
it("does not flag local @font-face usage", () => {
29+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
30+
<style>@font-face { font-family: 'Inter'; src: url('../capture/assets/fonts/Inter.woff2'); }</style>
31+
</div>`;
32+
const findings = findByCode(html, "google_fonts_import");
33+
expect(findings).toHaveLength(0);
34+
});
35+
});
36+
37+
describe("font_family_without_font_face", () => {
38+
it("flags font-family used without @font-face", () => {
39+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
40+
<style>body { font-family: 'GT Walsheim', sans-serif; }</style>
41+
</div>`;
42+
const findings = findByCode(html, "font_family_without_font_face");
43+
expect(findings).toHaveLength(1);
44+
expect(findings[0]!.message).toContain("gt walsheim");
45+
});
46+
47+
it("does not flag when @font-face is declared", () => {
48+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
49+
<style>
50+
@font-face { font-family: 'GT Walsheim'; src: url('../fonts/gt.woff2'); }
51+
body { font-family: 'GT Walsheim', sans-serif; }
52+
</style>
53+
</div>`;
54+
const findings = findByCode(html, "font_family_without_font_face");
55+
expect(findings).toHaveLength(0);
56+
});
57+
58+
it("does not flag generic font families", () => {
59+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
60+
<style>body { font-family: monospace; }</style>
61+
</div>`;
62+
const findings = findByCode(html, "font_family_without_font_face");
63+
expect(findings).toHaveLength(0);
64+
});
65+
66+
it("reports multiple missing families in one finding", () => {
67+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
68+
<style>
69+
h1 { font-family: 'Aeonik', sans-serif; }
70+
code { font-family: 'Feature Deck', monospace; }
71+
</style>
72+
</div>`;
73+
const findings = findByCode(html, "font_family_without_font_face");
74+
expect(findings).toHaveLength(1);
75+
expect(findings[0]!.message).toContain("aeonik");
76+
expect(findings[0]!.message).toContain("feature deck");
77+
});
78+
79+
it("does not flag fonts the producer has pre-bundled", () => {
80+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
81+
<style>
82+
body { font-family: 'Inter', sans-serif; }
83+
code { font-family: 'JetBrains Mono', monospace; }
84+
h1 { font-family: 'Roboto', sans-serif; }
85+
</style>
86+
</div>`;
87+
const findings = findByCode(html, "font_family_without_font_face");
88+
expect(findings).toHaveLength(0);
89+
});
90+
91+
it("still flags Google-Fonts-only fonts not pre-bundled", () => {
92+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
93+
<style>body { font-family: 'Geist', sans-serif; }</style>
94+
</div>`;
95+
const findings = findByCode(html, "font_family_without_font_face");
96+
expect(findings).toHaveLength(1);
97+
expect(findings[0]!.message).toContain("geist");
98+
});
99+
100+
it("is case-insensitive when matching @font-face to font-family", () => {
101+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
102+
<style>
103+
@font-face { font-family: 'Inter'; src: url('../fonts/inter.woff2'); }
104+
body { font-family: 'inter', sans-serif; }
105+
</style>
106+
</div>`;
107+
const findings = findByCode(html, "font_family_without_font_face");
108+
expect(findings).toHaveLength(0);
109+
});
110+
111+
it("ignores font-family inside @font-face blocks", () => {
112+
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
113+
<style>
114+
@font-face { font-family: 'CustomFont'; src: url('../fonts/custom.woff2'); }
115+
</style>
116+
</div>`;
117+
const findings = findByCode(html, "font_family_without_font_face");
118+
expect(findings).toHaveLength(0);
119+
});
120+
});
121+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { LintContext, HyperframeLintFinding } from "../context";
2+
3+
const GENERIC_FAMILIES = new Set([
4+
"serif",
5+
"sans-serif",
6+
"monospace",
7+
"cursive",
8+
"fantasy",
9+
"system-ui",
10+
"ui-serif",
11+
"ui-sans-serif",
12+
"ui-monospace",
13+
"ui-rounded",
14+
"math",
15+
"emoji",
16+
"fangsong",
17+
"inherit",
18+
"initial",
19+
"unset",
20+
"revert",
21+
]);
22+
23+
// Fonts pre-bundled as data URIs in the producer (deterministicFonts.ts FONT_ALIASES).
24+
// These render correctly without @font-face — the producer injects them automatically.
25+
// Must match the keys in packages/producer/src/services/deterministicFonts.ts exactly.
26+
const PRODUCER_BUNDLED_FONTS = new Set([
27+
"inter",
28+
"helvetica neue",
29+
"helvetica",
30+
"arial",
31+
"helvetica bold",
32+
"montserrat",
33+
"futura",
34+
"din alternate",
35+
"arial black",
36+
"outfit",
37+
"nunito",
38+
"oswald",
39+
"bebas neue",
40+
"league gothic",
41+
"archivo black",
42+
"space mono",
43+
"ibm plex mono",
44+
"jetbrains mono",
45+
"courier new",
46+
"courier",
47+
"eb garamond",
48+
"garamond",
49+
"playfair display",
50+
"source code pro",
51+
"noto sans jp",
52+
"noto sans japanese",
53+
"roboto",
54+
"open sans",
55+
"lato",
56+
"poppins",
57+
"segoe ui",
58+
]);
59+
60+
function extractFontFaceFamilies(styles: Array<{ content: string }>): Set<string> {
61+
const families = new Set<string>();
62+
const fontFaceRe = /@font-face\s*\{[^}]*\}/gi;
63+
const familyRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
64+
for (const style of styles) {
65+
let match: RegExpExecArray | null;
66+
while ((match = fontFaceRe.exec(style.content)) !== null) {
67+
const familyMatch = match[0].match(familyRe);
68+
if (familyMatch?.[2]) {
69+
families.add(familyMatch[2].trim().toLowerCase());
70+
}
71+
}
72+
}
73+
return families;
74+
}
75+
76+
function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
77+
const used: string[] = [];
78+
const seen = new Set<string>();
79+
const propRe = /font-family\s*:\s*([^;}{]+)/gi;
80+
for (const style of styles) {
81+
const withoutFontFace = style.content.replace(/@font-face\s*\{[^}]*\}/gi, "");
82+
let match: RegExpExecArray | null;
83+
while ((match = propRe.exec(withoutFontFace)) !== null) {
84+
const stack = match[1]!;
85+
for (const part of stack.split(",")) {
86+
const name = part
87+
.trim()
88+
.replace(/^['"]|['"]$/g, "")
89+
.trim()
90+
.toLowerCase();
91+
if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) {
92+
seen.add(name);
93+
used.push(name);
94+
}
95+
}
96+
}
97+
}
98+
return used;
99+
}
100+
101+
export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
102+
// google_fonts_import
103+
({ styles, source }) => {
104+
const findings: HyperframeLintFinding[] = [];
105+
const googleFontsInLink = /<link\b[^>]*fonts\.googleapis\.com[^>]*>/i.test(source);
106+
const googleFontsInImport = styles.some((s) =>
107+
/@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content),
108+
);
109+
110+
if (googleFontsInLink || googleFontsInImport) {
111+
findings.push({
112+
code: "google_fonts_import",
113+
severity: "warning",
114+
message:
115+
"Composition loads fonts from fonts.googleapis.com. External font requests " +
116+
"fail in sandboxed/offline renders and add latency. Use local @font-face " +
117+
"declarations with captured .woff2 files instead.",
118+
fixHint:
119+
"Replace the Google Fonts <link> or @import with @font-face { font-family: '...'; " +
120+
"src: url('capture/assets/fonts/Font.woff2'); } pointing to captured font files.",
121+
});
122+
}
123+
return findings;
124+
},
125+
126+
// font_family_without_font_face
127+
({ styles }) => {
128+
const findings: HyperframeLintFinding[] = [];
129+
const declared = extractFontFaceFamilies(styles);
130+
const used = extractUsedFontFamilies(styles);
131+
132+
const undeclared = used.filter(
133+
(name) => !declared.has(name) && !PRODUCER_BUNDLED_FONTS.has(name),
134+
);
135+
if (undeclared.length === 0) return findings;
136+
137+
findings.push({
138+
code: "font_family_without_font_face",
139+
severity: "warning",
140+
message:
141+
`Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. ` +
142+
"These are not in the auto-resolved font list, so the renderer cannot supply them automatically. " +
143+
"Text will fall back to a generic font, producing incorrect typography in the video.",
144+
fixHint:
145+
"Add @font-face { font-family: '...'; src: url('capture/assets/fonts/...woff2'); } " +
146+
"for each font family, pointing to the captured .woff2 files.",
147+
});
148+
return findings;
149+
},
150+
];

0 commit comments

Comments
 (0)