mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge remote-tracking branch 'origin/main' into feat/lint-gsap-non-transform-motion
# Conflicts: # skills-manifest.json
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user