` links both paths hoist into the host document. Stylesheets carry
+ * Links both paths hoist into the host document. Stylesheets carry
* webfonts a composition's CSS depends on; preconnects are their latency hint.
*/
const HOISTED_LINK_SELECTOR = 'link[rel="stylesheet"], link[rel="preconnect"]';
+export const EXTRACTED_COMPOSITION_ASSET_SELECTOR = `${STYLE_SELECTOR}, ${SCRIPT_SELECTOR}, ${HOISTED_LINK_SELECTOR}`;
+
/**
* The compiler's nesting cap, enforced against the ancestry chain rather than a
* counter so a wide tree is not penalised for a deep sibling.
@@ -131,7 +133,7 @@ export interface CompositionAssemblyPlan {
*/
scriptSources: TElement[];
- /** `` links to hoist into the host document. */
+ /** Head and content links to hoist into the host document. */
linkSources: TElement[];
/**
@@ -189,7 +191,10 @@ export function planCompositionAssembly(
...toArray(assetHead?.querySelectorAll(SCRIPT_SELECTOR)),
...toArray(contentNode.querySelectorAll(SCRIPT_SELECTOR)),
],
- linkSources: toArray(head?.querySelectorAll(HOISTED_LINK_SELECTOR)),
+ linkSources: [
+ ...toArray(head?.querySelectorAll(HOISTED_LINK_SELECTOR)),
+ ...toArray(contentNode.querySelectorAll(HOISTED_LINK_SELECTOR)),
+ ],
variableDefaultCarriers: [documentElement, innerRoot].filter(
(carrier): carrier is TElement => carrier != null,
),
diff --git a/packages/core/src/compiler/inlineSubCompositions.test.ts b/packages/core/src/compiler/inlineSubCompositions.test.ts
index a05e993fab..e89d0dee88 100644
--- a/packages/core/src/compiler/inlineSubCompositions.test.ts
+++ b/packages/core/src/compiler/inlineSubCompositions.test.ts
@@ -68,6 +68,26 @@ describe("inlineSubCompositions – #ID selector scoping divergence", () => {
expect(result.scripts).toHaveLength(0);
});
+ it("hoists stylesheet and preconnect links from a bare fragment", () => {
+ const document = makeHostDocument("intro");
+ const host = document.querySelector('[data-composition-src="intro.html"]')!;
+ const result = inlineSubCompositions(document, [host], {
+ resolveHtml: () => `
+
+
+
Hello
`,
+ parseHtml: (html) => parseHTML(html).document,
+ });
+
+ expect(result.externalLinks).toEqual([
+ { href: "theme.css", rel: "stylesheet", crossorigin: undefined },
+ { href: "https://fonts.example", rel: "preconnect", crossorigin: "" },
+ { href: "nested.css", rel: "stylesheet", crossorigin: undefined },
+ ]);
+ expect(host.querySelectorAll('link[rel="stylesheet"]')).toHaveLength(0);
+ expect(host.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
+ });
+
it("passes the failure reason through to onMissingComposition", () => {
const document = makeHostDocument("intro");
const host = document.querySelector('[data-composition-src="intro.html"]')!;
diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts
index 57d42ec5d9..c0fcadb64d 100644
--- a/packages/core/src/compiler/inlineSubCompositions.ts
+++ b/packages/core/src/compiler/inlineSubCompositions.ts
@@ -22,8 +22,15 @@ import {
wrapInlineScriptWithErrorBoundary,
wrapScopedCompositionScript,
} from "./compositionScoping";
-import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
-import { enumerateNestedCompositionHosts, planCompositionAssembly } from "./compositionAssembly";
+import {
+ checkSubCompositionUsability,
+ resolveSubCompositionContent,
+} from "@hyperframes/parsers/sub-composition-validity";
+import {
+ enumerateNestedCompositionHosts,
+ planCompositionAssembly,
+ EXTRACTED_COMPOSITION_ASSET_SELECTOR,
+} from "./compositionAssembly";
// ---------------------------------------------------------------------------
// Public interface
@@ -154,7 +161,7 @@ function defaultBuildScopeSelector(compId: string): string {
* Inline sub-compositions into a document. For each host element in `hosts`:
*
* 1. Resolve the sub-composition HTML via `options.resolveHtml`
- * 2. Parse it, find `` or `` content
+ * 2. Parse it, resolve template, body, or bare-fragment content
* 3. Find the inner `[data-composition-id]` root
* 4. Extract `
Hello
',
+ ])("accepts a bare fragment: %s", (html) => {
+ expect(checkSubCompositionUsability(html, parse)).toEqual({ ok: true });
+ });
+
+ it("keeps an empty template authoritative over body content", () => {
+ const html =
+ '
Hello
';
+ expect(checkSubCompositionUsability(html, parse).reason).toBe("no-content");
+ });
+
+ it("does not use head content when the body is empty", () => {
+ const html = '';
+ expect(checkSubCompositionUsability(html, parse).reason).toBe("no-content");
+ });
+
it("rejects an empty string without ever calling parseHtml (avoids the linkedom null-deref crash)", () => {
let parseCalled = false;
const spyParse = (html: string): ParsableDocumentLike => {
diff --git a/packages/parsers/src/subCompositionValidity.ts b/packages/parsers/src/subCompositionValidity.ts
index 2829bb8d5d..9f64eb1787 100644
--- a/packages/parsers/src/subCompositionValidity.ts
+++ b/packages/parsers/src/subCompositionValidity.ts
@@ -57,13 +57,26 @@ export interface ParsableDocumentLike {
querySelector(selector: string): { innerHTML?: string | null } | null;
}
+/** Resolve authored content without invoking linkedom's synthetic body getter. */
+export function resolveSubCompositionContent(html: string, document: ParsableDocumentLike) {
+ const template = document.querySelector("template");
+ if (template) return { contentHtml: template.innerHTML ?? "", hasTemplate: true };
+
+ const body = document.querySelector("body");
+ if (body) return { contentHtml: body.innerHTML ?? "", hasTemplate: false };
+
+ // Browsers create a body for bare fragments; linkedom keeps their top-level nodes.
+ const contentHtml = document.querySelector("html") ? "" : html;
+ return { contentHtml, hasTemplate: false };
+}
+
/**
* Check whether `html` (the raw file contents resolved for a
* `data-composition-src` reference) is non-empty and parses to a document
* that actually contains renderable content.
*
* Mirrors the content-detection steps in `inlineSubCompositions` exactly
- * (resolve → parse → find `` or `` content → parse that →
+ * (resolve → parse → resolve template, body, or fragment content → parse that →
* confirm a `[data-composition-id]` root exists in it), so a file that
* passes this check is guaranteed to produce non-empty output from the
* inliner, and a file that fails it is guaranteed to hit one of the
@@ -97,10 +110,7 @@ export function checkSubCompositionUsability(
};
}
- // Find content: prefer , fall back to — same precedence
- // inlineSubCompositions uses when extracting the sub-composition's markup.
- const contentRoot = compDoc.querySelector("template");
- const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
+ const { contentHtml } = resolveSubCompositionContent(html, compDoc);
if (!contentHtml.trim()) {
return {
ok: false,
@@ -114,7 +124,7 @@ export function checkSubCompositionUsability(
return {
ok: false,
reason: "unparsable",
- detail: "the file's / contents could not be parsed as HTML",
+ detail: "the file's resolved contents could not be parsed as HTML",
};
}
@@ -127,8 +137,7 @@ export function checkSubCompositionUsability(
return {
ok: false,
reason: "no-composition-root",
- detail:
- "the file's / content has no element with a data-composition-id attribute",
+ detail: "the file's resolved content has no element with a data-composition-id attribute",
};
}
diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts
index 95b49a8d56..4b20f64aff 100644
--- a/packages/producer/src/services/htmlCompiler.test.ts
+++ b/packages/producer/src/services/htmlCompiler.test.ts
@@ -827,6 +827,27 @@ describe("detectRenderModeHints", () => {
).rejects.toThrow(/compositions\/does-not-exist\.html/);
});
+ it("compileForRender preserves a bare fragment's markup, sibling styles, and script", async () => {
+ const projectDir = makeSubCompProject(
+ "hf-fragment-subcomp-",
+ [{ id: "intro", src: "compositions/intro.html" }],
+ {
+ "intro.html": `
+
Bare fragment
+`,
+ },
+ );
+ try {
+ const result = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
+ const { document } = parseHTML(result.html);
+ expect(document.querySelector(".fragment-title")?.textContent).toBe("Bare fragment");
+ expect(result.html).toContain("rgb(12, 34, 56)");
+ expect(result.html).toContain("window.__fragmentLoaded = true");
+ } finally {
+ rmSync(projectDir, { recursive: true, force: true });
+ }
+ });
+
it("compileForRender succeeds when the sub-composition file is valid (happy path)", async () => {
const projectDir = makeSubCompProject(
"hf-valid-subcomp-",
diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts
index e308eaf975..bf0c94492c 100644
--- a/packages/producer/src/services/htmlCompiler.ts
+++ b/packages/producer/src/services/htmlCompiler.ts
@@ -163,7 +163,7 @@ class EmptyCompositionError extends Error {
`${problems.length} composition file${problems.length === 1 ? "" : "s"} referenced by ` +
`data-composition-src cannot be rendered:\n${lines.join("\n")}\n\n` +
"Check that each file referenced by data-composition-src contains valid HTML with a " +
- " or containing a [data-composition-id] element. If a scene-authoring " +
+ "[data-composition-id] element in a , , or bare fragment. If a scene-authoring " +
"step is still running, wait for it to finish before referencing the file.",
);
this.name = "EmptyCompositionError";
diff --git a/packages/producer/tests/shard-schedule.json b/packages/producer/tests/shard-schedule.json
index 01a87918cb..d2674b3c66 100644
--- a/packages/producer/tests/shard-schedule.json
+++ b/packages/producer/tests/shard-schedule.json
@@ -54,6 +54,7 @@
"style-7-prod": 325,
"style-8-prod": 178,
"style-9-prod": 216,
+ "subcomp-bare-fragment": 5,
"sub-comp-id-selector": 10,
"sub-comp-t0": 18,
"sub-composition-video": 99,
diff --git a/packages/producer/tests/subcomp-bare-fragment/meta.json b/packages/producer/tests/subcomp-bare-fragment/meta.json
new file mode 100644
index 0000000000..3e235935f6
--- /dev/null
+++ b/packages/producer/tests/subcomp-bare-fragment/meta.json
@@ -0,0 +1,13 @@
+{
+ "name": "Bare fragment sub-composition",
+ "description": "A sub-composition without html, body, or template wrappers renders its root and sibling linked CSS, inline styles, and GSAP script. The moving square catches dropped markup, styles, or animation; raw-fragment rejection fails compilation.",
+ "tags": ["sub-composition", "regression"],
+ "minPsnr": 40,
+ "maxFrameFailures": 0,
+ "minAudioCorrelation": 0,
+ "maxAudioLagWindows": 1,
+ "renderConfig": {
+ "fps": 24,
+ "workers": 1
+ }
+}
diff --git a/packages/producer/tests/subcomp-bare-fragment/output/compiled.html b/packages/producer/tests/subcomp-bare-fragment/output/compiled.html
new file mode 100644
index 0000000000..c5ba22ad33
--- /dev/null
+++ b/packages/producer/tests/subcomp-bare-fragment/output/compiled.html
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bbe739daff8c969b5106ac0c95484556778604daf0d6f99ae1f99fd2fdf9d6ea
+size 88423
diff --git a/packages/producer/tests/subcomp-bare-fragment/output/output.mp4 b/packages/producer/tests/subcomp-bare-fragment/output/output.mp4
new file mode 100644
index 0000000000..a240793c37
--- /dev/null
+++ b/packages/producer/tests/subcomp-bare-fragment/output/output.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:21635cf3545ee9c36568cf2b68c31c23ae1c5d03b9b645b926b73ee9e4999eb2
+size 7693
diff --git a/packages/producer/tests/subcomp-bare-fragment/src/compositions/scene.css b/packages/producer/tests/subcomp-bare-fragment/src/compositions/scene.css
new file mode 100644
index 0000000000..3cf6998bff
--- /dev/null
+++ b/packages/producer/tests/subcomp-bare-fragment/src/compositions/scene.css
@@ -0,0 +1,3 @@
+.square {
+ background: #00d4aa;
+}
diff --git a/packages/producer/tests/subcomp-bare-fragment/src/compositions/scene.html b/packages/producer/tests/subcomp-bare-fragment/src/compositions/scene.html
new file mode 100644
index 0000000000..046e40e3f7
--- /dev/null
+++ b/packages/producer/tests/subcomp-bare-fragment/src/compositions/scene.html
@@ -0,0 +1,19 @@
+
+
+