mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(lint): consolidate lint and audit correctness (#2413)
* fix(lint): stop CSS comments in <style> from manufacturing phantom root tags
extractOpenTags scans raw source text with a flat regex that has no
concept of <style>/<script> block boundaries, so a CSS comment like
`/* <g> wrapper */` inside a <style> block reads as a real open tag.
findRootTag consumes that flat tag list and only skips tags literally
named script/style/meta/link/title, so the phantom <g> tag (not in
that skip list) wins the "first non-ignored body tag" search and gets
returned as the composition root instead of the real one that follows.
This manufactured root_missing_composition_id and root_missing_dimensions
(the phantom tag has neither) plus head_leaked_text (the leaked-text
scan slices up to the phantom tag's position, landing inside the
<style> block before its real closing tag, so the raw CSS text reads
as leaked markup) on an otherwise valid sub-composition — reported
with an exact bisected repro: a <template>-wrapped SVG sub-composition
whose <style> block comments reference an inner <g> element.
Fix: compute <style>/<script> content spans up front (reusing the
existing extractBlocks + STYLE_BLOCK_PATTERN/SCRIPT_BLOCK_PATTERN) and
skip any TAG_PATTERN match that falls inside one, before it ever
reaches findRootTag or any other extractOpenTags consumer. Same shape
as the prior fix for a leading <svg> defs block being mistaken for the
root (8ee4b7df) — this closes a sibling gap in the same function.
Test: new regression case with a <style> block containing a `/* <g> */`
comment ahead of an <svg data-composition-id> root, asserting none of
the three findings fire. Full lint package suite (318 tests) passes.
* feat(lint): flag duplicate data-composition-id values
Declaring data-composition-id on more than one element (commonly the <meta>
tag from the quickstart template AND the root <div> added to satisfy
root_missing_composition_id) is a silent collision: `compositions --json`
returns two entries for the same id (one duration:0) and inspect/snapshot
crash with "Cannot read properties of undefined (reading totalDuration)".
Lint passed clean through all of it.
New rule `duplicate_composition_id`: group elements by data-composition-id
value and error on any value shared by 2+ elements, naming the id and calling
out the meta-vs-root collision in the fixHint. 3 tests: dup fires, single id
passes, two distinct ids don't collide. (Implemented via Codex; verified
independently: 111 lint tests pass, oxfmt/oxlint clean.)
* fix(audits): avoid caption false positives
* fix(lint): ignore proxy-label tween overlaps
* fix(cli): preserve the five-percent text audit floor
* fix(lint): preserve proxy identity across lexical scopes
* fix(cli): audit only directly painted text
* fix(lint): compare live composition ids canonically
* fix(lint): preserve expanded proxy identities
* fix(cli): measure directly painted text geometry
* fix(lint): preserve first duplicate attribute value
* fix(lint): keep shared proxy identity across helpers
* fix(parsers): preserve expanded proxy identity
* fix(lint): decode composition IDs consistently
This commit is contained in:
@@ -3,7 +3,7 @@ import {
|
||||
parseHtmlStructure,
|
||||
findRootTag,
|
||||
collectCompositionIds,
|
||||
readAttr,
|
||||
readDecodedAttr,
|
||||
stripHtmlComments,
|
||||
} from "./utils";
|
||||
import type { OpenTag, ExtractedBlock } from "./utils";
|
||||
@@ -66,7 +66,7 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
|
||||
const scripts = structure.scripts;
|
||||
const compositionIds = collectCompositionIds(tags);
|
||||
const rootTag = findRootTag(source, tags);
|
||||
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
|
||||
const rootCompositionId = readDecodedAttr(rootTag?.raw || "", "data-composition-id");
|
||||
|
||||
return {
|
||||
source,
|
||||
|
||||
@@ -190,6 +190,89 @@ describe("composition rules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicate_composition_id", () => {
|
||||
it("flags a meta tag and root div sharing the same data-composition-id", async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="composition-id" data-composition-id="x">
|
||||
</head>
|
||||
<body>
|
||||
<div data-composition-id="x" data-width="1920" data-height="1080" data-start="0" data-duration="1" data-no-timeline></div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "duplicate_composition_id");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does not flag a single valid composition id", async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="1" data-no-timeline></div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "duplicate_composition_id");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag distinct composition ids in one file", async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5" data-no-timeline>
|
||||
<div data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "duplicate_composition_id");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores composition ids inside inert template content", async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="1" data-no-timeline></div>
|
||||
<template><div data-composition-id="main"></div></template>
|
||||
</body></html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "duplicate_composition_id");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flags entity-equivalent composition ids", async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="1" data-no-timeline></div>
|
||||
<meta data-composition-id="main">
|
||||
</body></html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "duplicate_composition_id");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses the browser's first value for duplicate attributes", async () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="1" data-no-timeline></div>
|
||||
<meta data-composition-id="main" data-composition-id="other">
|
||||
</body></html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "duplicate_composition_id");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports error when querySelector uses template literal variable", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LintContext, HyperframeLintFinding, ExtractedBlock, OpenTag } from
|
||||
import {
|
||||
findHtmlTag,
|
||||
readAttr,
|
||||
readDecodedAttr,
|
||||
readJsonAttr,
|
||||
stripJsComments,
|
||||
truncateSnippet,
|
||||
@@ -48,7 +49,7 @@ export function isRegistryInstalledFile(rawSource: string): boolean {
|
||||
|
||||
function isCompositionRootOrMount(rawTag: string): boolean {
|
||||
return Boolean(
|
||||
readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"),
|
||||
readDecodedAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,7 +157,47 @@ function declaredIdsForBindingCheck(tags: readonly OpenTag[]): Set<string> | nul
|
||||
return declared;
|
||||
}
|
||||
|
||||
function isInsideInertTemplate(tag: OpenTag, tags: readonly OpenTag[]): boolean {
|
||||
return tags.some(
|
||||
(candidate) =>
|
||||
candidate.name === "template" &&
|
||||
candidate.closeIndex != null &&
|
||||
tag.index > candidate.index &&
|
||||
tag.index < candidate.closeIndex,
|
||||
);
|
||||
}
|
||||
|
||||
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// duplicate_composition_id catches meta-tag/root collisions that create duplicate composition entries.
|
||||
({ tags }) => {
|
||||
const tagsByCompositionId = new Map<string, string[]>();
|
||||
for (const tag of tags) {
|
||||
if (isInsideInertTemplate(tag, tags)) continue;
|
||||
const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
|
||||
if (!compositionId || compositionId.trim().length === 0) continue;
|
||||
|
||||
const matchingTags = tagsByCompositionId.get(compositionId) ?? [];
|
||||
matchingTags.push(tag.raw);
|
||||
tagsByCompositionId.set(compositionId, matchingTags);
|
||||
}
|
||||
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const [compositionId, matchingTags] of tagsByCompositionId) {
|
||||
if (matchingTags.length < 2) continue;
|
||||
|
||||
findings.push({
|
||||
code: "duplicate_composition_id",
|
||||
severity: "error",
|
||||
message: `Composition id "${compositionId}" is used by ${matchingTags.length} elements. Each data-composition-id value must be unique within a composition file.`,
|
||||
fixHint:
|
||||
"Keep data-composition-id on exactly one element, the composition root. Remove it from metadata or duplicate hosts, especially a <meta> tag carrying the same data-composition-id as the root <div>, which causes a silent duplicate-id collision.",
|
||||
snippet: truncateSnippet(matchingTags[0] ?? ""),
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
|
||||
// invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
|
||||
// href, inline-style url(), and <style> url() asset references on
|
||||
// compositions. Sub-compositions live under compositions/ but are served
|
||||
@@ -285,7 +326,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
for (const tag of tags) {
|
||||
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
|
||||
if (!readAttr(tag.raw, "data-start")) continue;
|
||||
if (readAttr(tag.raw, "data-composition-id")) continue;
|
||||
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
||||
if (readAttr(tag.raw, "data-composition-src")) continue;
|
||||
const classAttr = readAttr(tag.raw, "class") || "";
|
||||
const styleAttr = readAttr(tag.raw, "style") || "";
|
||||
@@ -400,7 +441,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
for (const tag of tags) {
|
||||
if (skipTags.has(tag.name)) continue;
|
||||
// Skip composition hosts
|
||||
if (readAttr(tag.raw, "data-composition-id")) continue;
|
||||
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
||||
if (readAttr(tag.raw, "data-composition-src")) continue;
|
||||
|
||||
const hasStart = readAttr(tag.raw, "data-start") !== null;
|
||||
@@ -483,7 +524,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (options.isSubComposition) return findings;
|
||||
if (!rootTag) return findings;
|
||||
const compId = readAttr(rootTag.raw, "data-composition-id");
|
||||
const compId = readDecodedAttr(rootTag.raw, "data-composition-id");
|
||||
if (!compId) return findings;
|
||||
const hasStart = readAttr(rootTag.raw, "data-start") !== null;
|
||||
if (!hasStart) {
|
||||
@@ -919,7 +960,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
// — e.g. a slideshow demo.html mounts <hyperframes-player src="index.html">
|
||||
// with no data-composition-id of its own. Nothing to capture there, so
|
||||
// there's no duration contract to enforce.
|
||||
if (readAttr(rootTag.raw, "data-composition-id") === null) return [];
|
||||
if (readDecodedAttr(rootTag.raw, "data-composition-id") === null) return [];
|
||||
if (readAttr(rootTag.raw, "data-duration") !== null) return [];
|
||||
|
||||
// Strip comments before scanning for signals — a commented-out
|
||||
|
||||
@@ -214,6 +214,29 @@ describe("core rules", () => {
|
||||
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mistake a <tag>-shaped CSS comment inside <style> for the composition root", async () => {
|
||||
// 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
|
||||
// otherwise valid sub-composition.
|
||||
const html = `
|
||||
<html><body>
|
||||
<style>
|
||||
/* <g> wrapper for icon groups */
|
||||
.icon { fill: currentColor; }
|
||||
</style>
|
||||
<svg id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<g class="icon"></g>
|
||||
</svg>
|
||||
<script>window.__timelines = window.__timelines || {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
|
||||
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
||||
expect(result.findings.find((f) => f.code === "head_leaked_text")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when timeline registry is missing", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
@@ -778,6 +801,20 @@ body {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches timeline keys against browser-decoded composition ids", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts object-literal timeline registration and extracts its keys", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import postcss from "postcss";
|
||||
import {
|
||||
readAttr,
|
||||
readDecodedAttr,
|
||||
truncateSnippet,
|
||||
stripJsComments,
|
||||
extractCompositionIdsFromCss,
|
||||
@@ -40,7 +41,7 @@ function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
|
||||
function describeStudioElement(tag: { raw: string; name: string }): string {
|
||||
const parts = [`<${tag.name}`];
|
||||
const className = readAttr(tag.raw, "class");
|
||||
const compositionId = readAttr(tag.raw, "data-composition-id");
|
||||
const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
|
||||
const dataStart = readAttr(tag.raw, "data-start");
|
||||
const dataTrack = readAttr(tag.raw, "data-track-index") ?? readAttr(tag.raw, "data-track");
|
||||
|
||||
@@ -203,7 +204,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// root_missing_composition_id + root_missing_dimensions
|
||||
({ rootTag }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (!rootTag || !readAttr(rootTag.raw, "data-composition-id")) {
|
||||
if (!rootTag || !readDecodedAttr(rootTag.raw, "data-composition-id")) {
|
||||
findings.push({
|
||||
code: "root_missing_composition_id",
|
||||
severity: "error",
|
||||
@@ -300,15 +301,10 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
},
|
||||
|
||||
// timeline_id_mismatch
|
||||
({ source }) => {
|
||||
({ source, compositionIds }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const htmlCompIds = new Set<string>();
|
||||
const htmlCompIds = new Set(compositionIds);
|
||||
const timelineRegKeys = new Set<string>();
|
||||
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = compIdRe.exec(source)) !== null) {
|
||||
if (m[1]) htmlCompIds.add(m[1]);
|
||||
}
|
||||
for (const key of extractTimelineRegistryKeys(source)) {
|
||||
timelineRegKeys.add(key);
|
||||
}
|
||||
@@ -369,7 +365,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
for (const tag of tags) {
|
||||
const src = readAttr(tag.raw, "data-composition-src");
|
||||
if (!src) continue;
|
||||
if (readAttr(tag.raw, "data-composition-id")) continue;
|
||||
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
||||
findings.push({
|
||||
code: "host_missing_composition_id",
|
||||
severity: "error",
|
||||
@@ -452,8 +448,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
code: "studio_missing_editable_id",
|
||||
severity: "warning",
|
||||
message: `${descriptor} has no id, so Studio cannot use a stable edit target for its timeline and canvas controls.`,
|
||||
selector: readAttr(tag.raw, "data-composition-id")
|
||||
? `[data-composition-id="${readAttr(tag.raw, "data-composition-id")}"]`
|
||||
selector: readDecodedAttr(tag.raw, "data-composition-id")
|
||||
? `[data-composition-id="${readDecodedAttr(tag.raw, "data-composition-id")}"]`
|
||||
: undefined,
|
||||
fixHint:
|
||||
'Add a stable, human-readable id such as id="hero-title" or id="scene-1-card" to every timeline-visible element you want agents or Studio to edit.',
|
||||
|
||||
@@ -998,6 +998,92 @@ describe("GSAP rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT report overlapping_gsap_tweens for distinct loop-built DOM targets", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="caption-card-0"></div>
|
||||
<div id="caption-card-1"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const card = document.getElementById("caption-card-" + i);
|
||||
tl.to(card, { opacity: 1, duration: 1 }, i * 0.5);
|
||||
}
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT report overlapping_gsap_tweens for distinct object proxy drivers", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const particles = { value: 0 };
|
||||
const grain = { value: 0 };
|
||||
tl.to(particles, { value: 1, duration: 2, ease: "none" }, 0);
|
||||
tl.to(grain, { value: 1, duration: 2, ease: "none" }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports overlapping_gsap_tweens for the same object proxy driver", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const driver = { value: 0 };
|
||||
tl.to(driver, { value: 1, duration: 2, ease: "none" }, 0);
|
||||
tl.to(driver, { value: 2, duration: 2, ease: "none" }, 0.5);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not conflate same-named object proxies from sibling scopes", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
(() => {
|
||||
const driver = { value: 0 };
|
||||
tl.to(driver, { value: 1, duration: 2, ease: "none" }, 0);
|
||||
})();
|
||||
(() => {
|
||||
const driver = { value: 0 };
|
||||
tl.to(driver, { value: 1, duration: 2, ease: "none" }, 0.5);
|
||||
})();
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when an opacity exit ends at a clip start boundary without a hard kill", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
interface LintParsedGsap {
|
||||
animations: Array<{
|
||||
targetSelector: string;
|
||||
targetIdentity?: string;
|
||||
method: string;
|
||||
position: number | string;
|
||||
properties: Record<string, number | string>;
|
||||
@@ -27,6 +28,7 @@ import type { HyperframeLintFinding, LintRule } from "../types";
|
||||
import type { OpenTag } from "../utils";
|
||||
import {
|
||||
readAttr,
|
||||
readDecodedAttr,
|
||||
truncateSnippet,
|
||||
stripJsComments,
|
||||
hasCaptionStyles,
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
|
||||
type GsapWindow = {
|
||||
targetSelector: string;
|
||||
targetIdentity?: string;
|
||||
position: number;
|
||||
end: number;
|
||||
properties: string[];
|
||||
@@ -61,6 +64,16 @@ const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
|
||||
// overlap analysis must never treat them as one.
|
||||
const UNRESOLVED_TARGET = "__unresolved__";
|
||||
|
||||
// Parser labels for object-proxy tweens describe their role, not target
|
||||
// identity. Two independent proxies can both be labelled `dwell/hold` (or the
|
||||
// same driven DOM channel), so equality cannot prove they conflict.
|
||||
function targetHasNoStableIdentity(selector: string, identity?: string): boolean {
|
||||
if (identity) return false;
|
||||
return (
|
||||
selector === UNRESOLVED_TARGET || selector === "dwell/hold" || selector.startsWith("proxy → ")
|
||||
);
|
||||
}
|
||||
|
||||
// ── GSAP parsing utilities ─────────────────────────────────────────────────
|
||||
|
||||
function countClassUsage(tags: OpenTag[]): Map<string, number> {
|
||||
@@ -137,6 +150,7 @@ async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
|
||||
animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
|
||||
windows.push({
|
||||
targetSelector: animation.targetSelector,
|
||||
targetIdentity: animation.targetIdentity,
|
||||
position: animation.position,
|
||||
end: animation.position + effectiveDuration,
|
||||
properties: Object.keys(animation.properties),
|
||||
@@ -259,7 +273,7 @@ function findTagEnd(source: string, tag: OpenTag): number {
|
||||
function collectCompositionRanges(source: string, tags: OpenTag[]): CompositionRange[] {
|
||||
return tags
|
||||
.map((tag) => {
|
||||
const id = readAttr(tag.raw, "data-composition-id");
|
||||
const id = readDecodedAttr(tag.raw, "data-composition-id");
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
@@ -593,12 +607,14 @@ export const gsapRules: LintRule<LintContext>[] = [
|
||||
if (left.end <= left.position) continue;
|
||||
// Unresolved targets are unknown elements: two of them are not provably
|
||||
// the same element, so an overlap between them cannot be asserted.
|
||||
if (left.targetSelector === UNRESOLVED_TARGET) continue;
|
||||
if (targetHasNoStableIdentity(left.targetSelector, left.targetIdentity)) continue;
|
||||
for (let j = i + 1; j < gsapWindows.length; j++) {
|
||||
const right = gsapWindows[j];
|
||||
if (!right) continue;
|
||||
if (right.end <= right.position) continue;
|
||||
if (left.targetSelector !== right.targetSelector) continue;
|
||||
const leftIdentity = left.targetIdentity ?? left.targetSelector;
|
||||
const rightIdentity = right.targetIdentity ?? right.targetSelector;
|
||||
if (leftIdentity !== rightIdentity) continue;
|
||||
const overlapStart = Math.max(left.position, right.position);
|
||||
const overlapEnd = Math.min(left.end, right.end);
|
||||
if (overlapEnd <= overlapStart) continue;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { readAttr, truncateSnippet, isMediaTag } from "../utils";
|
||||
import { readAttr, readDecodedAttr, truncateSnippet, isMediaTag } from "../utils";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -308,7 +308,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
|
||||
if (tag.name === "video" || tag.name === "audio") continue;
|
||||
if (voidElements.has(tag.name)) continue;
|
||||
// Skip the composition root — it uses data-start as a playback anchor, not as a clip timer
|
||||
if (readAttr(tag.raw, "data-composition-id")) continue;
|
||||
if (readDecodedAttr(tag.raw, "data-composition-id")) continue;
|
||||
if (readAttr(tag.raw, "data-start")) {
|
||||
timedTagPositions.push({
|
||||
name: tag.name,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import type { LintRule } from "../types";
|
||||
import { readAttr } from "../utils";
|
||||
import { readAttr, readDecodedAttr } from "../utils";
|
||||
import {
|
||||
parseSlideshowManifest,
|
||||
resolveSlideshow,
|
||||
@@ -30,7 +30,7 @@ function parseTiming(raw: string): { start: number; duration: number } | null {
|
||||
|
||||
function collectCompositionIdScenes(ctx: LintContext, seen: Set<string>, out: Scene[]): void {
|
||||
for (const tag of ctx.tags) {
|
||||
const compositionId = readAttr(tag.raw, "data-composition-id");
|
||||
const compositionId = readDecodedAttr(tag.raw, "data-composition-id");
|
||||
if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId))
|
||||
continue;
|
||||
const timing = parseTiming(tag.raw);
|
||||
|
||||
@@ -121,7 +121,7 @@ export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): Op
|
||||
const bodyTag = tags.find((tag) => tag.name === "body");
|
||||
if (
|
||||
bodyTag &&
|
||||
(readAttr(bodyTag.raw, "data-composition-id") ||
|
||||
(readDecodedAttr(bodyTag.raw, "data-composition-id") ||
|
||||
readAttr(bodyTag.raw, "data-width") ||
|
||||
readAttr(bodyTag.raw, "data-height"))
|
||||
) {
|
||||
@@ -148,7 +148,7 @@ export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): Op
|
||||
// still eligible as the root.
|
||||
if (
|
||||
tag.name === "svg" &&
|
||||
!readAttr(tag.raw, "data-composition-id") &&
|
||||
!readDecodedAttr(tag.raw, "data-composition-id") &&
|
||||
!readAttr(tag.raw, "data-width") &&
|
||||
!readAttr(tag.raw, "data-height")
|
||||
) {
|
||||
@@ -173,6 +173,22 @@ export function readAttr(tagSource: string, attr: string): string | null {
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
/** Read an HTML attribute using browser-equivalent character-reference decoding. */
|
||||
export function readDecodedAttr(tagSource: string, attr: string): string | null {
|
||||
if (!tagSource) return null;
|
||||
let value: string | null = null;
|
||||
const parser = new Parser(
|
||||
{
|
||||
onattribute(name, decodedValue) {
|
||||
if (value === null && name.toLowerCase() === attr.toLowerCase()) value = decodedValue;
|
||||
},
|
||||
},
|
||||
{ decodeEntities: true, lowerCaseAttributeNames: false, lowerCaseTags: true },
|
||||
);
|
||||
parser.end(tagSource);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an attribute that may legitimately contain the opposite quote
|
||||
* character. `readAttr` truncates `data-variable-values='{"title":"Hello"}'`
|
||||
@@ -200,7 +216,7 @@ export function readJsonAttr(tagSource: string, attr: string): string | null {
|
||||
export function collectCompositionIds(tags: OpenTag[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const tag of tags) {
|
||||
const compId = readAttr(tag.raw, "data-composition-id");
|
||||
const compId = readDecodedAttr(tag.raw, "data-composition-id");
|
||||
if (compId) ids.add(compId);
|
||||
}
|
||||
return ids;
|
||||
|
||||
Reference in New Issue
Block a user