fix(sdk): align template script traversal

This commit is contained in:
Vance Ingalls
2026-07-10 17:36:09 -07:00
parent 46602d75f4
commit a61f7de8d7
7 changed files with 186 additions and 72 deletions
+36 -7
View File
@@ -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 * 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. * 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 { export function isCompositionTemplate(el: Element): boolean {
if (el.tagName.toLowerCase() !== "template") return false; if (el.tagName.toLowerCase() !== "template") return false;
if (el.getAttribute("data-composition-id") !== null) return true; 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; if (child.getAttribute("data-composition-id") !== null) return true;
} }
return false; 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 * Document-order walk of every element under `root`, descending into
* composition `<template>` subtrees — linkedom's querySelectorAll does not, so * composition `<template>` subtrees — linkedom's querySelectorAll does not, so
@@ -132,12 +166,7 @@ export function isCompositionTemplate(el: Element): boolean {
* skipped entirely (see isCompositionTemplate). * skipped entirely (see isCompositionTemplate).
*/ */
function walkElements(root: Element, visit: (el: Element) => void): void { function walkElements(root: Element, visit: (el: Element) => void): void {
for (const child of Array.from(root.children)) { walkCompositionDescendants(root, visit);
const isTemplate = child.tagName.toLowerCase() === "template";
if (isTemplate && !isCompositionTemplate(child)) continue;
visit(child);
walkElements(child, visit);
}
} }
export function ensureHfIds(html: string): string { export function ensureHfIds(html: string): string {
+55
View File
@@ -148,6 +148,22 @@ describe("parseHtml", () => {
expect(result.gsapScript).toContain('tl.to("#text1"'); 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", () => { it("extracts styles from style tags", () => {
const html = ` const html = `
<html> <html>
@@ -559,6 +575,26 @@ describe("removeElementFromHtml", () => {
expect(updated).not.toContain("x: 100"); expect(updated).not.toContain("x: 100");
expect(updated).not.toContain("x: 200"); 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", () => { describe("validateCompositionHtml", () => {
@@ -626,6 +662,25 @@ describe("validateCompositionHtml", () => {
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.errors).toContain("Inline event handlers (onclick, onload, etc.) not allowed"); 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", () => { describe("extractCompositionMetadata", () => {
+18 -10
View File
@@ -13,7 +13,7 @@ import type {
} from "./types.js"; } from "./types.js";
import { validateCompositionGsap } from "./gsapSerialize"; import { validateCompositionGsap } from "./gsapSerialize";
import { parseCompositionVariables } from "./compositionVariables.js"; import { parseCompositionVariables } from "./compositionVariables.js";
import { ensureHfIds } from "./hfIds.js"; import { ensureHfIds, walkCompositionDescendants } from "./hfIds.js";
import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js"; import { parseGsapScriptAcornForWrite } from "./gsapParserAcorn.js";
import { queryByAttr } from "./utils/cssSelector.js"; import { queryByAttr } from "./utils/cssSelector.js";
import { removeAnimationFromScript } from "./gsapWriterAcorn.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; let gsapScript: string | null = null;
for (const script of scriptTags) { for (const script of scriptTags) {
@@ -741,7 +741,7 @@ function stripGsapForId(script: string, elementId: string): string {
} }
function cascadeRemoveGsapById(doc: Document, elementId: string): void { 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 ?? ""; const text = script.textContent ?? "";
if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue; if (!text.includes("gsap") && !text.includes("ScrollTrigger")) continue;
const updated = stripGsapForId(text, elementId); const updated = stripGsapForId(text, elementId);
@@ -842,13 +842,12 @@ export function validateCompositionHtml(html: string): ValidationResult {
errors.push("javascript: URLs not allowed"); errors.push("javascript: URLs not allowed");
} }
const scripts = doc.querySelectorAll("script"); const scripts = findScriptElementsDeep(doc);
if (scripts.length > 2) { if (scripts.length > 2) {
warnings.push("Multiple script tags detected - only GSAP CDN and main script expected"); warnings.push("Multiple script tags detected - only GSAP CDN and main script expected");
} }
const gsapScript = extractGsapScript(doc); for (const gsapScript of extractGsapScripts(doc)) {
if (gsapScript) {
const gsapValidation = validateCompositionGsap(gsapScript); const gsapValidation = validateCompositionGsap(gsapScript);
errors.push(...gsapValidation.errors); errors.push(...gsapValidation.errors);
warnings.push(...gsapValidation.warnings); warnings.push(...gsapValidation.warnings);
@@ -861,8 +860,17 @@ export function validateCompositionHtml(html: string): ValidationResult {
}; };
} }
function extractGsapScript(doc: Document): string | null { function findScriptElementsDeep(doc: Document): Element[] {
const scripts = doc.querySelectorAll("script"); 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) { for (const script of scripts) {
const content = script.textContent || ""; const content = script.textContent || "";
if ( if (
@@ -870,8 +878,8 @@ function extractGsapScript(doc: Document): string | null {
content.includes(".set(") || content.includes(".set(") ||
content.includes(".to(") content.includes(".to(")
) { ) {
return content; gsapScripts.push(content);
} }
} }
return null; return gsapScripts;
} }
+19 -8
View File
@@ -70,23 +70,34 @@ function buildAnimationIdMap(document: Document): Map<string, string[]> {
const map = new Map<string, string[]>(); const map = new Map<string, string[]>();
for (const script of getGsapScripts(document)) { for (const script of getGsapScripts(document)) {
for (const { id, selector } of parseLocatedCached(script)) { for (const { id, selector } of parseLocatedCached(script)) {
if (!selector) continue; appendAnimationIdsForSelector(map, document, id, selector);
let matches: Element[] = []; }
}
return map;
}
function appendAnimationIdsForSelector(
map: Map<string, string[]>,
document: Document,
animationId: string,
selector: string,
): void {
if (!selector) return;
let matches: Element[];
try { try {
matches = querySelectorAllDeep(document, selector); matches = querySelectorAllDeep(document, selector);
} catch { } catch {
continue; // selector not valid for querySelectorAll — skip return; // selector not valid for querySelectorAll — skip
} }
for (const el of matches) { for (const el of matches) {
const hfId = el.getAttribute("data-hf-id"); const hfId = el.getAttribute("data-hf-id");
if (!hfId) continue; if (!hfId) continue;
const list = map.get(hfId); const list = map.get(hfId);
if (list) list.push(id); if (list) list.push(animationId);
else map.set(hfId, [id]); else map.set(hfId, [animationId]);
} }
}
}
return map;
} }
/** /**
+14 -22
View File
@@ -6,7 +6,11 @@
*/ */
import { parseHTML } from "linkedom"; import { parseHTML } from "linkedom";
import { ensureHfIds, isCompositionTemplate } from "@hyperframes/core/hf-ids"; import {
ensureHfIds,
isCompositionTemplate,
walkCompositionDescendants,
} from "@hyperframes/core/hf-ids";
export interface ParsedDocument { export interface ParsedDocument {
document: Document; document: Document;
@@ -374,38 +378,26 @@ export function setStyleSheet(document: Document, css: string): void {
function findScriptElementsDeep(document: Document): Element[] { function findScriptElementsDeep(document: Document): Element[] {
const scripts: Element[] = []; const scripts: Element[] = [];
const walk = (parent: Element): void => { walkCompositionDescendants(document, (child) => {
for (const child of Array.from(parent.children)) { if (child.tagName.toLowerCase() === "script") scripts.push(child);
const tag = child.tagName.toLowerCase(); });
if (tag === "script") {
scripts.push(child);
continue;
}
if (tag === "template") {
if (isCompositionTemplate(child)) walk(child);
continue;
}
walk(child);
}
};
if (document.documentElement) walk(document.documentElement);
return scripts; return scripts;
} }
function isGsapScriptText(text: string): boolean {
return text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger");
}
export function getGsapScripts(document: Document): string[] { export function getGsapScripts(document: Document): string[] {
return findScriptElementsDeep(document) return findScriptElementsDeep(document)
.map((script) => script.textContent ?? "") .map((script) => script.textContent ?? "")
.filter( .filter(isGsapScriptText);
(text) =>
text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger"),
);
} }
function findGsapScriptElement(document: Document): Element | null { function findGsapScriptElement(document: Document): Element | null {
for (const script of findScriptElementsDeep(document)) { for (const script of findScriptElementsDeep(document)) {
const text = script.textContent ?? ""; const text = script.textContent ?? "";
if (text.includes("gsap") || text.includes("__timelines") || text.includes("ScrollTrigger")) if (isGsapScriptText(text)) return script;
return script as unknown as Element;
} }
return null; return null;
} }
+20
View File
@@ -45,6 +45,19 @@ window.__timelines = { t: tl };</script>
</div> </div>
`.trim(); `.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 ────────────────────────────── // ─── getElementTimings — duration-authored clips ──────────────────────────────
describe("getElementTimings — duration-authored clips", () => { describe("getElementTimings — duration-authored clips", () => {
@@ -111,6 +124,13 @@ describe("getElementTimings — GSAP labels", () => {
const after = comp.getElementTimings()["hf-box"]?.labels ?? []; const after = comp.getElementTimings()["hf-box"]?.labels ?? [];
expect(after).toContain("intro"); 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 ────────────────────── // ─── getElementTimings — relative data-start references ──────────────────────
+18 -19
View File
@@ -35,12 +35,7 @@ import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
import { parseMutable } from "./engine/model.js"; import { parseMutable } from "./engine/model.js";
import type { ParsedDocument } from "./engine/model.js"; import type { ParsedDocument } from "./engine/model.js";
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js"; import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
import { import { getGsapScripts, resolveScoped, declarationElement } from "./engine/model.js";
getGsapScript,
getGsapScripts,
resolveScoped,
declarationElement,
} from "./engine/model.js";
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn"; import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document"; import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
import { parseStartExpression } from "@hyperframes/core/runtime/start-expression"; import { parseStartExpression } from "@hyperframes/core/runtime/start-expression";
@@ -319,26 +314,30 @@ class CompositionImpl implements Composition {
// ── WS-C: timing accessors + typed setHold ─────────────────────────────────── // ── WS-C: timing accessors + typed setHold ───────────────────────────────────
/** /**
* Cache of parsed GSAP labels keyed by EXACT script text. extractGsapLabels does * Cache of parsed GSAP labels keyed by the ordered list of EXACT script texts.
* a full acorn parse; caching avoids re-parsing on repeated getElementTimings reads * extractGsapLabels does a full acorn parse; caching avoids re-parsing on repeated
* when the script is unchanged. The content (not reference) key means any script * getElementTimings reads when the scripts are unchanged.
* edit changes the text and invalidates the cache, so renumbered tweens never yield
* stale label positions.
*/ */
private _gsapLabelCache: { script: string; labels: ReturnType<typeof extractGsapLabels> } | null = private _gsapLabelCache: {
null; scripts: string[];
labels: ReturnType<typeof extractGsapLabels>;
} | null = null;
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
getElementTimings(): Record<HfId, ElementTimingSnapshot> { 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>; let allLabels: ReturnType<typeof extractGsapLabels>;
if (script && this._gsapLabelCache?.script === script) { const cachedScripts = this._gsapLabelCache?.scripts;
allLabels = this._gsapLabelCache.labels; const cacheMatches =
cachedScripts?.length === scripts.length &&
cachedScripts.every((script, index) => script === scripts[index]);
if (cacheMatches) {
allLabels = this._gsapLabelCache?.labels ?? [];
} else { } else {
allLabels = script ? extractGsapLabels(script) : []; allLabels = scripts.flatMap((script) => extractGsapLabels(script));
this._gsapLabelCache = script ? { script, labels: allLabels } : null; this._gsapLabelCache = scripts.length ? { scripts: [...scripts], labels: allLabels } : null;
} }
// Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" — // Resolve a `data-start` that's a relative-timing REFERENCE ("intro", "intro + 2" —