mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(parsers): render bare fragment sub-compositions (#4335)
This commit is contained in:
@@ -29,11 +29,13 @@ const STYLE_SELECTOR = "style";
|
||||
const SCRIPT_SELECTOR = "script";
|
||||
|
||||
/**
|
||||
* `<head>` 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<TElement extends AssemblyAttributed> {
|
||||
*/
|
||||
scriptSources: TElement[];
|
||||
|
||||
/** `<head>` 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<TElement extends AssemblyAttributed>(
|
||||
...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,
|
||||
),
|
||||
|
||||
@@ -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: () => `<link rel="stylesheet" href="theme.css">
|
||||
<link rel="preconnect" href="https://fonts.example" crossorigin>
|
||||
<link rel="icon" href="favicon.ico">
|
||||
<div data-composition-id="intro"><link rel="stylesheet" href="nested.css"><link rel="icon" href="keep.ico"><div class="title">Hello</div></div>`,
|
||||
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"]')!;
|
||||
|
||||
@@ -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 `<template>` or `<body>` content
|
||||
* 2. Parse it, resolve template, body, or bare-fragment content
|
||||
* 3. Find the inner `[data-composition-id]` root
|
||||
* 4. Extract `<style>` elements, scope CSS, collect them
|
||||
* 5. Extract `<script>` elements, wrap inline scripts, collect them
|
||||
@@ -234,9 +241,7 @@ export function inlineSubCompositions(
|
||||
runtimeCompId = compId || "";
|
||||
}
|
||||
|
||||
// Find content: prefer <template>, fall back to <body>
|
||||
const contentRoot = compDoc.querySelector("template");
|
||||
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body?.innerHTML || "";
|
||||
const { contentHtml, hasTemplate } = resolveSubCompositionContent(compHtml, compDoc);
|
||||
if (!contentHtml.trim()) {
|
||||
onMissingComposition?.(src);
|
||||
continue;
|
||||
@@ -255,7 +260,7 @@ export function inlineSubCompositions(
|
||||
contentNode: contentDoc,
|
||||
head: compDoc.head,
|
||||
documentElement: compDoc.documentElement,
|
||||
hasTemplate: Boolean(contentRoot),
|
||||
hasTemplate,
|
||||
compositionId: compId,
|
||||
});
|
||||
const innerRoot = plan.innerRoot;
|
||||
@@ -308,7 +313,7 @@ export function inlineSubCompositions(
|
||||
|
||||
// <link> hoisting is unconditional. A templated sub-composition's webfont
|
||||
// link is as load-bearing as a non-templated one's, and the mount path has
|
||||
// always hoisted both; gating this on `!contentRoot` dropped a templated
|
||||
// always hoisted both; gating this on `!hasTemplate` dropped a templated
|
||||
// composition's font from the render while preview kept it.
|
||||
for (const link of plan.linkSources) {
|
||||
const href = resolveSubAssetPath(link.getAttribute("href"));
|
||||
@@ -408,7 +413,8 @@ export function inlineSubCompositions(
|
||||
// Inject content into the host element
|
||||
if (innerRoot) {
|
||||
innerRoot.setAttribute("data-composition-file", src);
|
||||
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
|
||||
for (const child of [...innerRoot.querySelectorAll(EXTRACTED_COMPOSITION_ASSET_SELECTOR)])
|
||||
child.remove();
|
||||
if (flattenInnerRoot) {
|
||||
const prepared = flattenInnerRoot(innerRoot);
|
||||
if (!compId && scopeCompId) {
|
||||
@@ -432,7 +438,8 @@ export function inlineSubCompositions(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
|
||||
for (const child of [...contentDoc.querySelectorAll(EXTRACTED_COMPOSITION_ASSET_SELECTOR)])
|
||||
child.remove();
|
||||
// linkedom fragment parsing: when content is `<div data-composition-id="X">...</div>`,
|
||||
// the div becomes documentElement and body is empty. Fall back to documentElement.outerHTML
|
||||
// to preserve the composition wrapper.
|
||||
|
||||
@@ -127,6 +127,28 @@ describe("loadExternalCompositions", () => {
|
||||
expect(host.querySelector("p")?.textContent).toBe("Scene");
|
||||
});
|
||||
|
||||
it.each(["scene", "different-host-id"])(
|
||||
"hoists fragment stylesheets once for %s",
|
||||
async (hostId) => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
|
||||
host.setAttribute("data-composition-id", hostId);
|
||||
document.body.appendChild(host);
|
||||
const html = `<div data-composition-id="scene">
|
||||
<link rel="stylesheet" href="scene.css"><link rel="icon" href="icon.svg"><p>Styled scene</p></div>`;
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(html, { status: 200 }));
|
||||
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
expect(
|
||||
document.head.querySelectorAll('link[href="https://example.com/compositions/scene.css"]'),
|
||||
).toHaveLength(1);
|
||||
expect(host.querySelectorAll('link[rel="stylesheet"]')).toHaveLength(0);
|
||||
expect(host.querySelectorAll('link[rel="icon"]')).toHaveLength(1);
|
||||
expect(host.querySelector("p")?.textContent).toBe("Styled scene");
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves head stylesheets when an external composition uses a template", async () => {
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { planCompositionAssembly } from "../compiler/compositionAssembly";
|
||||
import {
|
||||
planCompositionAssembly,
|
||||
EXTRACTED_COMPOSITION_ASSET_SELECTOR,
|
||||
} from "../compiler/compositionAssembly";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
||||
import { markFlattenedInnerRoot } from "./flattenedRoot";
|
||||
import {
|
||||
@@ -193,7 +196,7 @@ function resetCompositionHost(host: Element) {
|
||||
* inline-template path, and mutating it would leave a remount with no styles.
|
||||
*/
|
||||
function stripExtractedCompositionAssets(node: ParentNode): void {
|
||||
for (const el of Array.from(node.querySelectorAll("style, script"))) {
|
||||
for (const el of Array.from(node.querySelectorAll(EXTRACTED_COMPOSITION_ASSET_SELECTOR))) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
@@ -533,6 +536,7 @@ async function mountCompositionContent(params: {
|
||||
params.host.appendChild(mountedContent);
|
||||
} else {
|
||||
params.host.innerHTML = params.fallbackBodyInnerHtml;
|
||||
stripExtractedCompositionAssets(params.host);
|
||||
}
|
||||
|
||||
// Stash the per-instance variables BEFORE running scripts. The scoped
|
||||
|
||||
@@ -31,6 +31,24 @@ describe("checkSubCompositionUsability", () => {
|
||||
expect(checkSubCompositionUsability(VALID_HTML_NO_TEMPLATE, parse)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'<div data-composition-id="intro">Hello</div>',
|
||||
'<style>.title { color: red }</style><div data-composition-id="intro">Hello</div><script>window.sceneLoaded = true;</script>',
|
||||
])("accepts a bare fragment: %s", (html) => {
|
||||
expect(checkSubCompositionUsability(html, parse)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("keeps an empty template authoritative over body content", () => {
|
||||
const html =
|
||||
'<html><body><template></template><div data-composition-id="intro">Hello</div></body></html>';
|
||||
expect(checkSubCompositionUsability(html, parse).reason).toBe("no-content");
|
||||
});
|
||||
|
||||
it("does not use head content when the body is empty", () => {
|
||||
const html = '<html><head><meta data-composition-id="intro"></head><body></body></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 => {
|
||||
|
||||
@@ -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 `<template>` or `<body>` 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 <template>, fall back to <body> — 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 <template>/<body> 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 <template>/<body> 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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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": `<style>.fragment-title { color: rgb(12, 34, 56); }</style>
|
||||
<div data-composition-id="intro" data-width="100" data-height="100"><div class="fragment-title">Bare fragment</div></div>
|
||||
<script>window.__fragmentLoaded = true;</script>`,
|
||||
},
|
||||
);
|
||||
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-",
|
||||
|
||||
@@ -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 " +
|
||||
"<template> or <body> containing a [data-composition-id] element. If a scene-authoring " +
|
||||
"[data-composition-id] element in a <template>, <body>, or bare fragment. If a scene-authoring " +
|
||||
"step is still running, wait for it to finish before referencing the file.",
|
||||
);
|
||||
this.name = "EmptyCompositionError";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bbe739daff8c969b5106ac0c95484556778604daf0d6f99ae1f99fd2fdf9d6ea
|
||||
size 88423
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:21635cf3545ee9c36568cf2b68c31c23ae1c5d03b9b645b926b73ee9e4999eb2
|
||||
size 7693
|
||||
@@ -0,0 +1,3 @@
|
||||
.square {
|
||||
background: #00d4aa;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<link rel="stylesheet" href="scene.css" />
|
||||
<style>
|
||||
.square {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
</style>
|
||||
<div data-composition-id="scene" data-width="320" data-height="180" data-duration="2">
|
||||
<div class="square"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines.scene = gsap
|
||||
.timeline({ paused: true })
|
||||
.to(".square", { x: 160, duration: 2, ease: "none" });
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
background: #101820;
|
||||
}
|
||||
[data-composition-id="main"] {
|
||||
background: #101820;
|
||||
position: relative;
|
||||
width: 320px;
|
||||
height: 180px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.14.2/gsap.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div data-composition-id="main" data-width="320" data-height="180" data-duration="2">
|
||||
<div
|
||||
data-composition-id="scene"
|
||||
data-composition-src="compositions/scene.html"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
style="position: absolute; inset: 0"
|
||||
></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines.main = gsap.timeline({ paused: true }).to({}, { duration: 2 });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user