mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user