fix(cli): surface keyframes from scripts and styles inside <template> (#2374)

Sub-compositions are required to wrap markup, script, and style in <template>, but template content is an inert DocumentFragment that document-level querySelectorAll does not traverse — so 'hyperframes keyframes' silently surfaced zero tweens and zero CSS keyframes for every spec-conformant sub-composition. Extract from the document and every template content fragment (nested templates included, walked iteratively). Documents the extraction ordering contract and the deliberately document-root-only sub-composition discovery scan; pins template, multi-template, nested-template, and mixed-script cases with tests.
This commit is contained in:
Miguel Ángel
2026-07-13 20:47:50 -04:00
committed by GitHub
parent 9639824f4e
commit b231b6f963
2 changed files with 140 additions and 4 deletions
+112
View File
@@ -160,3 +160,115 @@ describe("keyframes runtime surfacing", () => {
expect(selectors).toEqual(expect.arrayContaining([".dot", ".chip"]));
});
});
describe("keyframes template-wrapped sub-compositions", () => {
// Sub-compositions are REQUIRED to put markup + script + style inside <template>.
// Template content is an inert DocumentFragment that document-level
// querySelectorAll does not traverse, so extraction must walk template.content
// too — otherwise every spec-conformant sub-composition surfaces zero motion.
const templateWrapped = `<template>
<style>
#hero { opacity: 0; }
@keyframes rise {
0% { transform: translateY(40px); }
100% { transform: translateY(0); }
}
</style>
<div id="root" data-composition-id="beat" data-duration="4">
<div id="hero" class="clip"></div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
tl.fromTo("#hero", { y: 34, opacity: 0 }, { y: 0, opacity: 1, duration: 0.75 }, 0.5);
window.__timelines = { beat: tl };
</script>
</template>`;
it("surfaces GSAP tweens from a script inside <template>", () => {
const { tweens } = surfaceComposition(templateWrapped, "beat.html", "beat.html");
expect(tweens).toHaveLength(1);
expect(tweens[0]!.target).toBe("#hero");
});
it("surfaces @keyframes from a style inside <template>", () => {
const { cssKeyframes } = surfaceComposition(templateWrapped, "beat.html", "beat.html");
expect(cssKeyframes.map((k) => k.name)).toContain("rise");
});
it("aggregates extraction across multiple <template> blocks in one document", () => {
// Pins the every-template-fragment walk: styles from BOTH templates must
// surface, and a script in a NON-FIRST template must surface. (Two GSAP
// timelines in one file is a separate parser limitation — the static parser
// follows a single __timelines registration — so scripts are split so that
// the tween lives in the second template.)
const twoTemplates = `<template>
<style>
@keyframes rise { 0% { opacity: 0; } 100% { opacity: 1; } }
</style>
<div id="a" class="clip"></div>
</template>
<template>
<style>
@keyframes spin { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } }
</style>
<div id="b" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#b", { y: 50, duration: 1 });
window.__timelines = { multi: tl };
</script>
</template>`;
const { tweens, cssKeyframes } = surfaceComposition(twoTemplates, "multi.html", "multi.html");
expect(cssKeyframes.map((k) => k.name)).toEqual(expect.arrayContaining(["rise", "spin"]));
expect(tweens.map((t) => t.target)).toContain("#b");
});
it("reaches a <template> nested inside another template's fragment", () => {
const nested = `<template>
<div id="outer" class="clip"></div>
<template>
<style>
@keyframes inner-spin { 0% { transform: rotate(0); } 100% { transform: rotate(360deg); } }
</style>
<div id="inner" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#inner", { rotation: 360, duration: 1 });
window.__timelines = { nested: tl };
</script>
</template>
</template>`;
const { tweens, cssKeyframes } = surfaceComposition(nested, "nested.html", "nested.html");
expect(tweens.map((t) => t.target)).toContain("#inner");
expect(cssKeyframes.map((k) => k.name)).toContain("inner-spin");
});
it("parses mixed top-level + template scripts (join is not source order)", () => {
// A top-level script precedes the template script in the joined text even
// though extraction order differs from source order — the join must still
// parse and the template timeline must still surface.
const mixed = `<!doctype html><html><body>
<script>const themeUtil = { accent: "#7c3aed" };</script>
<template>
<div id="hero" class="clip"></div>
<script>
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { x: 120, duration: 1 });
window.__timelines = { mixed: tl };
</script>
</template>
</body></html>`;
const { tweens } = surfaceComposition(mixed, "mixed.html", "mixed.html");
expect(tweens.map((t) => t.target)).toContain("#hero");
});
it("still surfaces top-level scripts outside any template", () => {
const topLevel = `<!doctype html><html><body><div id="dot" class="clip"></div><script>
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { x: 100, duration: 1 });
window.__timelines = [tl];
</script></body></html>`;
const { tweens } = surfaceComposition(topLevel, "index.html", "index.html");
expect(tweens.length).toBeGreaterThan(0);
});
});
+28 -4
View File
@@ -93,17 +93,37 @@ interface SurfacedComposition {
// ── GSAP extraction ──────────────────────────────────────────────────────────
function inlineScriptText(html: string): string {
// <template> content lives in an inert DocumentFragment that document-level
// querySelectorAll does not traverse — and sub-compositions are REQUIRED to wrap
// markup + script in <template>. Query the document and every template fragment
// (including templates nested inside another template's fragment, walked
// iteratively), or template-wrapped compositions surface zero tweens/keyframes.
// Ordering contract: document-level matches come first, then template contents
// in discovery order — NOT strict source order when a file mixes top-level and
// template scripts. Spec-conformant sub-compositions keep everything in one
// template, so mixed files only need to parse, not preserve interleaving.
function queryIncludingTemplates(html: string, selector: string): Element[] {
const doc = new DOMParser().parseFromString(html, "text/html");
return Array.from(doc.querySelectorAll("script"))
const roots: Array<{ querySelectorAll(s: string): Iterable<Element> }> = [doc];
const queue = Array.from(doc.querySelectorAll("template")) as HTMLTemplateElement[];
while (queue.length > 0) {
const content = queue.shift()!.content;
if (!content) continue;
roots.push(content);
queue.push(...(Array.from(content.querySelectorAll("template")) as HTMLTemplateElement[]));
}
return roots.flatMap((root) => Array.from(root.querySelectorAll(selector)));
}
function inlineScriptText(html: string): string {
return queryIncludingTemplates(html, "script")
.filter((s) => !s.getAttribute("src"))
.map((s) => s.textContent ?? "")
.join("\n");
}
function inlineStyleText(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
return Array.from(doc.querySelectorAll("style"))
return queryIncludingTemplates(html, "style")
.map((s) => s.textContent ?? "")
.join("\n");
}
@@ -560,6 +580,10 @@ function collectCompositions(indexPath: string): SurfacedComposition[] {
surfaceComposition(html, basename(indexPath), basename(indexPath)),
];
// Deliberately document-root only (NOT queryIncludingTemplates): host divs
// with [data-composition-src] live in the orchestrating index.html light
// tree, never inside <template>. Widening this scan would change discovery
// semantics, not fix a gap.
const doc = new DOMParser().parseFromString(html, "text/html");
for (const div of Array.from(doc.querySelectorAll("[data-composition-src]"))) {
const src = div.getAttribute("data-composition-src");