mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user