Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/core/src/lint/hyperframeLinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +18,7 @@ const ALL_RULES = [
...compositionRules,
...adapterRules,
...textureRules,
...fontRules,
];

export function lintHyperframeHtml(
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/lint/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,4 +772,50 @@ describe("composition rules", () => {
expect(finding).toBeUndefined();
});
});

describe("invalid_capture_path", () => {
it("errors when an <img> src uses ../capture/", () => {
const html = `<html><body>
<div data-composition-id="x">
<img src="../capture/assets/logo.svg" alt="logo">
</div>
</body></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?.severity).toBe("error");
});

it("errors when a CSS url() uses ../capture/ (counts all occurrences)", () => {
const html = `<html><body>
<style>
@font-face { font-family: 'Brand'; src: url('../capture/assets/fonts/Brand.woff2'); }
.hero { background-image: url('../capture/assets/hero.png'); }
</style>
<div data-composition-id="x"></div>
</body></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 = `<html><body>
<div data-composition-id="x">
<img src="capture/assets/logo.svg" alt="logo">
</div>
<style>.hero { background-image: url('capture/assets/hero.png'); }</style>
</body></html>`;
const result = lintHyperframeHtml(html, {
filePath: "/project/compositions/scene.html",
});
const finding = result.findings.find((f) => f.code === "invalid_capture_path");
expect(finding).toBeUndefined();
});
});
});
20 changes: 20 additions & 0 deletions packages/core/src/lint/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down
121 changes: 121 additions & 0 deletions packages/core/src/lint/rules/fonts.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<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 = findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("warning");
});

it("flags <link> to fonts.googleapis.com", () => {
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 = findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(1);
});

it("does not flag local @font-face usage", () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>@font-face { font-family: 'Inter'; src: url('../capture/assets/fonts/Inter.woff2'); }</style>
</div>`;
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 = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'GT Walsheim', sans-serif; }</style>
</div>`;
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 = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'GT Walsheim'; src: url('../fonts/gt.woff2'); }
body { font-family: 'GT Walsheim', sans-serif; }
</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});

it("does not flag generic font families", () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: monospace; }</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});

it("reports multiple missing families in one finding", () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
h1 { font-family: 'Aeonik', sans-serif; }
code { font-family: 'Feature Deck', monospace; }
</style>
</div>`;
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 = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
body { font-family: 'Inter', sans-serif; }
code { font-family: 'JetBrains Mono', monospace; }
h1 { font-family: 'Roboto', sans-serif; }
</style>
</div>`;
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 = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Geist', sans-serif; }</style>
</div>`;
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 = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'Inter'; src: url('../fonts/inter.woff2'); }
body { font-family: 'inter', sans-serif; }
</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});

it("ignores font-family inside @font-face blocks", () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'CustomFont'; src: url('../fonts/custom.woff2'); }
</style>
</div>`;
const findings = findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
});
});
150 changes: 150 additions & 0 deletions packages/core/src/lint/rules/fonts.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const families = new Set<string>();
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<string>();
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 = /<link\b[^>]*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 <link> 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;
},
];
Loading