Merge origin/main into the audio stack

Twelve of the stack's feature commits landed on main as squashes (#3274
through #3292, plus #3401's canary removal); the 96 review-and-fix commits
that followed them here did not, and main moved 64 commits on in the
meantime. This reconciles the two.

58 files conflicted. 44 were audio-only — main's side there is the
squashed form of commits this branch already carries and has since
superseded, so the branch side stands. The rest needed real work, in both
directions:

**Taken from main, absent here.**
- `ensureAudioGroupInertStyle` (#3278's review). An `<hf-audio-group>` is
  an unknown custom element, so it still takes a flex/grid slot and can
  open a line box — adding a group shifted authored layout. The helper and
  its `init.ts` call never came back to the branch, and this branch is
  what emits the element.
- `#3383`'s ended-audio replay: `canSeekEndedMediaBackward` and its five
  siblings in `media.ts`, with all six tests. Not present here in any
  form.
- `#3380`'s `asetpts=N/SR/TB` between `apad` and `atrim`. Also applied to
  `mixGroupMembers`, the group submix, which is new on this branch and so
  had the same bug in a path main's fix could not reach: delayed members
  padded then amix'd, where a group of four or more silently loses one.
- `#3401`'s `displayNumber` thread. The header derives its row from the
  group-aware order and the undo label from ascending element keys, so
  once a group exists the same click said "Hide track 2" and recorded
  "Hide track 1".
- `#3413`/`#3421`'s viewport handling — the popover's height cap and
  `inset()`, and `resolveFloatingPanelPosition` for the grouping dialog,
  which lives in a track header at the bottom of the window.
- Two extractions this branch had inline and at exactly the 600-line cap:
  `useTimelineDeleteOps` and `editingModeSlice`. Bodies were identical.

**Kept from the branch, against main.** Mute and solo are gone by
deliberate breaking change (`remove mute and solo from tracks and groups`,
`remove the group volume slider and level meter`), so eight files main
still carries are deleted again, `PlayerControls` keeps no
`previewIframeRef` (it existed only to feed `SoloBanner`), the
group-levels branch comes out of main's new `previewMessageRouter`, and
`STRIP_H` goes with the bus strip it sized. Main's
`TimelineTrackPlainHeader.test.tsx` is rewritten against the control that
actually exists — the visibility eye, withheld from an audible audio row
and offered back once hidden, which is the only way out of `data-hidden`.

**Unioned.** `TimelineFxPopover` — main's positioning, this branch's
audition telemetry (`auditionPresetChain`, `storedChain`,
`onAuditionTracked`); `SKILL.md` — main's #3416 "keep the carve group a
voice group" beside this branch's bus section, with the canary paragraph
dropped since the canaries no longer exist.

Every port is mutation-checked. core 2508, studio 4460, lint 528, engine
1630, cli 2813, sdk 549, producer green; tsc, oxlint, oxfmt, fallow and
the 600-line cap clean.
This commit is contained in:
Vance Ingalls
2026-08-23 03:04:47 -07:00
268 changed files with 10008 additions and 2807 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/lint",
"version": "0.8.5",
"version": "0.8.10",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
+3 -2
View File
@@ -11,6 +11,7 @@ import {
resolveExistingLocalAsset,
} from "@hyperframes/parsers/asset-resolution";
import type { HyperframeLintFinding } from "./types.js";
import { mediaSrcTagRe } from "./utils";
/** Structurally compatible with `project.ts`'s (unexported) `HtmlSource` —
* duplicated as a shape, not imported, to avoid a circular import between
@@ -82,14 +83,14 @@ export function collectLocalVideoCandidates(
htmlSources: HtmlSourceLike[],
): Map<string, string> {
const candidates = new Map<string, string>();
const videoSrcRe = /<video\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
const videoSrcRe = mediaSrcTagRe("video");
for (const { html, compSrcPath } of htmlSources) {
const scannable = maskNonScannableRanges(html);
const re = new RegExp(videoSrcRe.source, videoSrcRe.flags);
let match: RegExpExecArray | null;
while ((match = re.exec(scannable)) !== null) {
const rawSrc = match[1] ?? "";
const rawSrc = match[2] ?? "";
// Placeholder check runs on the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token.
if (isUnresolvedAssetPlaceholder(rawSrc)) continue;
const src = cleanAssetUrl(rawSrc);
+145
View File
@@ -69,6 +69,132 @@ describe("external symlink assets", () => {
});
});
describe("blank_root_with_standalone_composition", () => {
it("errors when the default entry is blank but an authored standalone composition lives under compositions", async () => {
const project = makeProject(validHtml(), {
"index.html": `<!doctype html><html><body>
<div data-composition-id="bona-brand-card" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div id="main-clip" class="clip" data-start="0" data-duration="5" data-track-index="0">BONA</div>
</div>
<script>window.__timelines = { "bona-brand-card": gsap.timeline({ paused: true }) };</script>
</body></html>`,
});
const { results, totalErrors } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(totalErrors).toBeGreaterThan(0);
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("compositions/index.html");
expect(finding?.message).toContain("index.html");
expect(finding?.message).toContain("publish");
expect(finding?.fixHint).toContain("data-composition-src");
expect(finding?.suggestedComposition).toBe("compositions/index.html");
});
it("treats non-rendering script, style, link, meta, and template children as blank", async () => {
const shellOnlyRoot = validHtml().replace(
"</div>",
`<script type="application/json">{}</script>
<style>.unused { color: white; }</style>
<link rel="stylesheet" href="data:text/css,.unused%7Bcolor:white%7D">
<meta name="description" content="shell">
<template id="row-template"><div>row</div></template>
</div>`,
);
const project = makeProject(shellOnlyRoot, {
"authored.html": `<!doctype html><html><body>
<div data-composition-id="authored" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="clip" data-start="0" data-duration="5">Visible</div>
</div>
</body></html>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeDefined();
});
it("does not fire when index.html already contains authored clip content", async () => {
const authoredRoot = validHtml().replace(
"</div>",
'<div class="clip" data-start="0" data-duration="10">Master content</div></div>',
);
const project = makeProject(authoredRoot, {
"alternate.html": `<!doctype html><html><body>
<div data-composition-id="alternate" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="clip" data-start="0" data-duration="5">Alternate</div>
</div>
</body></html>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeUndefined();
});
it("does not treat a template-wrapped sub-composition as a misplaced standalone entry", async () => {
const project = makeProject(validHtml(), {
"scene.html": `<template>
<div data-composition-id="scene" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="5">Scene</div>
</div>
</template>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeUndefined();
});
it("still catches a standalone composition that contains an unrelated nested template", async () => {
const project = makeProject(validHtml(), {
"card.html": `<!doctype html><html><body>
<div data-composition-id="card" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div class="clip" data-start="0" data-duration="5">Card</div>
<template id="repeated-row"><div class="row">Row</div></template>
</div>
</body></html>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeDefined();
});
it("does not treat a composition that only mounts another composition as standalone", async () => {
const project = makeProject(validHtml(), {
"wrapper.html": `<!doctype html><html><body>
<div data-composition-id="wrapper" data-width="1920" data-height="1080" data-start="0" data-duration="5">
<div data-composition-src="compositions/scene.html" data-start="0" data-duration="5"></div>
</div>
</body></html>`,
"scene.html": `<template><div data-composition-id="scene"><p>Scene</p></div></template>`,
});
const { results } = await lintProject(project);
const finding = results
.flatMap((result) => result.result.findings)
.find((item) => item.code === "blank_root_with_standalone_composition");
expect(finding).toBeUndefined();
});
});
describe("missing_or_empty_sub_composition", () => {
function htmlWithSubComp(srcPath: string): string {
return `<html><body>
@@ -508,6 +634,25 @@ describe("templating tokens are checked on the raw src, before cleanAssetUrl", (
}
});
// `\bsrc\s*=` also matched the tail of `data-var-src="bg"`, and `[^>]*` is greedy,
// so a real src earlier in the same tag lost to the variable id: bindings were
// reported as a missing file named after the variable.
it("reports the real src, not the data-var-src variable id", async () => {
const { results } = await lintProject(
projectWith(`<img src="assets/logo.png" data-var-src="bg" />`),
);
const finding = results
.flatMap((entry) => entry.result.findings)
.find((f) => f.code === "missing_local_asset");
expect(finding?.message).toContain("assets/logo.png");
expect(finding?.message).not.toContain("bg");
});
it("does not invent a missing asset for a binding on an element whose src resolves", async () => {
const c = await codes(projectWith(`<img src="${"${imgUrl}"}" data-var-src="bg" />`));
expect(c.has("missing_local_asset")).toBe(false);
});
it("still flags a genuinely missing local video file", async () => {
const c = await codes(
projectWith(
+46 -3
View File
@@ -18,6 +18,7 @@ import { collectLocalVideoCandidates, lintHevcPreviewCodec } from "./hevcPreview
import { lintHyperframeHtml } from "./hyperframeLinter.js";
import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js";
import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity";
import { mediaSrcTagRe } from "./utils";
/** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */
function parseSubCompHtml(html: string): ParsableDocumentLike {
@@ -230,6 +231,7 @@ export async function lintProject(
...lintMissingLocalAsset(projectDir, allHtmlSources),
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
...(!entryFile ? lintMultipleRootCompositions(projectDir) : []),
...(!entryFile ? lintBlankRootWithStandaloneComposition(rootHtml, allHtmlSources) : []),
...lintDuplicateAudioTracks(allHtmlSources),
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
...(await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))),
@@ -254,6 +256,47 @@ export async function lintProject(
return { results, totalErrors, totalWarnings, totalInfos };
}
function lintBlankRootWithStandaloneComposition(
rootHtml: string,
htmlSources: HtmlSource[],
): HyperframeLintFinding[] {
const { document: rootDocument } = parseHTML(rootHtml);
const root = rootDocument.querySelector("body [data-composition-id]");
// A no-media scaffold has no rendered descendants and can silently mask an authored file below.
// A scaffold that retained its A-roll <video>/<audio> is visibly non-blank, so this rule leaves it
// alone even when another composition is unmounted.
if (!root || root.querySelector("*:not(script):not(style):not(link):not(meta):not(template)")) {
return [];
}
const standaloneCandidates: string[] = [];
for (const source of htmlSources) {
if (!source.compSrcPath) continue;
const { document } = parseHTML(source.html);
const composition = document.querySelector("body [data-composition-id]");
if (!composition) continue;
const authoredTimedContent = Array.from(
composition.querySelectorAll(
".clip, [data-start], [data-end], video, audio, img, svg, canvas",
),
).some((element) => !element.hasAttribute("data-composition-src"));
if (authoredTimedContent) standaloneCandidates.push(source.compSrcPath);
}
if (standaloneCandidates.length === 0) return [];
return [
{
code: "blank_root_with_standalone_composition",
severity: "error",
message: `The default index.html composition has no renderable content, but ${standaloneCandidates.join(", ")} contains a standalone timed composition. Default check, snapshot, preview, render, and publish commands open index.html, so they will capture or publish only its background.`,
fixHint:
`Move the authored composition into index.html, or mount it from index.html with data-composition-src and the sub-composition <template> contract. ` +
`If the separate file is intentional, render it explicitly with --composition ${standaloneCandidates[0]}.`,
suggestedComposition: standaloneCandidates[0],
},
];
}
function lintProjectAudioFiles(
projectDir: string,
htmlSources: HtmlSource[],
@@ -294,13 +337,13 @@ function lintAudioSrcNotFound(
): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
const audioSrcRe = mediaSrcTagRe("audio");
const missingSrcs: string[] = [];
for (const { html, compSrcPath } of htmlSources) {
let match: RegExpExecArray | null;
while ((match = audioSrcRe.exec(html)) !== null) {
const src = match[1]!;
const src = match[2]!;
if (/^(https?:|data:|blob:)/i.test(src)) continue;
if (isUnresolvedAssetPlaceholder(src)) continue;
const rootRelative = compSrcPath
@@ -335,7 +378,7 @@ function lintMissingLocalAsset(
): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
const localAssetSrcRe = /<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
const localAssetSrcRe = mediaSrcTagRe("video|img|source");
const missingByTag = new Map<string, Map<string, string>>();
+29
View File
@@ -163,4 +163,33 @@ describe("caption rules", () => {
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
describe("caption_text_overflow_risk — its fix must not create an error", () => {
const cap = (css: string) => `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>.caption-group{${css}}</style><div class="caption-group"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
words.forEach((w) => tl.to(w, { scale: 1.3 }));
window.__timelines = { captions: tl };
</script>
</body></html>`;
it("clears when the fixHint is applied as written", async () => {
// The hint used to say "and overflow: hidden", which is exactly what
// caption_overflow_clips_scaled_words errors on. Following the warning
// produced an error.
const before = await lintHyperframeHtml(cap("position:absolute;white-space:nowrap"));
expect(before.findings.find((f) => f.code === "caption_text_overflow_risk")).toBeDefined();
const after = await lintHyperframeHtml(
cap("position:absolute;white-space:nowrap;max-width:1600px;overflow:visible"),
);
const blocking = after.findings.filter((f) => f.severity !== "info");
expect(blocking.map((f) => f.code)).not.toContain("caption_text_overflow_risk");
expect(blocking.map((f) => f.code)).not.toContain("caption_overflow_clips_scaled_words");
});
});
});
+5 -1
View File
@@ -57,7 +57,11 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]>
selector: (selector ?? "").trim(),
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
fixHint:
"Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden.",
// Deliberately does NOT say `overflow: hidden`: caption words are scaled
// above 1.0x, and clipping them is exactly what caption_overflow_clips_scaled_words
// errors on. Recommending it here made this warning's own fix produce an error.
"Add max-width: 1600px (landscape) or max-width: 900px (portrait). Keep " +
"overflow visible so scaled emphasis words are not clipped.",
});
}
}
+76 -72
View File
@@ -315,6 +315,50 @@ describe("composition rules", () => {
expect(finding).toBeUndefined();
});
it("does not flag one sub-composition mounted repeatedly with per-instance values", async () => {
// Regression: sub-compositions.md "Per-Instance Variables" documents
// mounting one source several times with different data-variable-values.
// That necessarily repeats the id, and the runtime rewrites repeated
// mounts to `id__hf1`/`id__hf2` so they coexist. Flagging it made the
// documented pattern an error with no correct way to satisfy it.
const html = `<!DOCTYPE html>
<html>
<body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="6" data-no-timeline>
<div data-composition-id="word" data-composition-src="compositions/word-caption.html" data-variable-values='{"text":"one"}' data-start="0" data-duration="2"></div>
<div data-composition-id="word" data-composition-src="compositions/word-caption.html" data-variable-values='{"text":"two"}' data-start="2" data-duration="2"></div>
<div data-composition-id="word" data-composition-src="compositions/word-caption.html" data-variable-values='{"text":"three"}' data-start="4" data-duration="2"></div>
</div>
</body>
</html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "duplicate_composition_id")).toBeUndefined();
});
it("still flags a real collision between a root and a non-mount element", async () => {
// The guard that keeps the exemption honest: skipping mounts must not
// blind the rule to the meta-versus-root collision it exists for, even
// when a legitimately repeated mount is present in the same file.
const html = `<!DOCTYPE html>
<html>
<head>
<meta name="composition-id" data-composition-id="main">
</head>
<body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="4" data-no-timeline>
<div data-composition-id="word" data-composition-src="compositions/word-caption.html" data-start="0" data-duration="2"></div>
<div data-composition-id="word" data-composition-src="compositions/word-caption.html" data-start="2" data-duration="2"></div>
</div>
</body>
</html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "duplicate_composition_id");
expect(finding).toBeDefined();
expect(finding?.message).toContain("main");
});
it("ignores composition ids inside inert template content", async () => {
const html = `<!DOCTYPE html>
<html><body>
@@ -528,78 +572,6 @@ describe("composition rules", () => {
});
});
describe("overlapping_clips_same_track", () => {
it("flags overlapping clips on the same track", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
<div class="clip" data-start="2" data-duration="3" 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");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not flag clips on different tracks", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="3" data-track-index="0">A</div>
<div class="clip" data-start="1" data-duration="3" data-track-index="1">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");
expect(finding).toBeUndefined();
});
it("does not flag sequential clips on the same track", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="2" data-track-index="0">A</div>
<div class="clip" data-start="2" data-duration="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");
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");
expect(finding).toBeUndefined();
});
});
describe("root_composition_missing_html_wrapper", () => {
it("flags bare composition div as error", async () => {
// Exact scenario from the screenshot — bare div with composition attributes, no HTML wrapper
@@ -1087,6 +1059,38 @@ describe("composition rules", () => {
});
});
describe("unloadable_media_variable_default", () => {
const CODE = "unloadable_media_variable_default";
const find = (r: { findings: { code: string }[] }) => r.findings.find((f) => f.code === CODE);
it("errors on an image variable defaulting to a file:// URL", async () => {
const html = `<html data-composition-variables='[{"id":"bg","type":"image","label":"BG","default":"file:///abs/assets/blue.png"}]'><body><img data-composition-id="x" src="assets/red.png" data-var-src="bg"></body></html>`;
const finding = find(await lintHyperframeHtml(html));
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toMatch(/authored fallback/);
});
it("errors on a non-image variable that a data-var-src binding consumes as a URL", async () => {
const html = `<html data-composition-variables='[{"id":"clip","type":"string","label":"Clip","default":"file:///abs/a.mp4"}]'><body><video data-composition-id="x" src="a.mp4" data-var-src="clip"></video></body></html>`;
expect(find(await lintHyperframeHtml(html))).toBeDefined();
});
it("stays quiet for relative, http(s) and data:image defaults", async () => {
const html = `<html data-composition-variables='[
{"id":"a","type":"image","label":"A","default":"assets/blue.png"},
{"id":"b","type":"image","label":"B","default":"https://example.com/b.png"},
{"id":"c","type":"image","label":"C","default":"data:image/png;base64,iVBORw0KGgo="}
]'><body><img data-composition-id="x" src="r.png" data-var-src="a"></body></html>`;
expect(find(await lintHyperframeHtml(html))).toBeUndefined();
});
it("does not treat an unbound scalar variable as a URL", async () => {
const html = `<html data-composition-variables='[{"id":"note","type":"string","label":"Note","default":"mailto:hi@example.com"}]'><body><div data-composition-id="x"></div></body></html>`;
expect(find(await lintHyperframeHtml(html))).toBeUndefined();
});
});
describe("invalid_parent_traversal_in_asset_path", () => {
const RULE_CODE = "invalid_parent_traversal_in_asset_path";
+36 -78
View File
@@ -8,7 +8,7 @@ import {
truncateSnippet,
WINDOW_TIMELINE_ASSIGN_PATTERN,
} from "../utils";
import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
import { COMPOSITION_VARIABLE_TYPES, isSafeMediaUrl } from "@hyperframes/parsers/composition";
import { COMPOSITION_ATTRIBUTES, readClipTiming } from "@hyperframes/parsers/composition-contract";
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
@@ -59,14 +59,6 @@ const HEAVY_OVERLAY_CSS_PATTERN =
/(?:filter\s*:[^;}]*\bblur\s*\()|(?:clip-path\s*:(?!\s*(?:none|inherit|initial|unset)\b)\s*[^;}]+)|(?:radial-gradient\s*\()/i;
const INLINE_STYLE_DISPLAY_NONE_PATTERN = /(?:^|;)\s*display\s*:\s*none\b/i;
// `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 readTagTiming(rawTag: string) {
return readClipTiming({ getAttribute: (name) => readAttr(rawTag, name) });
}
@@ -164,6 +156,7 @@ function leftmostCompoundId(selector: string): string | null {
// are scanned — the flat `[^{}]*` body class naturally skips @keyframes
// bodies (which contain nested `{...}` stops) and other @-rules, so keyframe
// selectors like `0%`/`100%` don't leak in.
// fallow-ignore-next-line complexity
function collectHeavyOverlayHooks(styles: ExtractedBlock[]): {
classes: Set<string>;
ids: Set<string>;
@@ -269,6 +262,16 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const tagsByCompositionId = new Map<string, string[]>();
for (const tag of tags) {
if (isInsideInertTemplate(tag, tags)) continue;
// A `data-composition-src` element is a MOUNT of a sub-composition, not a
// composition root, and sub-compositions.md documents mounting one source
// repeatedly with different `data-variable-values` to get per-instance
// variations. Those mounts legitimately share an id: the runtime rewrites
// repeated ones to `id__hf1`, `id__hf2` so they coexist. Counting them
// here made the documented pattern an error with no correct way to
// satisfy it. The collision this rule exists for -- a <meta> tag carrying
// the root's id, per its own fixHint -- is unaffected, since that tag has
// no `data-composition-src`.
if (readAttr(tag.raw, "data-composition-src")) continue;
const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
if (!compositionId || compositionId.trim().length === 0) continue;
@@ -557,75 +560,6 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// overlapping_clips_same_track
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
type ClipInfo = { start: number; end: number; elementId?: string; snippet: string };
const trackMap = new Map<string, ClipInfo[]>();
for (const tag of tags) {
const trackStr = readAttr(tag.raw, COMPOSITION_ATTRIBUTES.trackIndex);
if (!trackStr) continue;
const timing = readTagTiming(tag.raw);
const { start, duration } = timing;
const track = trackStr;
// Skip non-numeric (relative timing references like "intro-comp")
if (start == null || duration == null) continue;
const clips = trackMap.get(track) || [];
clips.push({
start,
end: start + duration,
elementId: readAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw) || "",
});
trackMap.set(track, clips);
}
for (const [track, clips] of trackMap) {
clips.sort((a, b) => a.start - b.start);
for (let i = 0; i < clips.length - 1; i++) {
const current = clips[i];
const next = clips[i + 1];
if (!current || !next) continue;
if (current.end - next.start > OVERLAP_EPSILON_SECONDS) {
findings.push({
code: "overlapping_clips_same_track",
severity: "error",
message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
fixHint:
"Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index.",
});
}
}
}
return findings;
},
// root_composition_missing_data_start
({ rootTag, options }) => {
const findings: HyperframeLintFinding[] = [];
if (options.isSubComposition) return findings;
if (!rootTag) return findings;
const compId = readDecodedAttr(rootTag.raw, "data-composition-id");
if (!compId) return findings;
const hasStart = readAttr(rootTag.raw, "data-start") !== null;
if (!hasStart) {
findings.push({
code: "root_composition_missing_data_start",
severity: "error",
message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
fixHint: 'Add data-start="0" to the root composition element.',
snippet: truncateSnippet(rootTag.raw),
});
}
return findings;
},
// standalone_composition_wrapped_in_template
({ rawSource, options }) => {
const findings: HyperframeLintFinding[] = [];
@@ -848,6 +782,14 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
const findings: HyperframeLintFinding[] = [];
const knownTypes = new Set<string>(COMPOSITION_VARIABLE_TYPES);
// Ids whose value the runtime pushes through isSafeMediaUrl: every
// data-var-src binding, plus image-typed variables (always consumed as a
// URL even when the binding lives in a sub-composition this file can't see).
const varSrcIds = new Set<string>();
for (const tag of tags) {
const bound = readAttr(tag.raw, "data-var-src");
if (bound) varSrcIds.add(bound);
}
for (let i = 0; i < parsed.length; i += 1) {
const entry = parsed[i];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
@@ -872,6 +814,22 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum, font, image.`,
snippet: truncateSnippet(htmlTag.raw),
});
continue;
}
const id = String(e.id);
if (
(e.type === "image" || varSrcIds.has(id)) &&
typeof e.default === "string" &&
e.default.length > 0 &&
!isSafeMediaUrl(e.default)
) {
findings.push({
code: "unloadable_media_variable_default",
severity: "error",
message: `Variable "${id}" defaults to a URL the runtime will refuse to load, so any element bound to it renders its authored fallback src instead and the render still exits 0.`,
fixHint: `Media URLs must be relative, http(s), blob:, or a data:image/* URI. Copy the file into the project and reference it relatively (e.g. "assets/bg.png") rather than by absolute path.`,
snippet: truncateSnippet(htmlTag.raw),
});
}
}
return findings;
+61 -389
View File
@@ -1,35 +1,6 @@
import { describe, it, expect } from "vitest";
import { lintHyperframeHtml } from "../hyperframeLinter.js";
function compositionWithHead(headContent: string): string {
return `
<html>
<head>
${headContent}
</head>
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body>
</html>`;
}
function compositionWithHeadBoundary(boundaryContent: string): string {
return `
<html>
<head>
<style>
body { margin: 0; }
</style>
</head>
${boundaryContent}
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</body>
</html>`;
}
function compositionWithBodyPrefix(prefixContent: string, rootContent = ""): string {
return `
<html>
@@ -48,34 +19,6 @@ ${rootContent}
</html>`;
}
function compositionWithImplicitBodyPrefix(prefixContent: string): string {
return `
<html>
<head>
<style>
body { margin: 0; }
</style>
</head>
${prefixContent}
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
<script>window.__timelines = {};</script>
</html>`;
}
function templateCompositionWithHead(headContent: string): string {
return `
<template>
<html>
<head>
${headContent}
</head>
<body>
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
</body>
</html>
</template>`;
}
describe("core rules", () => {
it("does not lint scripts embedded inside an iframe srcdoc attribute", async () => {
const html = `
@@ -218,7 +161,7 @@ describe("core rules", () => {
// Regression: a CSS comment referencing an SVG tag name (e.g. `/* <g> wrapper */`)
// inside a <style> block reads as a real open tag to the flat TAG_PATTERN scan,
// manufacturing a phantom root before the real composition root and firing
// root_missing_composition_id/root_missing_dimensions/head_leaked_text on an
// root_missing_composition_id/root_missing_dimensions on an
// otherwise valid sub-composition.
const html = `
<html><body>
@@ -234,7 +177,6 @@ describe("core rules", () => {
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();
expect(result.findings.find((f) => f.code === "head_leaked_text")).toBeUndefined();
});
it("reports error when timeline registry is missing", async () => {
@@ -343,167 +285,6 @@ describe("core rules", () => {
expect(finding).toBeUndefined();
});
it("reports error when CSS text is left outside a style block in the document head", async () => {
const html = compositionWithHead(`
<style>
body { margin: 0; }
</style>
</style>
/* Decorative Elements */
.particle {
position: absolute;
width: 4px;
height: 4px;
background: #fff;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("<head>");
expect(finding?.snippet).toContain(".particle");
});
it("reports error when CSS variables leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
--bg-color: #F5F1E8;
--text-color: #212121;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.message).toContain("<head>");
expect(finding?.snippet).toContain("body");
});
it("reports error when stray close tags leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
</style>
</script>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("</style>");
});
it("reports error when markdown code fences leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
\`\`\`css
.particle {
color: white;
}
\`\`\`
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("```css");
});
it("reports error when CSS at-rules leak between head and body", async () => {
const html = compositionWithHeadBoundary(`
@media (min-width: 800px) {
.particle {
transform: scale(1.2);
}
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("@media");
});
it("does not report leaked text for valid script and style blocks around the head boundary", async () => {
const html = compositionWithHeadBoundary(`
<script>
window.__headReady = true;
</script>
<template>
<style>
.template-only { color: red; }
</style>
</template>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports error when CSS text leaks before the composition root", async () => {
const html = compositionWithBodyPrefix(`
.orphan {
position: absolute;
inset: 0;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".orphan");
});
it("reports error when CSS text leaks before the composition root without an explicit body", async () => {
const html = compositionWithImplicitBodyPrefix(`
.implicit-body-orphan {
position: absolute;
inset: 0;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".implicit-body-orphan");
});
it("does not report leaked text for valid script and style blocks before the composition root", async () => {
const html = compositionWithBodyPrefix(`
<style>
.pre-root-helper { color: red; }
</style>
<script>
window.__preRootReady = true;
</script>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("does not report CSS-looking educational text inside the composition root", async () => {
const html = compositionWithBodyPrefix(
"",
`
<pre>
body {
margin: 0;
}
</pre>
`,
);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports error when CSS block comment syntax leaks into visible markup", async () => {
const html = compositionWithBodyPrefix(
"",
@@ -585,175 +366,6 @@ body {
expect(finding).toBeUndefined();
});
it("reports error when a stray style close tag is left in the document head", async () => {
const html = compositionWithHead(`
<style>
body { margin: 0; }
</style>
</style>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("</style>");
});
it("reports error when a stray script close tag is left in the document head", async () => {
const html = compositionWithHead(`
<script>
window.__headReady = true;
</script>
</script>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("</script>");
});
it("does not report leaked head text for valid closing tags with trailing whitespace", async () => {
const html = compositionWithHead(`
<style>
body { margin: 0; }
</style data-parser-error-close>
<script>
window.__headReady = true;
</script
data-parser-error-close>
<title>Particle Field</title >
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports error when markdown code fences leak into the document head", async () => {
const withLanguage = compositionWithHead(`
\`\`\`css
.particle {
position: absolute;
}
\`\`\`
`);
const withoutLanguage = compositionWithHead(`
\`\`\`
.particle {
position: absolute;
}
\`\`\`
`);
const withTsxLanguage = compositionWithHead(`
\`\`\`tsx
export function Particle() {
return <div className="particle" />;
}
\`\`\`
`);
const withLanguageResult = await lintHyperframeHtml(withLanguage);
const withoutLanguageResult = await lintHyperframeHtml(withoutLanguage);
const withTsxLanguageResult = await lintHyperframeHtml(withTsxLanguage);
const languageFinding = withLanguageResult.findings.find((f) => f.code === "head_leaked_text");
const unlabeledFinding = withoutLanguageResult.findings.find(
(f) => f.code === "head_leaked_text",
);
const tsxLanguageFinding = withTsxLanguageResult.findings.find(
(f) => f.code === "head_leaked_text",
);
expect(languageFinding).toBeDefined();
expect(languageFinding?.snippet).toContain("```css");
expect(unlabeledFinding).toBeDefined();
expect(unlabeledFinding?.snippet).toContain("```");
expect(tsxLanguageFinding).toBeDefined();
expect(tsxLanguageFinding?.snippet).toContain("```tsx");
});
it("reports error when CSS at-rules leak into the document head", async () => {
const html = compositionWithHead(`
@media (min-width: 800px) {
.particle {
transform: scale(1.2);
}
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain("@media");
});
it("reports leaked CSS when a style block is unclosed in the document head", async () => {
const html = compositionWithHead(`
<style>
.particle {
color: white;
}
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".particle");
});
it("does not report leaked head text for commented CSS", async () => {
const html = compositionWithHead(`
<!-- .particle { color: red; } -->
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("does not report leaked head text for valid noscript content", async () => {
const html = compositionWithHead(`
<noscript>
.no-js { display: block; }
</noscript>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("does not report orphan CSS for valid head metadata and style blocks", async () => {
const html = compositionWithHead(`
<title>Particle Field</title>
<meta name="description" content="Particle field">
<link rel="preconnect" href="https://fonts.gstatic.com">
<base href="https://example.com/">
<style>
.particle {
position: absolute;
width: 4px;
height: 4px;
}
</style>
`);
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeUndefined();
});
it("reports leaked head text inside template-wrapped sub-compositions", async () => {
const html = templateCompositionWithHead(`
</style>
.particle { color: white; }
`);
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "head_leaked_text");
expect(finding).toBeDefined();
expect(finding?.snippet).toContain(".particle");
});
describe("timeline_id_mismatch", () => {
it("accepts dot timeline registration", async () => {
const html = `
@@ -1096,4 +708,64 @@ body {
expect(finding).toBeUndefined();
});
});
describe("non_deterministic_code — determinism is about execution, not text", () => {
const comp = (script: string) => `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
<script src="gsap.min.js"></script>
<script>const tl = gsap.timeline({ paused: true }); ${script} window.__timelines = { main: tl };</script>
</body></html>`;
it("does not flag new Date() with a fixed timestamp", async () => {
// Deterministic, and the fixHint ("remove time-dependent code") cannot be
// applied without deleting the label the composition renders.
const result = await lintHyperframeHtml(
comp(`const label = new Date("2026-01-01T00:00:00Z").toISOString();`),
);
expect(result.findings.find((f) => f.code === "non_deterministic_code")).toBeUndefined();
});
it("does not flag non-deterministic APIs quoted inside a string literal", async () => {
// Code-display compositions render source they never execute.
const result = await lintHyperframeHtml(comp(`const SNIPPET = "const x = Math.random();";`));
expect(result.findings.find((f) => f.code === "non_deterministic_code")).toBeUndefined();
});
it("still flags a bare new Date()", async () => {
const result = await lintHyperframeHtml(comp(`const now = new Date();`));
expect(result.findings.find((f) => f.code === "non_deterministic_code")).toBeDefined();
});
it("still flags Math.random() in executed code", async () => {
const result = await lintHyperframeHtml(comp(`const r = Math.random();`));
expect(result.findings.find((f) => f.code === "non_deterministic_code")).toBeDefined();
});
});
describe("timeline_id_mismatch — only top-level registry keys are composition ids", () => {
const comp = (script: string) => `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"></div>
<script src="gsap.min.js"></script>
<script>${script}</script>
</body></html>`;
it("does not flag the one-liner registration form", async () => {
// The inlined options object is not a registration. Reading `paused` as a
// composition id produced an error whose fixHint named a registration that
// did not exist, so it could never be applied.
const result = await lintHyperframeHtml(
comp(`window.__timelines = { main: gsap.timeline({ paused: true }) };`),
);
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined();
});
it("still flags a genuinely mismatched id", async () => {
const result = await lintHyperframeHtml(
comp(`window.__timelines = { wrongid: gsap.timeline({ paused: true }) };`),
);
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeDefined();
});
});
});
+26 -94
View File
@@ -6,6 +6,7 @@ import {
readDecodedAttr,
truncateSnippet,
stripJsComments,
stripStringLiterals,
extractCompositionIdsFromCss,
extractTimelineRegistryKeys,
getInlineScriptSyntaxError,
@@ -130,17 +131,6 @@ function describeStudioElement(tag: { raw: string; name: string }): string {
return parts.join("");
}
const HEAD_BLOCKS_TO_IGNORE_PATTERN =
/<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi;
const HTML_TAG_PATTERN = /<[^>]+>/g;
const HEAD_CONTENT_PATTERN = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
const AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
const STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
const MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
const ORPHAN_CSS_AT_RULE_PATTERN =
/(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
const ORPHAN_CSS_RULE_PATTERN =
/(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
const VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
const VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN =
/<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;
@@ -150,64 +140,6 @@ interface SourceRange {
end: number;
}
function findCodeFenceLeak(headWithoutValidBlocks: string): string | null {
return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
}
function findOrphanCssLeak(headContent: string): string | null {
const residualText = headContent
.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ")
.replace(HTML_TAG_PATTERN, " ");
return (
ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ??
ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ??
null
);
}
function findStrayCloseLeak(headWithoutValidBlocks: string): string | null {
return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
}
function findLeakedTextInHeadContent(headContent: string): string | null {
const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ");
return (
findCodeFenceLeak(withoutValidBlocks) ??
findOrphanCssLeak(headContent) ??
findStrayCloseLeak(withoutValidBlocks)
);
}
function findLeakedTextInHead(rawSource: string): string | null {
const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)];
for (const match of headMatches) {
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
if (leakedText) return leakedText;
}
return null;
}
function findLeakedTextBetweenHeadAndBody(rawSource: string): string | null {
const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
for (const match of boundaryMatches) {
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
if (leakedText) return leakedText;
}
return null;
}
function findLeakedTextBeforeCompositionRoot(
source: string,
rootTag: LintContext["rootTag"],
): string | null {
if (!rootTag || rootTag.name === "body") return null;
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
const prefixEnd = rootTag.index;
if (prefixEnd <= prefixStart) return null;
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
}
function findProtectedVisibleMarkupRanges(source: string): SourceRange[] {
const ranges: SourceRange[] = [];
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
@@ -273,6 +205,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
// root_missing_composition_id + root_missing_dimensions
// fallow-ignore-next-line complexity
({ rootTag }) => {
const findings: HyperframeLintFinding[] = [];
if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) {
@@ -298,26 +231,6 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
return findings;
},
// head_leaked_text
({ source, rootTag }) => {
const snippet =
findLeakedTextInHead(source) ??
findLeakedTextBetweenHeadAndBody(source) ??
findLeakedTextBeforeCompositionRoot(source, rootTag);
if (!snippet) return [];
return [
{
code: "head_leaked_text",
severity: "error",
message:
"Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
fixHint:
"Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
snippet: truncateSnippet(snippet),
},
];
},
// visible_markup_comment
({ source }) => {
const snippet = findVisibleMarkupCommentLeak(source);
@@ -336,6 +249,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
// missing_timeline_registry + timeline_registry_missing_init
// fallow-ignore-next-line complexity
({ source, rawSource, rootTag, options }) => {
// Sub-compositions inherit window.__timelines from the host composition
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
@@ -528,7 +442,13 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// non_deterministic_code
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
const patterns: Array<{ pattern: RegExp; label: string; hint: string }> = [
const patterns: Array<{
pattern: RegExp;
label: string;
hint: string;
/** Match against raw source, because the value being matched is a string GSAP parses. */
scansStrings?: boolean;
}> = [
{
pattern: /Math\.random\s*\(/,
label: "Math.random()",
@@ -540,7 +460,10 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
},
{
pattern: /new\s+Date\s*\(/,
// Zero-arg only. `new Date(<fixed timestamp>)` is fully deterministic and is how
// a composition labels a fixed date on an axis or card; the hint ("remove
// time-dependent code") cannot be applied to it without deleting the label.
pattern: /new\s+Date\s*\(\s*\)/,
label: "new Date()",
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
},
@@ -561,16 +484,25 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
},
{
// GSAP string form: "random(...)" / "+=random(...)" — re-rolls at tween init.
// `scansStrings` because here the string IS the executed value: GSAP parses it.
// Every other pattern above matches executable code, so a match inside a string
// literal is inert text and must not be reported.
pattern: /["'`](?:[+-]=)?random\(\s*[-\d[]/,
scansStrings: true,
label: '"random(...)" tween value',
hint: "GSAP random string values re-roll at tween init and each render worker initializes independently. Use fixed values or precompute with a seeded PRNG.",
},
];
for (const script of scripts) {
const stripped = stripJsComments(script.content);
for (const { pattern, label, hint } of patterns) {
if (pattern.test(stripped)) {
const withoutComments = stripJsComments(script.content);
// Strings are content, not code. A composition that DISPLAYS source (the
// code-snippet blocks, /pr-to-video) carries `Math.random()` inside a string
// literal it never executes, and reported itself non-deterministic with no
// way to clear the error while still rendering the snippet.
const executable = stripStringLiterals(withoutComments);
for (const { pattern, label, hint, scansStrings } of patterns) {
if (pattern.test(scansStrings ? withoutComments : executable)) {
findings.push({
code: "non_deterministic_code",
severity: "error",
+40 -21
View File
@@ -240,27 +240,6 @@ describe("GSAP rules", () => {
).toHaveLength(1);
});
it("errors when a full-frame transition flash uses a GSAP from reveal", async () => {
const html = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
<div id="tr-flash-1" style="position:fixed;inset:0;background:#fff;pointer-events:none;z-index:990"></div>
<section class="clip" data-start="0" data-duration="8"><h1>Scene 1</h1></section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from("#tr-flash-1", { opacity: 0, duration: 0.18 }, 7.92);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "gsap_fullscreen_overlay_starts_visible",
);
expect(finding).toBeDefined();
expect(finding?.selector).toBe("#tr-flash-1");
});
it("errors when a grouped GSAP selector targets a visible full-frame flash", async () => {
const html = `
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
@@ -3051,4 +3030,44 @@ describe("SVG draw-on rules", () => {
const finding = result.findings.find((f) => f.code === "svg_measure_before_path_d");
expect(finding).toBeUndefined();
});
describe("gsap_fullscreen_overlay_starts_visible — the from() shape is not a defect", () => {
const overlay = (style: string, script: string) => `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080" data-start="0" data-duration="10">
<div id="flash" style="position:fixed;inset:0;background:#000;${style}"></div>
</div>
<script src="gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
${script}
window.__timelines["c1"] = tl;
</script>
</body></html>`;
it("does not flag a from() reveal, which already seats opacity 0 at t=0", async () => {
// This used to error, and BOTH its fixHints (authored CSS opacity:0, or an
// immediate gsap.set) produce gsap_from_opacity_noop — whose own fixHint says
// to remove exactly what was just added. Applying either hint looped forever.
const result = await lintHyperframeHtml(
overlay("", `tl.from("#flash", { opacity: 0, duration: 1 }, 2);`),
);
expect(
result.findings.find((f) => f.code === "gsap_fullscreen_overlay_starts_visible"),
).toBeUndefined();
});
it("still flags an overlay that is revealed and later hidden again", async () => {
const result = await lintHyperframeHtml(
overlay(
"",
`tl.to("#flash", { opacity: 1, duration: 1 }, 2);\n tl.to("#flash", { opacity: 0, duration: 1 }, 5);`,
),
);
expect(
result.findings.find((f) => f.code === "gsap_fullscreen_overlay_starts_visible"),
).toBeDefined();
});
});
});
+22 -2
View File
@@ -1181,10 +1181,30 @@ export const gsapRules: LintRule<LintContext>[] = [
) ||
selectors[0] ||
tag.name;
// A window only re-hides the overlay if it is a DIFFERENT tween that ends
// hidden. Two exclusions matter:
// - `win !== firstVisible`: the reveal counted itself.
// - `method !== "from"`: a from-tween's recorded propertyValues are its
// START state. `from({opacity: 0})` ENDS visible, so reading those values
// as an end state made every from-reveal look like its own later hide.
// Together these are what made the `from` shape report at all.
const laterHidden = visibilityWindows.some(
(win) => win.position >= firstVisible.position && isHiddenGsapState(win.propertyValues),
(win) =>
win !== firstVisible &&
win.method !== "from" &&
win.position >= firstVisible.position &&
isHiddenGsapState(win.propertyValues),
);
if (firstVisible.method !== "from" && !laterHidden) continue;
// Only the later-hidden shape is a real defect. The `from` shape is not:
// gsap.from() seats its start values immediately, so on a paused timeline the
// overlay already measures opacity 0 at t=0 and never covers an early frame.
//
// Worse, it had no exit. Both fixHints below (authored CSS `opacity: 0`, or an
// immediate `gsap.set`) turn a working composition into a real defect that
// `gsap_from_opacity_noop` correctly errors on — and that rule's fixHint says to
// remove the very thing we just asked for, closing the loop. An agent applying
// either hint bounces between the two errors forever.
if (!laterHidden) continue;
reportedVisibleOverlayKeys.add(overlayKey);
findings.push({
+2
View File
@@ -9,6 +9,8 @@ export type HyperframeLintFinding = {
elementId?: string;
fixHint?: string;
snippet?: string;
/** Optional standalone entry that command-specific guidance can act on. */
suggestedComposition?: string;
};
/**
+102 -12
View File
@@ -46,7 +46,11 @@ const TIMELINE_REGISTRY_KEY_PATTERN =
// 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;
// Locates the START of a `window.__timelines = { ... }` literal. Deliberately does
// not try to match the closing brace: see readTimelineRegistryObjectBody, which walks
// braces instead. A regex cannot tell the registry's own `}` from the `}` of an
// inlined options object.
const TIMELINE_REGISTRY_OBJECT_OPEN_PATTERN = /window\.__timelines\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 =
@@ -246,20 +250,71 @@ 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);
}
}
for (const entry of readTimelineRegistryTopLevelKeys(source)) keys.add(entry);
return [...keys];
}
/**
* Top-level keys of a `window.__timelines = { ... }` literal.
*
* Walks brace depth rather than regex-matching the body. The previous non-greedy
* body match stopped at the first `}` it saw, which for the legal one-liner
*
* window.__timelines = { main: gsap.timeline({ paused: true }) };
*
* was the brace of the INLINED OPTIONS OBJECT. The entry scanner then harvested
* `paused` as a composition id and timeline_id_mismatch reported a timeline
* "registered as paused" a registration that does not exist, so its fixHint
* could never be applied. Hoisting the timeline to a variable was the only escape,
* and nothing said so.
*/
/** Index of the brace that closes the group opened just before `bodyStart`. */
function findMatchingBrace(source: string, bodyStart: number): number {
let depth = 1;
for (let i = bodyStart; i < source.length; i += 1) {
if (source[i] === "{") depth += 1;
else if (source[i] === "}" && (depth -= 1) === 0) return i;
}
return source.length;
}
/** Replace every nested brace group with spaces so only depth-0 text remains. */
function blankNestedBraceGroups(body: string): string {
let out = "";
let depth = 0;
for (const ch of body) {
if (ch === "{") depth += 1;
else if (ch === "}") depth = Math.max(0, depth - 1);
else if (depth === 0) {
out += ch;
continue;
}
out += " ";
}
return out;
}
function readTimelineRegistryTopLevelKeys(source: string): string[] {
const open = TIMELINE_REGISTRY_OBJECT_OPEN_PATTERN.exec(source);
if (!open) return [];
const bodyStart = open.index + open[0].length;
const body = source.slice(bodyStart, findMatchingBrace(source, bodyStart));
const flattened = blankNestedBraceGroups(body);
const keys: string[] = [];
const entryPattern = new RegExp(
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source,
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags,
);
let entry: RegExpExecArray | null;
while ((entry = entryPattern.exec(flattened)) !== null) {
const key = entry[1] ?? entry[2];
if (key) keys.push(key);
}
return keys;
}
export function getInlineScriptSyntaxError(source: string): string | null {
if (!source.trim()) return null;
try {
@@ -272,6 +327,27 @@ export function getInlineScriptSyntaxError(source: string): string | null {
}
}
// fallow-ignore-next-line complexity
/**
* Blank the contents of every `'...'` and `"..."` literal, keeping the quotes so
* the source stays the same shape.
*
* Needed because a composition that *displays* source code carries things like
* `Math.random()` inside a string it never executes. Scanning raw script text for
* non-determinism reported those compositions as non-deterministic, and no edit
* could clear it while keeping the displayed snippet intact.
*
* Template literals are deliberately left alone: `${Math.random()}` inside one IS
* executed, and blanking it would hide real non-determinism. A snippet stored in a
* backtick string therefore still reports a narrower gap than the one this closes.
*/
export function stripStringLiterals(source: string): string {
return source.replace(
/(['"])(?:\\.|(?!\1)[^\\\n])*\1?/g,
(literal) => literal[0] + " ".repeat(Math.max(0, literal.length - 1)),
);
}
// fallow-ignore-next-line complexity
export function stripJsComments(source: string): string {
let out = "";
@@ -393,3 +469,17 @@ export function truncateSnippet(value: string, maxLength = 220): string | undefi
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, maxLength - 3)}...`;
}
/**
* Matches a media tag carrying a real `src` attribute, capturing the tag name in
* group 1 and the src value in group 2.
*
* The leading whitespace before `src` is load-bearing: `\bsrc\s*=` also matches
* the tail of `data-var-src="bg"` (a hyphen/`s` boundary is a word boundary), and
* since `[^>]*` is greedy it wins over a real `src` earlier in the same tag. Every
* element using a variable binding was therefore reported as referencing a missing
* file named after the variable id.
*/
export function mediaSrcTagRe(tagAlternation: string): RegExp {
return new RegExp(`<(${tagAlternation})\\b[^>]*\\ssrc\\s*=\\s*["']([^"']+)["'][^>]*>`, "gi");
}