mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2189 from heygen-com/fix/sdk-template-gsap-resolver-parity
fix(sdk): include template GSAP scripts in resolver parity
This commit is contained in:
@@ -114,15 +114,49 @@ export function mintHfId(el: Element, assigned: Set<string>): string {
|
||||
* inner id would be duplicated across every clone. Form B is distinguished from
|
||||
* a clone-source by the presence of a direct `[data-composition-id]` child.
|
||||
*/
|
||||
function getChildElements(parent: Element): Element[] {
|
||||
const directChildren = Array.from(parent.children);
|
||||
if (directChildren.length || parent.tagName.toLowerCase() !== "template") return directChildren;
|
||||
const content = (parent as HTMLTemplateElement).content;
|
||||
if (content?.children.length) return Array.from(content.children);
|
||||
return directChildren;
|
||||
}
|
||||
|
||||
export function isCompositionTemplate(el: Element): boolean {
|
||||
if (el.tagName.toLowerCase() !== "template") return false;
|
||||
if (el.getAttribute("data-composition-id") !== null) return true;
|
||||
for (const child of Array.from(el.children)) {
|
||||
for (const child of getChildElements(el)) {
|
||||
if (child.getAttribute("data-composition-id") !== null) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk document-order descendants, descending through composition templates
|
||||
* while keeping plain templates inert. linkedom's querySelectorAll does not
|
||||
* expose template contents, so callers that model the served composition use
|
||||
* this traversal instead.
|
||||
*/
|
||||
export function walkCompositionDescendants(
|
||||
root: Document | Element,
|
||||
visit: (el: Element) => void,
|
||||
): void {
|
||||
const rootElement: Element | null =
|
||||
root.nodeType === 9 ? (root as Document).documentElement : (root as Element);
|
||||
if (!rootElement) return;
|
||||
|
||||
const walk = (parent: Element): void => {
|
||||
for (const child of getChildElements(parent)) {
|
||||
const isTemplate = child.tagName.toLowerCase() === "template";
|
||||
if (isTemplate && !isCompositionTemplate(child)) continue;
|
||||
visit(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Document-order walk of every element under `root`, descending into
|
||||
* composition `<template>` subtrees — linkedom's querySelectorAll does not, so
|
||||
@@ -132,12 +166,7 @@ export function isCompositionTemplate(el: Element): boolean {
|
||||
* skipped entirely (see isCompositionTemplate).
|
||||
*/
|
||||
function walkElements(root: Element, visit: (el: Element) => void): void {
|
||||
for (const child of Array.from(root.children)) {
|
||||
const isTemplate = child.tagName.toLowerCase() === "template";
|
||||
if (isTemplate && !isCompositionTemplate(child)) continue;
|
||||
visit(child);
|
||||
walkElements(child, visit);
|
||||
}
|
||||
walkCompositionDescendants(root, visit);
|
||||
}
|
||||
|
||||
export function ensureHfIds(html: string): string {
|
||||
|
||||
@@ -148,6 +148,22 @@ describe("parseHtml", () => {
|
||||
expect(result.gsapScript).toContain('tl.to("#text1"');
|
||||
});
|
||||
|
||||
it("extracts GSAP script from composition templates", () => {
|
||||
const html = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<template data-composition-id="sub-comp">
|
||||
<script>const tl = gsap.timeline({ paused: true });</script>
|
||||
</template>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const result = parseHtml(html);
|
||||
|
||||
expect(result.gsapScript).toContain("gsap.timeline");
|
||||
});
|
||||
|
||||
it("extracts styles from style tags", () => {
|
||||
const html = `
|
||||
<html>
|
||||
@@ -559,6 +575,26 @@ describe("removeElementFromHtml", () => {
|
||||
expect(updated).not.toContain("x: 100");
|
||||
expect(updated).not.toContain("x: 200");
|
||||
});
|
||||
|
||||
it("strips GSAP tweens from composition templates", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="stage">
|
||||
<div id="box" data-hf-id="box" data-start="0" data-end="5">box</div>
|
||||
</div>
|
||||
<template data-composition-id="sub-comp">
|
||||
<script>
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"box\\"]", { x: 100, duration: 1 }, 0);
|
||||
</script>
|
||||
</template>
|
||||
</body></html>`;
|
||||
|
||||
const updated = removeElementFromHtml(html, "box");
|
||||
|
||||
expect(updated).not.toContain('data-hf-id="box"');
|
||||
expect(updated).not.toContain("x: 100");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCompositionHtml", () => {
|
||||
@@ -626,6 +662,25 @@ describe("validateCompositionHtml", () => {
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Inline event handlers (onclick, onload, etc.) not allowed");
|
||||
});
|
||||
|
||||
it("validates GSAP scripts inside composition templates", () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html data-composition-id="comp-1" data-composition-duration="10">
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<template data-composition-id="sub-comp">
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#text1", { onComplete: () => {}, duration: 1 }, 0);
|
||||
</script>
|
||||
</template>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const result = validateCompositionHtml(html);
|
||||
|
||||
expect(result.errors).toContain("onComplete callback not allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCompositionMetadata", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
} from "./types.js";
|
||||
import { validateCompositionGsap } from "./gsapSerialize";
|
||||
import { parseCompositionVariables } from "./compositionVariables.js";
|
||||
import { ensureHfIds } from "./hfIds.js";
|
||||
import { ensureHfIds, walkCompositionDescendants } from "./hfIds.js";
|
||||
import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js";
|
||||
import { queryByAttr } from "./utils/cssSelector.js";
|
||||
import { removeAnimationFromScript } from "./gsapWriterAcorn.js";
|
||||
@@ -388,7 +388,7 @@ export function parseHtml(html: string): ParsedHtml {
|
||||
}
|
||||
});
|
||||
|
||||
const scriptTags = doc.querySelectorAll("script");
|
||||
const scriptTags = findScriptElementsDeep(doc);
|
||||
let gsapScript: string | null = null;
|
||||
|
||||
for (const script of scriptTags) {
|
||||
@@ -741,7 +741,7 @@ function stripGsapForId(script: string, elementId: string): string {
|
||||
}
|
||||
|
||||
function cascadeRemoveGsapById(doc: Document, elementId: string): void {
|
||||
for (const script of Array.from(doc.querySelectorAll("script"))) {
|
||||
for (const script of findScriptElementsDeep(doc)) {
|
||||
const text = script.textContent ?? "";
|
||||
if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue;
|
||||
const updated = stripGsapForId(text, elementId);
|
||||
@@ -842,13 +842,12 @@ export function validateCompositionHtml(html: string): ValidationResult {
|
||||
errors.push("javascript: URLs not allowed");
|
||||
}
|
||||
|
||||
const scripts = doc.querySelectorAll("script");
|
||||
const scripts = findScriptElementsDeep(doc);
|
||||
if (scripts.length > 2) {
|
||||
warnings.push("Multiple script tags detected - only GSAP CDN and main script expected");
|
||||
}
|
||||
|
||||
const gsapScript = extractGsapScript(doc);
|
||||
if (gsapScript) {
|
||||
for (const gsapScript of extractGsapScripts(doc)) {
|
||||
const gsapValidation = validateCompositionGsap(gsapScript);
|
||||
errors.push(...gsapValidation.errors);
|
||||
warnings.push(...gsapValidation.warnings);
|
||||
@@ -861,8 +860,17 @@ export function validateCompositionHtml(html: string): ValidationResult {
|
||||
};
|
||||
}
|
||||
|
||||
function extractGsapScript(doc: Document): string | null {
|
||||
const scripts = doc.querySelectorAll("script");
|
||||
function findScriptElementsDeep(doc: Document): Element[] {
|
||||
const scripts: Element[] = [];
|
||||
walkCompositionDescendants(doc, (el) => {
|
||||
if (el.tagName.toLowerCase() === "script") scripts.push(el);
|
||||
});
|
||||
return scripts;
|
||||
}
|
||||
|
||||
function extractGsapScripts(doc: Document): string[] {
|
||||
const scripts = findScriptElementsDeep(doc);
|
||||
const gsapScripts: string[] = [];
|
||||
for (const script of scripts) {
|
||||
const content = script.textContent || "";
|
||||
if (
|
||||
@@ -870,8 +878,8 @@ function extractGsapScript(doc: Document): string | null {
|
||||
content.includes(".set(") ||
|
||||
content.includes(".to(")
|
||||
) {
|
||||
return content;
|
||||
gsapScripts.push(content);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return gsapScripts;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acor
|
||||
import {
|
||||
findRoot,
|
||||
getElementStyles,
|
||||
getGsapScripts,
|
||||
getOwnText,
|
||||
isNewHostBoundary,
|
||||
querySelectorAllDeep,
|
||||
@@ -67,27 +68,38 @@ function parseLocatedCached(script: string): Array<{ id: string; selector: strin
|
||||
*/
|
||||
function buildAnimationIdMap(document: Document): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
const script = extractGsapScript(document);
|
||||
if (!script) return map;
|
||||
for (const { id, selector } of parseLocatedCached(script)) {
|
||||
if (!selector) continue;
|
||||
let matches: Element[] = [];
|
||||
try {
|
||||
matches = querySelectorAllDeep(document, selector);
|
||||
} catch {
|
||||
continue; // selector not valid for querySelectorAll — skip
|
||||
}
|
||||
for (const el of matches) {
|
||||
const hfId = el.getAttribute("data-hf-id");
|
||||
if (!hfId) continue;
|
||||
const list = map.get(hfId);
|
||||
if (list) list.push(id);
|
||||
else map.set(hfId, [id]);
|
||||
for (const script of getGsapScripts(document)) {
|
||||
for (const { id, selector } of parseLocatedCached(script)) {
|
||||
appendAnimationIdsForSelector(map, document, id, selector);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function appendAnimationIdsForSelector(
|
||||
map: Map<string, string[]>,
|
||||
document: Document,
|
||||
animationId: string,
|
||||
selector: string,
|
||||
): void {
|
||||
if (!selector) return;
|
||||
|
||||
let matches: Element[];
|
||||
try {
|
||||
matches = querySelectorAllDeep(document, selector);
|
||||
} catch {
|
||||
return; // selector not valid for querySelectorAll — skip
|
||||
}
|
||||
|
||||
for (const el of matches) {
|
||||
const hfId = el.getAttribute("data-hf-id");
|
||||
if (!hfId) continue;
|
||||
const list = map.get(hfId);
|
||||
if (list) list.push(animationId);
|
||||
else map.set(hfId, [animationId]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every GSAP tween id `parseLocatedCached` finds in the script, with no DOM
|
||||
* matching at all — the same id space the server-side script ops
|
||||
@@ -198,16 +210,7 @@ function buildElement(
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function extractGsapScript(doc: Document): string | null {
|
||||
// GSAP script is the first <script> tag whose text references gsap. Marker
|
||||
// set must match studio sdkShadow.ts isGsapScriptBody so both pick the same
|
||||
// script from a given composition.
|
||||
for (const script of Array.from(doc.querySelectorAll("script"))) {
|
||||
const text = script.textContent ?? "";
|
||||
if (text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger")) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return getGsapScripts(doc)[0] ?? null;
|
||||
}
|
||||
|
||||
function extractStyles(doc: Document): string | null {
|
||||
|
||||
@@ -6,7 +6,11 @@
|
||||
*/
|
||||
|
||||
import { parseHTML } from "linkedom";
|
||||
import { ensureHfIds, isCompositionTemplate } from "@hyperframes/core/hf-ids";
|
||||
import {
|
||||
ensureHfIds,
|
||||
isCompositionTemplate,
|
||||
walkCompositionDescendants,
|
||||
} from "@hyperframes/core/hf-ids";
|
||||
|
||||
export interface ParsedDocument {
|
||||
document: Document;
|
||||
@@ -372,12 +376,28 @@ export function setStyleSheet(document: Document, css: string): void {
|
||||
|
||||
// ─── GSAP script helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function findScriptElementsDeep(document: Document): Element[] {
|
||||
const scripts: Element[] = [];
|
||||
walkCompositionDescendants(document, (child) => {
|
||||
if (child.tagName.toLowerCase() === "script") scripts.push(child);
|
||||
});
|
||||
return scripts;
|
||||
}
|
||||
|
||||
function isGsapScriptText(text: string): boolean {
|
||||
return text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger");
|
||||
}
|
||||
|
||||
export function getGsapScripts(document: Document): string[] {
|
||||
return findScriptElementsDeep(document)
|
||||
.map((script) => script.textContent ?? "")
|
||||
.filter(isGsapScriptText);
|
||||
}
|
||||
|
||||
function findGsapScriptElement(document: Document): Element | null {
|
||||
const scripts = document.querySelectorAll("script");
|
||||
for (const script of Array.from(scripts)) {
|
||||
for (const script of findScriptElementsDeep(document)) {
|
||||
const text = script.textContent ?? "";
|
||||
if (text.includes("gsap") || text.includes("ScrollTrigger"))
|
||||
return script as unknown as Element;
|
||||
if (isGsapScriptText(text)) return script;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,22 @@ describe("template-based sub-comp compositions", () => {
|
||||
);
|
||||
expect(comp.getElement("hf-dup")?.text).toBe("tpl");
|
||||
});
|
||||
|
||||
it("models GSAP animations declared inside a composition template", async () => {
|
||||
const comp = await openComposition(`
|
||||
<template data-composition-id="document-card">
|
||||
<div data-hf-id="hf-line" class="line">line</div>
|
||||
<script>
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-line\\"]", { x: 100, duration: 1 }, 0);
|
||||
</script>
|
||||
</template>
|
||||
`);
|
||||
|
||||
const animationIds = comp.getElement("hf-line")?.animationIds ?? [];
|
||||
expect(animationIds).toHaveLength(1);
|
||||
expect(comp.getAllAnimationIds()).toEqual(new Set(animationIds));
|
||||
});
|
||||
});
|
||||
|
||||
// The authored sub-comp form `hyperframes add` scaffolds: the composition id is
|
||||
|
||||
@@ -45,6 +45,19 @@ window.__timelines = { t: tl };</script>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
const GSAP_TEMPLATE_LABEL_HTML = `
|
||||
<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<template data-composition-id="label-sub-comp">
|
||||
<div data-hf-id="hf-box" data-start="0" data-duration="5"></div>
|
||||
<script>
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.addLabel("template-label", 1.5);
|
||||
window.__timelines = { t: tl };
|
||||
</script>
|
||||
</template>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
// ─── getElementTimings — duration-authored clips ──────────────────────────────
|
||||
|
||||
describe("getElementTimings — duration-authored clips", () => {
|
||||
@@ -111,6 +124,13 @@ describe("getElementTimings — GSAP labels", () => {
|
||||
const after = comp.getElementTimings()["hf-box"]?.labels ?? [];
|
||||
expect(after).toContain("intro");
|
||||
});
|
||||
|
||||
it("reads labels from GSAP scripts inside composition templates", async () => {
|
||||
const comp = await openComposition(GSAP_TEMPLATE_LABEL_HTML);
|
||||
const labels = comp.getElementTimings()["hf-box"]?.labels ?? [];
|
||||
|
||||
expect(labels).toContain("template-label");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getElementTimings — relative data-start references ──────────────────────
|
||||
|
||||
+23
-16
@@ -35,7 +35,7 @@ import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
|
||||
import { parseMutable } from "./engine/model.js";
|
||||
import type { ParsedDocument } from "./engine/model.js";
|
||||
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
|
||||
import { getGsapScript, resolveScoped, declarationElement } from "./engine/model.js";
|
||||
import { getGsapScripts, resolveScoped, declarationElement } from "./engine/model.js";
|
||||
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
|
||||
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
|
||||
@@ -314,26 +314,30 @@ class CompositionImpl implements Composition {
|
||||
// ── WS-C: timing accessors + typed setHold ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cache of parsed GSAP labels keyed by EXACT script text. extractGsapLabels does
|
||||
* a full acorn parse; caching avoids re-parsing on repeated getElementTimings reads
|
||||
* when the script is unchanged. The content (not reference) key means any script
|
||||
* edit changes the text and invalidates the cache, so renumbered tweens never yield
|
||||
* stale label positions.
|
||||
* Cache of parsed GSAP labels keyed by the ordered list of EXACT script texts.
|
||||
* extractGsapLabels does a full acorn parse; caching avoids re-parsing on repeated
|
||||
* getElementTimings reads when the scripts are unchanged.
|
||||
*/
|
||||
private _gsapLabelCache: { script: string; labels: ReturnType<typeof extractGsapLabels> } | null =
|
||||
null;
|
||||
private _gsapLabelCache: {
|
||||
scripts: string[];
|
||||
labels: ReturnType<typeof extractGsapLabels>;
|
||||
} | null = null;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
getElementTimings(): Record<HfId, ElementTimingSnapshot> {
|
||||
const script = getGsapScript(this.parsed.document);
|
||||
const scripts = getGsapScripts(this.parsed.document);
|
||||
|
||||
// Extract all addLabel("name", position) calls from the GSAP script (see cache note above).
|
||||
// Extract all addLabel("name", position) calls from every GSAP script.
|
||||
let allLabels: ReturnType<typeof extractGsapLabels>;
|
||||
if (script && this._gsapLabelCache?.script === script) {
|
||||
allLabels = this._gsapLabelCache.labels;
|
||||
const cachedScripts = this._gsapLabelCache?.scripts;
|
||||
const cacheMatches =
|
||||
cachedScripts?.length === scripts.length &&
|
||||
cachedScripts.every((script, index) => script === scripts[index]);
|
||||
if (cacheMatches) {
|
||||
allLabels = this._gsapLabelCache?.labels ?? [];
|
||||
} else {
|
||||
allLabels = script ? extractGsapLabels(script) : [];
|
||||
this._gsapLabelCache = script ? { script, labels: allLabels } : null;
|
||||
allLabels = scripts.flatMap((script) => extractGsapLabels(script));
|
||||
this._gsapLabelCache = scripts.length ? { scripts: [...scripts], labels: allLabels } : null;
|
||||
}
|
||||
|
||||
// Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" —
|
||||
@@ -555,8 +559,11 @@ class CompositionImpl implements Composition {
|
||||
}
|
||||
|
||||
getAllAnimationIds(): Set<string> {
|
||||
const script = getGsapScript(this.parsed.document);
|
||||
return script ? parsedAnimationIds(script) : new Set();
|
||||
const ids = new Set<string>();
|
||||
for (const script of getGsapScripts(this.parsed.document)) {
|
||||
for (const id of parsedAnimationIds(script)) ids.add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ── Selection API ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user