Merge remote-tracking branch 'origin/main' into feat/lint-gsap-non-transform-motion

# Conflicts:
#	skills-manifest.json
This commit is contained in:
Vance Ingalls
2026-07-08 01:36:50 -07:00
913 changed files with 74664 additions and 8626 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/lint",
"version": "0.7.18",
"version": "0.7.42",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
@@ -54,6 +54,7 @@
},
"dependencies": {
"@hyperframes/parsers": "workspace:*",
"linkedom": "^0.18.12",
"postcss": "^8.5.8"
},
"devDependencies": {
+8 -1
View File
@@ -34,8 +34,15 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
// 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 sourceWithoutTemplates = source.replace(
/<template\b[^>]*>[\s\S]*?<\/template(?:\s[^>]*)?>/gi,
" ",
);
const templateMatch = source.match(/<template[^>]*>([\s\S]*)<\/template>/i);
if (templateMatch?.[1]) source = templateMatch[1];
// 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.
if (templateMatch?.[1] && !findRootTag(sourceWithoutTemplates)) source = templateMatch[1];
const tags = extractOpenTags(source);
const styles = [
+211
View File
@@ -0,0 +1,211 @@
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");
});
});
+108
View File
@@ -3,8 +3,16 @@ 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;
@@ -221,6 +229,7 @@ export async function lintProject(projectDir: string): Promise<ProjectLintResult
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
...lintMultipleRootCompositions(projectDir),
...lintDuplicateAudioTracks(allHtmlSources),
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
];
if (projectFindings.length > 0) {
for (const finding of projectFindings) {
@@ -502,3 +511,102 @@ function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFind
}
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;
}
+82
View File
@@ -124,6 +124,88 @@ describe("adapter rules", () => {
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>
+5 -2
View File
@@ -36,8 +36,11 @@ export const adapterRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
/["']three["']/.test(t) &&
/importmap/.test(scripts.find((s) => s.content === t)?.attrs || ""),
);
const hasThreeModuleImport = texts.some(
(t) => /\bimport\b.*['"]three['"]/.test(t) || /\bfrom\s+['"]three['"]/.test(t),
// 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 [];
+457
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
@@ -384,6 +385,24 @@ describe("composition rules", () => {
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 === "overlapping_clips_same_track");
expect(finding).toBeUndefined();
});
it("does not flag adjacencies where parseFloat + add drifts by a few ulps", async () => {
// parseFloat("0.1") + parseFloat("0.2") = 0.30000000000000004
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip" data-start="0.1" data-duration="0.2" data-track-index="0">A</div>
<div class="clip" data-start="0.3" data-duration="0.2" data-track-index="0">B</div>
</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 === "overlapping_clips_same_track");
@@ -589,6 +608,78 @@ describe("composition rules", () => {
});
});
describe("missing_data_no_timeline", () => {
it("warns when root has no timeline registration and no data-no-timeline", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "missing_data_no_timeline");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when data-no-timeline is present (boolean form)", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-no-timeline data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});
it("does not warn when a script registers window.__timelines[id]", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></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 === "missing_data_no_timeline")).toBeUndefined();
});
it("does not warn when there is no root composition-id", async () => {
const html = `<!DOCTYPE html><html><body><p>hello</p></body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});
it("does not false-positive when data-no-timeline appears only inside an attribute value", async () => {
// Regression: /\bdata-no-timeline\b/ matched substrings inside values
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" title="add data-no-timeline here" data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeDefined();
});
it("does not suppress when a hyphenated variant like data-no-timeline-start is present", async () => {
// Regression: /\bdata-no-timeline\b/ matched data-no-timeline-start because
// hyphen is a non-word char and \b fires between 'e' and '-'
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-no-timeline-start="0" data-width="320" data-height="180" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeDefined();
});
it("does not warn for sub-compositions", async () => {
const html = `<template><div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div></template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});
it("does not warn when composition has external scripts (cannot scan for timeline registration)", async () => {
const html = `<!DOCTYPE html><html><body>
<div data-composition-id="c1" data-width="320" data-height="180" data-duration="5"></div>
<script src="app.js"></script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "missing_data_no_timeline")).toBeUndefined();
});
});
describe("root_composition_missing_data_duration (removed)", () => {
// The rule was a static proxy for the runtime's loop-inflation Infinity
// emission, but lint cannot observe GSAP timeline duration statically and
@@ -991,6 +1082,70 @@ describe("composition rules", () => {
});
});
describe("html_dir_attribute_breaks_render", () => {
const CODE = "html_dir_attribute_breaks_render";
const find = (findings: { code: string }[]) => findings.find((f) => f.code === CODE);
it('flags dir="rtl" on <html>', async () => {
const html = `<!DOCTYPE html>
<html lang="ar" dir="rtl">
<body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5">مرحبا</div>
</body>
</html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain('dir="rtl"');
expect(finding?.fixHint).toContain("direction: rtl");
});
it('flags dir="auto" on <html>', async () => {
const html = `<html dir="AUTO"><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.fixHint).toContain('dir="auto"');
});
it('does not flag dir="ltr"', async () => {
const html = `<html dir="ltr"><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not flag invalid dir values that browsers treat as ltr", async () => {
const html = `<html dir="bogus"><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not flag when <html> has no dir attribute", async () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5"></div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it('does not flag dir="rtl" scoped to an individual element (the documented fix)', async () => {
const html = `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-duration="5">
<p style="direction: rtl;">مرحبا</p>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
});
describe("subcomposition_blanks_before_host", () => {
const find = (findings: Array<{ code: string }>) =>
findings.find((f) => f.code === "subcomposition_blanks_before_host");
@@ -1101,4 +1256,306 @@ describe("composition rules", () => {
expect(find(result.findings)).toBeUndefined();
});
});
describe("root_composition_missing_duration_source", () => {
const CODE = "root_composition_missing_duration_source";
const find = (findings: { code: string }[]) => findings.find((f) => f.code === CODE);
it("errors when there is no data-duration, no GSAP timeline, and no animation signal at all", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<div>static content</div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not error when data-duration is declared on the root", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-duration="6" data-width="1920" data-height="1080">
<div>static content</div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error when a GSAP timeline is registered", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error when a GSAP timeline is registered with a computed bracket key", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080"></div>
<script>
var spec = { id: "main" };
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines[spec.id] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error for a finite CSS animation (runtime auto-infers duration)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.box { animation: fadeIn 3s ease forwards; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>
<div class="box"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error for a WAAPI .animate() call (runtime auto-infers duration)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<div class="box"></div>
</div>
<script>
document.querySelector(".box").animate([{ opacity: 0 }, { opacity: 1 }], { duration: 2000 });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error for a registered Lottie animation (runtime auto-infers duration)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<div id="anim"></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
<script>
window.__hfLottie = window.__hfLottie || [];
const anim = lottie.loadAnimation({
container: document.getElementById("anim"),
renderer: "svg",
loop: false,
autoplay: false,
path: "animation.json",
});
window.__hfLottie.push(anim);
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("errors for an infinite CSS animation with no data-duration", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<div class="spinner"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("errors for a mixed finite + infinite CSS animation with no data-duration (length is ambiguous)", async () => {
// The runtime CAN infer 3s here (from the finite `fadeIn`), but an
// unbounded `spin infinite` alongside it makes the intended total length
// ambiguous, so the rule stays strict and requires an explicit
// data-duration. Deliberately stricter than runtime inference — see the
// rule's block comment. Message must NOT claim the render will fail
// (it wouldn't — the runtime falls back to the finite animation).
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.fade { animation: fadeIn 3s ease forwards; }
.spinner { animation: spin 1s linear infinite; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<div class="fade"></div>
<div class="spinner"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
// Honest message: describes the ambiguity, does not assert a hard failure.
expect(finding?.message).toContain("ambiguous");
expect(finding?.message).not.toContain("will fail");
});
it("does not error for an infinite CSS animation when data-duration is declared", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-duration="8" data-width="1920" data-height="1080">
<style>
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<div class="spinner"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("errors for Three.js usage with no data-duration", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<canvas id="scene"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script>
<script>
const renderer = new THREE.WebGLRenderer();
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not error for Three.js usage when data-duration is declared", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-duration="10" data-width="1920" data-height="1080">
<canvas id="scene"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script>
<script>
const renderer = new THREE.WebGLRenderer();
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not apply to sub-compositions", async () => {
const html = `<template id="scene-template">
<div data-composition-id="scene" data-start="0" data-width="1920" data-height="1080">
<div>static content</div>
</div>
</template>`;
const result = await lintHyperframeHtml(html, {
filePath: "compositions/scene.html",
isSubComposition: true,
});
expect(find(result.findings)).toBeUndefined();
});
it("errors when the only .animate() call is commented out (no real duration source)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<div class="box"></div>
</div>
<script>
// document.querySelector(".box").animate([{ opacity: 0 }, { opacity: 1 }], { duration: 2000 });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("errors when the only CSS animation is inside a comment (no real duration source)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
/* .box { animation: spin 2s infinite; } */
.box { color: red; }
</style>
<div class="box"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not error for the object-literal (PropertyIndexedKeyframes) WAAPI form", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<div class="box"></div>
</div>
<script>
document.querySelector(".box").animate({ opacity: [0, 1] }, { duration: 2000 });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error for a finite CSS animation whose name merely contains 'infinite'", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.marquee { animation: infinite-spin 2s ease; }
@keyframes infinite-spin { from { transform: translateX(0); } to { transform: translateX(-100%); } }
</style>
<div class="marquee"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("errors for the longhand animation-name + animation-iteration-count: infinite combination", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.spinner {
animation-name: infinite-scroll;
animation-duration: 1s;
animation-iteration-count: infinite;
}
@keyframes infinite-scroll { from { transform: translateX(0); } to { transform: translateX(-100%); } }
</style>
<div class="spinner"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not error for the longhand animation-name form with a finite iteration count", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.spinner {
animation-name: infinite-scroll;
animation-duration: 1s;
animation-iteration-count: 3;
}
@keyframes infinite-scroll { from { transform: translateX(0); } to { transform: translateX(-100%); } }
</style>
<div class="spinner"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
});
});
+229 -2
View File
@@ -1,5 +1,12 @@
import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context";
import { findHtmlTag, readAttr, readJsonAttr, stripJsComments, truncateSnippet } from "../utils";
import {
findHtmlTag,
readAttr,
readJsonAttr,
stripJsComments,
truncateSnippet,
WINDOW_TIMELINE_ASSIGN_PATTERN,
} from "../utils";
import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
@@ -8,6 +15,14 @@ const MAX_COMPOSITION_LINES = 300;
const MAX_TIMED_ELEMENTS_PER_TRACK = 3;
const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"]);
// `parseFloat("0.1") + parseFloat("0.2") = 0.30000000000000004`. Sub-second
// authored adjacencies survive parse + add as a value a few ulps above the
// next clip's start; a strict `>` fires the overlap rule on adjacencies that
// are exact in the source HTML. 1μs sits ~11 orders of magnitude above the
// observed drift (worst ~2e-16s across every realistic decimal pair) and 4
// below one 60fps frame (~16.67ms), so this only ever swallows float slop.
const OVERLAP_EPSILON_SECONDS = 1e-6;
function countPhysicalLines(source: string): number {
if (source.length === 0) return 0;
@@ -399,7 +414,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const current = clips[i];
const next = clips[i + 1];
if (!current || !next) continue;
if (current.end > next.start) {
if (current.end - next.start > OVERLAP_EPSILON_SECONDS) {
findings.push({
code: "overlapping_clips_same_track",
severity: "error",
@@ -480,6 +495,42 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// missing_data_no_timeline
// The producer polls window.__timelines[id] with a 45-second timeout waiting
// for GSAP timeline registration. Compositions that never call
// window.__timelines[id] = tl stall for 45 s every render. Adding
// data-no-timeline to the root element tells the producer to skip the poll.
({ rootTag, rootCompositionId, scripts, rawSource, options }) => {
if (options.isSubComposition) return [];
if (!rootCompositionId || !rootTag) return [];
// readAttr only matches valued attrs (attr="..."); data-no-timeline is
// typically boolean (no value). Strip quoted attribute values first to
// avoid matching attr names that appear inside other values
// (e.g. title="add data-no-timeline here"), then check with a boundary
// that rejects hyphenated variants (data-no-timeline-start has '-' next,
// not a word-break char).
const tagNoValues = rootTag.raw.replace(/"[^"]*"|'[^']*'/g, '""');
if (/(?:^|\s)data-no-timeline(?=[\s>=/]|$)/i.test(tagNoValues)) return [];
// Can't scan external script files for timeline registration; skip to avoid
// false positives on compositions that register via a bundled JS file.
if (/<script\b[^>]*\bsrc\s*=/i.test(rawSource)) return [];
const registersTimeline = scripts.some((s) => s.content.includes("window.__timelines["));
if (registersTimeline) return [];
return [
{
code: "missing_data_no_timeline",
severity: "warning",
message:
"This composition has no `window.__timelines` registration but is missing `data-no-timeline`. " +
"The producer polls for timeline registration for up to 45 seconds before timing out, " +
"adding 45 s to every render.",
fixHint:
'Add `data-no-timeline` to the root element to skip the poll: `<div data-composition-id="..." data-no-timeline ...>`.',
snippet: truncateSnippet(rootTag.raw),
},
];
},
// requestanimationframe_in_composition
({ scripts, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
@@ -619,6 +670,38 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// html_dir_attribute_breaks_render — valid non-LTR dir values on
// <html> renders correctly in preview/snapshot but produces a fully
// blank/black video from render, with no other lint/validate/inspect
// check catching it (output file size, far smaller than expected, is the
// only tell). Confirmed independently by two separate reports, both
// diagnosing the same exact trigger and the same fix: drop dir from
// <html>, keep lang, and scope `direction: rtl` to individual
// text-containing elements via CSS instead (text still bidi-shapes
// correctly). Advisory-only — this does not attempt to fix the render
// pipeline's own root cause (suspected to be a capture step that clips a
// fixed top-left-origin screenshot region, which RTL layout can shift the
// actual content away from), only surfaces the already-confirmed footgun
// before someone hits it blind.
({ source }) => {
const htmlTag = findHtmlTag(source);
if (!htmlTag) return [];
const dir = readAttr(htmlTag.raw, "dir");
if (!dir) return [];
const normalizedDir = dir.toLowerCase();
if (normalizedDir !== "rtl" && normalizedDir !== "auto") return [];
const scopedDirection = normalizedDir === "auto" ? 'dir="auto"' : `direction: ${normalizedDir}`;
return [
{
code: "html_dir_attribute_breaks_render",
severity: "error",
message: `<html dir="${dir}"> renders correctly in preview/snapshot but produces a fully blank/black video from render — a confirmed, silent failure.`,
fixHint: `Remove dir="${dir}" from <html>. Keep lang, and scope ${scopedDirection} to individual text-containing elements instead — text still shapes correctly via the browser's own bidi algorithm.`,
snippet: truncateSnippet(htmlTag.raw),
},
];
},
// subcomposition_blanks_before_host
// Warns when a full-bleed sub-composition slot ends before the host composition
// does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to
@@ -720,4 +803,148 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
},
];
},
// root_composition_missing_duration_source
//
// The render engine (packages/engine/src/services/frameCapture.ts) needs a
// positive window.__hf.duration to know how many frames to capture. GSAP
// timelines set this automatically. Non-GSAP runtimes (CSS, WAAPI, Lottie)
// are now auto-inferred by the runtime too (see
// packages/core/src/runtime/init.ts resolveAdapterDurationFloorSeconds and
// the adapters' getInferredDurationSeconds) — so data-duration is optional
// wherever the runtime can work it out on its own.
//
// This rule fires for cases where the total render length is not reliably
// determinable without an explicit data-duration:
// - No GSAP timeline AND no data-duration AND no non-GSAP animation
// signal at all (nothing for any adapter to discover — render fails).
// - Three.js used with no data-duration (no discoverable AnimationClip
// duration in this codebase's adapter — see adapters/three.ts).
// - Any infinite CSS animation-iteration-count with no data-duration,
// EVEN when a finite CSS animation is present alongside it. An unbounded
// animation makes the intended total length ambiguous — the runtime will
// infer a finite sibling's length if one exists, but that's a fallback,
// not a declaration of intent, so we still require data-duration here.
// (This is intentionally stricter than the runtime's own inference.)
// Purely finite CSS/WAAPI animations and Lottie are excluded — the runtime
// infers those unambiguously, so requiring data-duration there would be a
// false positive against the runtime's own auto-inference. Note lint is
// advisory by default (see shouldBlockRender) — it only blocks render under
// --strict/--strict-all — so a strict flag here nudges toward an explicit,
// guaranteed-correct value without failing renders that would succeed.
// fallow-ignore-next-line complexity
({ rootTag, scripts, styles, tags, options }) => {
if (options.isSubComposition) return [];
if (!rootTag) return [];
// Not every file linted as a "root" HTML document is a video composition
// — e.g. a slideshow demo.html mounts <hyperframes-player src="index.html">
// with no data-composition-id of its own. Nothing to capture there, so
// there's no duration contract to enforce.
if (readAttr(rootTag.raw, "data-composition-id") === null) return [];
if (readAttr(rootTag.raw, "data-duration") !== null) return [];
// Strip comments before scanning for signals — a commented-out
// `.animate(...)` call or `/* animation: spin 2s infinite; */` must not
// satisfy the "has a duration source" check, or the composition still
// fails at render with zero duration despite lint passing.
const allScriptTexts = scripts.map((s) => stripJsComments(s.content));
const hasGsapTimeline = allScriptTexts.some((t) => /gsap\.timeline\s*\(/.test(t));
const hasRegisteredTimeline = allScriptTexts.some((t) =>
WINDOW_TIMELINE_ASSIGN_PATTERN.test(t),
);
// A GSAP timeline drives duration via window.__timelines regardless of
// data-duration — nothing to flag once one is registered.
if (hasGsapTimeline && hasRegisteredTimeline) return [];
const allCss = styles.map((s) => s.content).join("\n");
const allInlineStyles = tags.map((t) => readAttr(t.raw, "style") || "").join("\n");
const combinedCss = `${allCss}\n${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
const usesLottie =
tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null) ||
allScriptTexts.some((t) => /lottie\.(loadAnimation)\b|__hfLottie\b/.test(t));
const usesThree = allScriptTexts.some((t) => /\bTHREE\./.test(t));
// `.animate([...], ...)` catches the array-literal keyframes form;
// `.animate({...}, ...)` catches the object-literal (PropertyIndexedKeyframes)
// form; `.animate(someVar, ...)` catches keyframes built up in a variable
// first.
const usesWaapi = allScriptTexts.some((t) => /\.animate\s*\(\s*[[{$A-Za-z_]/.test(t));
const hasCssAnimationName = /\banimation(?:-name)?\s*:/.test(combinedCss);
const hasInfiniteCssAnimation =
/\banimation(?:-iteration-count)?\s*:[^;{}]*(?<![\w-])infinite(?![\w-])/.test(combinedCss);
const hasAnyNonGsapSignal = usesLottie || usesThree || usesWaapi || hasCssAnimationName;
if (!hasAnyNonGsapSignal) {
// No GSAP timeline, no data-duration, and nothing for any adapter to
// discover — the composition has no source of truth for duration at
// all. This is the exact shape of the 27K "zero duration" render
// failures this rule exists to catch before render time.
return [
{
code: "root_composition_missing_duration_source",
severity: "error",
message:
"Root composition has no data-duration, no GSAP timeline, and no CSS/WAAPI/Lottie/Three.js " +
"animation for the runtime to infer a duration from. The render engine cannot determine " +
'how long to capture and will fail with "Composition has zero duration".',
fixHint:
'Add data-duration="<seconds>" to the root element, or add a paused GSAP timeline registered ' +
"on window.__timelines.",
snippet: truncateSnippet(rootTag.raw),
},
];
}
if (usesThree) {
// No AnimationMixer/AnimationClip discovery in the three.js adapter
// today (see adapters/three.ts) — genuinely not inferable.
return [
{
code: "root_composition_missing_duration_source",
severity: "error",
message:
"Root composition uses Three.js with no data-duration. The runtime cannot discover a " +
"Three.js scene's duration automatically (no AnimationClip/AnimationMixer inspection) — " +
'render will fail with "Composition has zero duration".',
fixHint: 'Add data-duration="<seconds>" to the root element.',
snippet: truncateSnippet(rootTag.raw),
},
];
}
if (hasInfiniteCssAnimation && !usesLottie && !usesWaapi) {
// An infinite/unbounded CSS animation makes the intended total length
// ambiguous, so we require an explicit data-duration even when a finite
// CSS animation is present alongside it. This is deliberately stricter
// than the runtime's own inference: the CSS adapter's
// getInferredDurationSeconds (see adapters/css.ts) returns the longest
// finite animation end-time when one exists (so a finite sibling would
// render at that length) and null when every animation is unbounded (so
// a render with no finite source fails outright). Either way the author
// hasn't declared how long the video should be — a decorative infinite
// spinner next to a 3s fade doesn't tell us the clip is meant to be 3s
// — so we flag it and let them state intent. The message stays honest
// about both outcomes rather than claiming the render always fails.
return [
{
code: "root_composition_missing_duration_source",
severity: "error",
message:
"Root composition uses a CSS animation with animation-iteration-count: infinite and no " +
"data-duration, so the intended total length is ambiguous. If a finite animation is also " +
"present the runtime infers that length; with no finite source the render fails with " +
'"Composition has zero duration". Declare the intended length explicitly.',
fixHint:
'Add data-duration="<seconds>" to the root element with the intended total length.',
snippet: truncateSnippet(rootTag.raw),
},
];
}
// Finite CSS animation, WAAPI .animate(), or Lottie — the runtime infers
// duration from these at render time (see resolveAdapterDurationFloorSeconds
// in runtime/init.ts). Not an error; data-duration is optional here.
return [];
},
];
+144
View File
@@ -112,6 +112,37 @@ describe("core rules", () => {
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>
@@ -370,6 +401,87 @@ body {
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>
@@ -585,6 +697,38 @@ body {
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 () => {
+72 -1
View File
@@ -9,6 +9,7 @@ import {
getInlineScriptSyntaxError,
TIMELINE_REGISTRY_INIT_PATTERN,
TIMELINE_REGISTRY_ASSIGN_PATTERN,
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
INVALID_SCRIPT_CLOSE_PATTERN,
} from "../utils";
@@ -68,6 +69,14 @@ 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;
@@ -127,6 +136,50 @@ function findLeakedTextBeforeCompositionRoot(
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[]> = [
// root_missing_composition_id + root_missing_dimensions
({ rootTag }) => {
@@ -174,6 +227,23 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
];
},
// 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, options }) => {
// Sub-compositions inherit window.__timelines from the host composition
@@ -183,7 +253,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
const findings: HyperframeLintFinding[] = [];
if (
!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
!TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(source)
) {
findings.push({
code: "missing_timeline_registry",
+68
View File
@@ -146,6 +146,30 @@ describe("font rules", () => {
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>
@@ -261,6 +285,16 @@ describe("font rules", () => {
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` +
@@ -281,5 +315,39 @@ describe("font rules", () => {
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");
});
});
});
+28 -8
View File
@@ -16,6 +16,12 @@ const GENERIC_FAMILIES = new Set([
"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",
@@ -50,6 +56,22 @@ function extractFontFaceFamilies(styles: Array<{ content: string }>): Set<string
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>();
@@ -58,13 +80,8 @@ function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
const withoutFontFace = stripCssComments(style.content).replace(/@font-face\s*\{[^}]*\}/gi, "");
let match: RegExpExecArray | null;
while ((match = propRe.exec(withoutFontFace)) !== null) {
const stack = match[1]!;
for (const part of stack.split(",")) {
const name = part
.trim()
.replace(/^['"]|['"]$/g, "")
.trim()
.toLowerCase();
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);
@@ -215,7 +232,10 @@ export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
"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 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;
},
+129
View File
@@ -952,6 +952,32 @@ describe("GSAP rules", () => {
expect(finding).toBeDefined();
});
it("does NOT report overlapping_gsap_tweens for distinct unresolved-target tweens", async () => {
// Each tween targets a DIFFERENT element via a target the parser cannot resolve
// statically (a helper call). Both collapse to the `__unresolved__` sentinel, but
// they are not the same element, so an overlap must not be asserted between them.
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="s0"><div class="hl"><span class="w">a</span></div></div>
<div id="s1"><div class="hl"><span class="w">b</span></div></div>
</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 });
const a = pickWord(0);
const b = pickWord(1);
tl.to(a, { x: 100, duration: 1 }, 0);
tl.to(b, { x: 100, duration: 1 }, 0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
expect(finding).toBeUndefined();
});
it("warns when an opacity exit ends at a clip start boundary without a hard kill", async () => {
const html = `
<html><body>
@@ -979,6 +1005,63 @@ describe("GSAP rules", () => {
expect(finding?.message).toContain("3.00s");
});
it("gsap_exit_missing_hard_kill points at the inner-wrapper pattern when the exiting selector is a clip element", async () => {
// Regression: a tl.set hard kill on a clip-classed selector is exactly what
// gsap_animates_clip_element then errors on — the two rules must not give
// contradictory advice for a crossfading scene that is itself class="clip".
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="6">
<div id="scene-a" class="clip" data-start="0" data-duration="3" data-track-index="0"></div>
<div id="scene-b" class="clip" data-start="3" data-duration="3" data-track-index="0"></div>
</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("#scene-a", { opacity: 0, duration: 0.3 }, 2.7);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_exit_missing_hard_kill");
expect(finding).toBeDefined();
expect(finding?.fixHint).toContain("clip element");
expect(finding?.fixHint).toContain("inner");
expect(finding?.fixHint).not.toContain('tl.set("#scene-a"');
});
it("does NOT report gsap_exit_missing_hard_kill for an unresolved-target boundary exit", async () => {
// The exit tween targets an element via a value the parser cannot resolve (a helper
// call), so it collapses to the `__unresolved__` sentinel. You cannot assert a missing
// hard kill on an unknown element, and a `tl.set("__unresolved__", ...)` hint is
// meaningless. The resolved-target exit in the same timeline is still flagged.
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="6">
<div id="scene-a" class="clip" data-start="0" data-duration="3" data-track-index="0">
<h1 id="headline">First beat</h1>
</div>
<div id="scene-b" class="clip" data-start="3" data-duration="3" data-track-index="0">
<h1>Second beat</h1>
</div>
</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 });
const el = pickWord(0);
tl.to(el, { opacity: 0, duration: 0.3 }, 2.7);
tl.to("#headline", { opacity: 0, duration: 0.3 }, 2.7);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const exitFindings = result.findings.filter((f) => f.code === "gsap_exit_missing_hard_kill");
expect(exitFindings).toHaveLength(1);
expect(exitFindings[0]?.selector).toBe("#headline");
});
it("does not warn when a boundary exit has a matching hard kill", async () => {
const html = `
<html><body>
@@ -1239,6 +1322,26 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it("does NOT warn when timeline is registered with a computed bracket key", async () => {
const html = `
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080">
<div id="box">Hello</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
var spec = { id: "root" };
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { opacity: 0.5, duration: 2 });
window.__timelines[spec.id] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_timeline_not_registered");
expect(finding).toBeUndefined();
});
it("does NOT warn for sub-compositions (template-based)", async () => {
const html = `
<template>
@@ -1276,6 +1379,32 @@ describe("GSAP rules", () => {
expect(finding?.elementId).toBe("scene1");
});
it("scene_layer_missing_visibility_kill points at the inner-wrapper pattern when the scene element is a clip", async () => {
// Same contradiction as gsap_exit_missing_hard_kill above, via the older
// id-pattern-based rule: `tl.set("#scene1", { visibility: "hidden" }, ...)`
// on a class="clip" scene element is exactly what gsap_animates_clip_element
// then errors on.
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="scene1" class="clip"></div>
<div id="scene2" class="clip"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#scene1", { opacity: 0, duration: 0.5 }, 2.0);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "scene_layer_missing_visibility_kill");
expect(finding).toBeDefined();
expect(finding?.fixHint).toContain("clip element");
expect(finding?.fixHint).toContain("inner");
expect(finding?.fixHint).not.toContain('tl.set("#scene1"');
});
it("scene_layer_missing_visibility_kill: DOES fire when kill is only in a comment (stripJsComments guard)", async () => {
const html = `
<html><body>
+147 -82
View File
@@ -29,7 +29,9 @@ import {
readAttr,
truncateSnippet,
stripJsComments,
hasCaptionStyles,
WINDOW_TIMELINE_ASSIGN_PATTERN,
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
} from "../utils";
// ── GSAP-specific types ────────────────────────────────────────────────────
@@ -53,6 +55,12 @@ type CompositionRange = {
const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
// Sentinel the GSAP parser assigns to a tween whose target it cannot statically
// resolve to a concrete element (a computed variable, a helper call, etc.). It is
// NOT an identity: two distinct unresolved selectors are not the same element, so
// overlap analysis must never treat them as one.
const UNRESOLVED_TARGET = "__unresolved__";
// ── GSAP parsing utilities ─────────────────────────────────────────────────
function countClassUsage(tags: OpenTag[]): Map<string, number> {
@@ -271,6 +279,17 @@ function findContainingCompositionId(tag: OpenTag, ranges: CompositionRange[]):
return match?.id || null;
}
// A tag's `class` attribute, split into tokens, but only when it carries the
// `clip` marker class — the common "is this a clip element?" filter used by
// several rules that walk every tag looking for clips.
type ClipTagClasses = { classAttr: string; classes: string[] };
function getClipTagClasses(tag: OpenTag): ClipTagClasses | null {
const classAttr = readAttr(tag.raw, "class") || "";
const classes = classAttr.split(/\s+/).filter(Boolean);
return classes.includes("clip") ? { classAttr, classes } : null;
}
function collectClipStartBoundariesByComposition(
source: string,
tags: OpenTag[],
@@ -279,9 +298,7 @@ function collectClipStartBoundariesByComposition(
const boundaries = new Map<string, Set<number>>();
for (const tag of tags) {
const classAttr = readAttr(tag.raw, "class") || "";
const classes = classAttr.split(/\s+/).filter(Boolean);
if (!classes.includes("clip")) continue;
if (!getClipTagClasses(tag)) continue;
const compositionId = findContainingCompositionId(tag, ranges);
if (!compositionId) continue;
const start = numberValue(readAttr(tag.raw, "data-start") ?? undefined);
@@ -504,6 +521,32 @@ function extractStandaloneGsapTransformCalls(script: string): GsapTransformCall[
return calls;
}
// Run a global regex over every script's content, yielding each match plus a
// context-padded snippet around it. Shared by the repeat-count and
// group-selector-keyframes rules below, which differ only in the pattern,
// whether comments are stripped first, and the context window size.
function scanScriptsForRegexMatches(
scripts: LintContext["scripts"],
pattern: RegExp,
options: { stripComments: boolean; contextBefore: number; contextAfter: number },
): Array<{ match: RegExpExecArray; snippet: string }> {
const hits: Array<{ match: RegExpExecArray; snippet: string }> = [];
for (const script of scripts) {
const content = options.stripComments ? stripJsComments(script.content) : script.content;
const regex = new RegExp(pattern.source, pattern.flags);
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const contextStart = Math.max(0, match.index - options.contextBefore);
const contextEnd = Math.min(
content.length,
match.index + match[0].length + options.contextAfter,
);
hits.push({ match, snippet: content.slice(contextStart, contextEnd) });
}
}
return hits;
}
// ── GSAP rules ─────────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
@@ -518,17 +561,16 @@ export const gsapRules: LintRule<LintContext>[] = [
const clipIds = new Map<string, ClipInfo>();
const clipClasses = new Map<string, ClipInfo>();
for (const tag of tags) {
const classAttr = readAttr(tag.raw, "class") || "";
const classes = classAttr.split(/\s+/).filter(Boolean);
if (!classes.includes("clip")) continue;
const clipTag = getClipTagClasses(tag);
if (!clipTag) continue;
const id = readAttr(tag.raw, "id");
const info: ClipInfo = {
tag: tag.name,
id: id || "",
classes: classAttr,
classes: clipTag.classAttr,
};
if (id) clipIds.set(`#${id}`, info);
for (const cls of classes) {
for (const cls of clipTag.classes) {
if (cls !== "clip") clipClasses.set(`.${cls}`, info);
}
}
@@ -549,6 +591,9 @@ export const gsapRules: LintRule<LintContext>[] = [
const left = gsapWindows[i];
if (!left) continue;
if (left.end <= left.position) continue;
// Unresolved targets are unknown elements: two of them are not provably
// the same element, so an overlap between them cannot be asserted.
if (left.targetSelector === UNRESOLVED_TARGET) continue;
for (let j = i + 1; j < gsapWindows.length; j++) {
const right = gsapWindows[j];
if (!right) continue;
@@ -576,6 +621,9 @@ export const gsapRules: LintRule<LintContext>[] = [
// gsap_exit_missing_hard_kill
if (clipStartBoundaries.length > 0) {
for (const win of gsapWindows) {
// Unresolved targets are unknown elements: you cannot assert a missing
// hard kill on one, and a `tl.set("__unresolved__", ...)` hint is meaningless.
if (win.targetSelector === UNRESOLVED_TARGET) continue;
if (!isSceneBoundaryExit(win)) continue;
const boundary = findMatchingSceneBoundary(win.end, clipStartBoundaries);
if (boundary == null) continue;
@@ -584,6 +632,20 @@ export const gsapRules: LintRule<LintContext>[] = [
);
if (hasHardKill) continue;
// A tl.set hard kill on the exiting selector itself is the fix — unless
// that selector IS a clip element, in which case gsap_animates_clip_element
// (below) errors on that exact tl.set: the framework already owns
// visibility/display on clip elements. Point at the inner-wrapper
// pattern instead so the two rules' advice doesn't contradict.
const exitClipInfo =
clipIds.get(win.targetSelector) || clipClasses.get(win.targetSelector);
const fixHint = exitClipInfo
? `"${win.targetSelector}" is a clip element — the framework already manages its visibility. ` +
"Wrap the scene's content in an inner non-clip <div>, move the exit tween and the hard kill " +
`(\`tl.set("<inner-selector>", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\`) onto that wrapper instead.`
: `Add \`tl.set("${win.targetSelector}", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\` ` +
"after the exit tween.";
findings.push({
code: "gsap_exit_missing_hard_kill",
severity: "error",
@@ -591,9 +653,7 @@ export const gsapRules: LintRule<LintContext>[] = [
`GSAP exit on "${win.targetSelector}" ends at the ${boundary.toFixed(2)}s clip start boundary ` +
"without a matching tl.set hard kill. Non-linear seeking can land after the fade and leave stale visibility state.",
selector: win.targetSelector,
fixHint:
`Add \`tl.set("${win.targetSelector}", ${hiddenStateLiteral(win.propertyValues)}, ${boundary.toFixed(2)})\` ` +
"after the exit tween.",
fixHint,
snippet: truncateSnippet(win.raw),
});
}
@@ -850,8 +910,7 @@ export const gsapRules: LintRule<LintContext>[] = [
// fallow-ignore-next-line complexity
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
if (!isCaptionFile) return findings;
if (!hasCaptionStyles(styles)) return findings;
for (const script of scripts) {
const content = script.content;
@@ -895,28 +954,25 @@ export const gsapRules: LintRule<LintContext>[] = [
// gsap_infinite_repeat
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = stripJsComments(script.content);
// Match repeat: -1 in GSAP tweens or timeline configs
const pattern = /repeat\s*:\s*-1(?!\d)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const contextStart = Math.max(0, match.index - 60);
const contextEnd = Math.min(content.length, match.index + match[0].length + 60);
const snippet = content.slice(contextStart, contextEnd).trim();
findings.push({
code: "gsap_infinite_repeat",
severity: "error",
message:
"GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " +
"capture engine which seeks to exact frame times. Use a finite repeat count calculated " +
"from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
fixHint:
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. " +
"Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
snippet: truncateSnippet(snippet),
});
}
// Match repeat: -1 in GSAP tweens or timeline configs
const pattern = /repeat\s*:\s*-1(?!\d)/g;
for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: true,
contextBefore: 60,
contextAfter: 60,
})) {
findings.push({
code: "gsap_infinite_repeat",
severity: "error",
message:
"GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " +
"capture engine which seeks to exact frame times. Use a finite repeat count calculated " +
"from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
fixHint:
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. " +
"Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
snippet: truncateSnippet(snippet),
});
}
return findings;
},
@@ -924,29 +980,26 @@ export const gsapRules: LintRule<LintContext>[] = [
// gsap_repeat_ceil_overshoot
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = script.content;
// Match patterns like: repeat: Math.ceil(duration / X) - 1
// or repeat: Math.ceil(totalDuration / cycleDuration) - 1
const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const contextStart = Math.max(0, match.index - 40);
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
const snippet = content.slice(contextStart, contextEnd).trim();
findings.push({
code: "gsap_repeat_ceil_overshoot",
severity: "warning",
message:
"GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. " +
"For example, Math.ceil(10.5 / 2) - 1 = 5 repeats → 6 cycles × 2s = 12s, exceeding 10.5s.",
fixHint:
"Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: " +
"`repeat: Math.floor(totalDuration / cycleDuration) - 1`. " +
"Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
snippet: truncateSnippet(snippet),
});
}
// Match patterns like: repeat: Math.ceil(duration / X) - 1
// or repeat: Math.ceil(totalDuration / cycleDuration) - 1
const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: false,
contextBefore: 40,
contextAfter: 40,
})) {
findings.push({
code: "gsap_repeat_ceil_overshoot",
severity: "warning",
message:
"GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. " +
"For example, Math.ceil(10.5 / 2) - 1 = 5 repeats → 6 cycles × 2s = 12s, exceeding 10.5s.",
fixHint:
"Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: " +
"`repeat: Math.floor(totalDuration / cycleDuration) - 1`. " +
"Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
snippet: truncateSnippet(snippet),
});
}
return findings;
},
@@ -976,6 +1029,18 @@ export const gsapRules: LintRule<LintContext>[] = [
const killPattern = new RegExp(`["']#${id}["'][^)]*visibility\\s*:\\s*["']hidden["']`);
const hasKill = killPattern.test(content);
if (!hasKill) {
// A tl.set on "#id" is only safe advice when the scene element isn't
// itself a clip — otherwise gsap_animates_clip_element errors on that
// exact tl.set, since the framework already owns visibility/display on
// clip elements. Point at the inner-wrapper pattern instead.
const classes = (readAttr(tag.raw, "class") || "").split(/\s+/).filter(Boolean);
const isClip = classes.includes("clip");
const fixHint = isClip
? `"#${id}" is a clip element — the framework already manages its visibility. ` +
"Wrap the scene's content in an inner non-clip <div>, move the exit tween and the hard kill " +
'(`tl.set("<inner-selector>", { visibility: "hidden" }, <exit-end-time>)`) onto that wrapper instead.'
: `Add \`tl.set("#${id}", { visibility: "hidden" }, <exit-end-time>)\` after the scene's exit tweens.`;
findings.push({
code: "scene_layer_missing_visibility_kill",
severity: "error",
@@ -983,7 +1048,7 @@ export const gsapRules: LintRule<LintContext>[] = [
message:
`Scene layer "#${id}" exits via opacity tween but has no visibility: hidden hard kill. ` +
"When scrubbing or when tweens conflict, the scene may remain partially visible and overlap the next scene.",
fixHint: `Add \`tl.set("#${id}", { visibility: "hidden" }, <exit-end-time>)\` after the scene's exit tweens.`,
fixHint,
});
}
}
@@ -1000,7 +1065,9 @@ export const gsapRules: LintRule<LintContext>[] = [
for (const script of scripts) {
const content = script.content;
if (!/gsap\.timeline/.test(content)) continue;
const hasRegistration = WINDOW_TIMELINE_ASSIGN_PATTERN.test(content);
const hasRegistration =
WINDOW_TIMELINE_ASSIGN_PATTERN.test(content) ||
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(content);
if (hasRegistration || canInheritFromHost) continue;
findings.push({
code: "gsap_timeline_not_registered",
@@ -1295,28 +1362,26 @@ export const gsapRules: LintRule<LintContext>[] = [
// gsap_group_selector_keyframes
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = stripJsComments(script.content);
const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const selector = match[1]!;
const count = selector.split(",").length;
const contextStart = Math.max(0, match.index - 20);
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
findings.push({
code: "gsap_group_selector_keyframes",
severity: "warning",
message:
`GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). ` +
`Editing one element's keyframes in Studio will affect all ${count} elements. ` +
`Split into individual tweens for per-element keyframe control.`,
fixHint:
`Replace the group selector with individual tl.to() calls per element, ` +
`each with their own keyframes object.`,
snippet: truncateSnippet(content.slice(contextStart, contextEnd)),
});
}
const pattern = /\.(?:to|from|fromTo)\(\s*["']([^"']+,\s*[^"']+)["']\s*,\s*\{[^}]*keyframes/g;
for (const { match, snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: true,
contextBefore: 20,
contextAfter: 40,
})) {
const selector = match[1]!;
const count = selector.split(",").length;
findings.push({
code: "gsap_group_selector_keyframes",
severity: "warning",
message:
`GSAP tween targets ${count} elements with shared keyframes ("${truncateSnippet(selector, 60)}"). ` +
`Editing one element's keyframes in Studio will affect all ${count} elements. ` +
`Split into individual tweens for per-element keyframe control.`,
fixHint:
`Replace the group selector with individual tl.to() calls per element, ` +
`each with their own keyframes object.`,
snippet: truncateSnippet(snippet),
});
}
return findings;
},
+47
View File
@@ -48,6 +48,25 @@ describe("media rules", () => {
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>
@@ -248,4 +267,32 @@ describe("media rules", () => {
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();
});
});
+27
View File
@@ -483,6 +483,33 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
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 }) => {
+77 -3
View File
@@ -21,15 +21,36 @@ export const SCRIPT_BLOCK_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
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*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
/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 extractOpenTags(source: string): OpenTag[] {
const tags: OpenTag[] = [];
let match: RegExpExecArray | null;
@@ -79,6 +100,7 @@ export function findHtmlTag(source: string): OpenTag | null {
};
}
// fallow-ignore-next-line complexity
export function findRootTag(source: string): OpenTag | null {
const bodyOpenMatch = /<body\b([^>]*)>/i.exec(source);
const bodyCloseMatch = /<\/body>/i.exec(source);
@@ -102,8 +124,34 @@ export function findRootTag(source: string): OpenTag | null {
: source.length;
const bodyContent = bodyOpenMatch ? source.slice(bodyStart, bodyEnd) : source;
const bodyTags = extractOpenTags(bodyContent);
// 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")
) {
const closeMatch = /<\/svg\s*>/i.exec(bodyContent.slice(tag.index));
// No closing tag found (malformed HTML) — skip everything rather than
// risk returning one of the svg's own children as the root.
skipBefore = closeMatch ? tag.index + closeMatch.index + closeMatch[0].length : Infinity;
continue;
}
return { ...tag, index: tag.index + bodyStart };
}
return null;
@@ -112,7 +160,11 @@ export function findRootTag(source: string): OpenTag | null {
export function readAttr(tagSource: string, attr: string): string | null {
if (!tagSource) return null;
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
// `(?<![\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;
}
@@ -131,7 +183,11 @@ export function readAttr(tagSource: string, attr: string): string | null {
export function readJsonAttr(tagSource: string, attr: string): string | null {
if (!tagSource) return null;
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = tagSource.match(new RegExp(`\\b${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"));
// 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;
}
@@ -169,6 +225,17 @@ export function extractTimelineRegistryKeys(source: string): string[] {
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];
}
@@ -292,6 +359,13 @@ 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;