feat(core): support inline <template> compositions without data-composition-src

Adds loadInlineTemplateCompositions to the runtime to handle compositions
defined inline via <template id="X-template"> elements paired with empty
host elements that have data-composition-id="X" but no data-composition-src.

Also updates the HTML bundler to inline template content during compilation,
hoisting styles and scripts from the template into the appropriate locations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-03-31 02:08:54 +02:00
co-authored by Claude Opus 4.6
parent 0d51fb751c
commit 92f23f181e
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) {
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { loadExternalCompositions } from "./compositionLoader";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
// jsdom doesn't provide CSS.escape
beforeAll(() => {
@@ -212,3 +212,207 @@ describe("loadExternalCompositions", () => {
expect(host2.querySelector("p")?.textContent).toBe("B");
});
});
describe("loadInlineTemplateCompositions", () => {
afterEach(() => {
document.body.innerHTML = "";
document.head.querySelectorAll("style").forEach((s) => s.remove());
vi.restoreAllMocks();
});
const defaultParams = {
injectedStyles: [] as HTMLStyleElement[],
injectedScripts: [] as HTMLScriptElement[],
parseDimensionPx: (v: string | null) => (v ? `${v}px` : null),
};
it("mounts template content into matching empty host", async () => {
const template = document.createElement("template");
template.id = "logo-reveal-template";
template.innerHTML = `
<div data-composition-id="logo-reveal" data-width="1920" data-height="1080">
<p>Logo content</p>
</div>
`;
document.body.appendChild(template);
const host = document.createElement("div");
host.setAttribute("data-composition-id", "logo-reveal");
host.setAttribute("data-start", "0");
host.setAttribute("data-duration", "10");
document.body.appendChild(host);
await loadInlineTemplateCompositions({ ...defaultParams });
expect(host.querySelector("p")?.textContent).toBe("Logo content");
});
it("does nothing when no matching template exists", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-id", "no-template");
document.body.appendChild(host);
await loadInlineTemplateCompositions({ ...defaultParams });
// Host should remain empty
expect(host.children.length).toBe(0);
});
it("does nothing when no inline template hosts exist", async () => {
// Add a template with no matching host
const template = document.createElement("template");
template.id = "orphan-template";
template.innerHTML = "<p>Orphan</p>";
document.body.appendChild(template);
await loadInlineTemplateCompositions({ ...defaultParams });
// Nothing should change — no hosts match
expect(document.querySelector("p")).toBeNull();
});
it("skips hosts that already have content", async () => {
const template = document.createElement("template");
template.id = "filled-template";
template.innerHTML = `
<div data-composition-id="filled" data-width="800" data-height="600">
<p>Template content</p>
</div>
`;
document.body.appendChild(template);
const host = document.createElement("div");
host.setAttribute("data-composition-id", "filled");
host.innerHTML = "<span>Existing content</span>";
document.body.appendChild(host);
await loadInlineTemplateCompositions({ ...defaultParams });
// Original content should remain
expect(host.querySelector("span")?.textContent).toBe("Existing content");
expect(host.querySelector("p")).toBeNull();
});
it("skips hosts that have data-composition-src", async () => {
const template = document.createElement("template");
template.id = "external-template";
template.innerHTML = `
<div data-composition-id="external" data-width="800" data-height="600">
<p>Should not mount</p>
</div>
`;
document.body.appendChild(template);
const host = document.createElement("div");
host.setAttribute("data-composition-id", "external");
host.setAttribute("data-composition-src", "https://example.com/comp.html");
document.body.appendChild(host);
await loadInlineTemplateCompositions({ ...defaultParams });
// Host should not have template content (it has data-composition-src)
expect(host.querySelector("p")).toBeNull();
});
it("processes multiple inline templates", async () => {
const template1 = document.createElement("template");
template1.id = "comp-a-template";
template1.innerHTML = `
<div data-composition-id="comp-a" data-width="1920" data-height="1080">
<p>Content A</p>
</div>
`;
document.body.appendChild(template1);
const template2 = document.createElement("template");
template2.id = "comp-b-template";
template2.innerHTML = `
<div data-composition-id="comp-b" data-width="800" data-height="600">
<p>Content B</p>
</div>
`;
document.body.appendChild(template2);
const host1 = document.createElement("div");
host1.setAttribute("data-composition-id", "comp-a");
document.body.appendChild(host1);
const host2 = document.createElement("div");
host2.setAttribute("data-composition-id", "comp-b");
document.body.appendChild(host2);
await loadInlineTemplateCompositions({ ...defaultParams });
expect(host1.querySelector("p")?.textContent).toBe("Content A");
expect(host2.querySelector("p")?.textContent).toBe("Content B");
});
it("injects styles from template into document head", async () => {
const template = document.createElement("template");
template.id = "styled-comp-template";
template.innerHTML = `
<div data-composition-id="styled-comp" data-width="1920" data-height="1080">
<style>.test-inline { color: blue; }</style>
<p>Styled content</p>
</div>
`;
document.body.appendChild(template);
const host = document.createElement("div");
host.setAttribute("data-composition-id", "styled-comp");
document.body.appendChild(host);
const injectedStyles: HTMLStyleElement[] = [];
await loadInlineTemplateCompositions({
...defaultParams,
injectedStyles,
});
expect(injectedStyles.length).toBeGreaterThan(0);
});
it("injects scripts from template", async () => {
const template = document.createElement("template");
template.id = "scripted-comp-template";
template.innerHTML = `
<div data-composition-id="scripted-comp" data-width="1920" data-height="1080">
<p>Content with script</p>
<script>console.log("inline template script")</script>
</div>
`;
document.body.appendChild(template);
const host = document.createElement("div");
host.setAttribute("data-composition-id", "scripted-comp");
document.body.appendChild(host);
const injectedScripts: HTMLScriptElement[] = [];
await loadInlineTemplateCompositions({
...defaultParams,
injectedScripts,
});
expect(injectedScripts.length).toBeGreaterThan(0);
expect(injectedScripts[0].textContent).toContain("inline template script");
});
it("copies dimension attributes from template inner root to host", async () => {
const template = document.createElement("template");
template.id = "dim-comp-template";
template.innerHTML = `
<div data-composition-id="dim-comp" data-width="1920" data-height="1080">
<p>Dimensioned</p>
</div>
`;
document.body.appendChild(template);
const host = document.createElement("div");
host.setAttribute("data-composition-id", "dim-comp");
document.body.appendChild(host);
await loadInlineTemplateCompositions({ ...defaultParams });
expect(host.getAttribute("data-width")).toBe("1920");
expect(host.getAttribute("data-height")).toBe("1080");
});
});
@@ -191,6 +191,47 @@ async function mountCompositionContent(params: {
}
}
export async function loadInlineTemplateCompositions(
params: LoadExternalCompositionsParams,
): Promise<void> {
// Find all elements with data-composition-id but WITHOUT data-composition-src
// that are empty (no children) and have a matching <template id="[compId]-template">
const hosts = Array.from(
document.querySelectorAll<Element>("[data-composition-id]:not([data-composition-src])"),
).filter((host) => {
// Only process empty hosts (no meaningful content)
if (host.children.length > 0) return false;
const compId = host.getAttribute("data-composition-id");
if (!compId) return false;
// Check for matching template
return !!document.querySelector(`template#${CSS.escape(compId)}-template`);
});
if (hosts.length === 0) return;
for (const host of hosts) {
const compId = host.getAttribute("data-composition-id")!;
const template = document.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(compId)}-template`,
)!;
resetCompositionHost(host);
await mountCompositionContent({
host,
hostCompositionId: compId,
hostCompositionSrc: `template#${compId}-template`,
sourceNode: template.content,
hasTemplate: true,
fallbackBodyInnerHtml: "",
compositionUrl: null,
injectedStyles: params.injectedStyles,
injectedScripts: params.injectedScripts,
parseDimensionPx: params.parseDimensionPx,
onDiagnostic: params.onDiagnostic,
});
}
}
export async function loadExternalCompositions(
params: LoadExternalCompositionsParams,
): Promise<void> {
+39 -12
View File
@@ -11,7 +11,7 @@ import { createRuntimePlayer } from "./player";
import { createRuntimeState } from "./state";
import { collectRuntimeTimelinePayload } from "./timeline";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { loadExternalCompositions } from "./compositionLoader";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types";
import type { PlayerAPI } from "../core.types";
@@ -362,7 +362,25 @@ export function initSandboxRuntimeModular(): void {
});
return resolver.resolveDurationForElement(element);
};
let externalCompositionsReady = !document.querySelector("[data-composition-src]");
const hasExternalCompositions = !!document.querySelector("[data-composition-src]");
let hasInlineTemplateCompositions = false;
{
const candidates = document.querySelectorAll(
"[data-composition-id]:not([data-composition-src])",
);
for (const el of candidates) {
const cid = el.getAttribute("data-composition-id");
if (
cid &&
el.children.length === 0 &&
document.querySelector(`template#${CSS.escape(cid)}-template`)
) {
hasInlineTemplateCompositions = true;
break;
}
}
}
let externalCompositionsReady = !hasExternalCompositions && !hasInlineTemplateCompositions;
const getTimelineDurationSeconds = (timeline: RuntimeTimelineLike | null): number | null => {
if (!timeline || typeof timeline.duration !== "function") return null;
@@ -1272,11 +1290,17 @@ export function initSandboxRuntimeModular(): void {
};
if (!externalCompositionsReady) {
void loadExternalCompositions({
const compositionLoaderParams = {
injectedStyles: state.injectedCompStyles,
injectedScripts: state.injectedCompScripts,
parseDimensionPx,
onDiagnostic: ({ code, details }) => {
onDiagnostic: ({
code,
details,
}: {
code: string;
details: Record<string, string | number | boolean | null | string[]>;
}) => {
postRuntimeMessage({
source: "hf-preview",
type: "diagnostic",
@@ -1284,14 +1308,17 @@ export function initSandboxRuntimeModular(): void {
details,
});
},
}).finally(() => {
externalCompositionsReady = true;
runAdapters("discover", state.currentTime);
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
postTimeline();
postState(true);
});
};
void loadExternalCompositions(compositionLoaderParams)
.then(() => loadInlineTemplateCompositions(compositionLoaderParams))
.finally(() => {
externalCompositionsReady = true;
runAdapters("discover", state.currentTime);
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
postTimeline();
postState(true);
});
}
const picker = createPickerModule({