chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
{
"name": "@hyperframes/lint",
"version": "0.7.55",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
"directory": "packages/lint"
},
"files": [
"dist",
"README.md"
],
"type": "module",
"sideEffects": false,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"node": "./dist/index.js",
"import": "./src/index.ts",
"types": "./src/index.ts"
},
"./browser": {
"browser": "./src/browser.ts",
"bun": "./src/browser.ts",
"import": "./src/browser.ts",
"types": "./src/browser.ts"
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./browser": {
"import": "./dist/browser.js",
"types": "./dist/browser.d.ts"
},
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"prepublishOnly": "echo skip"
},
"dependencies": {
"@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0",
"linkedom": "^0.18.12",
"postcss": "^8.5.8"
},
"devDependencies": {
"@types/node": "^25.0.10",
"tsup": "^8.0.0",
"tsx": "^4.21.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { lintHyperframeHtml, shouldBlockRender } from "./browser.js";
// Guards that @hyperframes/lint/browser exposes a working, node-free rule engine.
// (The platform:"browser" tsup build is the compile-time node-free guarantee;
// this verifies the API actually runs.)
describe("@hyperframes/lint/browser", () => {
it("lints an HTML string with no filesystem access", async () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
</body></html>`;
const result = await lintHyperframeHtml(html, { filePath: "index.html" });
expect(typeof result.ok).toBe("boolean");
expect(Array.isArray(result.findings)).toBe(true);
});
it("exposes the pure shouldBlockRender gate", () => {
expect(shouldBlockRender(true, false, 1, 0)).toBe(true);
expect(shouldBlockRender(true, false, 0, 3)).toBe(false);
expect(shouldBlockRender(false, true, 0, 1)).toBe(true);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* Browser-safe entry for @hyperframes/lint.
*
* Exposes the composition rule engine — HTML-string in, findings out — with
* **zero Node.js dependencies**: no `node:fs`, no filesystem, no server. This
* lets browser-only editors and tools validate compositions entirely
* client-side, before any network call.
*
* The Node-only project layer (`lintProject`, which walks a directory) is NOT
* exported here — import it from the main `@hyperframes/lint` entry in Node.
*/
export type {
HyperframeLintSeverity,
HyperframeLintFinding,
HyperframeLintResult,
HyperframeLinterOptions,
} from "./types.js";
export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter.js";
export { shouldBlockRender } from "./shouldBlockRender.js";
+82
View File
@@ -0,0 +1,82 @@
import type { HyperframeLintFinding, HyperframeLinterOptions } from "./types";
import {
parseHtmlStructure,
findRootTag,
collectCompositionIds,
readAttr,
stripHtmlComments,
} from "./utils";
import type { OpenTag, ExtractedBlock } from "./utils";
export type { OpenTag, ExtractedBlock };
export type LintContext = {
source: string;
rawSource: string;
tags: OpenTag[];
styles: ExtractedBlock[];
scripts: ExtractedBlock[];
compositionIds: Set<string>;
rootTag: OpenTag | null;
rootCompositionId: string | null;
options: HyperframeLinterOptions;
};
// Re-export for convenience so rule modules only need one import for the finding type
export type { HyperframeLintFinding };
export function buildLintContext(html: string, options: HyperframeLinterOptions = {}): LintContext {
const rawSource = html || "";
// Strip HTML comments before scanning so a commented-out <template> or tag can't
// hijack the boundary match below. Linear + fixpoint (see stripHtmlComments) to
// stay ReDoS-free and catch markers that re-form when a comment is removed.
let source = stripHtmlComments(rawSource);
const initialStructure = parseHtmlStructure(source);
const templateTags = initialStructure.tags.filter(
(tag) => tag.name === "template" && tag.closeIndex != null,
);
let sourceWithoutTemplates = source;
for (const template of [...templateTags].reverse()) {
const end = template.endIndex ?? template.index;
sourceWithoutTemplates =
sourceWithoutTemplates.slice(0, template.index) +
" ".repeat(end - template.index) +
sourceWithoutTemplates.slice(end);
}
// Some sub-composition files are HTML shells whose real root lives inside a
// <template>. Keep nested templates intact when the visible document already
// has a composition root; only unwrap when no root exists outside templates.
const template = templateTags[0];
let structure = initialStructure;
if (template && !findRootTag(sourceWithoutTemplates)) {
source = source.slice(template.index + template.raw.length, template.closeIndex);
structure = parseHtmlStructure(source);
}
const tags = structure.tags;
const styles = [
...structure.styles,
...(options.externalStyles ?? []).map((style) => ({
attrs: `href="${style.href}"`,
content: style.content,
raw: style.content,
index: -1,
})),
];
const scripts = structure.scripts;
const compositionIds = collectCompositionIds(tags);
const rootTag = findRootTag(source, tags);
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
return {
source,
rawSource,
tags,
styles,
scripts,
compositionIds,
rootTag,
rootCompositionId,
options,
};
}
+149
View File
@@ -0,0 +1,149 @@
import { afterEach, describe, it, expect, vi } from "vitest";
import { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter.js";
afterEach(() => vi.unstubAllGlobals());
describe("lintHyperframeHtml — orchestrator", () => {
const validComposition = `
<html>
<body>
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080" data-start="0">
<div id="stage"></div>
</div>
<script src="https://cdn.gsap.com/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["comp-1"] = tl;
</script>
</body>
</html>`;
it("reports no errors for a valid composition", async () => {
const result = await lintHyperframeHtml(validComposition);
expect(result.ok).toBe(true);
expect(result.errorCount).toBe(0);
});
it("attaches filePath to findings when option is set", async () => {
const html = "<html><body><div></div></body></html>";
const result = await lintHyperframeHtml(html, { filePath: "test.html" });
for (const finding of result.findings) {
expect(finding.file).toBe("test.html");
}
});
it("deduplicates identical findings", async () => {
const html = `
<html><body>
<div id="root"></div>
<script>const tl = gsap.timeline();</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const codes = result.findings.map((f) => `${f.code}|${f.message}`);
const uniqueCodes = [...new Set(codes)];
expect(codes.length).toBe(uniqueCodes.length);
});
it("strips <template> wrapper before linting composition files", async () => {
const html = `<template id="my-comp-template">
<div data-composition-id="my-comp" data-width="1920" data-height="1080"
style="position:relative;width:1920px;height:1080px;">
<div id="stage"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["my-comp"] = tl;
</script>
</template>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const missing = result.findings.filter(
(f) => f.code === "missing-composition-id" || f.code === "missing-dimensions",
);
expect(missing).toHaveLength(0);
});
it("ignores comments that mention template tags before the real template", async () => {
const html = `<!doctype html>
<html>
<head>
<!-- Authoring note: styles and scripts live inside <template>. -->
</head>
<body>
<template id="my-comp-template">
<style>#root { width: 1920px; height: 1080px; }</style>
<div data-composition-id="my-comp" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["my-comp"] = tl;
</script>
</template>
</body>
</html>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const rootFindings = result.findings.filter(
(f) => f.code === "root_missing_composition_id" || f.code === "root_missing_dimensions",
);
expect(rootFindings).toHaveLength(0);
});
it("strips comments whose markers re-form after one pass (no decoy template survives)", async () => {
// Adjacent comment markers: removing the inner `<!-- -->` in a single pass
// re-joins `<` + `!-- … -->` into a fresh, complete `<!-- … -->` that a lone
// global replace leaves behind — surfacing a decoy <template> with no
// composition-id. A fixpoint strip removes it; this guards that behavior.
const html = `<!doctype html>
<html>
<body>
<<!-- -->!-- <template id="decoy-template"></template> -->
<template id="my-comp-template">
<style>#root { width: 1920px; height: 1080px; }</style>
<div data-composition-id="my-comp" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["my-comp"] = tl;
</script>
</template>
</body>
</html>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
const rootFindings = result.findings.filter(
(f) => f.code === "root_missing_composition_id" || f.code === "root_missing_dimensions",
);
expect(rootFindings).toHaveLength(0);
});
});
describe("lintMediaUrls", () => {
it("checks top-level remote media elements", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
await lintMediaUrls('<img id="hero" src="https://example.com/hero.png">');
expect(fetchMock).toHaveBeenCalledWith(
"https://example.com/hero.png",
expect.objectContaining({ method: "HEAD" }),
);
});
it("ignores remote media embedded inside an iframe srcdoc attribute", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await lintMediaUrls(`<iframe srcdoc='<img src="https://example.com/embedded.png">'></iframe>`);
expect(fetchMock).not.toHaveBeenCalled();
});
});
+152
View File
@@ -0,0 +1,152 @@
import type { HyperframeLintFinding, HyperframeLintResult, HyperframeLinterOptions } from "./types";
import { buildLintContext } from "./context";
import { parseHtmlStructure, readAttr, truncateSnippet } from "./utils";
import { coreRules } from "./rules/core";
import { mediaRules } from "./rules/media";
import { gsapRules } from "./rules/gsap";
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";
import { slideshowRules } from "./rules/slideshow";
const ALL_RULES = [
...coreRules,
...mediaRules,
...gsapRules,
...captionRules,
...compositionRules,
...adapterRules,
...textureRules,
...fontRules,
...slideshowRules,
];
export async function lintHyperframeHtml(
html: string,
options: HyperframeLinterOptions = {},
): Promise<HyperframeLintResult> {
const ctx = buildLintContext(html, options);
const findings: HyperframeLintFinding[] = [];
const seen = new Set<string>();
for (const rule of ALL_RULES) {
for (const finding of await Promise.resolve(rule(ctx))) {
const dedupeKey = [
finding.code,
finding.severity,
finding.selector || "",
finding.elementId || "",
finding.message,
].join("|");
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
findings.push(options.filePath ? { ...finding, file: options.filePath } : finding);
}
}
const errorCount = findings.filter((f) => f.severity === "error").length;
const warningCount = findings.filter((f) => f.severity === "warning").length;
const infoCount = findings.filter((f) => f.severity === "info").length;
return {
ok: errorCount === 0,
errorCount,
warningCount,
infoCount,
findings,
};
}
// ── Async media URL accessibility checker ─────────────────────────────────
function extractMediaUrls(html: string): Array<{
url: string;
tagName: string;
elementId?: string;
snippet: string;
}> {
const results: Array<{
url: string;
tagName: string;
elementId?: string;
snippet: string;
}> = [];
for (const { name: tagName, raw } of parseHtmlStructure(html).tags) {
if (!/^(?:video|audio|img|source)$/.test(tagName)) continue;
const src = readAttr(raw, "src");
if (!src) continue;
if (/^https?:\/\//i.test(src)) {
results.push({
url: src,
tagName,
elementId: readAttr(raw, "id") || undefined,
snippet: truncateSnippet(raw) ?? "",
});
}
}
return results;
}
/**
* Async lint pass: HEAD-checks every remote media URL in the HTML.
* Returns findings for URLs that are unreachable (non-2xx status or network error).
*
* Call this after `lintHyperframeHtml()` and merge the findings.
*
* @param timeoutMs - per-request timeout (default 8000ms)
*/
export async function lintMediaUrls(
html: string,
options: { timeoutMs?: number } = {},
): Promise<HyperframeLintFinding[]> {
const urls = extractMediaUrls(html);
if (urls.length === 0) return [];
const timeout = options.timeoutMs ?? 8000;
const findings: HyperframeLintFinding[] = [];
const seen = new Set<string>();
const unique = urls.filter((u) => {
if (seen.has(u.url)) return false;
seen.add(u.url);
return true;
});
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const resp = await fetch(url, {
method: "HEAD",
signal: controller.signal,
redirect: "follow",
});
clearTimeout(timer);
if (!resp.ok) {
findings.push({
code: "inaccessible_media_url",
severity: "error",
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`,
elementId,
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
snippet,
});
}
} catch (err) {
const reason = err instanceof Error ? err.name : "unknown";
findings.push({
code: "inaccessible_media_url",
severity: "error",
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`,
elementId,
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
snippet,
});
}
});
await Promise.all(checks);
return findings;
}
+9
View File
@@ -0,0 +1,9 @@
export type {
HyperframeLintSeverity,
HyperframeLintFinding,
HyperframeLintResult,
HyperframeLinterOptions,
} from "./types.js";
export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter.js";
export { lintProject, shouldBlockRender } from "./project.js";
export type { ProjectLintResult } from "./project.js";
+237
View File
@@ -0,0 +1,237 @@
import { describe, it, expect, afterEach } from "vitest";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { HyperframeLintFinding } from "./types.js";
import { lintProject } from "./project.js";
function tmpProject(name: string): string {
return mkdtempSync(join(tmpdir(), `hf-lint-test-${name}-`));
}
function validHtml(compId = "main"): string {
return `<html><body>
<div data-composition-id="${compId}" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>window.__timelines = window.__timelines || {}; window.__timelines["${compId}"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}
let dirs: string[] = [];
function makeProject(indexHtml: string, subComps?: Record<string, string>): string {
const dir = tmpProject("lint");
dirs.push(dir);
writeFileSync(join(dir, "index.html"), indexHtml);
if (subComps) {
const compsDir = join(dir, "compositions");
mkdirSync(compsDir, { recursive: true });
for (const [name, html] of Object.entries(subComps)) {
writeFileSync(join(compsDir, name), html);
}
}
return dir;
}
afterEach(() => {
for (const d of dirs) {
rmSync(d, { recursive: true, force: true });
}
dirs = [];
});
describe("missing_or_empty_sub_composition", () => {
function htmlWithSubComp(srcPath: string): string {
return `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
<div data-composition-src="${srcPath}" data-composition-id="scene-title" data-start="0" data-duration="5"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}
function validSubCompHtml(): string {
return `<!doctype html><html><body>
<div data-composition-id="scene-title" data-width="1920" data-height="1080">
<div class="title">Hello</div>
</div>
</body></html>`;
}
// Shared assertion: lint a project referencing "compositions/scene-title.html"
// (or a custom srcPath) and return the missing_or_empty_sub_composition
// finding, if any, plus the raw lint result for callers that need totalErrors.
async function lintSubComp(
srcPath: string,
subCompFiles?: Record<string, string>,
): Promise<{ finding: HyperframeLintFinding | undefined; totalErrors: number }> {
const project = makeProject(htmlWithSubComp(srcPath), subCompFiles);
const { totalErrors, results } = await lintProject(project);
const finding = results
.flatMap((r) => r.result.findings)
.find((f) => f.code === "missing_or_empty_sub_composition");
return { finding, totalErrors };
}
it.each([
{
label: "empty",
content: "",
expectMessageContains: "empty",
},
{
label: "whitespace-only",
content: " \n\t ",
expectMessageContains: "empty",
},
{
label: "malformed / non-HTML",
content: "just some plain text, no tags at all",
expectMessageContains: "could not be parsed",
},
])(
"errors when the referenced sub-composition file is $label",
async ({ content, expectMessageContains }) => {
const { finding, totalErrors } = await lintSubComp("compositions/scene-title.html", {
"scene-title.html": content,
});
expect(totalErrors).toBeGreaterThan(0);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain(expectMessageContains);
},
);
it("errors when the referenced sub-composition file does not exist", async () => {
// No subComps passed — compositions/ directory doesn't even exist.
const { finding, totalErrors } = await lintSubComp("compositions/does-not-exist.html");
expect(totalErrors).toBeGreaterThan(0);
expect(finding).toBeDefined();
expect(finding?.message).toContain("compositions/does-not-exist.html");
expect(finding?.message).toContain("does not exist");
});
it("errors when the referenced sub-composition file has content but no data-composition-id root", async () => {
const { finding, totalErrors } = await lintSubComp("compositions/scene-title.html", {
"scene-title.html": "<!doctype html><html><body><p>TODO: scene content</p></body></html>",
});
expect(totalErrors).toBeGreaterThan(0);
expect(finding).toBeDefined();
expect(finding?.message).toContain("data-composition-id");
});
it("does not error when the referenced sub-composition file is valid (happy path)", async () => {
const { finding } = await lintSubComp("compositions/scene-title.html", {
"scene-title.html": validSubCompHtml(),
});
expect(finding).toBeUndefined();
});
it("does not error on a project with no data-composition-src references", async () => {
const project = makeProject(validHtml());
const { results } = await lintProject(project);
const finding = results
.flatMap((r) => r.result.findings)
.find((f) => f.code === "missing_or_empty_sub_composition");
expect(finding).toBeUndefined();
});
it("dedupes a single bad reference into one finding even if repeated", async () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
<div data-composition-src="compositions/scene-title.html" data-composition-id="a" data-start="0" data-duration="5"></div>
<div data-composition-src="compositions/scene-title.html" data-composition-id="b" data-start="5" data-duration="5"></div>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const project = makeProject(html, { "scene-title.html": "" });
const { results } = await lintProject(project);
const findings = results
.flatMap((r) => r.result.findings)
.filter((f) => f.code === "missing_or_empty_sub_composition");
expect(findings).toHaveLength(1);
});
// Regression: lint used to raw-filesystem-walk every .html under
// compositions/, regardless of whether the root composition actually
// references it. render's pre-flight (assertSubCompositionsUsable) only
// follows real data-composition-src references starting from the root, so
// an orphaned file with its own dangling reference made `lint`/`validate`
// fail even though `render` succeeds fine on the same project.
it("does not error on an orphaned, unreferenced file under compositions/ with a dangling reference inside it", async () => {
const project = makeProject(validHtml(), {});
const archivedDir = join(project, "compositions", "archived");
mkdirSync(archivedDir, { recursive: true });
// Never referenced from index.html — this file is unreachable.
writeFileSync(
join(archivedDir, "old-draft.html"),
`<!doctype html><html><body>
<div data-composition-id="old-draft" data-width="1920" data-height="1080">
<div data-composition-src="compositions/does-not-exist.html" data-composition-id="ghost"></div>
</div>
</body></html>`,
);
const { results, totalErrors } = await lintProject(project);
const finding = results
.flatMap((r) => r.result.findings)
.find((f) => f.code === "missing_or_empty_sub_composition");
expect(finding).toBeUndefined();
expect(totalErrors).toBe(0);
});
it("still errors when a broken reference IS reachable from the root (nested, not just top-level)", async () => {
const project = makeProject(htmlWithSubComp("compositions/parent.html"));
mkdirSync(join(project, "compositions"), { recursive: true });
writeFileSync(
join(project, "compositions", "parent.html"),
`<!doctype html><html><body>
<div data-composition-id="scene-title" data-width="1920" data-height="1080">
<div data-composition-src="compositions/does-not-exist.html" data-composition-id="child"></div>
</div>
</body></html>`,
);
const { results, totalErrors } = await lintProject(project);
const finding = results
.flatMap((r) => r.result.findings)
.find((f) => f.code === "missing_or_empty_sub_composition");
expect(totalErrors).toBeGreaterThan(0);
expect(finding).toBeDefined();
expect(finding?.message).toContain("compositions/does-not-exist.html");
});
});
describe("template shell style sources", () => {
it("collects links, style blocks, and inline styles from template content", async () => {
const project = makeProject(`<html><body>
<div id="scene" data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
<template data-composition-id="shell">
<link rel="stylesheet" href="shell.css">
<style>[data-composition-id="main"] .title { opacity: 0; }</style>
<div style="mask-image: url(missing-inline-mask.png)"></div>
<template><style>[data-composition-id="main"] .nested { opacity: 0; }</style></template>
</template>
<script>window.__timelines = {};</script>
</body></html>`);
writeFileSync(
join(project, "shell.css"),
'[data-composition-id="main"] .from-link { opacity: 0; }',
);
const { results } = await lintProject(project);
const findings = results.flatMap((entry) => entry.result.findings);
expect(
findings.filter((finding) => finding.code === "composition_self_attribute_selector"),
).toHaveLength(3);
expect(findings.some((finding) => finding.code === "texture_mask_asset_not_found")).toBe(true);
});
});
+642
View File
@@ -0,0 +1,642 @@
export { shouldBlockRender } from "./shouldBlockRender.js";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path";
import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
import { parseHTML } from "linkedom";
import { lintHyperframeHtml } from "./hyperframeLinter.js";
import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js";
import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity";
/** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */
function parseSubCompHtml(html: string): ParsableDocumentLike {
return parseHTML(html).document as unknown as ParsableDocumentLike;
}
interface HtmlSource {
html: string;
compSrcPath?: string;
}
interface CssSource {
content: string;
rootRelativePath?: string;
}
/** Linkedom keeps template contents in a DocumentFragment that is not part of
* the document query tree. Lint rules must still see shell styles and links
* inside templates, so walk each template's content recursively without
* falling back to regex parsing. */
function querySelectorAllIncludingTemplates(root: ParentNode, selector: string): Element[] {
const matches: Element[] = [...root.querySelectorAll(selector)];
for (const template of root.querySelectorAll("template")) {
const content = (template as HTMLTemplateElement).content;
if (content) matches.push(...querySelectorAllIncludingTemplates(content, selector));
}
return matches;
}
export interface ProjectLintResult {
results: Array<{ file: string; result: HyperframeLintResult }>;
totalErrors: number;
totalWarnings: number;
totalInfos: number;
}
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
const MASK_IMAGE_URL_RE =
/\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi;
function isLocalStylesheetHref(href: string): boolean {
return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href);
}
function collectLocalStylesheets(
projectDir: string,
document: ParentNode,
compSrcPath?: string,
): Array<{ href: string; content: string; rootRelativePath: string }> {
const styles: Array<{ href: string; content: string; rootRelativePath: string }> = [];
for (const link of querySelectorAllIncludingTemplates(document, "link")) {
const rel = link.getAttribute("rel") ?? "";
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
const href = link.getAttribute("href") ?? "";
if (!isLocalStylesheetHref(href)) continue;
const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href;
const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative);
if (!stylesheet) continue;
styles.push({
href,
content: readFileSync(stylesheet.resolved, "utf-8"),
rootRelativePath: stylesheet.rootRelativePath,
});
}
return styles;
}
function collectExternalStyles(
projectDir: string,
html: string,
compSrcPath?: string,
): Array<{ href: string; content: string }> {
const styles: Array<{ href: string; content: string }> = [];
const { document } = parseHTML(html);
for (const { href, content } of collectLocalStylesheets(projectDir, document, compSrcPath)) {
styles.push({ href, content });
}
return styles;
}
function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] {
const sources: CssSource[] = [];
const { document } = parseHTML(html);
for (const style of querySelectorAllIncludingTemplates(document, "style")) {
sources.push({ content: style.textContent ?? "" });
}
for (const { content, rootRelativePath } of collectLocalStylesheets(
projectDir,
document,
compSrcPath,
)) {
sources.push({ content, rootRelativePath });
}
for (const element of querySelectorAllIncludingTemplates(document, "[style]")) {
const style = element.getAttribute("style");
if (!style) continue;
sources.push({ content: style });
}
return sources;
}
function isRemoteOrInlineUrl(url: string): boolean {
return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
}
function cleanAssetUrl(url: string): string {
return url.trim().split(/[?#]/, 1)[0] ?? "";
}
function isWithinProjectRoot(projectDir: string, candidate: string): boolean {
const projectRoot = resolve(projectDir);
const relativePath = relative(projectRoot, candidate);
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
function addCandidate(candidates: string[], candidate: string): void {
if (!candidates.includes(candidate)) candidates.push(candidate);
}
function resolveLocalAssetCandidates(projectDir: string, url: string): string[] {
const cleanUrl = cleanAssetUrl(url);
const projectRoot = resolve(projectDir);
const candidates: string[] = [];
for (const variant of decodeUrlPathVariants(cleanUrl)) {
const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
const resolved = resolve(projectRoot, projectRelative);
if (isWithinProjectRoot(projectRoot, resolved)) {
addCandidate(candidates, resolved);
continue;
}
const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
const clamped = normalized.replace(/^(\.\.\/)+/, "");
if (clamped && !clamped.startsWith("..")) {
addCandidate(candidates, resolve(projectRoot, clamped));
}
}
return candidates;
}
function resolveExistingLocalAsset(
projectDir: string,
url: string,
): { resolved: string; rootRelativePath: string } | null {
const projectRoot = resolve(projectDir);
const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync);
if (!resolved) return null;
return { resolved, rootRelativePath: relative(projectRoot, resolved) };
}
function resolveCssAssetCandidates(
projectDir: string,
url: string,
htmlCompSrcPath?: string,
cssRootRelativePath?: string,
): string[] {
if (url.startsWith("/")) return resolveLocalAssetCandidates(projectDir, url);
if (cssRootRelativePath) {
return resolveLocalAssetCandidates(projectDir, join(dirname(cssRootRelativePath), url));
}
if (htmlCompSrcPath) {
return resolveLocalAssetCandidates(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
}
return resolveLocalAssetCandidates(projectDir, url);
}
export async function lintProject(
projectDir: string,
entryFile?: string,
): Promise<ProjectLintResult> {
const indexPath = entryFile ? resolve(entryFile) : resolve(projectDir, "index.html");
if (entryFile && !isWithinProjectRoot(projectDir, indexPath)) {
throw new Error(`Explicit lint entry is outside the project directory: ${entryFile}`);
}
const rootFile = relative(resolve(projectDir), indexPath).replace(/\\/g, "/") || "index.html";
const rootCompSrcPath = rootFile === "index.html" ? undefined : rootFile;
const results: Array<{ file: string; result: HyperframeLintResult }> = [];
let totalErrors = 0;
let totalWarnings = 0;
let totalInfos = 0;
const rootHtml = readFileSync(indexPath, "utf-8");
const rootResult = await lintHyperframeHtml(rootHtml, {
filePath: indexPath,
externalStyles: collectExternalStyles(projectDir, rootHtml, rootCompSrcPath),
});
results.push({ file: rootFile, result: rootResult });
totalErrors += rootResult.errorCount;
totalWarnings += rootResult.warningCount;
totalInfos += rootResult.infoCount;
const allHtmlSources: HtmlSource[] = [{ html: rootHtml, compSrcPath: rootCompSrcPath }];
const compositionsDir = resolve(projectDir, "compositions");
if (!entryFile && existsSync(compositionsDir)) {
const collectHtmlFiles = (dir: string, rel: string): string[] => {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) out.push(...collectHtmlFiles(join(dir, entry.name), relPath));
else if (entry.isFile() && entry.name.endsWith(".html") && !entry.name.startsWith("._")) {
out.push(relPath);
}
}
return out;
};
const files = collectHtmlFiles(compositionsDir, "").sort();
for (const file of files) {
const filePath = join(compositionsDir, file);
const html = readFileSync(filePath, "utf-8");
const compSrcPath = `compositions/${file}`;
allHtmlSources.push({ html, compSrcPath });
// Mountable fragments (figma component imports, registry snippets) are
// not standalone compositions — composition-root rules don't apply.
// Anchored to the file's ROOT element so a real composition that merely
// inlines snippet markup (or mentions the token in text) is still linted.
if (isSnippetFragment(html)) continue;
const result = await lintHyperframeHtml(html, {
filePath,
isSubComposition: true,
externalStyles: collectExternalStyles(projectDir, html, compSrcPath),
});
results.push({ file: `compositions/${file}`, result });
totalErrors += result.errorCount;
totalWarnings += result.warningCount;
totalInfos += result.infoCount;
}
}
const projectFindings = [
...lintProjectAudioFiles(projectDir, allHtmlSources),
...lintAudioSrcNotFound(projectDir, allHtmlSources),
...lintMissingLocalAsset(projectDir, allHtmlSources),
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
...(!entryFile ? lintMultipleRootCompositions(projectDir) : []),
...lintDuplicateAudioTracks(allHtmlSources),
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
];
if (projectFindings.length > 0) {
for (const finding of projectFindings) {
rootResult.findings.push(finding);
if (finding.severity === "error") {
rootResult.errorCount++;
rootResult.ok = false;
totalErrors++;
} else if (finding.severity === "warning") {
rootResult.warningCount++;
totalWarnings++;
} else {
rootResult.infoCount++;
totalInfos++;
}
}
}
return { results, totalErrors, totalWarnings, totalInfos };
}
function lintProjectAudioFiles(
projectDir: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
let audioFiles: string[];
try {
audioFiles = readdirSync(projectDir).filter((f) =>
AUDIO_EXTENSIONS.has(extname(f).toLowerCase()),
);
} catch {
return findings;
}
if (audioFiles.length === 0) return findings;
const hasAudioElement = htmlSources.some(({ html }) => /<audio\b/i.test(html));
if (!hasAudioElement) {
findings.push({
code: "audio_file_without_element",
severity: "warning",
message: `Found audio file(s) in project (${audioFiles.join(", ")}) but no <audio> element in any composition. The rendered video will be silent.`,
fixHint:
'Add an <audio id="my-audio" src="' +
audioFiles[0] +
'" data-start="0" data-duration="__DURATION__" data-track-index="0" data-volume="1"></audio> element inside the composition root. Replace __DURATION__ with the audio length in seconds.',
});
}
return findings;
}
function lintAudioSrcNotFound(
projectDir: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
const missingSrcs: string[] = [];
for (const { html, compSrcPath } of htmlSources) {
let match: RegExpExecArray | null;
while ((match = audioSrcRe.exec(html)) !== null) {
const src = match[1]!;
if (/^(https?:|data:|blob:)/i.test(src)) continue;
if (/^__[A-Z_]+__$/.test(src)) continue;
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) {
missingSrcs.push(src);
}
}
}
if (missingSrcs.length > 0) {
const unique = [...new Set(missingSrcs)];
findings.push({
code: "audio_src_not_found",
severity: "error",
message: `<audio> element references file(s) not found in the project: ${unique.join(", ")}. The rendered video will be silent.`,
fixHint:
unique.length === 1
? `Add the file "${unique[0]}" to the project directory, or update the src attribute to point to an existing file.`
: `Add the missing files to the project directory, or update the src attributes to point to existing files.`,
});
}
return findings;
}
function maskRange(src: string, pattern: RegExp): string {
return src.replace(pattern, (m) => " ".repeat(m.length));
}
function maskNonScannableRanges(html: string): string {
let out = maskRange(html, /<!--[\s\S]*?-->/g);
out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
return out;
}
// fallow-ignore-next-line complexity
function lintMissingLocalAsset(
projectDir: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
const localAssetSrcRe = /<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
const missingByTag = new Map<string, Map<string, string>>();
for (const { html, compSrcPath } of htmlSources) {
const scannable = maskNonScannableRanges(html);
const re = new RegExp(localAssetSrcRe.source, localAssetSrcRe.flags);
let match: RegExpExecArray | null;
while ((match = re.exec(scannable)) !== null) {
const tagName = (match[1] ?? "").toLowerCase();
const rawSrc = match[2] ?? "";
const src = cleanAssetUrl(rawSrc);
if (!src) continue;
if (isRemoteOrInlineUrl(src)) continue;
if (/^__[A-Z_]+__$/.test(src)) continue;
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
if (resolvedAsset) continue;
const resolvedKey = resolve(projectDir, rootRelative);
let bucket = missingByTag.get(tagName);
if (!bucket) {
bucket = new Map<string, string>();
missingByTag.set(tagName, bucket);
}
if (!bucket.has(resolvedKey)) bucket.set(resolvedKey, src);
}
}
for (const [tagName, byResolved] of missingByTag) {
const unique = [...byResolved.values()];
findings.push({
code: "missing_local_asset",
severity: "error",
message:
`<${tagName}> element references local file(s) not found in the project: ${unique.join(", ")}. ` +
"The renderer will silently skip these and produce a video with missing visuals.",
fixHint:
unique.length === 1
? `Add "${unique[0]}" to the project directory, or update the src attribute to point to an existing file. ` +
"Common cause: captured asset filenames are unreliable (heygen-logo.svg often contains Google, nvidia-logo.svg may contain Autodesk, etc.). " +
"Open the contact sheets and verify the file actually exists at this path before referencing it."
: "Add the missing files to the project directory, or update the src attributes to point to existing files. " +
"Captured asset filenames are unreliable — verify against capture/contact-sheets/ and capture/extracted/asset-descriptions.md.",
});
}
return findings;
}
function lintTextureMaskAssetNotFound(
projectDir: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const missing = new Map<string, string>();
for (const { html, compSrcPath } of htmlSources) {
for (const cssSource of collectCssSources(projectDir, html, compSrcPath)) {
let match: RegExpExecArray | null;
const pattern = new RegExp(MASK_IMAGE_URL_RE.source, MASK_IMAGE_URL_RE.flags);
while ((match = pattern.exec(cssSource.content)) !== null) {
const rawUrl = match[1] ?? match[2] ?? match[3] ?? "";
const url = cleanAssetUrl(rawUrl);
if (!url || isRemoteOrInlineUrl(url)) continue;
if (/^__[A-Z_]+__$/.test(url)) continue;
const candidates = resolveCssAssetCandidates(
projectDir,
url,
compSrcPath,
cssSource.rootRelativePath,
);
if (candidates.some(existsSync)) continue;
missing.set(url, candidates[0] ?? resolve(projectDir, url));
}
}
}
if (missing.size === 0) return [];
const urls = [...missing.keys()];
return [
{
code: "texture_mask_asset_not_found",
severity: "error",
message: `CSS mask-image references file(s) not found in the project: ${urls.join(", ")}.`,
fixHint:
urls.length === 1
? `Add "${urls[0]}" to the project, or update the mask-image URL to point to an existing texture mask.`
: "Add the missing texture mask files to the project, or update the mask-image URLs to point to existing files.",
},
];
}
function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
try {
const rootHtmlFiles = readdirSync(projectDir).filter(
(file) => file.endsWith(".html") && !file.startsWith("._"),
);
const rootCompositions: string[] = [];
for (const file of rootHtmlFiles) {
if (file === "caption-skin.html") continue;
const content = readFileSync(join(projectDir, file), "utf-8");
if (/data-composition-id/i.test(content)) {
rootCompositions.push(file);
}
}
if (rootCompositions.length > 1) {
findings.push({
code: "multiple_root_compositions",
severity: "error",
message: `Multiple root-level HTML files with data-composition-id: ${rootCompositions.join(", ")}. The runtime may discover both as entry points, causing duplicate audio playback.`,
fixHint:
"A project should have exactly one root index.html with data-composition-id. Remove or rename extra files.",
});
}
} catch {
/* directory read failed — skip */
}
return findings;
}
function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
function extractAttr(tag: string, name: string): string | null {
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i");
const m = tag.match(re);
return m?.[1] ?? null;
}
const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
const seen = new Set<string>();
for (const { html } of htmlSources) {
const audioTagRe = /<audio\b[^>]*>/gi;
let match: RegExpExecArray | null;
while ((match = audioTagRe.exec(html)) !== null) {
const tag = match[0];
const trackStr = extractAttr(tag, "data-track-index");
const startStr = extractAttr(tag, "data-start");
const durStr = extractAttr(tag, "data-duration");
const src = extractAttr(tag, "src") ?? "unknown";
if (!trackStr || !startStr) continue;
const trackIndex = parseInt(trackStr, 10);
const start = parseFloat(startStr);
const duration = durStr ? parseFloat(durStr) : Infinity;
const key = `${src}:${start}:${duration}:${trackIndex}`;
if (seen.has(key)) continue;
seen.add(key);
tracks.push({ trackIndex, start, end: start + duration, src });
}
}
for (let i = 0; i < tracks.length; i++) {
for (let j = i + 1; j < tracks.length; j++) {
const a = tracks[i]!;
const b = tracks[j]!;
if (a.trackIndex !== b.trackIndex) continue;
if (a.start < b.end && b.start < a.end) {
findings.push({
code: "duplicate_audio_track",
severity: "warning",
message: `Multiple <audio> elements on track ${a.trackIndex} overlap (${a.src} at ${a.start}-${Number.isFinite(a.end) ? a.end.toFixed(1) : "end"}s, ${b.src} at ${b.start}-${Number.isFinite(b.end) ? b.end.toFixed(1) : "end"}s). This causes layered audio playback.`,
fixHint: "Use non-overlapping time windows or different track indices.",
});
}
}
}
return findings;
}
/**
* Error if a `data-composition-src` reference points at a file that is
* missing, empty, or does not parse to usable HTML. This is the #1 render
* failure bucket in production telemetry: a scene-authoring step (an AI
* agent, most commonly) writes the reference before — or without ever —
* writing valid content into the scene file.
*
* The render pre-flight check (`assertSubCompositionsUsable` in
* `packages/producer/src/services/htmlCompiler.ts`) now aborts the render
* loudly and immediately when this happens, rather than silently dropping
* the scene — so catching it here, before the render even starts, means the
* failure surfaces at lint/validate time with the same message instead of
* only at render time.
*
* Only follows files actually reachable via `data-composition-src` starting
* from the root composition — mirroring the reachability semantics of
* `assertSubCompositionsUsable`. A raw filesystem walk of every `.html`
* under `compositions/` would flag orphaned/unreferenced files that the
* renderer never visits, producing false-positive lint/validate failures on
* projects that actually render fine. Lint, render, and the inliner must
* never disagree about whether a given file would actually render
* something.
*/
function lintMissingOrEmptySubComposition(
projectDir: string,
rootHtml: string,
): HyperframeLintFinding[] {
// Dedup by src path — the same reference can appear from nested sub-comps.
const checked = new Map<string, { srcPath: string; problem: string }>();
const visited = new Set<string>();
// fallow-ignore-next-line complexity
const walk = (html: string): void => {
const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
const scannable = maskNonScannableRanges(html);
let match: RegExpExecArray | null;
while ((match = compositionSrcRe.exec(scannable)) !== null) {
const srcPath = (match[1] ?? "").trim();
if (!srcPath) continue;
if (/^__[A-Z_]+__$/.test(srcPath)) continue; // template placeholder
// data-composition-src is always written root-relative (even from a
// nested sub-composition) — matches the resolution the renderer uses
// in packages/producer/src/services/htmlCompiler.ts (parseSubCompositions
// / assertSubCompositionsUsable).
const filePath = resolve(projectDir, srcPath);
// Circular reference guard — same as assertSubCompositionsUsable.
// Already-visited files were already checked (or are mid-walk); skip
// re-checking/re-recursing but still let a later distinct reference to
// the same broken file surface (checked is keyed by srcPath, not filePath).
if (visited.has(filePath)) continue;
visited.add(filePath);
if (!existsSync(filePath)) {
if (!checked.has(srcPath)) {
checked.set(srcPath, { srcPath, problem: "the file does not exist" });
}
continue;
}
const fileHtml = readFileSync(filePath, "utf-8");
const validity = checkSubCompositionUsability(fileHtml, parseSubCompHtml);
if (!validity.ok) {
if (!checked.has(srcPath)) {
checked.set(srcPath, {
srcPath,
problem: validity.detail ?? "the file is empty or could not be parsed",
});
}
continue;
}
// Usable — recurse into it so nested references are validated too,
// but only because this file is itself reachable from the root.
walk(fileHtml);
}
};
walk(rootHtml);
const findings: HyperframeLintFinding[] = [];
for (const { srcPath, problem } of checked.values()) {
findings.push({
code: "missing_or_empty_sub_composition",
severity: "error",
message: `data-composition-src references "${srcPath}", but ${problem}.`,
fixHint:
`Fix this before rendering — the render pre-flight rejects unusable sub-compositions. ` +
`Write valid HTML into "${srcPath}" — it needs a <template> or <body> containing an element with ` +
`data-composition-id, data-width, and data-height. Preview/studio still tolerates and skips the ` +
"scene while you author it. If a scene-authoring step is still running, wait for it to finish " +
"before referencing the file, or re-run the step that generates it.",
});
}
return findings;
}
/** True when the file's first element carries data-hf-snippet — i.e. the file
* IS a mountable fragment, not a composition that merely contains one. */
function isSnippetFragment(html: string): boolean {
const firstTag = html.match(/<[a-zA-Z][^>]*>/);
if (!firstTag) return false;
return /\bdata-hf-snippet\b/.test(firstTag[0]);
}
+226
View File
@@ -0,0 +1,226 @@
// fallow-ignore-file code-duplication
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("adapter rules", () => {
it("reports error when GSAP is used without a GSAP script tag", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("GSAP");
});
it("does not report missing_gsap_script when GSAP CDN script is present", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 100, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
expect(finding).toBeUndefined();
});
it("reports error when Lottie container exists without a Lottie script tag", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="lottie-player" data-lottie-src="animation.json"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Lottie");
});
it("reports error when lottie.loadAnimation is used without a Lottie script tag", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
lottie.loadAnimation({ container: document.getElementById('lottie'), path: 'anim.json' });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not report missing_lottie_script when Lottie CDN script is present", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="lottie-player" data-lottie-src="animation.json"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
expect(finding).toBeUndefined();
});
it("reports error when Three.js is used without a Three.js script tag", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Three.js");
});
it("does not report missing_three_script when Three.js CDN script is present", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("does not report missing_three_script for an ESM +esm CDN import (jsdelivr)", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/+esm';
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("does not report missing_three_script for an esm.sh/three import", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import * as THREE from 'https://esm.sh/three';
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("does not report missing_three_script for a local three.module.js import", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import { Scene } from './vendor/three.module.js';
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new Scene();
THREE.foo();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("does not report missing_three_script for a bare 'three' import (regression)", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import * as THREE from 'three';
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeUndefined();
});
it("still reports missing_three_script when THREE is used with no three loaded", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script type="module">
import { gsap } from 'https://cdn.jsdelivr.net/npm/gsap@3/+esm';
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_three_script");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not report any adapter errors for composition with no adapter usage", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<div id="content">Hello World</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = { totalDuration: function() { return 3; } };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const adapterFindings = result.findings.filter((f) =>
["missing_gsap_script", "missing_lottie_script", "missing_three_script"].includes(f.code),
);
expect(adapterFindings).toHaveLength(0);
});
});
+58
View File
@@ -0,0 +1,58 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, extractScriptTextsAndSrcs } from "../utils";
export const adapterRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// missing_lottie_script
({ tags, scripts }) => {
const { texts, srcs } = extractScriptTextsAndSrcs(scripts);
const hasLottieAttr = tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null);
const usesLottieApi = texts.some((t) =>
/lottie\.(loadAnimation|setSpeed|play|stop|destroy)\b/.test(t),
);
const hasLottieScript = srcs.some((src) => /lottie/i.test(src));
if (!(hasLottieAttr || usesLottieApi) || hasLottieScript) return [];
return [
{
code: "missing_lottie_script",
severity: "error",
message:
"Composition uses Lottie but no Lottie script is loaded. The animation will not render.",
fixHint:
'Add <script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"></script> before your Lottie code.',
},
];
},
// missing_three_script
({ scripts }) => {
const { texts, srcs } = extractScriptTextsAndSrcs(scripts);
const usesThree = texts.some((t) => /\bTHREE\./.test(t));
const hasThreeScript = srcs.some((src) => /three/i.test(src));
const hasThreeImportMap = texts.some(
(t) =>
/["']three["']/.test(t) &&
/importmap/.test(scripts.find((s) => s.content === t)?.attrs || ""),
);
// Matches any import/from whose specifier contains "three" (bare 'three', or a
// URL/path like .../+esm, esm.sh/three, three.module.js), mirroring the loose
// /three/i treatment of <script src>.
const hasThreeModuleImport = texts.some((t) =>
/\b(?:import|from)\s*[^;\n]*['"][^'"]*three[^'"]*['"]/i.test(t),
);
if (!usesThree || hasThreeScript || hasThreeImportMap || hasThreeModuleImport) return [];
return [
{
code: "missing_three_script",
severity: "error",
message:
"Composition uses Three.js but no Three.js script is loaded. The 3D scene will not render.",
fixHint:
'Add <script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script> before your Three.js code.',
},
];
},
];
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("caption rules", () => {
it("warns when caption exit has no hard kill tl.set", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<div id="caption-container"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "cg-" + gi;
tl.set(groupEl, { opacity: 1 }, group.start);
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
});
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not warn when caption exit has hard kill tl.set", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<div id="caption-container"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "cg-" + gi;
tl.set(groupEl, { opacity: 1 }, group.start);
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
});
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("does not warn for generic GSAP opacity exits in non-caption loops", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
var sceneCaption = document.querySelector("#scene-caption");
CARDS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "card-" + gi;
tl.to(groupEl, { opacity: 0, duration: 0.12 }, 2);
});
window.__timelines["main"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("does not warn on a content frame that only mentions karaoke in a comment", async () => {
const html = `<template id="06-one-platform-template">
<div id="root" data-composition-id="06-one-platform" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// "Minutes, not weeks" lands with a karaoke-style keyword glow
SCREENS.forEach(function (s, i) {
var el = document.getElementById("screen-" + i);
tl.to(el, { y: -40, opacity: 0, duration: 0.3 }, i * 1.3);
});
window.__timelines["06-one-platform"] = tl;
</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("warns when caption group has nowrap without max-width", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: absolute;
white-space: nowrap;
text-align: center;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_text_overflow_risk");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when caption group has nowrap with max-width", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: absolute;
white-space: nowrap;
max-width: 1600px;
overflow: hidden;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "caption_text_overflow_risk" && f.severity === "warning",
);
expect(finding).toBeUndefined();
});
it("warns when caption container uses position: relative", async () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: relative;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_container_relative_position");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
});
+271
View File
@@ -0,0 +1,271 @@
import type { LintContext, HyperframeLintFinding } from "../context";
/** Extract a bracket-balanced array literal starting at the `[` found by `varMatch`. */
// fallow-ignore-next-line complexity
function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | null {
const openIdx = varMatch.index + varMatch[0].length - 1;
let depth = 0;
let inStr = false;
let strChar = "";
for (let i = openIdx; i < src.length; i++) {
const c = src[i]!;
if (inStr) {
if (c === "\\") {
i++;
continue;
}
if (c === strChar) inStr = false;
} else if (c === '"' || c === "'") {
inStr = true;
strChar = c;
} else if (c === "[") {
depth++;
} else if (c === "]") {
depth--;
if (depth === 0) return src.slice(openIdx, i + 1);
}
}
return null;
}
export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// caption_exit_missing_hard_kill
({ scripts, styles, options, rootCompositionId }) => {
const findings: HyperframeLintFinding[] = [];
// Only the ACTUAL captions composition. A content frame that merely mentions
// "karaoke" / "caption-*" in a comment (or uses an unrelated forEach + opacity:0
// screen-swap) is NOT captions — gating here prevents the false positive that fired
// on a content frame whose only caption signal was a descriptive comment.
const isCaptionComposition =
Boolean(options.filePath && /caption/i.test(options.filePath)) ||
rootCompositionId === "captions" ||
styles.some((s) => /\.caption[-_]?(?:group|word|line|block)\b|\.cg-/.test(s.content));
if (!isCaptionComposition) return findings;
for (const script of scripts) {
const content = script.content;
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
const hasHardKill =
/\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(
content,
);
const hasCaptionLoop =
/forEach|\.forEach\s*\(/.test(content) &&
/karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content);
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
findings.push({
code: "caption_exit_missing_hard_kill",
severity: "error",
message:
"Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. " +
"Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
fixHint:
'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every ' +
"exit tl.to animation as a deterministic kill.",
});
}
}
return findings;
},
// caption_text_overflow_risk
({ styles }) => {
const findings: HyperframeLintFinding[] = [];
for (const style of styles) {
const captionBlocks = style.content.matchAll(
/(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
);
for (const [, selector, body] of captionBlocks) {
if (!body) continue;
const hasNowrap = /white-space\s*:\s*nowrap/i.test(body);
const hasMaxWidth = /max-width/i.test(body);
if (hasNowrap && !hasMaxWidth) {
findings.push({
code: "caption_text_overflow_risk",
severity: "warning",
selector: (selector ?? "").trim(),
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
fixHint:
"Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden.",
});
}
}
}
return findings;
},
// caption_transcript_not_inline
// fallow-ignore-next-line complexity
({ scripts, styles, options }) => {
const findings: HyperframeLintFinding[] = [];
// Only check files that look like caption compositions
const isCaptionFile =
(options.filePath && /caption/i.test(options.filePath)) ||
styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
if (!isCaptionFile) return findings;
const allScript = scripts.map((s) => s.content).join("\n");
const hasInlineTranscript = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.test(
allScript,
);
const hasFetchTranscript = /fetch\s*\(\s*["'][^"']*transcript/i.test(allScript);
if (!hasInlineTranscript && hasFetchTranscript) {
findings.push({
code: "caption_transcript_not_inline",
severity: "error",
message:
"Captions composition loads transcript via fetch(). The studio caption editor " +
"requires an inline `var TRANSCRIPT = [...]` array to detect and edit captions.",
fixHint:
'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` ' +
"with JSON-quoted property keys. See the captions skill for details.",
});
}
if (hasInlineTranscript) {
// Verify the inline transcript can be parsed.
// Use a balanced-bracket scan instead of a regex to correctly handle
// nested arrays (e.g. word-level timing arrays inside each entry).
const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
if (transcriptJson) {
try {
JSON.parse(transcriptJson);
} catch {
findings.push({
code: "caption_transcript_parse_error",
severity: "error",
message:
"Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail " +
"to parse it. Common cause: unquoted property keys with apostrophes in text.",
fixHint:
'Use JSON-quoted keys: { "text": "don\'t", "start": 0, "end": 1 } instead of ' +
'{ text: "don\'t", start: 0, end: 1 }.',
});
}
}
}
return findings;
},
// caption_container_relative_position
({ styles }) => {
const findings: HyperframeLintFinding[] = [];
for (const style of styles) {
const captionBlocks = style.content.matchAll(
/(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
);
for (const [, selector, body] of captionBlocks) {
if (!body) continue;
if (/position\s*:\s*relative/i.test(body)) {
findings.push({
code: "caption_container_relative_position",
severity: "error",
selector: (selector ?? "").trim(),
message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
fixHint: "Use position: absolute for all caption elements.",
});
}
}
}
return findings;
},
// caption_overflow_clips_scaled_words
({ styles, scripts }) => {
const findings: HyperframeLintFinding[] = [];
const hasScaledWords = scripts.some(
(s) => /scale\s*:\s*1\.[2-9]/.test(s.content) && /caption|word|cg-/.test(s.content),
);
if (!hasScaledWords) return findings;
for (const style of styles) {
const captionBlocks = style.content.matchAll(
/(\.caption[-_]?(?:group|container)|#caption[-_]?(?:layer|container))\s*\{([^}]+)\}/gi,
);
for (const [, selector, body] of captionBlocks) {
if (!body) continue;
if (/overflow\s*:\s*hidden/i.test(body)) {
findings.push({
code: "caption_overflow_clips_scaled_words",
severity: "error",
selector: (selector ?? "").trim(),
message: `"${(selector ?? "").trim()}" has overflow: hidden but GSAP scales caption words above 1.0x. Scaled emphasis words and their glow effects will be clipped.`,
fixHint:
"Use overflow: visible on caption containers. Rely on fitTextFontSize with reduced maxWidth to prevent overflow instead.",
});
}
}
}
return findings;
},
// caption_textshadow_on_group_container
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
if (!isCaptionFile) return findings;
for (const script of scripts) {
// Detect textShadow tweened on a group container (div with child word spans)
const groupShadowPattern =
/\.to\s*\(\s*(?:div|groupEl|el|captionEl|document\.getElementById\s*\(\s*["']cg-)\s*[^,]*,\s*\{[^}]*textShadow/g;
// Also catch selector-based targeting of group containers
const selectorShadowPattern =
/\.to\s*\(\s*["'](?:#cg-\d+|\.caption[-_]?group)["']\s*,\s*\{[^}]*textShadow/g;
if (groupShadowPattern.test(script.content) || selectorShadowPattern.test(script.content)) {
findings.push({
code: "caption_textshadow_on_group_container",
severity: "warning",
message:
"textShadow is tweened on a caption group container. When children have semi-transparent " +
"color (e.g., inactive karaoke words at rgba opacity), the glow renders as a visible " +
"rectangle behind the entire group.",
fixHint:
"Apply textShadow to individual active word elements instead of the group container. " +
"Use scale on the group for bass-reactive pulsing.",
});
}
}
return findings;
},
// caption_fittext_scale_mismatch
// fallow-ignore-next-line complexity
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = script.content;
const fitTextMatch = content.match(/fitTextFontSize\s*\([^)]*maxWidth\s*:\s*(\d+)/);
if (!fitTextMatch) continue;
const maxWidth = parseInt(fitTextMatch[1] ?? "0", 10);
if (!maxWidth) continue;
// Find max scale on caption words
const scaleMatches = [...content.matchAll(/scale\s*:\s*(1\.\d+)/g)];
const captionContext = /caption|word|cg-|karaoke/i.test(content);
if (!captionContext || scaleMatches.length === 0) continue;
let maxScale = 1;
for (const m of scaleMatches) {
const val = parseFloat(m[1] ?? "1");
if (val > maxScale) maxScale = val;
}
// Check if maxWidth * maxScale exceeds safe bounds (1920 - reasonable margins)
const effectiveWidth = maxWidth * maxScale;
if (effectiveWidth > 1760) {
findings.push({
code: "caption_fittext_scale_mismatch",
severity: "warning",
message:
`fitTextFontSize uses maxWidth: ${maxWidth}px but emphasis words scale up to ${maxScale}x. ` +
`Effective width ${Math.round(effectiveWidth)}px may overflow the composition (1920px minus margins).`,
fixHint: `Reduce maxWidth to ${Math.floor(1700 / maxScale)}px to leave headroom for scaled emphasis words.`,
});
}
}
return findings;
},
];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+954
View File
@@ -0,0 +1,954 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
function compositionWithHead(headContent: string): string {
return `
<html>
<head>
${headContent}
</head>
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body>
</html>`;
}
function compositionWithHeadBoundary(boundaryContent: string): string {
return `
<html>
<head>
<style>
body { margin: 0; }
</style>
</head>
${boundaryContent}
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body>
</html>`;
}
function compositionWithBodyPrefix(prefixContent: string, rootContent = ""): string {
return `
<html>
<head>
<style>
body { margin: 0; }
</style>
</head>
<body>
${prefixContent}
<div data-composition-id="c1" data-width="1920" data-height="1080">
${rootContent}
</div>
<script>window.__timelines = {};</script>
</body>
</html>`;
}
function compositionWithImplicitBodyPrefix(prefixContent: string): string {
return `
<html>
<head>
<style>
body { margin: 0; }
</style>
</head>
${prefixContent}
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</html>`;
}
function templateCompositionWithHead(headContent: string): string {
return `
<template>
<html>
<head>
${headContent}
</head>
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
</body>
</html>
</template>`;
}
describe("core rules", () => {
it("does not lint scripts embedded inside an iframe srcdoc attribute", async () => {
const html = `
<html><body>
<div data-composition-id="root" data-width="1280" data-height="720"></div>
<iframe srcdoc="<script>const child = gsap.timeline({ paused: true }); child.to(&quot;#x&quot;, { opacity: 1 });</script>"></iframe>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const rootTl = gsap.timeline({ paused: true });
window.__timelines["root"] = rootTl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((finding) => finding.code === "invalid_inline_script_syntax"),
).toBeUndefined();
expect(
result.findings.find((finding) => finding.code === "gsap_timeline_not_registered"),
).toBeUndefined();
});
it("does not lint elements embedded inside an iframe srcdoc attribute", async () => {
const html = `
<html><body>
<div data-composition-id="root" data-width="1280" data-height="720"></div>
<iframe srcdoc='<video src="child.mp4" data-start="0"></video>'></iframe>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find(
(finding) => finding.elementId === undefined && finding.message.includes("<video"),
),
).toBeUndefined();
});
it("warns when an id starts with a digit and is unsafe in a hash selector", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="123-frame"></div>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "id_requires_css_escape");
expect(finding?.severity).toBe("warning");
expect(finding?.elementId).toBe("123-frame");
expect(finding?.fixHint).toContain("CSS.escape");
});
it("accepts ids that start with a letter", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="frame-123"></div>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((item) => item.code === "id_requires_css_escape")).toBeUndefined();
});
it("reports error when root is missing data-composition-id", async () => {
const html = `
<html><body>
<div id="root" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_missing_composition_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error when root is missing data-width or data-height", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "root_missing_dimensions");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("accepts body as the composition root", async () => {
const html = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<div id="overlay-flash"></div>
<script>window.__timelines = window.__timelines || {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
});
it("skips a leading <svg> defs block when detecting the composition root", async () => {
// Regression: two independent reports of a leading <svg><defs><filter>...
// block (icon/gradient/filter plumbing referenced via url(#id) elsewhere)
// getting mistaken for the composition root, since findRootTag returned
// the first non-script/style/meta/link/title body child unconditionally.
// The <svg> here carries no composition markers, so it must be skipped in
// favor of the real root that follows it.
const html = `
<html><body>
<svg width="0" height="0" style="position:absolute">
<defs><filter id="glow"><feGaussianBlur stdDeviation="4" /></filter></defs>
</svg>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = window.__timelines || {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
});
it("still treats an <svg> as the root when it carries composition markers itself", async () => {
const html = `
<html><body>
<svg id="root" data-composition-id="c1" data-width="1920" data-height="1080"></svg>
<script>window.__timelines = window.__timelines || {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
});
it("reports error when timeline registry is missing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
expect(finding).toBeDefined();
});
it("allows a timeline-free root that explicitly declares data-no-timeline", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-no-timeline data-width="1920" data-height="1080" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined();
});
it("does not flag missing_timeline_registry on a sub-composition (inherits from host)", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
expect(finding).toBeUndefined();
});
it("reports error for composition host missing data-composition-id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="host1" data-composition-src="child.html"></div>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "host_missing_composition_id");
expect(finding).toBeDefined();
});
it("reports error when timeline registry is assigned without initializing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("without initializing");
});
it("reports error when dot timeline registry is assigned without initializing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines.c1 = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not flag timeline assignment when init guard is present", async () => {
const validComposition = `
<html>
<body>
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080">
<div id="stage"></div>
</div>
<script src="https://cdn.gsap.com/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
window.__timelines["comp-1"] = tl;
</script>
</body>
</html>`;
const result = await lintHyperframeHtml(validComposition);
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
expect(finding).toBeUndefined();
});
it("reports error when CSS text is left outside a style block in the document head", async () => {
const html = compositionWithHead(`
<style>
body { margin: 0; }
</style>
</style>
/* Decorative Elements */
.particle {
position: absolute;
width: 4px;
height: 4px;
background: #fff;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("<head>");
expect(finding?.snippet).toContain(".particle");
});
it("reports error when CSS variables leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
--bg-color: #F5F1E8;
--text-color: #212121;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.message).toContain("<head>");
expect(finding?.snippet).toContain("body");
});
it("reports error when stray close tags leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
</style>
</script>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("</style>");
});
it("reports error when markdown code fences leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
\`\`\`css
.particle {
color: white;
}
\`\`\`
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("```css");
});
it("reports error when CSS at-rules leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
@media (min-width: 800px) {
.particle {
transform: scale(1.2);
}
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("@media");
});
it("does not report leaked text for valid script and style blocks around the head boundary", async () => {
const html = compositionWithHeadBoundary(`
<script>
window.__headReady = true;
</script>
<template>
<style>
.template-only { color: red; }
</style>
</template>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports error when CSS text leaks before the composition root", async () => {
const html = compositionWithBodyPrefix(`
.orphan {
position: absolute;
inset: 0;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".orphan");
});
it("reports error when CSS text leaks before the composition root without an explicit body", async () => {
const html = compositionWithImplicitBodyPrefix(`
.implicit-body-orphan {
position: absolute;
inset: 0;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".implicit-body-orphan");
});
it("does not report leaked text for valid script and style blocks before the composition root", async () => {
const html = compositionWithBodyPrefix(`
<style>
.pre-root-helper { color: red; }
</style>
<script>
window.__preRootReady = true;
</script>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("does not report CSS-looking educational text inside the composition root", async () => {
const html = compositionWithBodyPrefix(
"",
`
<pre>
body {
margin: 0;
}
</pre>
`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports error when CSS block comment syntax leaks into visible markup", async () => {
const html = compositionWithBodyPrefix(
"",
`
/* Main Content Block */
<div class="editorial-block">Hello</div>
`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("visible HTML markup");
expect(finding?.snippet).toContain("Main Content Block");
});
it("reports error when a misbalanced style block leaves block comment syntax visible", async () => {
const html = compositionWithBodyPrefix(
"",
`
<style>
.editorial-block { color: #fff; }
</style>
</style>
/* Main Content Block */
<div class="editorial-block">Hello</div>
`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("Main Content Block");
});
it("does not report block comments inside style or script blocks", async () => {
const html = `
<html>
<head>
<title>/* tab name */ Particle Field</title>
<style>
/* Layout reset */
body { margin: 0; }
</style>
<noscript>/* fallback note */</noscript>
</head>
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
/* Timeline registry */
window.__timelines = {};
</script>
</body>
</html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
expect(finding).toBeUndefined();
});
it("does not report block comments in attributes, html comments, or protected text contexts", async () => {
const html = compositionWithBodyPrefix(
"",
`
<!-- /* hidden implementation note */ -->
<div data-note="/* attribute note */"></div>
<div data-note="a > b /* quoted attribute note */"></div>
<pre>/* visible code sample */</pre>
<code>/* visible inline code sample */</code>
<textarea>/* editable code sample */</textarea>
<template>/* template-only note */</template>
<svg viewBox="0 0 100 20"><text x="0" y="15">/* svg label */</text></svg>
`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
expect(finding).toBeUndefined();
});
it("reports error when a stray style close tag is left in the document head", async () => {
const html = compositionWithHead(`
<style>
body { margin: 0; }
</style>
</style>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("</style>");
});
it("reports error when a stray script close tag is left in the document head", async () => {
const html = compositionWithHead(`
<script>
window.__headReady = true;
</script>
</script>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("</script>");
});
it("does not report leaked head text for valid closing tags with trailing whitespace", async () => {
const html = compositionWithHead(`
<style>
body { margin: 0; }
</style data-parser-error-close>
<script>
window.__headReady = true;
</script
data-parser-error-close>
<title>Particle Field</title >
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports error when markdown code fences leak into the document head", async () => {
const withLanguage = compositionWithHead(`
\`\`\`css
.particle {
position: absolute;
}
\`\`\`
`);
const withoutLanguage = compositionWithHead(`
\`\`\`
.particle {
position: absolute;
}
\`\`\`
`);
const withTsxLanguage = compositionWithHead(`
\`\`\`tsx
export function Particle() {
return <div className="particle" />;
}
\`\`\`
`);
const withLanguageResult = await lintHyperframeHtml(withLanguage);
const withoutLanguageResult = await lintHyperframeHtml(withoutLanguage);
const withTsxLanguageResult = await lintHyperframeHtml(withTsxLanguage);
const languageFinding = withLanguageResult.findings.find((f) => f.code === "head_leaked_text");
const unlabeledFinding = withoutLanguageResult.findings.find(
(f) => f.code === "head_leaked_text",
);
const tsxLanguageFinding = withTsxLanguageResult.findings.find(
(f) => f.code === "head_leaked_text",
);
expect(languageFinding).toBeDefined();
expect(languageFinding?.snippet).toContain("```css");
expect(unlabeledFinding).toBeDefined();
expect(unlabeledFinding?.snippet).toContain("```");
expect(tsxLanguageFinding).toBeDefined();
expect(tsxLanguageFinding?.snippet).toContain("```tsx");
});
it("reports error when CSS at-rules leak into the document head", async () => {
const html = compositionWithHead(`
@media (min-width: 800px) {
.particle {
transform: scale(1.2);
}
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("@media");
});
it("reports leaked CSS when a style block is unclosed in the document head", async () => {
const html = compositionWithHead(`
<style>
.particle {
color: white;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".particle");
});
it("does not report leaked head text for commented CSS", async () => {
const html = compositionWithHead(`
<!-- .particle { color: red; } -->
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("does not report leaked head text for valid noscript content", async () => {
const html = compositionWithHead(`
<noscript>
.no-js { display: block; }
</noscript>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("does not report orphan CSS for valid head metadata and style blocks", async () => {
const html = compositionWithHead(`
<title>Particle Field</title>
<meta name="description" content="Particle field">
<link rel="preconnect" href="https://fonts.gstatic.com">
<base href="https://example.com/">
<style>
.particle {
position: absolute;
width: 4px;
height: 4px;
}
</style>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports leaked head text inside template-wrapped sub-compositions", async () => {
const html = templateCompositionWithHead(`
</style>
.particle { color: white; }
`);
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".particle");
});
describe("timeline_id_mismatch", () => {
it("accepts dot timeline registration", async () => {
const html = `
<html><body>
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines.launch = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeUndefined();
});
it("reports mismatched dot timeline registration", async () => {
const html = `
<html><body>
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines.intro = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeDefined();
expect(finding?.message).toContain('Timeline registered as "intro"');
});
it("accepts bracket timeline registration for hyphenated ids", async () => {
const html = `
<html><body>
<div data-composition-id="product-launch" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["product-launch"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeUndefined();
});
it("accepts object-literal timeline registration and extracts its keys", async () => {
const html = `
<html><body>
<div data-composition-id="comp-1" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
window.__timelines = { "comp-1": tl };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined();
expect(
result.findings.find((f) => f.code === "timeline_registry_missing_init"),
).toBeUndefined();
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined();
});
it("reports mismatched object-literal timeline registration keys", async () => {
const html = `
<html><body>
<div data-composition-id="comp-1" data-width="1920" data-height="1080"></div>
<script>
const tl = gsap.timeline({ paused: true });
window.__timelines = { main: tl };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
expect(finding).toBeDefined();
expect(finding?.message).toContain('Timeline registered as "main"');
});
});
it("warns when a timeline-visible element has no stable id for Studio editing", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<section class="clip hero-card" data-start="0" data-duration="3"></section>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "studio_missing_editable_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.message).toContain('<section class="hero-card" data-start="0">');
expect(finding?.fixHint).toContain("stable, human-readable id");
});
it("does not warn about the composition root or timeline elements with ids", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
<section id="hero-card" class="clip hero-card" data-start="0" data-duration="3"></section>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "studio_missing_editable_id");
expect(finding).toBeUndefined();
});
describe("non_deterministic_code", () => {
it("detects Math.random() in script content", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const x = Math.random();
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Math.random");
});
it("detects Date.now() in script content", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const ts = Date.now();
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("Date.now");
});
it("does not flag non-deterministic calls inside single-line comments", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
// const x = Math.random();
// Date.now() is not used here
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
expect(finding).toBeUndefined();
});
});
describe("composition_self_attribute_selector", () => {
it("warns when inline CSS targets the root composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
<style>
[data-composition-id="scene"] .title { opacity: 0; }
[data-composition-id="other"] .title { color: red; }
</style>
<h1 class="title">Hello</h1>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter(
(f) => f.code === "composition_self_attribute_selector",
);
expect(findings).toHaveLength(1);
expect(findings[0]?.severity).toBe("warning");
expect(findings[0]?.selector).toBe('[data-composition-id="scene"] .title');
expect(findings[0]?.fixHint).toContain("#scene");
expect(findings[0]?.fixHint).not.toContain("#556");
});
it("warns when external CSS targets the root composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html, {
externalStyles: [
{
href: "scene.css",
content: '[data-composition-id="scene"] .title { opacity: 0; }',
},
],
});
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
expect(finding).toBeDefined();
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
});
it("does not warn when CSS targets a different composition id", async () => {
const html = `
<html><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
<style>[data-composition-id="other"] .title { opacity: 0; }</style>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
expect(finding).toBeUndefined();
});
});
});
+566
View File
@@ -0,0 +1,566 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import postcss from "postcss";
import {
readAttr,
truncateSnippet,
stripJsComments,
extractCompositionIdsFromCss,
extractTimelineRegistryKeys,
getInlineScriptSyntaxError,
TIMELINE_REGISTRY_INIT_PATTERN,
TIMELINE_REGISTRY_ASSIGN_PATTERN,
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
INVALID_SCRIPT_CLOSE_PATTERN,
} from "../utils";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function selectorTargetsCompositionId(selector: string, compositionId: string): boolean {
const escaped = escapeRegExp(compositionId);
return new RegExp(
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escaped}"|'${escaped}')\s*\]`,
).test(selector);
}
function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
return false;
}
return Boolean(
readAttr(tag.raw, "data-start") ||
readAttr(tag.raw, "data-track-index") ||
readAttr(tag.raw, "data-track") ||
readAttr(tag.raw, "data-composition-src") ||
readAttr(tag.raw, "data-composition-file"),
);
}
function describeStudioElement(tag: { raw: string; name: string }): string {
const parts = [`<${tag.name}`];
const className = readAttr(tag.raw, "class");
const compositionId = readAttr(tag.raw, "data-composition-id");
const dataStart = readAttr(tag.raw, "data-start");
const dataTrack = readAttr(tag.raw, "data-track-index") ?? readAttr(tag.raw, "data-track");
if (className) {
const primaryClass = className
.split(/\s+/)
.map((value) => value.trim())
.find((value) => value && value !== "clip");
if (primaryClass) parts.push(` class="${primaryClass}"`);
}
if (compositionId) parts.push(` data-composition-id="${compositionId}"`);
if (dataStart) parts.push(` data-start="${dataStart}"`);
if (dataTrack) parts.push(` data-track-index="${dataTrack}"`);
parts.push(">");
return parts.join("");
}
const HEAD_BLOCKS_TO_IGNORE_PATTERN =
/<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi;
const HTML_TAG_PATTERN = /<[^>]+>/g;
const HEAD_CONTENT_PATTERN = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
const AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
const STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
const MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
const ORPHAN_CSS_AT_RULE_PATTERN =
/(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
const ORPHAN_CSS_RULE_PATTERN =
/(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
const VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
const VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN =
/<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;
interface SourceRange {
start: number;
end: number;
}
function findCodeFenceLeak(headWithoutValidBlocks: string): string | null {
return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
}
function findOrphanCssLeak(headContent: string): string | null {
const residualText = headContent
.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ")
.replace(HTML_TAG_PATTERN, " ");
return (
ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ??
ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ??
null
);
}
function findStrayCloseLeak(headWithoutValidBlocks: string): string | null {
return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
}
function findLeakedTextInHeadContent(headContent: string): string | null {
const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ");
return (
findCodeFenceLeak(withoutValidBlocks) ??
findOrphanCssLeak(headContent) ??
findStrayCloseLeak(withoutValidBlocks)
);
}
function findLeakedTextInHead(rawSource: string): string | null {
const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)];
for (const match of headMatches) {
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
if (leakedText) return leakedText;
}
return null;
}
function findLeakedTextBetweenHeadAndBody(rawSource: string): string | null {
const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
for (const match of boundaryMatches) {
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
if (leakedText) return leakedText;
}
return null;
}
function findLeakedTextBeforeCompositionRoot(
source: string,
rootTag: LintContext["rootTag"],
): string | null {
if (!rootTag || rootTag.name === "body") return null;
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
const prefixEnd = rootTag.index;
if (prefixEnd <= prefixStart) return null;
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
}
function findProtectedVisibleMarkupRanges(source: string): SourceRange[] {
const ranges: SourceRange[] = [];
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
return ranges;
}
function isInsideSourceRange(index: number, ranges: SourceRange[]): boolean {
return ranges.some((range) => range.start <= index && index < range.end);
}
function isInsideHtmlTag(source: string, index: number): boolean {
let inTag = false;
let quote: '"' | "'" | null = null;
for (let i = 0; i < index; i++) {
const char = source[i];
if (!inTag) {
if (char === "<") inTag = true;
continue;
}
if (quote) {
if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
} else if (char === ">") {
inTag = false;
}
}
return inTag;
}
function findVisibleMarkupCommentLeak(source: string): string | null {
const protectedRanges = findProtectedVisibleMarkupRanges(source);
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PATTERN)) {
if (isInsideHtmlTag(source, match.index)) continue;
if (isInsideSourceRange(match.index, protectedRanges)) continue;
return match[0];
}
return null;
}
export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// id_requires_css_escape
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
const id = readAttr(tag.raw, "id");
if (!id || !/^\d/.test(id)) continue;
findings.push({
code: "id_requires_css_escape",
severity: "warning",
message: `id="${id}" starts with a digit, so the common selector \`#${id}\` throws a SyntaxError in querySelector().`,
elementId: id,
fixHint:
"Rename the id to start with a letter (recommended), or build selectors with `#${CSS.escape(id)}` at runtime.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// root_missing_composition_id + root_missing_dimensions
({ rootTag }) => {
const findings: HyperframeLintFinding[] = [];
if (!rootTag || !readAttr(rootTag.raw, "data-composition-id")) {
findings.push({
code: "root_missing_composition_id",
severity: "error",
message: "Root composition is missing `data-composition-id`.",
elementId: rootTag ? readAttr(rootTag.raw, "id") || undefined : undefined,
fixHint: "Add a stable `data-composition-id` to the entry composition wrapper.",
snippet: truncateSnippet(rootTag?.raw || ""),
});
}
if (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height")) {
findings.push({
code: "root_missing_dimensions",
severity: "error",
message: "Root composition is missing `data-width` or `data-height`.",
elementId: rootTag ? readAttr(rootTag.raw, "id") || undefined : undefined,
fixHint: "Set numeric `data-width` and `data-height` on the entry composition root.",
snippet: truncateSnippet(rootTag?.raw || ""),
});
}
return findings;
},
// head_leaked_text
({ source, rootTag }) => {
const snippet =
findLeakedTextInHead(source) ??
findLeakedTextBetweenHeadAndBody(source) ??
findLeakedTextBeforeCompositionRoot(source, rootTag);
if (!snippet) return [];
return [
{
code: "head_leaked_text",
severity: "error",
message:
"Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
fixHint:
"Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
snippet: truncateSnippet(snippet),
},
];
},
// visible_markup_comment
({ source }) => {
const snippet = findVisibleMarkupCommentLeak(source);
if (!snippet) return [];
return [
{
code: "visible_markup_comment",
severity: "error",
message:
"CSS/JS block comment syntax (`/* ... */`) appears in visible HTML markup. HTML only treats `<!-- ... -->` as comments, so this renders as on-screen text.",
fixHint:
"Remove the text or convert it to a real HTML comment (`<!-- ... -->`). Keep CSS comments inside `<style>` and JS comments inside `<script>`.",
snippet: truncateSnippet(snippet),
},
];
},
// missing_timeline_registry + timeline_registry_missing_init
({ source, rawSource, rootTag, options }) => {
// Sub-compositions inherit window.__timelines from the host composition
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
return [];
}
if (/(?:^|\s)data-no-timeline(?=[\s=/]|$)/i.test(rootTag?.attrs || "")) return [];
const findings: HyperframeLintFinding[] = [];
if (
!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
!TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(source)
) {
findings.push({
code: "missing_timeline_registry",
severity: "error",
message: "Missing `window.__timelines` registration.",
fixHint: "Register each composition timeline on `window.__timelines[compositionId]`.",
});
}
if (
TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
!TIMELINE_REGISTRY_INIT_PATTERN.test(source)
) {
findings.push({
code: "timeline_registry_missing_init",
severity: "error",
message:
"`window.__timelines[…] = …` is used without initializing `window.__timelines` first.",
fixHint:
"Add `window.__timelines = window.__timelines || {};` before any timeline assignment.",
});
}
return findings;
},
// timeline_id_mismatch
({ source }) => {
const findings: HyperframeLintFinding[] = [];
const htmlCompIds = new Set<string>();
const timelineRegKeys = new Set<string>();
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
let m: RegExpExecArray | null;
while ((m = compIdRe.exec(source)) !== null) {
if (m[1]) htmlCompIds.add(m[1]);
}
for (const key of extractTimelineRegistryKeys(source)) {
timelineRegKeys.add(key);
}
for (const key of timelineRegKeys) {
if (!htmlCompIds.has(key)) {
findings.push({
code: "timeline_id_mismatch",
severity: "error",
message: `Timeline registered as "${key}" but no element has data-composition-id="${key}". The runtime cannot auto-nest this timeline.`,
fixHint: `Change window.__timelines["${key}"] to match the data-composition-id attribute, or vice versa.`,
});
}
}
return findings;
},
// invalid_inline_script_syntax (malformed close tag)
({ source }) => {
if (!INVALID_SCRIPT_CLOSE_PATTERN.test(source)) return [];
return [
{
code: "invalid_inline_script_syntax",
severity: "error",
message: "Detected malformed inline `<script>` closing syntax.",
fixHint: "Close inline scripts with a valid `</script>` tag.",
},
];
},
// invalid_inline_script_syntax (JS parse error)
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const attrs = script.attrs || "";
if (
/\bsrc\s*=/.test(attrs) ||
/\btype\s*=\s*["'](?:application\/json|application\/hyperframes-slideshow\+json|importmap|module)["']/.test(
attrs,
)
)
continue;
const syntaxError = getInlineScriptSyntaxError(script.content);
if (!syntaxError) continue;
findings.push({
code: "invalid_inline_script_syntax",
severity: "error",
message: `Inline script has invalid syntax: ${syntaxError}`,
fixHint: "Fix the inline script syntax before render verification.",
snippet: truncateSnippet(script.content),
});
}
return findings;
},
// host_missing_composition_id
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
const src = readAttr(tag.raw, "data-composition-src");
if (!src) continue;
if (readAttr(tag.raw, "data-composition-id")) continue;
findings.push({
code: "host_missing_composition_id",
severity: "error",
message: `Composition host for "${src}" is missing \`data-composition-id\`.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint: "Set `data-composition-id` on every `data-composition-src` host element.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// scoped_css_missing_wrapper
({ styles, compositionIds }) => {
const findings: HyperframeLintFinding[] = [];
const scopedCssCompositionIds = new Set<string>();
for (const style of styles) {
for (const compId of extractCompositionIdsFromCss(style.content)) {
scopedCssCompositionIds.add(compId);
}
}
for (const compId of scopedCssCompositionIds) {
if (compositionIds.has(compId)) continue;
findings.push({
code: "scoped_css_missing_wrapper",
severity: "warning",
message: `Scoped CSS targets composition "${compId}" but no matching wrapper exists in this HTML.`,
selector: `[data-composition-id="${compId}"]`,
fixHint:
"Preserve the matching composition wrapper or align the CSS scope to an existing wrapper.",
});
}
return findings;
},
// composition_self_attribute_selector
({ styles, rootCompositionId, rootTag }) => {
const findings: HyperframeLintFinding[] = [];
if (!rootCompositionId) return findings;
const seenSelectors = new Set<string>();
const rootId = readAttr(rootTag?.raw || "", "id");
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkRules((rule) => {
for (const selector of rule.selectors) {
if (!selectorTargetsCompositionId(selector, rootCompositionId)) continue;
if (seenSelectors.has(selector)) continue;
seenSelectors.add(selector);
findings.push({
code: "composition_self_attribute_selector",
severity: "warning",
message:
"Selector matches the block's own id; will leak to sibling instances when the block is embedded twice.",
selector,
fixHint: rootId
? `Use #${rootId} for clearer authoring intent and instance-isolated styling.`
: "Add a stable id to the composition root and use that id selector for clearer authoring intent and instance-isolated styling.",
});
}
});
}
return findings;
},
// studio_missing_editable_id
({ tags, rootTag }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (rootTag && tag.index === rootTag.index) continue;
if (!isStudioTimelineElement(tag)) continue;
if (readAttr(tag.raw, "id")) continue;
const descriptor = describeStudioElement(tag);
findings.push({
code: "studio_missing_editable_id",
severity: "warning",
message: `${descriptor} has no id, so Studio cannot use a stable edit target for its timeline and canvas controls.`,
selector: readAttr(tag.raw, "data-composition-id")
? `[data-composition-id="${readAttr(tag.raw, "data-composition-id")}"]`
: undefined,
fixHint:
'Add a stable, human-readable id such as id="hero-title" or id="scene-1-card" to every timeline-visible element you want agents or Studio to edit.',
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// non_deterministic_code
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
const patterns: Array<{ pattern: RegExp; label: string; hint: string }> = [
{
pattern: /Math\.random\s*\(/,
label: "Math.random()",
hint: "Use a seeded PRNG (e.g. a simple mulberry32) so renders are deterministic across frames.",
},
{
pattern: /Date\.now\s*\(/,
label: "Date.now()",
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
},
{
pattern: /new\s+Date\s*\(/,
label: "new Date()",
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
},
{
pattern: /performance\.now\s*\(/,
label: "performance.now()",
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
},
{
pattern: /crypto\.getRandomValues\s*\(/,
label: "crypto.getRandomValues()",
hint: "Remove time-dependent code. Use a seeded PRNG for deterministic renders.",
},
];
for (const script of scripts) {
const stripped = stripJsComments(script.content);
for (const { pattern, label, hint } of patterns) {
if (pattern.test(stripped)) {
findings.push({
code: "non_deterministic_code",
severity: "error",
message: `Script contains \`${label}\` which produces non-deterministic output. Renders may differ between frames or runs.`,
fixHint: hint,
snippet: truncateSnippet(script.content),
});
}
}
}
return findings;
},
// pointer_events_none
// fallow-ignore-next-line complexity
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const reported = new Set<string>();
for (const tag of tags) {
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
const inlineStyle = readAttr(tag.raw, "style") ?? "";
if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
const id = readAttr(tag.raw, "id");
const key = id ?? tag.raw;
if (reported.has(key)) continue;
reported.add(key);
findings.push({
code: "pointer_events_none",
severity: "info",
message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`,
elementId: id || undefined,
fixHint:
"If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
snippet: truncateSnippet(tag.raw),
});
}
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
root.walkDecls("pointer-events", (decl) => {
if (decl.value.trim().toLowerCase() !== "none") return;
const rule = decl.parent;
if (!rule || rule.type !== "rule") return;
const selector = (rule as postcss.Rule).selector;
if (reported.has(selector)) return;
reported.add(selector);
findings.push({
code: "pointer_events_none",
severity: "info",
message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
selector,
fixHint:
"If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
});
});
}
return findings;
},
];
+353
View File
@@ -0,0 +1,353 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
async function findByCode(html: string, code: string, isSubComposition = true) {
const result = await lintHyperframeHtml(html, { isSubComposition });
return result.findings.filter((f) => f.code === code);
}
describe("font rules", () => {
describe("google_fonts_import", () => {
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 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("warning");
expect(result.errorCount).toBe(0);
});
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 () => {
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 = await findByCode(html, "google_fonts_import");
expect(findings).toHaveLength(0);
});
it("does not flag installed registry blocks that bundle Google Fonts", async () => {
const html =
`<!-- hyperframes-registry-item: my-block -->\n` +
`<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(0);
});
});
describe("system_font_will_alias", () => {
it("flags SF Mono as aliased to JetBrains Mono", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>code { font-family: 'SF Mono', monospace; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(1);
expect(findings[0]!.severity).toBe("info");
expect(findings[0]!.message).toContain("JetBrains Mono");
});
it("flags Helvetica Neue as aliased to Inter", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Helvetica Neue', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("Inter");
});
it("does not flag canonical font names", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Inter', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(0);
});
it("does not flag Roboto (canonical name)", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Roboto', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(0);
});
it("does not flag unknown fonts (handled by font_family_without_font_face)", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Comic Sans MS', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(0);
});
it("does not flag aliased fonts that have explicit @font-face", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'Menlo'; src: url('../fonts/menlo.woff2'); }
code { font-family: 'Menlo', monospace; }
</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(0);
});
it("handles case-insensitive font names", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'VERDANA', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("Inter");
});
it("reports multiple aliased fonts in one finding", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
body { font-family: 'Verdana', sans-serif; }
code { font-family: 'Consolas', monospace; }
</style>
</div>`;
const findings = await findByCode(html, "system_font_will_alias");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("Inter");
expect(findings[0]!.message).toContain("JetBrains Mono");
});
});
describe("font_family_without_font_face", () => {
it("flags font-family used without @font-face", async () => {
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 = await 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", async () => {
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 = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag a system font declared via @font-face src: local()", async () => {
// Regression: two independent reports of this rule hard-erroring on OS
// system fonts (Hiragino Sans, Microsoft YaHei) that have no downloadable
// file. src: local(...) already satisfies the check (extractFontFaceFamilies
// only looks at the font-family declaration, not the src value) — the gap
// was that the fixHint didn't mention this as an option.
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { font-family: 'Microsoft YaHei'; src: local('Microsoft YaHei'); }
body { font-family: 'Microsoft YaHei', sans-serif; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("fixHint mentions the local() pattern for system fonts", async () => {
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 = await findByCode(html, "font_family_without_font_face");
expect(findings[0]!.fixHint).toContain("local(");
});
it("does not flag generic font families", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: monospace; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("reports multiple missing families in one finding", async () => {
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 = await 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", async () => {
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 = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("still flags Google-Fonts-only fonts not pre-bundled", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<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("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>
@font-face { font-family: 'Inter'; src: url('../fonts/inter.woff2'); }
body { font-family: 'inter', sans-serif; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("ignores font-family inside @font-face blocks", async () => {
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 = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag vendor-prefixed system-font keywords (-apple-system, BlinkMacSystemFont)", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag installed registry blocks that declare fonts via Google Fonts", async () => {
const html =
`<!-- hyperframes-registry-item: my-block -->\n` +
`<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Poppins', sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("matches @font-face even when a CSS comment inside the block contains a brace (#1534)", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>
@font-face { /* weight 400 } regular */ font-family: 'Noto Sans SC'; src: url('../fonts/noto-400.woff2'); }
.title { font-family: 'Noto Sans SC'; }
</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag the -apple-system / BlinkMacSystemFont system-ui stack", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag a var() font-family indirection it cannot resolve", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>:root { --heading: 'Inter'; } h1 { font-family: var(--heading); }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("does not flag a var() with a quoted fallback font", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>h1 { font-family: var(--heading, 'Geist'), sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(0);
});
it("still flags a real undeclared font sitting next to a system stack", async () => {
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
<style>body { font-family: 'Aeonik', -apple-system, BlinkMacSystemFont, sans-serif; }</style>
</div>`;
const findings = await findByCode(html, "font_family_without_font_face");
expect(findings).toHaveLength(1);
expect(findings[0]!.message).toContain("aeonik");
expect(findings[0]!.message).not.toContain("apple-system");
});
});
});
+242
View File
@@ -0,0 +1,242 @@
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers/composition";
import type { LintContext, HyperframeLintFinding } from "../context";
import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition";
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",
// Vendor-prefixed system-font keywords. Like `system-ui`, the engine resolves
// these to the OS UI font — they are never installable files and must not be
// flagged as a missing @font-face, even when a generic fallback follows them
// (e.g. `-apple-system, system-ui, sans-serif`).
"-apple-system",
"blinkmacsystemfont",
"inherit",
"initial",
"unset",
"revert",
]);
// A CSS comment can contain a `}` (e.g. `@font-face { /* 400 } regular */
// font-family: 'X'; ... }`), which truncates the naive `@font-face\s*\{[^}]*\}`
// block match at the comment's brace — so the rule never sees the real
// `font-family` and reports a false-positive font_family_without_font_face.
// Large/"framework" stylesheets hit this far more often than minimal ones,
// which is why a simple <style> passes while a complex one fails. Strip
// comments before scanning so a brace inside one cannot split a block. See #1534.
function stripCssComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
}
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) {
const content = stripCssComments(style.content);
let match: RegExpExecArray | null;
while ((match = fontFaceRe.exec(content)) !== null) {
const familyMatch = match[0].match(familyRe);
if (familyMatch?.[2]) {
families.add(familyMatch[2].trim().toLowerCase());
}
}
}
return families;
}
// Normalize one comma-separated font-family entry to a lowercase family name,
// or null if it carries no resolvable name. `var(--heading)` (or any function
// token) is an indirection the linter cannot statically resolve, so the literal
// `var(...)` is not a font name and flagging it is a false positive. Comma-split
// fallbacks like `var(--x, 'Inter')` also leave a dangling `)` on the fallback
// part, so skip anything bearing parentheses.
function normalizeUsedFontName(part: string): string | null {
const name = part
.trim()
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
if (!name || name.includes("(") || name.includes(")")) return null;
return name;
}
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 = stripCssComments(style.content).replace(/@font-face\s*\{[^}]*\}/gi, "");
let match: RegExpExecArray | null;
while ((match = propRe.exec(withoutFontFace)) !== null) {
for (const part of match[1]!.split(",")) {
const name = normalizeUsedFontName(part);
if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) {
seen.add(name);
used.push(name);
}
}
}
}
return used;
}
function collectAliasedFonts(used: string[], declared: Set<string>): string[] {
const aliased: string[] = [];
for (const name of used) {
if (declared.has(name)) continue;
const displayName = resolveAliasDisplayName(name);
if (!displayName) continue;
if (displayName.toLowerCase() === name) continue;
aliased.push(`'${name}' → ${displayName}`);
}
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 }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
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. 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:
"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;
},
// system_font_will_alias — inform when a font will be silently substituted
({ styles, options }) => {
const declared = extractFontFaceFamilies(styles);
const used = extractUsedFontFamilies(styles);
const aliased = collectAliasedFonts(used, declared);
if (aliased.length === 0) return [];
// In distributed / Lambda renders system-font capture is disabled, so
// the alias substitution does NOT happen — elevate to a warning.
const severity = options.distributed ? ("warning" as const) : ("info" as const);
return [
{
code: "system_font_will_alias",
severity,
message:
`Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}. ` +
(options.distributed
? "In distributed/Lambda rendering system-font capture is disabled — these fonts will fall back to OS defaults. Embed explicit @font-face declarations instead."
: "The renderer maps these to bundled fonts for cross-platform consistency. " +
"Use the target font name directly for consistent preview and render results."),
},
];
},
// font_family_without_font_face
({ 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) && !googleFonts.has(name),
);
if (undeclared.length === 0) return findings;
findings.push({
code: "font_family_without_font_face",
severity: "error",
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. For an OS-bundled " +
"system font (e.g. Hiragino Sans, Microsoft YaHei) that has no downloadable file, " +
"use src: local('Exact Font Name') instead — the declaration alone satisfies this " +
"check without needing a font file.",
});
return findings;
},
];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+319
View File
@@ -0,0 +1,319 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
describe("media rules", () => {
it("reports error for duplicate media ids", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="a.mp4" data-start="0" data-duration="5"></video>
<video id="v1" src="b.mp4" data-start="0" data-duration="3"></video>
</div>
<script>window.__timelines = {};</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "duplicate_media_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
});
it("reports error for audio with data-start but no id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio data-start="0" data-duration="10" src="narration.wav"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("SILENT");
});
it("reports error for video with data-start but no id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("FROZEN");
});
it("flags media that has data-hf-id but no real id", async () => {
// Regression: readAttr(tag, "id") used a \b boundary that matched the
// trailing `id="…"` inside `data-hf-id="…"`, so media carrying only a
// Studio-stamped data-hf-id passed the check and then rendered as a blank
// wash (video) / silent (audio). data-hf-id is NOT a render id.
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video data-hf-id="hf-v1a2b3" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
<audio data-hf-id="hf-a4c5d6" data-start="0" data-duration="10" src="narration.wav"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const findings = result.findings.filter((f) => f.code === "media_missing_id");
expect(findings).toHaveLength(2);
expect(findings.every((f) => f.severity === "error")).toBe(true);
});
it("does not flag media elements that have id", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="a1" data-start="0" data-duration="10" src="narration.wav"></audio>
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_id");
expect(finding).toBeUndefined();
});
it("reports warning for media with preload=none", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline preload="none"></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_preload_none");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("reports error for media with id but no src", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<audio id="a1" data-start="0" data-duration="10"></audio>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_src");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports error for media with src but no data-start", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_data_start");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("allows audible video clips to omit muted when data-has-audio is true", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "video_missing_muted")).toBeUndefined();
expect(
result.findings.find((f) => f.code === "video_muted_with_declared_audio"),
).toBeUndefined();
});
it("reports error for videos that declare audio while muted", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "video_muted_with_declared_audio");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("does NOT flag <video> as nested in a void element with data-start (regression)", async () => {
// Regression: void elements like <img> have no closing tag, so the previous
// implementation kept them on the parent stack indefinitely and flagged any
// later <video> with data-start as "nested" inside them.
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<img id="hdr-img" src="hdr.png" data-start="0" data-duration="5" data-track-index="0" />
<video id="hdr-vid" src="clip.mp4" data-start="5" data-duration="5" data-track-index="1" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "video_nested_in_timed_element");
expect(finding).toBeUndefined();
});
it("reports imperative play() control on managed media ids", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" src="clip.mp4" muted playsinline></video>
</div>
<script>
const video = document.getElementById("demo-video");
video.play();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("reports imperative currentTime writes on query-selected managed media", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" src="clip.mp4" muted playsinline></video>
</div>
<script>
const demo = document.querySelector("#demo-video");
demo.currentTime = 1.5;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("reports imperative muted/play control on class-selected media without ids", async () => {
const html = `
<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
<video class="demo-video" src="clip.mp4" muted playsinline></video>
<script>
const vid = document.querySelector('[data-composition-id="scene"] .demo-video');
if (vid) { vid.muted = true; vid.play(); }
</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
const imperativeFindings = result.findings.filter((f) => f.code === "imperative_media_control");
expect(imperativeFindings.length).toBe(2);
expect(imperativeFindings.some((f) => f.snippet === "vid.muted =")).toBe(true);
expect(imperativeFindings.some((f) => f.snippet === "vid.play(")).toBe(true);
});
it("does not flag play() on non-media elements", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<div id="panel"></div>
</div>
<script>
const panel = document.getElementById("panel");
panel.play?.();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeUndefined();
});
it("flags <video> inside a sub-composition (media must be a host-root child)", async () => {
const html = `<template id="scene-template">
<div id="root" data-composition-id="scene" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
expect(finding?.message).toContain("sub-composition");
});
it("does not flag media in a host-root (non-sub) composition", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeUndefined();
});
it("reports error for media with crossorigin (breaks preview when host omits CORS)", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" crossorigin="anonymous" src="https://cdn.example.com/clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_crossorigin_breaks_preview");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
});
it("does not flag media without crossorigin", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="https://cdn.example.com/clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_crossorigin_breaks_preview");
expect(finding).toBeUndefined();
});
});
describe("media_variable_src_no_fallback", () => {
it("downgrades missing src to a warning when data-var-src is present", async () => {
const html = `<html><body>
<video id="clip" data-start="0" data-duration="2" data-var-src="media"></video>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.some((f) => f.code === "media_missing_src")).toBe(false);
const finding = result.findings.find((f) => f.code === "media_variable_src_no_fallback");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("keeps the hard error when neither src nor data-var-src exists", async () => {
const html = `<html><body>
<video id="clip" data-start="0" data-duration="2"></video>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.some((f) => f.code === "media_missing_src")).toBe(true);
});
});
+569
View File
@@ -0,0 +1,569 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import { readAttr, truncateSnippet, isMediaTag } from "../utils";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function hasAttrName(tagSource: string, attr: string): boolean {
const escaped = escapeRegExp(attr);
const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
}
function classNamesFromAttr(classAttr: string | null): string[] {
if (!classAttr) return [];
return classAttr.split(/\s+/).filter(Boolean);
}
type MediaSelectorIndex = {
ids: Set<string>;
classes: Set<string>;
hasVideo: boolean;
hasAudio: boolean;
};
function selectorTargetsManagedMedia(selector: string, mediaIndex: MediaSelectorIndex): boolean {
const normalized = selector.trim();
if (!normalized) return false;
if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
for (const mediaId of mediaIndex.ids) {
const escapedId = escapeRegExp(mediaId);
if (
new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) ||
normalized.includes(`[id="${mediaId}"]`) ||
normalized.includes(`[id='${mediaId}']`)
) {
return true;
}
}
for (const className of mediaIndex.classes) {
if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
return true;
}
}
return false;
}
function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
const mediaTags = ctx.tags.filter((tag) => tag.name === "video" || tag.name === "audio");
const mediaIndex: MediaSelectorIndex = {
ids: new Set(
mediaTags.map((tag) => readAttr(tag.raw, "id")).filter((id): id is string => Boolean(id)),
),
classes: new Set(mediaTags.flatMap((tag) => classNamesFromAttr(readAttr(tag.raw, "class")))),
hasVideo: mediaTags.some((tag) => tag.name === "video"),
hasAudio: mediaTags.some((tag) => tag.name === "audio"),
};
if (mediaTags.length === 0 || ctx.scripts.length === 0) return findings;
for (const script of ctx.scripts) {
const mediaVars = new Map<string, string | undefined>();
const assignmentPatterns = [
{
pattern:
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
variableIndex: 1,
targetIndex: 2,
},
{
pattern:
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\2\s*\)/g,
variableIndex: 1,
targetIndex: 3,
},
];
for (const { pattern, variableIndex, targetIndex } of assignmentPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
const variableName = match[variableIndex];
const target = match[targetIndex];
if (!variableName || !target) continue;
if (mediaIndex.ids.has(target) || selectorTargetsManagedMedia(target, mediaIndex)) {
mediaVars.set(variableName, mediaIndex.ids.has(target) ? target : undefined);
}
}
}
const directIdPatterns = [
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
kind: "play()",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
kind: "pause()",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
kind: "currentTime",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.muted\s*=/g,
kind: "muted assignment",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.play\s*\(/g,
kind: "play()",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.pause\s*\(/g,
kind: "pause()",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.currentTime\s*=/g,
kind: "currentTime",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.muted\s*=/g,
kind: "muted assignment",
targetIndex: 2,
},
];
for (const { pattern, kind, targetIndex } of directIdPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
const target = match[targetIndex];
if (!target) continue;
const elementId = mediaIndex.ids.has(target)
? target
: selectorTargetsManagedMedia(target, mediaIndex)
? undefined
: null;
if (elementId === null) continue;
findings.push({
code: "imperative_media_control",
severity: "error",
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
elementId: elementId || undefined,
fixHint:
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
snippet: truncateSnippet(match[0]),
});
}
}
for (const [variableName, elementId] of mediaVars) {
const escapedVar = escapeRegExp(variableName);
const variablePatterns = [
{ pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
{ pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
{ pattern: new RegExp(`\\b${escapedVar}\\.currentTime\\s*=`, "g"), kind: "currentTime" },
{
pattern: new RegExp(`\\b${escapedVar}\\.muted\\s*=`, "g"),
kind: "muted assignment",
},
];
for (const { pattern, kind } of variablePatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
findings.push({
code: "imperative_media_control",
severity: "error",
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
elementId,
fixHint:
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
snippet: truncateSnippet(match[0]),
});
}
}
}
}
return findings;
}
export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// duplicate_media_id + duplicate_media_discovery_risk
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
const mediaById = new Map<string, typeof tags>();
const mediaFingerprintCounts = new Map<string, number>();
for (const tag of tags) {
if (!isMediaTag(tag.name)) continue;
const elementId = readAttr(tag.raw, "id");
if (elementId) {
const existing = mediaById.get(elementId) || [];
existing.push(tag);
mediaById.set(elementId, existing);
}
const fingerprint = [
tag.name,
readAttr(tag.raw, "src") || "",
readAttr(tag.raw, "data-start") || "",
readAttr(tag.raw, "data-duration") || "",
].join("|");
mediaFingerprintCounts.set(fingerprint, (mediaFingerprintCounts.get(fingerprint) || 0) + 1);
}
for (const [elementId, mediaTags] of mediaById) {
if (mediaTags.length < 2) continue;
findings.push({
code: "duplicate_media_id",
severity: "error",
message: `Media id "${elementId}" is defined multiple times.`,
elementId,
fixHint:
"Give each media element a unique id so preview and producer discover the same media graph.",
snippet: truncateSnippet(mediaTags[0]?.raw || ""),
});
}
for (const [fingerprint, count] of mediaFingerprintCounts) {
if (count < 2) continue;
const [tagName, src, dataStart, dataDuration] = fingerprint.split("|");
findings.push({
code: "duplicate_media_discovery_risk",
severity: "warning",
message: `Detected ${count} matching ${tagName} entries with the same source/start/duration.`,
fixHint: "Avoid duplicated media nodes that can be discovered twice during compilation.",
snippet: truncateSnippet(
`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`,
),
});
}
return findings;
},
// video_missing_muted
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name !== "video") continue;
const hasMuted = hasAttrName(tag.raw, "muted");
const hasDeclaredAudio = readAttr(tag.raw, "data-has-audio") === "true";
if (!hasMuted && !hasDeclaredAudio && readAttr(tag.raw, "data-start")) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "video_missing_muted",
severity: "error",
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. Mark audible videos with data-has-audio="true"; otherwise keep video muted and use a separate <audio> element for sound.`,
elementId,
fixHint:
'Add the `muted` attribute for silent video, or add data-has-audio="true" when the video track should contribute audio.',
snippet: truncateSnippet(tag.raw),
});
}
if (hasMuted && hasDeclaredAudio) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "video_muted_with_declared_audio",
severity: "error",
message: `<video${elementId ? ` id="${elementId}"` : ""}> declares data-has-audio="true" but also has muted. Studio preview will silence the video audio.`,
elementId,
fixHint:
'Remove the `muted` attribute if this video should be audible, or remove data-has-audio="true" and use data-volume="0" for silent visual video.',
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// video_nested_in_timed_element
({ source, tags }) => {
const findings: HyperframeLintFinding[] = [];
// HTML5 void elements cannot contain children, so they can never be a
// parent of a nested <video>. Skipping them avoids false positives where
// the linter looks for `</img>` and never finds it.
const voidElements = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"source",
"track",
"wbr",
]);
const timedTagPositions: Array<{ name: string; start: number; id?: string }> = [];
for (const tag of tags) {
if (tag.name === "video" || tag.name === "audio") continue;
if (voidElements.has(tag.name)) continue;
// Skip the composition root — it uses data-start as a playback anchor, not as a clip timer
if (readAttr(tag.raw, "data-composition-id")) continue;
if (readAttr(tag.raw, "data-start")) {
timedTagPositions.push({
name: tag.name,
start: tag.index,
id: readAttr(tag.raw, "id") || undefined,
});
}
}
for (const tag of tags) {
if (tag.name !== "video") continue;
if (!readAttr(tag.raw, "data-start")) continue;
for (const parent of timedTagPositions) {
if (parent.start < tag.index) {
const parentClosePattern = new RegExp(`</${parent.name}>`, "gi");
const between = source.substring(parent.start, tag.index);
if (!parentClosePattern.test(between)) {
findings.push({
code: "video_nested_in_timed_element",
severity: "error",
message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The framework cannot manage playback of nested media — video will be FROZEN in renders.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
snippet: truncateSnippet(tag.raw),
});
break;
}
}
}
}
return findings;
},
// media_in_subcomposition — <video>/<audio> only render as a DIRECT child of the host
// root (index.html). Inside a sub-composition <template> the runtime never seeks/decodes
// them, so they render BLANK/black in preview and renders — and the other lint/validate
// passes otherwise miss it (only a per-frame snapshot reveals the blank panel).
({ tags, options }) => {
const findings: HyperframeLintFinding[] = [];
if (!options.isSubComposition) return findings;
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "media_in_subcomposition",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> is inside a sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html); media inside a sub-comp <template> is never seeked/decoded and renders BLANK/black in preview and renders.`,
elementId,
fixHint:
"Move the media OUT of the sub-composition: place the <video>/<audio> as a direct child of #root in index.html, positioned over the scene, and drive any per-scene motion on the MAIN timeline at global time (a sub-comp timeline cannot reach host elements). See composition-patterns.md archetype B.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// self_closing_media_tag
({ source }) => {
const findings: HyperframeLintFinding[] = [];
const selfClosingMediaRe = /<(audio|video)\b[^>]*\/>/gi;
let scMatch: RegExpExecArray | null;
while ((scMatch = selfClosingMediaRe.exec(source)) !== null) {
const tagName = scMatch[1] || "audio";
const elementId = readAttr(scMatch[0], "id") || undefined;
findings.push({
code: "self_closing_media_tag",
severity: "error",
message: `Self-closing <${tagName}/> is invalid HTML. The browser will leave the tag open, swallowing all subsequent elements as invisible fallback content. This makes compositions INVISIBLE.`,
elementId,
fixHint: `Change <${tagName} .../> to <${tagName} ...></${tagName}> — media elements MUST have explicit closing tags.`,
snippet: truncateSnippet(scMatch[0]),
});
}
return findings;
},
// placeholder_media_url
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
const PLACEHOLDER_DOMAINS =
/\b(placehold\.co|placeholder\.com|placekitten\.com|picsum\.photos|example\.com|via\.placeholder\.com|dummyimage\.com)\b/i;
for (const tag of tags) {
if (!isMediaTag(tag.name)) continue;
const src = readAttr(tag.raw, "src");
if (!src) continue;
if (PLACEHOLDER_DOMAINS.test(src)) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "placeholder_media_url",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses a placeholder URL that will 404 at render time: ${src.slice(0, 80)}`,
elementId,
fixHint: "Replace with a real media URL. Placeholder domains will 404 at render time.",
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// base64_media_prohibited
({ source }) => {
const findings: HyperframeLintFinding[] = [];
const base64MediaRe =
/src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{20,}))["']/gi;
let b64Match: RegExpExecArray | null;
while ((b64Match = base64MediaRe.exec(source)) !== null) {
const sample = (b64Match[2] || "").slice(0, 200);
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
findings.push({
code: "base64_media_prohibited",
severity: "error",
message: `Inline base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB)${isSuspicious ? " — likely fabricated data" : ""}. Base64 media is prohibited — it bloats file size and breaks rendering.`,
fixHint:
"Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.",
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
});
}
return findings;
},
// media_missing_data_start + media_missing_id + media_missing_src + media_preload_none
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
const hasDataStart = readAttr(tag.raw, "data-start");
const hasId = readAttr(tag.raw, "id");
const hasSrc = readAttr(tag.raw, "src");
if (hasSrc && !hasDataStart) {
findings.push({
code: "media_missing_data_start",
severity: "error",
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has src but no data-start. HyperFrames cannot own playback for untimed media, so preview and render behavior can diverge.`,
elementId: hasId || undefined,
fixHint: `Add data-start="0" (or the intended start time) and data-duration if the clip should stop before the source ends.`,
snippet: truncateSnippet(tag.raw),
});
}
if (hasDataStart && !hasId) {
findings.push({
code: "media_missing_id",
severity: "error",
message: `<${tag.name}> has data-start but no id attribute. The renderer requires id to discover media elements — this ${tag.name === "audio" ? "audio will be SILENT" : "video will be FROZEN"} in renders.`,
fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
snippet: truncateSnippet(tag.raw),
});
}
if (hasDataStart && hasId && !hasSrc) {
const varSrc = readAttr(tag.raw, "data-var-src");
if (varSrc) {
// Variable-bound media without a fallback still renders when the
// variable resolves, but a render without a value can't load the
// media, and the audio pipeline discovers tracks from the AUTHORED
// src — warn instead of hard-failing the binding pattern.
findings.push({
code: "media_variable_src_no_fallback",
severity: "warning",
message: `<${tag.name} id="${hasId}"> relies on data-var-src="${varSrc}" with no fallback src. Renders without a "${varSrc}" value cannot load this media, and audio extraction reads the authored src.`,
elementId: hasId,
fixHint: `Add a fallback src the composition can render with when the variable is not provided.`,
snippet: truncateSnippet(tag.raw),
});
} else {
findings.push({
code: "media_missing_src",
severity: "error",
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
elementId: hasId,
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
snippet: truncateSnippet(tag.raw),
});
}
}
if (readAttr(tag.raw, "preload") === "none") {
findings.push({
code: "media_preload_none",
severity: "warning",
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has preload="none" which prevents the renderer from loading this media. The compiler strips it for renders, but preview may also have issues.`,
elementId: hasId || undefined,
fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// media_crossorigin_breaks_preview — `crossorigin` on <video>/<audio> forces a
// CORS-checked fetch. The server-side renderer downloads media directly (no CORS),
// so it always works there; but Studio preview runs in the browser, where a media
// host that omits Access-Control-Allow-Origin silently fails the load — the media
// shows BLANK/black in preview while renders look fine, hiding the bug. Plain
// displayed media never needs crossorigin; it's only required to read pixels/samples
// back (canvas/WebGL texture, WebAudio createMediaElementSource) AND only when the
// host is known CORS-enabled.
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
if (!hasAttrName(tag.raw, "crossorigin")) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "media_crossorigin_breaks_preview",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has crossorigin, which forces a CORS-checked fetch. If the media host omits Access-Control-Allow-Origin, the load silently fails in Studio preview (media shows BLANK/black) while server-side renders still work — hiding the bug.`,
elementId,
fixHint:
"Remove the crossorigin attribute unless you read the media back via canvas/WebGL/WebAudio AND the host is known to send CORS headers. Plain displayed media never needs it.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// video_audio_double_source — catches audible <video> paired with a separate
// <audio> pointing to the same file, which causes double playback at runtime
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
const videoSources = new Map<string, { id?: string; raw: string }>();
const audioSources = new Map<string, { id?: string; raw: string }>();
for (const tag of tags) {
if (!readAttr(tag.raw, "data-start")) continue;
const src = readAttr(tag.raw, "src");
if (!src) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
if (tag.name === "video") {
const isMuted = hasAttrName(tag.raw, "muted");
if (!isMuted) {
videoSources.set(src, { id: elementId, raw: tag.raw });
}
} else if (tag.name === "audio") {
audioSources.set(src, { id: elementId, raw: tag.raw });
}
}
for (const [src, audioInfo] of audioSources) {
const videoInfo = videoSources.get(src);
if (!videoInfo) continue;
findings.push({
code: "video_audio_double_source",
severity: "error",
message: `<audio${audioInfo.id ? ` id="${audioInfo.id}"` : ""}> and <video${videoInfo.id ? ` id="${videoInfo.id}"` : ""}> both point to the same source. The unmuted video already provides audio — the duplicate <audio> will cause double playback and echo.`,
elementId: audioInfo.id,
fixHint:
"Either mute the video (add `muted` attribute) and keep the separate <audio>, or remove the <audio> element and let the video provide its own audio track.",
snippet: truncateSnippet(audioInfo.raw),
});
}
return findings;
},
// imperative_media_control
findImperativeMediaControlFindings,
];
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
async function findSlideshow(html: string) {
const result = await lintHyperframeHtml(html, { isSubComposition: true });
return result.findings.filter((f) => f.code.startsWith("slideshow_"));
}
describe("slideshow lint rule", () => {
it("passes a composition with no slideshow island", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div id="a" class="clip" data-start="0" data-duration="5"></div>
</div>`;
expect(await findSlideshow(html)).toEqual([]);
});
it("passes a valid island where sceneId resolves to a data-composition-id scene", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div data-composition-id="a" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"a"}]}</script>
</div>`;
expect(await findSlideshow(html)).toEqual([]);
});
it("flags a sceneId that matches only a .clip[id] (not a data-composition-id)", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div id="a" class="clip" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"a"}]}</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]?.message).toContain("a");
});
it("flags an unresolved sceneId", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div id="a" class="clip" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"ghost"}]}</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]!.message).toContain("ghost");
});
it("flags invalid JSON in the island", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<script type="application/hyperframes-slideshow+json">NOT_JSON</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]!.code).toBe("slideshow_invalid");
});
it("passes when sceneId resolves to a data-composition-id element (no .clip[id])", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div data-composition-id="scene-a" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"scene-a"}]}</script>
</div>`;
expect(await findSlideshow(html)).toEqual([]);
});
it("flags a hotspot targeting an unknown sequence", async () => {
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
<div data-composition-id="a" data-start="0" data-duration="5"></div>
<script type="application/hyperframes-slideshow+json">${JSON.stringify({
slides: [{ sceneId: "a", hotspots: [{ id: "h1", label: "Go", target: "no-such-seq" }] }],
})}</script>
</div>`;
const findings = await findSlideshow(html);
expect(findings.length).toBeGreaterThan(0);
expect(findings[0]!.message).toContain("no-such-seq");
});
});
+85
View File
@@ -0,0 +1,85 @@
import type { LintContext, HyperframeLintFinding } from "../context";
import type { LintRule } from "../types";
import { readAttr } from "../utils";
import {
parseSlideshowManifest,
resolveSlideshow,
isSceneLikeCompositionId,
} from "@hyperframes/parsers/slideshow";
type Scene = { id: string; start: number; duration: number };
function parseTiming(raw: string): { start: number; duration: number } | null {
const startStr = readAttr(raw, "data-start");
if (startStr === null) return null;
const start = Number(startStr);
if (!Number.isFinite(start)) return null;
const durationStr = readAttr(raw, "data-duration");
if (durationStr !== null) {
const duration = Number(durationStr);
if (Number.isFinite(duration)) return { start, duration };
}
const endStr = readAttr(raw, "data-end") ?? readAttr(raw, "data-hf-authored-end");
if (endStr !== null) {
const end = Number(endStr);
if (Number.isFinite(end) && end > start) return { start, duration: end - start };
}
return null;
}
function collectCompositionIdScenes(ctx: LintContext, seen: Set<string>, out: Scene[]): void {
for (const tag of ctx.tags) {
const compositionId = readAttr(tag.raw, "data-composition-id");
if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId))
continue;
const timing = parseTiming(tag.raw);
if (!timing || timing.duration <= 0) continue;
seen.add(compositionId);
out.push({ id: compositionId, ...timing });
}
}
function extractScenesFromClips(ctx: LintContext): Scene[] {
const seen = new Set<string>();
const scenes: Scene[] = [];
collectCompositionIdScenes(ctx, seen, scenes);
return scenes;
}
export const slideshowRules: LintRule<LintContext>[] = [
(ctx) => {
const findings: HyperframeLintFinding[] = [];
let manifest;
try {
manifest = parseSlideshowManifest(ctx.source);
} catch (e) {
findings.push({
code: "slideshow_invalid",
severity: "error",
message: `Slideshow island contains invalid JSON or structure: ${e instanceof Error ? e.message : String(e)}`,
fixHint:
'Ensure the <script type="application/hyperframes-slideshow+json"> block contains valid JSON matching the SlideshowManifest schema.',
});
return findings;
}
if (!manifest) return findings;
const scenes = extractScenesFromClips(ctx);
const { errors } = resolveSlideshow(manifest, scenes);
for (const error of errors) {
findings.push({
code: "slideshow_unresolved_ref",
severity: "error",
message: `Slideshow manifest error: ${error}`,
fixHint:
"Ensure every sceneId in the slideshow island matches the data-composition-id of a scene element in the composition, or provide explicit startTime/endTime.",
});
}
return findings;
},
];
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
function baseHtml(body: string, style = ""): string {
return `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080">
${body}
</div>
<style>${style}</style>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}
const textureCss = `
.hf-texture-text {
color: #fff;
-webkit-mask-size: var(--mask-size, cover);
mask-size: var(--mask-size, cover);
}
.hf-texture-lava {
-webkit-mask-image: url("masks/lava.png");
mask-image: url("masks/lava.png");
}
`;
describe("texture rules", () => {
it("does not warn for a valid texture mask text usage", async () => {
const html = baseHtml(
'<div class="shadow"><div class="hf-texture-text hf-texture-lava">TEXT</div></div>',
`${textureCss}.shadow { filter: drop-shadow(1px 2px 1px rgba(0,0,0,.48)); }`,
);
const result = await lintHyperframeHtml(html);
expect(result.findings.filter((finding) => finding.code.startsWith("texture_"))).toEqual([]);
});
it("warns when a material class is used without hf-texture-text", async () => {
const html = baseHtml('<div class="hf-texture-lava">TEXT</div>', textureCss);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_class_missing_base");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.fixHint).toContain("hf-texture-text");
});
it("warns when hf-texture-text has no material class or custom mask image", async () => {
const html = baseHtml('<div class="hf-texture-text">TEXT</div>', textureCss);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("allows hf-texture-text with an inline custom mask image", async () => {
const html = baseHtml(
'<div class="hf-texture-text" style="-webkit-mask-image:url(custom.png); mask-image:url(custom.png)">TEXT</div>',
textureCss,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
expect(finding).toBeUndefined();
});
it("warns when a texture material class is not defined by local CSS", async () => {
const html = baseHtml('<div class="hf-texture-text hf-texture-marbel">TEXT</div>', textureCss);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_class_unknown");
expect(finding).toBeDefined();
expect(finding?.message).toContain("hf-texture-marbel");
});
it("warns when drop-shadow is applied inline to the textured text element", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava" style="filter: drop-shadow(1px 2px 1px black)">TEXT</div>',
textureCss,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.fixHint).toContain("wrapper");
});
it("warns when drop-shadow is applied by CSS directly to hf-texture-text", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
`${textureCss}.hf-texture-text { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".hf-texture-text");
});
it("warns when drop-shadow targets a material class before the mask rule is declared", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
`.hf-texture-lava { filter: drop-shadow(1px 2px 1px black); }
${textureCss}`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".hf-texture-lava");
});
it("warns when drop-shadow targets another class on the textured text element", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
`${textureCss}.headline { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeDefined();
expect(finding?.selector).toBe(".headline");
});
it("does not warn when another-class drop-shadow selector needs an unmatched ancestor", async () => {
const html = baseHtml(
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
`${textureCss}.card .headline { filter: drop-shadow(1px 2px 1px black); }`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
expect(finding).toBeUndefined();
});
});
+226
View File
@@ -0,0 +1,226 @@
import postcss from "postcss";
import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
import { readAttr, truncateSnippet } from "../utils";
const TEXTURE_BASE_CLASS = "hf-texture-text";
const TEXTURE_CLASS_PREFIX = "hf-texture-";
type DropShadowRule = {
selector: string;
directlyTargetsTexture: boolean;
};
function classNames(tag: OpenTag): string[] {
return (readAttr(tag.raw, "class") ?? "").split(/\s+/).filter(Boolean);
}
function isTextureMaterialClass(className: string): boolean {
return className.startsWith(TEXTURE_CLASS_PREFIX) && className !== TEXTURE_BASE_CLASS;
}
function hasInlineMaskImage(tag: OpenTag): boolean {
const style = readAttr(tag.raw, "style") ?? "";
return /\b(?:-webkit-)?mask-image\s*:/i.test(style);
}
function hasInlineDropShadow(tag: OpenTag): boolean {
const style = readAttr(tag.raw, "style") ?? "";
return /\bfilter\s*:\s*[^;]*\bdrop-shadow\s*\(/i.test(style);
}
function classNamesInSelector(selector: string): string[] {
const classes = new Set<string>();
const pattern = /\.([A-Za-z_][\w-]*)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(selector)) !== null) {
const className = match[1];
if (!className) continue;
classes.add(className);
}
return [...classes];
}
function textureClassesInSelector(selector: string): string[] {
return classNamesInSelector(selector).filter(isTextureMaterialClass);
}
function simpleSelectorMatchesTag(selector: string, tag: OpenTag, tagClasses: string[]): boolean {
const trimmed = selector.trim();
const simpleSelectorPattern = /^(?:[A-Za-z][\w-]*)?(?:\.[A-Za-z_][\w-]*)+$/;
if (!simpleSelectorPattern.test(trimmed)) return false;
const typeMatch = /^([A-Za-z][\w-]*)/.exec(trimmed);
if (typeMatch && typeMatch[1]!.toLowerCase() !== tag.name) return false;
const selectorClasses = classNamesInSelector(trimmed);
return (
selectorClasses.length > 0 &&
selectorClasses.every((className) => tagClasses.includes(className))
);
}
function collectTextureCss(styles: LintContext["styles"]): {
definedTextureClasses: Set<string>;
dropShadowRules: DropShadowRule[];
} {
const definedTextureClasses = new Set<string>();
const dropShadowRules: DropShadowRule[] = [];
const roots: postcss.Root[] = [];
for (const style of styles) {
let root: postcss.Root;
try {
root = postcss.parse(style.content);
} catch {
continue;
}
roots.push(root);
// fallow-ignore-next-line complexity
root.walkRules((rule) => {
const selectors = rule.selectors ?? [];
let hasMaskImage = false;
for (const node of rule.nodes ?? []) {
if (node.type !== "decl") continue;
const prop = node.prop.toLowerCase();
if (prop === "mask-image" || prop === "-webkit-mask-image") hasMaskImage = true;
}
if (hasMaskImage) {
for (const selector of selectors) {
for (const className of textureClassesInSelector(selector)) {
definedTextureClasses.add(className);
}
}
}
});
}
for (const root of roots) {
// fallow-ignore-next-line complexity
root.walkRules((rule) => {
const selectors = rule.selectors ?? [];
let hasDropShadow = false;
for (const node of rule.nodes ?? []) {
if (node.type !== "decl") continue;
if (node.prop.toLowerCase() === "filter" && /\bdrop-shadow\s*\(/i.test(node.value)) {
hasDropShadow = true;
}
}
if (hasDropShadow) {
for (const selector of selectors) {
const targetsBaseClass = /\.hf-texture-text\b/.test(selector);
const targetsDefinedTextureClass = textureClassesInSelector(selector).some((className) =>
definedTextureClasses.has(className),
);
dropShadowRules.push({
selector,
directlyTargetsTexture: targetsBaseClass || targetsDefinedTextureClass,
});
}
}
});
}
return { definedTextureClasses, dropShadowRules };
}
// fallow-ignore-next-line complexity
export const textureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const { definedTextureClasses, dropShadowRules } = collectTextureCss(styles);
for (const { selector, directlyTargetsTexture } of dropShadowRules) {
if (!directlyTargetsTexture) continue;
findings.push({
code: "texture_drop_shadow_on_text",
severity: "warning",
message: "Drop shadow is applied directly to textured text.",
selector,
fixHint:
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
});
}
for (const tag of tags) {
if (tag.name === "style" || tag.name === "script") continue;
const classes = classNames(tag);
if (classes.length === 0) continue;
const hasBaseClass = classes.includes(TEXTURE_BASE_CLASS);
const textureClasses = classes.filter(isTextureMaterialClass);
if (textureClasses.length > 0 && !hasBaseClass) {
findings.push({
code: "texture_class_missing_base",
severity: "warning",
message: `Texture material class \`${textureClasses[0]}\` is used without \`${TEXTURE_BASE_CLASS}\`.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint: `Add \`${TEXTURE_BASE_CLASS}\` alongside the material class, for example \`class="${TEXTURE_BASE_CLASS} ${textureClasses[0]}"\`.`,
snippet: truncateSnippet(tag.raw),
});
}
if (hasBaseClass && textureClasses.length === 0 && !hasInlineMaskImage(tag)) {
findings.push({
code: "texture_text_missing_mask",
severity: "warning",
message: `\`${TEXTURE_BASE_CLASS}\` is used without a texture material class or custom mask image.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Add a material class such as `hf-texture-lava`, or set `mask-image` and `-webkit-mask-image` on the element.",
snippet: truncateSnippet(tag.raw),
});
}
for (const textureClass of textureClasses) {
if (definedTextureClasses.has(textureClass)) continue;
findings.push({
code: "texture_class_unknown",
severity: "error",
message: `Texture material class \`${textureClass}\` is not defined by local CSS.`,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Paste the Texture Mask Text component `<style>...</style>` block into the composition, or fix the texture class typo.",
snippet: truncateSnippet(tag.raw),
});
}
if (hasBaseClass) {
for (const rule of dropShadowRules) {
if (rule.directlyTargetsTexture) continue;
if (!simpleSelectorMatchesTag(rule.selector, tag, classes)) continue;
findings.push({
code: "texture_drop_shadow_on_text",
severity: "warning",
message: "Drop shadow is applied directly to textured text.",
selector: rule.selector,
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
snippet: truncateSnippet(tag.raw),
});
}
}
if (hasBaseClass && hasInlineDropShadow(tag)) {
findings.push({
code: "texture_drop_shadow_on_text",
severity: "warning",
message: "Drop shadow is applied directly to textured text.",
elementId: readAttr(tag.raw, "id") || undefined,
fixHint:
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
];
+12
View File
@@ -0,0 +1,12 @@
/**
* Pure render-gate decision — no Node.js dependencies, so it is safe to import
* from the browser entry alongside the rule engine.
*/
export function shouldBlockRender(
strictErrors: boolean,
strictAll: boolean,
totalErrors: number,
totalWarnings: number,
): boolean {
return (strictErrors && totalErrors > 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0));
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, afterEach } from "vitest";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { lintProject } from "./project.js";
let dirs: string[] = [];
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs = [];
});
const INDEX = `<html><body>
<div id="root" data-composition-id="main" data-start="0" data-duration="2" data-width="640" data-height="360"></div>
<script>window.__timelines = { main: { paused: true } };</script>
</body></html>`;
async function lintWithFragment(name: string, html: string) {
const dir = mkdtempSync(join(tmpdir(), "hf-snippet-lint-"));
dirs.push(dir);
writeFileSync(join(dir, "index.html"), INDEX);
mkdirSync(join(dir, "compositions"), { recursive: true });
writeFileSync(join(dir, "compositions", name), html);
const result = await lintProject(dir);
return result.results.filter((r) => r.file.includes(name.replace(".html", "")));
}
describe("snippet fragment exemption", () => {
it("skips composition-root rules for files whose ROOT carries data-hf-snippet", async () => {
const findings = await lintWithFragment(
"frag.html",
'<div id="frag" data-hf-snippet="" data-figma-id="1:1" style="width: 10px"></div>\n',
);
expect(findings).toHaveLength(0);
});
it("still lints a composition that merely CONTAINS snippet markup", async () => {
const results = await lintWithFragment(
"scene.html",
'<div id="scene"><div data-hf-snippet="" data-figma-id="1:1"></div></div>\n',
);
expect(results).toHaveLength(1);
expect(results[0]?.result.findings.some((f) => f.code === "root_missing_composition_id")).toBe(
true,
);
});
});
+40
View File
@@ -0,0 +1,40 @@
export type HyperframeLintSeverity = "error" | "warning" | "info";
export type HyperframeLintFinding = {
code: string;
severity: HyperframeLintSeverity;
message: string;
file?: string;
selector?: string;
elementId?: string;
fixHint?: string;
snippet?: string;
};
export type HyperframeLintResult = {
ok: boolean;
errorCount: number;
warningCount: number;
infoCount: number;
findings: HyperframeLintFinding[];
};
export type HyperframeLinterOptions = {
filePath?: string;
isSubComposition?: boolean;
externalStyles?: Array<{ href: string; content: string }>;
/**
* Set to `true` when linting compositions destined for distributed / Lambda
* rendering, where system-font capture (`allowSystemFontCapture`) is
* disabled. When `true`, the `system_font_will_alias` rule is elevated from
* `"info"` to `"warning"` because the alias substitution will NOT happen at
* render time — the font will silently fall back to whatever the OS provides.
*/
distributed?: boolean;
};
// A rule is a function: receives parsed context, returns zero or more findings.
// Rules may be async (e.g. when lazy-loading heavy dependencies like recast).
export type LintRule<TContext> = (
ctx: TContext,
) => HyperframeLintFinding[] | Promise<HyperframeLintFinding[]>;
+379
View File
@@ -0,0 +1,379 @@
// Shared types, regex constants, and utility functions used across lint rule modules.
// Nothing in this file should emit findings — it only parses and extracts.
import { Parser } from "htmlparser2";
export type OpenTag = {
raw: string;
name: string;
attrs: string;
index: number;
closeIndex?: number;
endIndex?: number;
};
export type ExtractedBlock = {
attrs: string;
content: string;
raw: string;
index: number;
};
const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
export const TIMELINE_REGISTRY_INIT_PATTERN =
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
// Object-literal registration that assigns at least one `key: value` entry inline,
// e.g. `window.__timelines = { main: tl }` or `window.__timelines = { "comp-1": tl }`.
// Distinct from the empty-init form (`= {}`) — requires a key followed by `:`.
export const TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN =
/window\.__timelines\s*=\s*\{\s*(?:["'][^"']+["']|[A-Za-z_$][\w$]*)\s*:/i;
export const TIMELINE_REGISTRY_ASSIGN_PATTERN =
/window\.__timelines(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i;
// The bracket branch accepts either a quoted string key (`["root"]`) or a
// computed key (`[spec.id]`, `[id]`) — a bare-identifier-only bracket branch
// missed `window.__timelines[spec.id] = tl`, a pattern the shipped
// code-particle-assemble/code-3d-extrude registry blocks actually use,
// making gsap_timeline_not_registered false-fire on correctly registered
// timelines. The computed-key alternative is deliberately non-capturing:
// its text isn't a literal composition id, so callers reading group 1/2
// (readRegisteredTimelineCompositionId) must keep falling back to null for it.
export const WINDOW_TIMELINE_ASSIGN_PATTERN =
/window\.__timelines(?:\[\s*(?:["']([^"']+)["']|[A-Za-z_$][\w$.]*)\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
export const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
const TIMELINE_REGISTRY_KEY_PATTERN =
/window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
// The `window.__timelines = { ... }` object-literal body (group 1), captured so its
// `key: value` entries can be scanned for registered keys.
const TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\S]*?)\}/i;
// A single object-literal entry whose value is an identifier (real timeline registration),
// e.g. `main: tl` or `"comp-1": tl`. Captures the key in group 1 (quoted) or 2 (bare).
const TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN =
/(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g;
export function parseHtmlStructure(source: string): {
tags: OpenTag[];
scripts: ExtractedBlock[];
styles: ExtractedBlock[];
} {
const tags: OpenTag[] = [];
const blocks = { script: [] as ExtractedBlock[], style: [] as ExtractedBlock[] };
const openTagsByName = new Map<string, OpenTag[]>();
const openBlocks: Array<{
name: "script" | "style";
attrs: string;
contentStart: number;
index: number;
}> = [];
const parser: Parser = new Parser(
{
onopentag(name) {
const index = parser.startIndex;
const raw = source.slice(index, parser.endIndex + 1);
const attrs = raw.slice(name.length + 1, -1).replace(/\s*\/$/, "");
const tag = { raw, name, attrs, index };
tags.push(tag);
const sameNameStack = openTagsByName.get(name) ?? [];
sameNameStack.push(tag);
openTagsByName.set(name, sameNameStack);
if (name === "script" || name === "style") {
openBlocks.push({ name, attrs, contentStart: parser.endIndex + 1, index });
}
},
onclosetag(name) {
const tag = openTagsByName.get(name)?.pop();
if (tag) {
tag.closeIndex = parser.startIndex;
tag.endIndex = parser.endIndex + 1;
}
if (name !== "script" && name !== "style") return;
const block = openBlocks.pop();
if (!block || block.name !== name) return;
blocks[name].push({
attrs: block.attrs,
content: source.slice(block.contentStart, parser.startIndex),
raw: source.slice(block.index, parser.endIndex + 1),
index: block.index,
});
},
},
{ decodeEntities: false, lowerCaseAttributeNames: false, lowerCaseTags: true },
);
parser.end(source);
return { tags, scripts: blocks.script, styles: blocks.style };
}
/**
* Find the `<html>` open tag in the source. Distinct from `findRootTag`,
* which returns the first element inside `<body>` — the latter is "the
* composition's visible root", whereas `<html>` is where document-level
* metadata like `data-composition-variables` lives.
*/
export function findHtmlTag(tags: readonly OpenTag[]): OpenTag | null {
return tags.find((tag) => tag.name === "html") ?? null;
}
// fallow-ignore-next-line complexity
export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): OpenTag | null {
const tags = parsedTags ?? parseHtmlStructure(source).tags;
const bodyTag = tags.find((tag) => tag.name === "body");
if (
bodyTag &&
(readAttr(bodyTag.raw, "data-composition-id") ||
readAttr(bodyTag.raw, "data-width") ||
readAttr(bodyTag.raw, "data-height"))
) {
return bodyTag;
}
const bodyStart = bodyTag ? bodyTag.index + bodyTag.raw.length : 0;
const bodyEnd = bodyTag?.closeIndex ?? source.length;
const bodyTags = tags.filter((tag) => tag.index >= bodyStart && tag.index < bodyEnd);
// Set when a leading <svg> defs block is skipped (see below) — extractOpenTags
// is a flat, nesting-unaware scan, so without this the very next tag it
// returns is the svg's own nested child (<defs>, <filter>, ...), not the
// sibling that follows the closed </svg>.
let skipBefore = -1;
for (const tag of bodyTags) {
if (tag.index < skipBefore) continue;
if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue;
// A leading <svg> block (icon/gradient/filter <defs>, referenced by url(#id)
// from elsewhere in the document) is shared visual plumbing, not the
// composition root — two independent reports of this being mistaken for
// the root, manufacturing root_missing_composition_id/root_missing_dimensions
// on an otherwise-correct composition. Only skip it when it carries none of
// the composition markers itself, so an intentionally SVG-rooted composition
// (data-composition-id/data-width/data-height directly on the <svg>) is
// still eligible as the root.
if (
tag.name === "svg" &&
!readAttr(tag.raw, "data-composition-id") &&
!readAttr(tag.raw, "data-width") &&
!readAttr(tag.raw, "data-height")
) {
// No closing tag found (malformed HTML) — skip everything rather than
// risk returning one of the svg's own children as the root.
skipBefore = tag.endIndex ?? Infinity;
continue;
}
return tag;
}
return null;
}
export function readAttr(tagSource: string, attr: string): string | null {
if (!tagSource) return null;
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// `(?<![\w-])` not `\b`: a plain `\b` boundary treats the hyphen in a longer
// attribute as a word break, so reading "id" would wrongly match the trailing
// `id="…"` inside `data-hf-id="…"` (and "width" inside `data-width`, etc.).
// The lookbehind requires the match to start a fresh attribute name.
const match = tagSource.match(new RegExp(`(?<![\\w-])${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
return match?.[1] || null;
}
/**
* Read an attribute that may legitimately contain the opposite quote
* character. `readAttr` truncates `data-variable-values='{"title":"Hello"}'`
* at the first internal `"` because its `[^"']+` class excludes both quote
* types. This variant alternates: a double-quoted value never contains an
* unescaped `"`, and a single-quoted value never contains an unescaped `'`,
* so each branch can use a quote-specific class.
*
* Use for attributes whose values are JSON or otherwise carry the opposite
* quote character. Existing single-token attributes (`id`, `class`, etc.)
* stick with `readAttr` for consistency with the rest of the lint code.
*/
export function readJsonAttr(tagSource: string, attr: string): string | null {
if (!tagSource) return null;
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// See readAttr: `(?<![\w-])` prevents a short name from matching the tail of a
// longer hyphenated attribute (e.g. "id" inside `data-hf-id`).
const match = tagSource.match(
new RegExp(`(?<![\\w-])${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"),
);
if (!match) return null;
return match[1] ?? match[2] ?? null;
}
export function collectCompositionIds(tags: OpenTag[]): Set<string> {
const ids = new Set<string>();
for (const tag of tags) {
const compId = readAttr(tag.raw, "data-composition-id");
if (compId) ids.add(compId);
}
return ids;
}
export function extractCompositionIdsFromCss(css: string): string[] {
const ids = new Set<string>();
let match: RegExpExecArray | null;
const pattern = new RegExp(
COMPOSITION_ID_IN_CSS_PATTERN.source,
COMPOSITION_ID_IN_CSS_PATTERN.flags,
);
while ((match = pattern.exec(css)) !== null) {
if (match[1]) ids.add(match[1]);
}
return [...ids];
}
export function extractTimelineRegistryKeys(source: string): string[] {
const keys = new Set<string>();
let match: RegExpExecArray | null;
const pattern = new RegExp(
TIMELINE_REGISTRY_KEY_PATTERN.source,
TIMELINE_REGISTRY_KEY_PATTERN.flags,
);
while ((match = pattern.exec(source)) !== null) {
const key = match[1] ?? match[2];
if (key) keys.add(key);
}
const objectBody = TIMELINE_REGISTRY_OBJECT_BODY_PATTERN.exec(source)?.[1];
if (objectBody) {
const entryPattern = new RegExp(
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source,
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags,
);
while ((match = entryPattern.exec(objectBody)) !== null) {
const key = match[1] ?? match[2];
if (key) keys.add(key);
}
}
return [...keys];
}
export function getInlineScriptSyntaxError(source: string): string | null {
if (!source.trim()) return null;
try {
// eslint-disable-next-line no-new-func
new Function(source);
return null;
} catch (error) {
if (error instanceof Error) return error.message;
return String(error);
}
}
// fallow-ignore-next-line complexity
export function stripJsComments(source: string): string {
let out = "";
let i = 0;
let quote: "'" | '"' | "`" | null = null;
let escaped = false;
while (i < source.length) {
const ch = source[i] ?? "";
const next = source[i + 1] ?? "";
if (quote) {
out += ch;
if (escaped) {
escaped = false;
} else if (ch === "\\") {
escaped = true;
} else if (ch === quote) {
quote = null;
}
i += 1;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") {
quote = ch;
out += ch;
i += 1;
continue;
}
if (ch === "/" && next === "/") {
out += " ";
i += 2;
while (i < source.length && source[i] !== "\n" && source[i] !== "\r") {
out += " ";
i += 1;
}
continue;
}
if (ch === "/" && next === "*") {
out += " ";
i += 2;
while (i < source.length) {
const blockCh = source[i] ?? "";
const blockNext = source[i + 1] ?? "";
if (blockCh === "*" && blockNext === "/") {
out += " ";
i += 2;
break;
}
out += blockCh === "\n" || blockCh === "\r" ? blockCh : " ";
i += 1;
}
continue;
}
out += ch;
i += 1;
}
return out;
}
// One linear pass that drops every `<!-- … -->` region. Uses indexOf, not a
// `/<!--[\s\S]*?-->/` regex: that pattern backtracks O(n²) on inputs with many
// unterminated "<!--" (CodeQL js/polynomial-redos). An unterminated "<!--" with
// no closing "-->" is kept verbatim, matching the prior regex's no-match behavior.
function stripHtmlCommentsOnce(source: string): string {
let out = "";
let i = 0;
for (;;) {
const start = source.indexOf("<!--", i);
if (start < 0) return out + source.slice(i);
const end = source.indexOf("-->", start + 4);
if (end < 0) return out + source.slice(i);
out += source.slice(i, start);
i = end + 3;
}
}
// Strip HTML comments to a fixpoint. A single pass is not enough: deleting one
// comment can splice adjacent markers into a fresh, complete <!-- … --> (e.g.
// "<<!-- -->!-- … -->" → "<!-- … -->"), which would otherwise survive and let a
// commented-out <template>/tag hijack the linter's tag scan.
export function stripHtmlComments(source: string): string {
let out = source;
for (let prev = ""; prev !== out; ) {
prev = out;
out = stripHtmlCommentsOnce(out);
}
return out;
}
export function extractScriptTextsAndSrcs(scripts: ExtractedBlock[]): {
texts: string[];
srcs: string[];
} {
const texts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
const srcs = scripts.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "").filter(Boolean);
return { texts, srcs };
}
export function isMediaTag(tagName: string): boolean {
return tagName === "video" || tagName === "audio" || tagName === "img";
}
// Whether any <style> block in the composition defines caption group/word
// classes (`.caption-group`, `.caption_word`, etc.) — the signal several
// caption-specific rules use to skip non-caption compositions entirely.
export function hasCaptionStyles(styles: ExtractedBlock[]): boolean {
return styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
}
export function truncateSnippet(value: string, maxLength = 220): string | undefined {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) return undefined;
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, maxLength - 3)}...`;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from "tsup";
export default defineConfig([
{
entry: { index: "src/index.ts" },
format: ["esm"],
outDir: "dist",
target: "node22",
platform: "node",
bundle: true,
splitting: false,
sourcemap: true,
clean: true,
dts: true,
},
{
// Browser-safe subset. platform: "browser" makes the build FAIL if any
// node:* builtin sneaks into the rule engine — a compile-time guarantee
// that @hyperframes/lint/browser stays client-side runnable.
entry: { browser: "src/browser.ts" },
format: ["esm"],
outDir: "dist",
target: "es2022",
platform: "browser",
bundle: true,
splitting: false,
sourcemap: true,
clean: false,
dts: true,
},
]);
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "jsdom",
},
});