feat(core): support inline template compositions (#146)

## Summary

- Adds `loadInlineTemplateCompositions` to the runtime to handle compositions defined inline via `<template id="X-template">` paired with empty host elements that have `data-composition-id="X"` but no `data-composition-src`
- Updates the HTML bundler to inline template content into matching hosts during compilation
- Users can now define sub-compositions inline instead of requiring separate files in `compositions/`

### Before

```html
<!-- This showed nothing — the template was inert and the host was empty -->
<template id="logo-reveal-template">
  <div data-composition-id="logo-reveal" data-width="1920" data-height="1080">
    <style>...</style>
    <script>/* animation */</script>
  </div>
</template>

<div data-composition-id="logo-reveal" data-start="0" data-duration="10"
     data-width="1920" data-height="1080"></div>
```

### After

The runtime detects the matching `<template>` and injects its content into the host element — styles hoisted to `<head>`, scripts executed, dimensions copied. Works in both preview and render.

## Test plan

- [x] 9 new unit tests for `loadInlineTemplateCompositions` (basic mount, no-op cases, style/script injection, dimensions)
- [x] 3 new unit tests for bundler inline template handling
- [x] All 384 existing tests pass
This commit is contained in:
Miguel Ángel
2026-03-31 03:46:23 +02:00
committed by GitHub
parent 1aca29a414
commit 6d54217e74
5 changed files with 460 additions and 13 deletions
@@ -97,4 +97,102 @@ describe("bundleToSingleHtml", () => {
).length;
expect(gsapOccurrences).toBe(1);
});
it("inlines <template> compositions into matching empty host elements", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
</head><body>
<template id="logo-reveal-template">
<div data-composition-id="logo-reveal" data-width="1920" data-height="1080">
<style>.logo { opacity: 0; }</style>
<div class="logo">Logo Here</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["logo-reveal"] = gsap.timeline({ paused: true });
</script>
</div>
</template>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="logo-host"
data-composition-id="logo-reveal"
data-start="0" data-duration="5"
data-track-index="1"></div>
</div>
<script>window.__timelines={}; const tl=gsap.timeline({paused:true}); window.__timelines["main"]=tl;</script>
</body></html>`,
});
const bundled = await bundleToSingleHtml(dir);
// Template element should be removed
expect(bundled).not.toContain("<template");
// Host should contain the template content (the logo div)
expect(bundled).toContain("Logo Here");
// Styles from template should be hoisted
expect(bundled).toContain(".logo");
// Scripts from template should be included
expect(bundled).toContain('window.__timelines["logo-reveal"]');
});
it("does not inline template when host already has content", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<template id="comp-template">
<div data-composition-id="comp" data-width="800" data-height="600">
<p>Template content</p>
</div>
</template>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div data-composition-id="comp" data-start="0" data-duration="5">
<span>Already filled</span>
</div>
</div>
<script>window.__timelines={};</script>
</body></html>`,
});
const bundled = await bundleToSingleHtml(dir);
// Existing content should be preserved
expect(bundled).toContain("Already filled");
// Template content should NOT replace the existing host content
// (template element may still exist in the output since it was not consumed)
const hostMatch = bundled.match(
/data-composition-id="comp"[^>]*data-start="0"[^>]*>([\s\S]*?)<\/div>/,
);
expect(hostMatch).toBeTruthy();
expect(hostMatch![1]).toContain("Already filled");
expect(hostMatch![1]).not.toContain("Template content");
});
it("copies dimension attributes from inline template to host", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<template id="sized-template">
<div data-composition-id="sized" data-width="800" data-height="600">
<p>Sized content</p>
</div>
</template>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div data-composition-id="sized" data-start="0" data-duration="3"></div>
</div>
<script>window.__timelines={};</script>
</body></html>`,
});
const bundled = await bundleToSingleHtml(dir);
// The host should have dimensions copied from the template inner root
expect(bundled).toContain('data-width="800"');
expect(bundled).toContain('data-height="600"');
expect(bundled).toContain("Sized content");
});
});
+77
View File
@@ -449,6 +449,83 @@ export async function bundleToSingleHtml(
$(hostEl).removeAttr("data-composition-src");
});
// Inline template compositions: inject <template id="X-template"> content into
// matching empty host elements with data-composition-id="X" (no data-composition-src)
$("template[id]").each((_, templateEl) => {
const templateId = $(templateEl).attr("id") || "";
const match = templateId.match(/^(.+)-template$/);
if (!match) return;
const compId = match[1];
// Find the matching host element (must have data-composition-id, no data-composition-src,
// and must NOT be inside a <template> element). In cheerio, elements inside <template>
// have a detached parent chain (parents().length === 0), so we filter those out.
const hostSelector = `[data-composition-id="${compId}"]:not([data-composition-src])`;
const $candidates = $(hostSelector).filter((__, el) => $(el).parents().length > 0);
const $host = $candidates.first();
if ($host.length === 0) return;
if ($host.children().length > 0) return; // already has content
// Get template content and inject into host
const templateHtml = $(templateEl).html() || "";
const $inner = cheerio.load(templateHtml, { xml: false });
const $innerRoot = $inner(`[data-composition-id="${compId}"]`).first();
if ($innerRoot.length > 0) {
// Hoist styles into the collected style chunks
$innerRoot.find("style").each((__, styleEl) => {
compStyleChunks.push($inner(styleEl).html() || "");
$inner(styleEl).remove();
});
// Hoist scripts into the collected script chunks
$innerRoot.find("script").each((__, scriptEl) => {
const externalSrc = ($inner(scriptEl).attr("src") || "").trim();
if (externalSrc) {
if (!compExternalScriptSrcs.includes(externalSrc)) {
compExternalScriptSrcs.push(externalSrc);
}
} else {
compScriptChunks.push(
`(function(){ try { ${$inner(scriptEl).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
$inner(scriptEl).remove();
});
// Copy dimension attributes from inner root to host if not already set
const innerW = $innerRoot.attr("data-width");
const innerH = $innerRoot.attr("data-height");
if (innerW && !$host.attr("data-width")) $host.attr("data-width", innerW);
if (innerH && !$host.attr("data-height")) $host.attr("data-height", innerH);
// Set host content from inner root
$host.html($innerRoot.html() || "");
} else {
// No matching inner root — inject all template content directly
$inner("style").each((__, styleEl) => {
compStyleChunks.push($inner(styleEl).html() || "");
$inner(styleEl).remove();
});
$inner("script").each((__, scriptEl) => {
const externalSrc = ($inner(scriptEl).attr("src") || "").trim();
if (externalSrc) {
if (!compExternalScriptSrcs.includes(externalSrc)) {
compExternalScriptSrcs.push(externalSrc);
}
} else {
compScriptChunks.push(
`(function(){ try { ${$inner(scriptEl).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
$inner(scriptEl).remove();
});
$host.html($inner.html() || "");
}
// Remove the template element from the document
$(templateEl).remove();
});
// Inject external scripts from sub-compositions (e.g., Lottie CDN)
// that aren't already present in the main document.
for (const extSrc of compExternalScriptSrcs) {