fix: isolate studio sub-composition previews

This commit is contained in:
Miguel Ángel
2026-04-27 15:51:53 -04:00
parent 2935be6bf6
commit c32db3ea0a
16 changed files with 1015 additions and 158 deletions
@@ -95,6 +95,29 @@ describe("composition rules", () => {
expect(finding).toBeUndefined();
});
it("reports error when a selector combines data attributes in one bracket", () => {
const html = `
<template id="scene-template">
<div data-composition-id="scene" data-start="0" data-width="1920" data-height="1080">
<style>
[data-composition-id="scene" data-start="0"] .title { opacity: 0; }
</style>
<script>
window.__timelines = window.__timelines || {};
const title = document.querySelector('[data-composition-id="scene" data-start="0"] .title');
const tl = gsap.timeline({ paused: true });
tl.to('[data-composition-id="scene" data-start="0"]', { opacity: 0, duration: 0.5 }, 4);
window.__timelines["scene"] = tl;
</script>
</div>
</template>`;
const result = lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
const findings = result.findings.filter((f) => f.code === "split_data_attribute_selector");
expect(findings.length).toBe(1);
expect(findings[0]?.severity).toBe("error");
expect(findings[0]?.fixHint).toContain('[data-composition-id="scene"][data-start="0"]');
});
describe("timed_element_missing_clip_class", () => {
it("flags element with data-start but no class='clip'", () => {
const html = `
@@ -62,6 +62,35 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// split_data_attribute_selector
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
const splitDataAttrSelectorPattern =
/\[data-composition-id=(["'])([^"'\]]+)\1\s+(data-[\w:-]+)=(["'])([^"'\]]*)\4\]/g;
const scan = (content: string) => {
splitDataAttrSelectorPattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = splitDataAttrSelectorPattern.exec(content)) !== null) {
const compId = match[2] ?? "";
const attrName = match[3] ?? "";
const attrValue = match[5] ?? "";
findings.push({
code: "split_data_attribute_selector",
severity: "error",
message:
`Selector "${match[0]}" combines two attributes inside one CSS attribute selector. ` +
"Browsers reject it, so GSAP timelines or querySelector calls will fail before registering.",
selector: match[0],
fixHint: `Use separate attribute selectors: [data-composition-id="${compId}"][${attrName}="${attrValue}"].`,
snippet: truncateSnippet(match[0]),
});
}
};
for (const style of styles) scan(style.content);
for (const script of scripts) scan(script.content);
return findings;
},
// template_literal_selector
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
@@ -90,6 +90,51 @@ describe("media rules", () => {
expect(finding?.severity).toBe("error");
});
it("reports error for media with src but no data-start", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_missing_data_start");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("allows audible video clips to omit muted when data-has-audio is true", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "video_missing_muted")).toBeUndefined();
expect(
result.findings.find((f) => f.code === "video_muted_with_declared_audio"),
).toBeUndefined();
});
it("reports error for videos that declare audio while muted", () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "video_muted_with_declared_audio");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("demo-video");
});
it("does NOT flag <video> as nested in a void element with data-start (regression)", () => {
// Regression: void elements like <img> have no closing tag, so the previous
// implementation kept them on the parent stack indefinitely and flagged any
@@ -142,6 +187,24 @@ describe("media rules", () => {
expect(finding?.severity).toBe("error");
});
it("reports imperative muted/play control on class-selected media without ids", () => {
const html = `
<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
<video class="demo-video" src="clip.mp4" muted playsinline></video>
<script>
const vid = document.querySelector('[data-composition-id="scene"] .demo-video');
if (vid) { vid.muted = true; vid.play(); }
</script>
</div>
</template>`;
const result = lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
const imperativeFindings = result.findings.filter((f) => f.code === "imperative_media_control");
expect(imperativeFindings.length).toBe(2);
expect(imperativeFindings.some((f) => f.snippet === "vid.muted =")).toBe(true);
expect(imperativeFindings.some((f) => f.snippet === "vid.play(")).toBe(true);
});
it("does not flag play() on non-media elements", () => {
const html = `
<html><body>
+115 -32
View File
@@ -5,48 +5,86 @@ function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function selectorTargetsManagedMedia(selector: string, mediaIds: Set<string>): boolean {
function hasAttrName(tagSource: string, attr: string): boolean {
const escaped = escapeRegExp(attr);
const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
}
function classNamesFromAttr(classAttr: string | null): string[] {
if (!classAttr) return [];
return classAttr.split(/\s+/).filter(Boolean);
}
type MediaSelectorIndex = {
ids: Set<string>;
classes: Set<string>;
hasVideo: boolean;
hasAudio: boolean;
};
function selectorTargetsManagedMedia(selector: string, mediaIndex: MediaSelectorIndex): boolean {
const normalized = selector.trim();
if (!normalized) return false;
if (/\b(video|audio)\b/i.test(normalized)) return true;
for (const mediaId of mediaIds) {
if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
for (const mediaId of mediaIndex.ids) {
const escapedId = escapeRegExp(mediaId);
if (
normalized.includes(`#${mediaId}`) ||
new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) ||
normalized.includes(`[id="${mediaId}"]`) ||
normalized.includes(`[id='${mediaId}']`)
) {
return true;
}
}
for (const className of mediaIndex.classes) {
if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
return true;
}
}
return false;
}
function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFinding[] {
const findings: HyperframeLintFinding[] = [];
const managedMediaIds = new Set(
ctx.tags
.filter((tag) => tag.name === "video" || tag.name === "audio")
.map((tag) => readAttr(tag.raw, "id"))
.filter((id): id is string => Boolean(id)),
);
const mediaTags = ctx.tags.filter((tag) => tag.name === "video" || tag.name === "audio");
const mediaIndex: MediaSelectorIndex = {
ids: new Set(
mediaTags.map((tag) => readAttr(tag.raw, "id")).filter((id): id is string => Boolean(id)),
),
classes: new Set(mediaTags.flatMap((tag) => classNamesFromAttr(readAttr(tag.raw, "class")))),
hasVideo: mediaTags.some((tag) => tag.name === "video"),
hasAudio: mediaTags.some((tag) => tag.name === "audio"),
};
if (managedMediaIds.size === 0 || ctx.scripts.length === 0) return findings;
if (mediaTags.length === 0 || ctx.scripts.length === 0) return findings;
for (const script of ctx.scripts) {
const mediaVars = new Map<string, string | undefined>();
const assignmentPatterns = [
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)/g,
{
pattern:
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
variableIndex: 1,
targetIndex: 2,
},
{
pattern:
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\2\s*\)/g,
variableIndex: 1,
targetIndex: 3,
},
];
for (const pattern of assignmentPatterns) {
for (const { pattern, variableIndex, targetIndex } of assignmentPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
const variableName = match[1];
const target = match[2];
const variableName = match[variableIndex];
const target = match[targetIndex];
if (!variableName || !target) continue;
if (managedMediaIds.has(target) || selectorTargetsManagedMedia(target, managedMediaIds)) {
mediaVars.set(variableName, managedMediaIds.has(target) ? target : undefined);
if (mediaIndex.ids.has(target) || selectorTargetsManagedMedia(target, mediaIndex)) {
mediaVars.set(variableName, mediaIndex.ids.has(target) ? target : undefined);
}
}
}
@@ -56,42 +94,60 @@ function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFin
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
kind: "play()",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
kind: "pause()",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
kind: "currentTime",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.muted\s*=/g,
kind: "muted assignment",
targetIndex: 1,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.play\s*\(/g,
kind: "play()",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.pause\s*\(/g,
kind: "pause()",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.currentTime\s*=/g,
kind: "currentTime",
targetIndex: 2,
},
{
pattern:
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.muted\s*=/g,
kind: "muted assignment",
targetIndex: 2,
},
];
for (const { pattern, kind } of directIdPatterns) {
for (const { pattern, kind, targetIndex } of directIdPatterns) {
let match: RegExpExecArray | null;
while ((match = pattern.exec(script.content)) !== null) {
const target = match[1];
const target = match[targetIndex];
if (!target) continue;
const elementId = managedMediaIds.has(target)
const elementId = mediaIndex.ids.has(target)
? target
: selectorTargetsManagedMedia(target, managedMediaIds)
: selectorTargetsManagedMedia(target, mediaIndex)
? undefined
: null;
if (elementId === null) continue;
@@ -101,7 +157,7 @@ function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFin
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
elementId: elementId || undefined,
fixHint:
"Remove imperative media play/pause/currentTime control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
snippet: truncateSnippet(match[0]),
});
}
@@ -113,6 +169,10 @@ function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFin
{ pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
{ pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
{ pattern: new RegExp(`\\b${escapedVar}\\.currentTime\\s*=`, "g"), kind: "currentTime" },
{
pattern: new RegExp(`\\b${escapedVar}\\.muted\\s*=`, "g"),
kind: "muted assignment",
},
];
for (const { pattern, kind } of variablePatterns) {
let match: RegExpExecArray | null;
@@ -123,7 +183,7 @@ function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFin
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
elementId,
fixHint:
"Remove imperative media play/pause/currentTime control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
snippet: truncateSnippet(match[0]),
});
}
@@ -192,16 +252,29 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name !== "video") continue;
const hasMuted = /\bmuted\b/i.test(tag.raw);
if (!hasMuted && readAttr(tag.raw, "data-start")) {
const hasMuted = hasAttrName(tag.raw, "muted");
const hasDeclaredAudio = readAttr(tag.raw, "data-has-audio") === "true";
if (!hasMuted && !hasDeclaredAudio && readAttr(tag.raw, "data-start")) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "video_missing_muted",
severity: "error",
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. The framework expects video to be muted with a separate <audio> element for sound.`,
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. Mark audible videos with data-has-audio="true"; otherwise keep video muted and use a separate <audio> element for sound.`,
elementId,
fixHint:
"Add the `muted` attribute to the <video> tag and use a separate <audio> element with the same src for audio playback.",
'Add the `muted` attribute for silent video, or add data-has-audio="true" when the video track should contribute audio.',
snippet: truncateSnippet(tag.raw),
});
}
if (hasMuted && hasDeclaredAudio) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "video_muted_with_declared_audio",
severity: "error",
message: `<video${elementId ? ` id="${elementId}"` : ""}> declares data-has-audio="true" but also has muted. Studio preview will silence the video audio.`,
elementId,
fixHint:
'Remove the `muted` attribute if this video should be audible, or remove data-has-audio="true" and use data-volume="0" for silent visual video.',
snippet: truncateSnippet(tag.raw),
});
}
@@ -336,7 +409,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
return findings;
},
// media_missing_id + media_missing_src + media_preload_none
// media_missing_data_start + media_missing_id + media_missing_src + media_preload_none
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
@@ -344,6 +417,16 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
const hasDataStart = readAttr(tag.raw, "data-start");
const hasId = readAttr(tag.raw, "id");
const hasSrc = readAttr(tag.raw, "src");
if (hasSrc && !hasDataStart) {
findings.push({
code: "media_missing_data_start",
severity: "error",
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has src but no data-start. HyperFrames cannot own playback for untimed media, so preview and render behavior can diverge.`,
elementId: hasId || undefined,
fixHint: `Add data-start="0" (or the intended start time) and data-duration if the clip should stop before the source ends.`,
snippet: truncateSnippet(tag.raw),
});
}
if (hasDataStart && !hasId) {
findings.push({
code: "media_missing_id",