Files
hyperframes/packages/core/src/runtime/compositionLoader.test.ts
T
Miguel Ángel 6d54217e74 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
2026-03-31 03:46:23 +02:00

419 lines
14 KiB
TypeScript

import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
// jsdom doesn't provide CSS.escape
beforeAll(() => {
if (typeof globalThis.CSS === "undefined") {
(globalThis as any).CSS = {};
}
if (typeof CSS.escape !== "function") {
CSS.escape = (value: string) => value.replace(/([^\w-])/g, "\\$1");
}
});
describe("loadExternalCompositions", () => {
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("does nothing when no composition-src elements exist", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
await loadExternalCompositions({ ...defaultParams });
expect(fetchSpy).not.toHaveBeenCalled();
});
it("fetches and mounts external composition HTML", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/comp.html");
host.setAttribute("data-composition-id", "scene-1");
document.body.appendChild(host);
const compositionHtml = `
<html><body>
<div data-composition-id="scene-1" data-width="1920" data-height="1080">
<p>Hello World</p>
</div>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
await loadExternalCompositions({ ...defaultParams });
const mountedParagraph = host.querySelector("p");
expect(mountedParagraph).toBeTruthy();
expect(mountedParagraph?.textContent).toBe("Hello World");
});
it("injects styles into document head", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/comp.html");
document.body.appendChild(host);
const compositionHtml = `
<html><body>
<style>.test { color: red; }</style>
<p>Styled</p>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
const injectedStyles: HTMLStyleElement[] = [];
await loadExternalCompositions({
...defaultParams,
injectedStyles,
});
expect(injectedStyles.length).toBeGreaterThan(0);
});
it("calls onDiagnostic when fetch fails", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/broken.html");
host.setAttribute("data-composition-id", "broken");
document.body.appendChild(host);
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
const onDiagnostic = vi.fn();
await loadExternalCompositions({
...defaultParams,
onDiagnostic,
});
expect(onDiagnostic).toHaveBeenCalledWith(
expect.objectContaining({
code: "external_composition_load_failed",
details: expect.objectContaining({
hostCompositionSrc: "https://example.com/broken.html",
errorMessage: "Network error",
}),
}),
);
});
it("calls onDiagnostic when HTTP response is not ok", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/404.html");
document.body.appendChild(host);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 }));
const onDiagnostic = vi.fn();
await loadExternalCompositions({
...defaultParams,
onDiagnostic,
});
expect(onDiagnostic).toHaveBeenCalledWith(
expect.objectContaining({
code: "external_composition_load_failed",
}),
);
});
it("uses local template when available", async () => {
const template = document.createElement("template");
template.id = "local-comp-template";
template.innerHTML = "<p>From template</p>";
document.body.appendChild(template);
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/comp.html");
host.setAttribute("data-composition-id", "local-comp");
document.body.appendChild(host);
const fetchSpy = vi.spyOn(globalThis, "fetch");
await loadExternalCompositions({ ...defaultParams });
// Should use local template and not fetch
expect(fetchSpy).not.toHaveBeenCalled();
expect(host.querySelector("p")?.textContent).toBe("From template");
});
it("skips hosts without data-composition-src value", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "");
document.body.appendChild(host);
const fetchSpy = vi.spyOn(globalThis, "fetch");
await loadExternalCompositions({ ...defaultParams });
expect(fetchSpy).not.toHaveBeenCalled();
});
it("clears host content before mounting", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/comp.html");
host.innerHTML = "<span>Old content</span>";
document.body.appendChild(host);
const compositionHtml = `<html><body><p>New</p></body></html>`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
await loadExternalCompositions({ ...defaultParams });
expect(host.querySelector("span")).toBeNull();
});
it("handles inline scripts", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/comp.html");
document.body.appendChild(host);
// Only inline scripts (no external src) to avoid waitForExternalScriptLoad timeout
const compositionHtml = `
<html><body>
<script>console.log("inline")</script>
<p>With inline script</p>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
const injectedScripts: HTMLScriptElement[] = [];
await loadExternalCompositions({
...defaultParams,
injectedScripts,
});
expect(injectedScripts.length).toBeGreaterThan(0);
expect(injectedScripts[0].textContent).toContain("console.log");
});
it("handles multiple compositions in parallel", async () => {
const host1 = document.createElement("div");
host1.setAttribute("data-composition-src", "https://example.com/a.html");
document.body.appendChild(host1);
const host2 = document.createElement("div");
host2.setAttribute("data-composition-src", "https://example.com/b.html");
document.body.appendChild(host2);
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = typeof input === "string" ? input : (input as Request).url;
if (url.includes("a.html")) {
return new Response("<html><body><p>A</p></body></html>", { status: 200 });
}
return new Response("<html><body><p>B</p></body></html>", { status: 200 });
});
await loadExternalCompositions({ ...defaultParams });
expect(host1.querySelector("p")?.textContent).toBe("A");
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");
});
});