mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: isolate studio sub-composition previews
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user