import { describe, expect, it } from "vitest"; import { parseHTML } from "linkedom"; import { inlineSubCompositions } from "./inlineSubCompositions"; import { readDeclaredDefaults, parseHostVariableValues } from "../runtime/getVariables"; // Fixtures reference GSAP CDN but are never loaded in a real browser — resolveHtml is mocked. /** * Minimal sub-composition HTML that uses `#intro` as its CSS and GSAP scope. * This is the pattern that breaks when the producer path strips the inner root. */ const SUB_COMP_HTML = ``; function makeHostDocument(compId: string) { const { document } = parseHTML(`
`); return document; } describe("inlineSubCompositions – #ID selector scoping divergence", () => { it.each([ { label: "empty", html: "" }, { label: "whitespace-only", html: " \n \t " }, { label: "valid-parse-empty-body", html: "", }, // linkedom's parseHTML("just some text") returns documentElement === null. // Any code that then touches .head/.body (as linkedom's own internals do) // throws "Cannot destructure property 'firstElementChild' of // 'documentElement' as it is null" — the #1 raw crash in production // telemetry. Must be skipped gracefully, not crash. { label: "malformed non-HTML text", html: "just some plain text, no tags at all" }, ])("skips $label sub-composition files gracefully", ({ html }) => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; const missing: string[] = []; const result = inlineSubCompositions(document, [host], { resolveHtml: () => html, parseHtml: (h) => parseHTML(h).document, onMissingComposition: (src) => missing.push(src), }); expect(missing).toEqual(["intro.html"]); expect(result.styles).toHaveLength(0); expect(result.scripts).toHaveLength(0); }); it("passes the failure reason through to onMissingComposition", () => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; const reasons: Array = []; inlineSubCompositions(document, [host], { resolveHtml: () => "", parseHtml: (h) => parseHTML(h).document, onMissingComposition: (_src, reason) => reasons.push(reason), }); expect(reasons).toHaveLength(1); expect(reasons[0]).toContain("empty"); }); it("producer path (no flattenInnerRoot): strips inner root, losing #id attribute", () => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; const result = inlineSubCompositions(document, [host], { resolveHtml: () => SUB_COMP_HTML, parseHtml: (html) => parseHTML(html).document, }); // The producer path takes innerHTML when compId matches, stripping the // wrapper
. The host element should NOT contain a // child with id="intro" — the id attribute is lost. const innerRootById = host.querySelector("#intro"); expect(innerRootById).toBeNull(); // The host itself still has data-composition-id="intro" (from the // original markup), but no element inside has id="intro". expect(host.getAttribute("data-composition-id")).toBe("intro"); // CSS was scoped: #intro selectors should be rewritten to use // data-hf-authored-id attribute selector so they still resolve. const scopedCss = result.styles.join("\n"); expect(scopedCss).toContain('[data-hf-authored-id="intro"]'); expect(scopedCss).not.toContain("#intro"); }); it("producer path: scoped CSS rewrites #id selectors to [data-hf-authored-id] attribute", () => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; const result = inlineSubCompositions(document, [host], { resolveHtml: () => SUB_COMP_HTML, parseHtml: (html) => parseHTML(html).document, }); // The CSS scoper rewrites `#intro` to `[data-hf-authored-id="intro"]` // so that the selector resolves against the flattened structure. const scopedCss = result.styles.join("\n"); expect(scopedCss).toContain('[data-hf-authored-id="intro"]'); expect(scopedCss).toContain('[data-hf-authored-id="intro"] .title'); }); it("producer path: scoped scripts rewrite #intro selectors for GSAP targets", () => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; const result = inlineSubCompositions(document, [host], { resolveHtml: () => SUB_COMP_HTML, parseHtml: (html) => parseHTML(html).document, }); // The wrapped script should contain the authored root id normalization // logic so that runtime querySelector('#intro .title') maps to the // data-hf-authored-id attribute selector. const wrappedScript = result.scripts.join("\n"); expect(wrappedScript).toContain("__hfAuthoredRootId"); expect(wrappedScript).toContain('"intro"'); }); it("maps a template-local timeline id onto a differently named mount", () => { const document = makeHostDocument("captions-comp"); const host = document.querySelector('[data-composition-src="intro.html"]')!; const captionsHtml = ``; const result = inlineSubCompositions(document, [host], { resolveHtml: () => captionsHtml, parseHtml: (html) => parseHTML(html).document, }); expect(host.getAttribute("data-composition-id")).toBe("captions-comp"); expect(host.querySelector('[data-composition-id="captions"]')).not.toBeNull(); expect(result.styles.join("\n")).toContain('[data-composition-id="captions-comp"]'); const wrappedScript = result.scripts.join("\n"); expect(wrappedScript).toContain('var __hfCompId = "captions"'); expect(wrappedScript).toContain('var __hfTimelineCompId = "captions-comp"'); }); it("bundler path (with flattenInnerRoot): preserves inner root as a child element", () => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; // Simulate the bundler's flattenInnerRoot: clone the element, add // data-hf-authored-id, strip timing attrs (simplified here). function flattenInnerRoot(innerRoot: Element): Element { const clone = innerRoot.cloneNode(true) as Element; const authoredId = clone.getAttribute("id"); if (authoredId) { clone.setAttribute("data-hf-authored-id", authoredId); clone.removeAttribute("id"); } clone.removeAttribute("data-start"); clone.removeAttribute("data-duration"); return clone; } const result = inlineSubCompositions(document, [host], { resolveHtml: () => SUB_COMP_HTML, parseHtml: (html) => parseHTML(html).document, flattenInnerRoot, }); // With flattenInnerRoot, the inner root is preserved as a child of the // host via outerHTML. The data-hf-authored-id attribute is present. const authoredRoot = host.querySelector('[data-hf-authored-id="intro"]'); expect(authoredRoot).not.toBeNull(); // CSS is still rewritten to use the attribute selector. const scopedCss = result.styles.join("\n"); expect(scopedCss).toContain('[data-hf-authored-id="intro"]'); }); it("with flattenInnerRoot: restores data-composition-id on the wrapper for an anonymous host", () => { // Regression test: a host mounted via data-composition-src with no // data-composition-id of its own (an "anonymous" host). The composition // styles its own root box via the bare composition-id selector and a // script self-references it too — both need something in the render DOM // to actually carry that id once flattenInnerRoot strips it from the // wrapper by default. const { document } = parseHTML(`
`); const host = document.querySelector('[data-composition-src="scoped-text.html"]')!; const scopedTextHtml = ``; function flattenInnerRoot(innerRoot: Element): Element { const clone = innerRoot.cloneNode(true) as Element; clone.removeAttribute("data-composition-id"); clone.removeAttribute("data-start"); clone.removeAttribute("data-duration"); clone.setAttribute("data-hf-inner-root", "true"); return clone; } const result = inlineSubCompositions(document, [host], { resolveHtml: () => scopedTextHtml, parseHtml: (html) => parseHTML(html).document, flattenInnerRoot, }); const wrapper = host.querySelector("[data-hf-inner-root]"); expect(wrapper?.getAttribute("data-composition-id")).toBe("scoped-text"); const scopedCss = result.styles.join("\n"); expect(scopedCss).toContain("display: flex"); }); it("extracts elements from sub-composition with original rel and crossorigin", () => { const subCompWithLinks = `
Hello
`; const document = makeHostDocument("captions"); const host = document.querySelector('[data-composition-src="intro.html"]')!; const result = inlineSubCompositions(document, [host], { resolveHtml: () => subCompWithLinks, parseHtml: (html) => parseHTML(html).document, }); expect(result.externalLinks).toHaveLength(3); expect(result.externalLinks[0]).toEqual({ href: "https://fonts.googleapis.com", rel: "preconnect", crossorigin: undefined, }); expect(result.externalLinks[1]).toEqual({ href: "https://fonts.gstatic.com", rel: "preconnect", crossorigin: "", }); expect(result.externalLinks[2]).toEqual({ href: "https://fonts.googleapis.com/css2?family=Montserrat:wght@800&display=swap", rel: "stylesheet", crossorigin: undefined, }); }); it("collects an inline script instead of discarding it", () => { // The loop had a `src` branch and no else, so an inline // script was silently dropped on render while the mount path executed it. const subCompWithHeadScript = `
Hi
`; const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; const result = inlineSubCompositions(document, [host], { resolveHtml: () => subCompWithHeadScript, parseHtml: (html) => parseHTML(html).document, }); expect(result.scripts.join("\n")).toContain("window.__headScriptRan = true;"); expect(result.scriptItems).toContainEqual({ kind: "inline", content: expect.stringContaining("window.__headScriptRan = true;"), }); }); it("hoists a from a TEMPLATED sub-composition's head", () => { // Hoisting used to be gated on the composition being non-templated, so a // templated composition's webfont link was kept in preview (the mount path // hoists unconditionally) and dropped from the render. const templatedSubCompWithLink = ` `; const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; const result = inlineSubCompositions(document, [host], { resolveHtml: () => templatedSubCompWithLink, parseHtml: (html) => parseHTML(html).document, }); expect(result.externalLinks).toEqual([ { href: "https://fonts.googleapis.com/css2?family=Montserrat", rel: "stylesheet", crossorigin: undefined, }, ]); }); it("deduplicates link hrefs across multiple sub-compositions", () => { const subComp = `
A
`; const { document } = parseHTML(`
`); const hosts = Array.from(document.querySelectorAll("[data-composition-src]")); const result = inlineSubCompositions(document, hosts, { resolveHtml: () => subComp, parseHtml: (html) => parseHTML(html).document, }); expect(result.externalLinks).toHaveLength(1); expect(result.externalLinks[0]!.href).toBe( "https://fonts.googleapis.com/css2?family=Montserrat:wght@800", ); }); it("propagates data-timeline-locked from inner root to host element", () => { const lockedSubComp = `
Hello
`; const document = makeHostDocument("captions"); const host = document.querySelector('[data-composition-src="intro.html"]')!; inlineSubCompositions(document, [host], { resolveHtml: () => lockedSubComp, parseHtml: (html) => parseHTML(html).document, }); expect(host.hasAttribute("data-timeline-locked")).toBe(true); }); it("producer path propagates data-hf-authored-id to host when inner root has id", () => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; inlineSubCompositions(document, [host], { resolveHtml: () => SUB_COMP_HTML, parseHtml: (html) => parseHTML(html).document, }); // The inner root's id="intro" is stripped (innerHTML), but the producer // now propagates it as data-hf-authored-id on the host element so that // rewritten #ID selectors ([data-hf-authored-id="intro"]) resolve. expect(host.getAttribute("data-hf-authored-id")).toBe("intro"); // The original #intro element is still gone — innerHTML stripped it. const introById = host.querySelector("#intro"); expect(introById).toBeNull(); expect(host.getAttribute("data-composition-id")).toBe("intro"); }); it("producer path: scoped CSS matches host element when both attributes coexist", () => { const document = makeHostDocument("intro"); const host = document.querySelector('[data-composition-src="intro.html"]')!; const result = inlineSubCompositions(document, [host], { resolveHtml: () => SUB_COMP_HTML, parseHtml: (html) => parseHTML(html).document, compoundAuthoredRoot: true, }); // After inlining, the host has both data-composition-id and data-hf-authored-id. // CSS selectors targeting the root must be compound (no space) so they match // when both attributes are on the same element. expect(host.getAttribute("data-composition-id")).toBe("intro"); expect(host.getAttribute("data-hf-authored-id")).toBe("intro"); const scopedCss = result.styles.join("\n"); // Root-only selector: must be compound expect(scopedCss).toMatch(/\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]/); // Must NOT have a descendant combinator between the two attribute selectors expect(scopedCss).not.toMatch( /\[data-composition-id="intro"\]\s+\[data-hf-authored-id="intro"\]\s*\{/, ); // Descendant selector: compound root + space + child expect(scopedCss).toMatch( /\[data-composition-id="intro"\]\[data-hf-authored-id="intro"\]\s+\.title/, ); }); }); describe("inlineSubCompositions – variable defaults on a template sub-comp root div", () => { const SUB_COMP_WITH_VAR = ``; function hostDoc() { const { document } = parseHTML(`
`); return document; } it("aggregates defaults declared on the inner root div (template comps have no to hold them)", () => { const document = hostDoc(); const host = document.querySelector('[data-composition-src="card.html"]')!; const result = inlineSubCompositions(document, [host], { resolveHtml: () => SUB_COMP_WITH_VAR, parseHtml: (h) => parseHTML(h).document, readVariableDefaults: readDeclaredDefaults, parseHostVariables: parseHostVariableValues, }); expect(result.variablesByComp["card"]).toMatchObject({ headline: "Hi there" }); }); it("lets a per-instance host value override the declared default", () => { const document = hostDoc(); const host = document.querySelector('[data-composition-src="card.html"]')!; host.setAttribute("data-variable-values", JSON.stringify({ headline: "Overridden" })); const result = inlineSubCompositions(document, [host], { resolveHtml: () => SUB_COMP_WITH_VAR, parseHtml: (h) => parseHTML(h).document, readVariableDefaults: readDeclaredDefaults, parseHostVariables: parseHostVariableValues, }); expect(result.variablesByComp["card"]).toMatchObject({ headline: "Overridden" }); }); }); describe("inlineSubCompositions – recursive host discovery", () => { const MAX_NESTING_DEPTH = 20; function compositionHtml(label: string, nestedSrcs: string[] = []) { const nestedHosts = nestedSrcs .map( (src, index) => `
`, ) .join(""); return ``; } function inlineFixture(rootHosts: string[], compositions: Record) { const { document } = parseHTML(`
root
${rootHosts .map( (src, index) => `
`, ) .join("")} `); const missing: Array<{ src: string; reason?: string }> = []; inlineSubCompositions( document, Array.from(document.querySelectorAll("[data-composition-src]")), { resolveHtml: (src) => compositions[src] ?? null, parseHtml: (html) => parseHTML(html).document, onMissingComposition: (src, reason) => missing.push({ src, reason }), }, ); return { document, missing }; } it("inlines a three-level root -> A -> B chain", () => { const { document, missing } = inlineFixture(["a.html"], { "a.html": compositionHtml("A", ["b.html"]), "b.html": compositionHtml("B"), }); expect(document.querySelector('[data-level="root"]')?.textContent).toBe("root"); expect(document.querySelector('[data-level="A"]')?.textContent).toBe("A"); expect(document.querySelector('[data-level="B"]')?.textContent).toBe("B"); expect(missing).toEqual([]); }); it("inlines a four-level root -> A -> B -> C chain", () => { const { document, missing } = inlineFixture(["a.html"], { "a.html": compositionHtml("A", ["b.html"]), "b.html": compositionHtml("B", ["c.html"]), "c.html": compositionHtml("C"), }); expect( Array.from(document.querySelectorAll("[data-level]")).map((element) => element.textContent), ).toEqual(["root", "A", "B", "C"]); expect(missing).toEqual([]); }); it("reports and leaves a direct circular reference uninlined", () => { const { document, missing } = inlineFixture(["a.html"], { "a.html": compositionHtml("A", ["a.html"]), }); expect(document.querySelectorAll('[data-level="A"]')).toHaveLength(1); expect(document.querySelector('[data-composition-src="a.html"]')).not.toBeNull(); expect(missing).toEqual([{ src: "a.html", reason: "circular composition reference" }]); }); it("reports and leaves an indirect circular reference uninlined", () => { const { document, missing } = inlineFixture(["a.html"], { "a.html": compositionHtml("A", ["b.html"]), "b.html": compositionHtml("B", ["a.html"]), }); expect(document.querySelectorAll('[data-level="A"]')).toHaveLength(1); expect(document.querySelectorAll('[data-level="B"]')).toHaveLength(1); expect(document.querySelector('[data-composition-src="a.html"]')).not.toBeNull(); expect(missing).toEqual([{ src: "a.html", reason: "circular composition reference" }]); }); it("inlines the same sub-composition in sibling branches", () => { const { document, missing } = inlineFixture(["a.html"], { "a.html": compositionHtml("A", ["shared.html", "shared.html"]), "shared.html": compositionHtml("shared"), }); expect(document.querySelectorAll('[data-level="shared"]')).toHaveLength(2); expect(missing).toEqual([]); }); it("allows the depth ceiling and stops only the branch one level beyond it", () => { const compositions: Record = { "sibling.html": compositionHtml("sibling"), }; for (const prefix of ["exact", "overflow"]) { const length = prefix === "exact" ? MAX_NESTING_DEPTH : MAX_NESTING_DEPTH + 1; for (let level = 1; level <= length; level += 1) { const nextSrc = level < length ? [`${prefix}-${level + 1}.html`] : []; compositions[`${prefix}-${level}.html`] = compositionHtml(`${prefix}-${level}`, nextSrc); } } const { document, missing } = inlineFixture( ["exact-1.html", "overflow-1.html", "sibling.html"], compositions, ); expect(document.querySelector(`[data-level="exact-${MAX_NESTING_DEPTH}"]`)).not.toBeNull(); expect(document.querySelector(`[data-level="overflow-${MAX_NESTING_DEPTH}"]`)).not.toBeNull(); expect(document.querySelector(`[data-level="overflow-${MAX_NESTING_DEPTH + 1}"]`)).toBeNull(); expect(document.querySelector('[data-level="sibling"]')).not.toBeNull(); expect(missing).toEqual([ { src: `overflow-${MAX_NESTING_DEPTH + 1}.html`, reason: "nesting depth exceeded", }, ]); }); }); describe("inlineSubCompositions – sub-composition asset paths", () => { // Every asset ref a sub-composition in a subdirectory carries has to be // re-pointed when its content moves into the project-root document. Hoisted // /
`; const PROJECT_FILES = [ "design/styleframes/_shared.css", "design/styleframes/frame.png", "design/styleframes/helper.js", "design/shared/theme.css", ]; function inlineFrame() { const { document } = parseHTML(`
`); const host = document.querySelector("[data-composition-src]")!; const result = inlineSubCompositions(document, [host], { resolveHtml: () => SUB_COMP, parseHtml: (html) => parseHTML(html).document, rewriteInlineStyles: true, assetExists: (path: string) => PROJECT_FILES.includes(path), }); return { document, result }; } it("rewrites hoisted hrefs against the sub-composition dir", () => { const { result } = inlineFrame(); const hrefs = result.externalLinks.map((l) => l.href); expect(hrefs).toContain("design/styleframes/_shared.css"); expect(hrefs).toContain("design/shared/theme.css"); expect(hrefs).not.toContain("_shared.css"); expect(hrefs).not.toContain("../shared/theme.css"); }); it("rewrites hoisted external script srcs", () => { const { result } = inlineFrame(); expect(result.externalScriptSrcs).toContain("design/styleframes/helper.js"); }); it("rewrites sibling refs in markup, hoisted CSS, and inline styles", () => { const { document, result } = inlineFrame(); expect(document.querySelector("img")?.getAttribute("src")).toBe("design/styleframes/frame.png"); expect(result.styles.join("\n")).toContain("design/styleframes/frame.png"); expect(document.querySelector("[style]")?.getAttribute("style")).toContain( "design/styleframes/frame.png", ); }); });