mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): handle full HTML doc sub-compositions in preview (#885)
## Summary - **Root cause**: `buildSubCompositionHtml` assumed all sub-compositions used `<template>` wrappers. Full HTML document blocks (like `north-korea-locked-down` and `nyc-paris-flight`) were nested as-is inside `<body>`, producing invalid HTML with nested `<html>` and `<head>` elements - **Effect**: the composition's `<style>` tags ended up misplaced inside `<body>`, and `<img src="assets/...">` paths failed to resolve when combined with the injected `<base>` tag — resulting in missing map images in the Studio sub-composition preview - **Fix**: detect full HTML documents and properly extract head styles/scripts and body content into separate sections, producing valid HTML where CSS lands in `<head>` and relative asset paths resolve correctly ## Test plan - [x] New unit test: full HTML document composition produces clean output without nested `<html>` in `<body>` - [x] Existing test: `<template>`-wrapped compositions still rewrite `../` asset paths correctly - [x] Visual verification: captured sub-composition preview frames before/after fix — maps now render correctly for both blocks - [x] Manual: open a project with `north-korea-locked-down` or `nyc-paris-flight` as a sub-composition in Studio, click on the sub-comp in the timeline → map should be visible
This commit is contained in:
@@ -16,6 +16,91 @@ function makeTempProject(files: Record<string, string>): string {
|
||||
}
|
||||
|
||||
describe("buildSubCompositionHtml", () => {
|
||||
it("handles full HTML document compositions without nesting <html> in <body>", () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head><title>Host</title></head><body></body></html>`,
|
||||
"compositions/map-block.html": `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<link rel="stylesheet" href="../styles/theme.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
.map { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
#root { position: relative; width: 1920px; height: 1080px; overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" data-composition-id="map-block" data-width="1920" data-height="1080">
|
||||
<img class="map" src="assets/map.png" alt="" />
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["map-block"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
});
|
||||
|
||||
const html = buildSubCompositionHtml(
|
||||
dir,
|
||||
"compositions/map-block.html",
|
||||
"/api/runtime.js",
|
||||
"/api/projects/demo/preview/",
|
||||
);
|
||||
|
||||
expect(html).not.toBeNull();
|
||||
// Must not nest a full HTML document inside <body>
|
||||
const bodyStart = html!.indexOf("<body>");
|
||||
const afterBody = html!.slice(bodyStart);
|
||||
expect(afterBody).not.toContain("<html");
|
||||
expect(afterBody).not.toContain("<head>");
|
||||
// Composition styles must be in <head>, not lost
|
||||
expect(html).toContain(".map {");
|
||||
expect(html).toContain("#root {");
|
||||
// Image src preserved (no ../ rewrite needed for bare relative paths)
|
||||
expect(html).toContain('src="assets/map.png"');
|
||||
// Base tag for asset resolution
|
||||
expect(html).toContain('<base href="/api/projects/demo/preview/">');
|
||||
// GSAP from the composition's own <head> must be preserved
|
||||
expect(html).toContain("gsap@3.14.2");
|
||||
// Body script content preserved
|
||||
expect(html).toContain('__timelines["map-block"]');
|
||||
// <link> and <meta> from composition head must not be dropped
|
||||
expect(html).toContain('rel="stylesheet"');
|
||||
expect(html).toContain('href="styles/theme.css"');
|
||||
expect(html).toContain('name="viewport"');
|
||||
// <html lang="en"> attribute forwarded to the output
|
||||
expect(html).toContain('lang="en"');
|
||||
});
|
||||
|
||||
it("handles raw fragment compositions (no template, no full document)", () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head><title>Host</title></head><body></body></html>`,
|
||||
"compositions/card.html": `<div data-composition-id="card" data-width="400" data-height="300">
|
||||
<img src="../icon.svg" alt="" />
|
||||
<p>Hello</p>
|
||||
</div>`,
|
||||
});
|
||||
|
||||
const html = buildSubCompositionHtml(
|
||||
dir,
|
||||
"compositions/card.html",
|
||||
"/api/runtime.js",
|
||||
"/api/projects/demo/preview/",
|
||||
);
|
||||
|
||||
expect(html).not.toBeNull();
|
||||
expect(html).toContain('<base href="/api/projects/demo/preview/">');
|
||||
// ../icon.svg from compositions/ rewrites to icon.svg at project root
|
||||
expect(html).toContain('src="icon.svg"');
|
||||
expect(html).not.toContain('src="../icon.svg"');
|
||||
expect(html).toContain("<p>Hello</p>");
|
||||
});
|
||||
|
||||
it("rewrites sub-composition asset paths against the project root preview base", () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
|
||||
@@ -7,12 +7,108 @@ import {
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "../../compiler/rewriteSubCompPaths.js";
|
||||
|
||||
/**
|
||||
* Detect whether `html` is a full document (has `<html>`, `<head>`, or
|
||||
* `<!doctype`), as opposed to a `<template>`-wrapped fragment.
|
||||
* Anchored to start-of-string (ignoring leading whitespace) so stray
|
||||
* occurrences inside script/template content don't false-positive.
|
||||
*/
|
||||
function isFullHtmlDocument(html: string): boolean {
|
||||
return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite relative asset paths in a parsed DOM tree. Shared across all
|
||||
* three dispatch branches (template, full-doc, fragment) to avoid drift.
|
||||
*/
|
||||
function rewriteRelativePaths(root: ParentNode, compPath: string): void {
|
||||
rewriteAssetPaths(
|
||||
root.querySelectorAll("[src], [href]"),
|
||||
compPath,
|
||||
(el: Element, attr: string) => el.getAttribute(attr),
|
||||
(el: Element, attr: string, value: string) => el.setAttribute(attr, value),
|
||||
);
|
||||
rewriteInlineStyleAssetUrls(
|
||||
root.querySelectorAll("[style]"),
|
||||
compPath,
|
||||
(el: Element) => el.getAttribute("style"),
|
||||
(el: Element, value: string) => el.setAttribute("style", value),
|
||||
);
|
||||
for (const styleEl of root.querySelectorAll("style")) {
|
||||
styleEl.textContent = rewriteCssAssetUrls(styleEl.textContent || "", compPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a full HTML document and extract its head elements and body
|
||||
* content separately, so they can be reassembled into a clean standalone
|
||||
* page without nesting `<html>` inside `<body>`.
|
||||
*
|
||||
* Extracts the full innerHTML of `<head>` — this preserves `<style>`,
|
||||
* `<script>`, `<link>`, `<meta>`, and any other head-level tags the
|
||||
* composition declares. Dropping `<link rel="stylesheet">` or `<meta>`
|
||||
* would cause silent rendering failures for compositions that ship with
|
||||
* external CSS or viewport-dependent meta.
|
||||
*
|
||||
* `<html>` and `<body>` attributes (lang, class, data-*) are extracted
|
||||
* so callers can forward them to the assembled page.
|
||||
*/
|
||||
function extractFullDocumentParts(
|
||||
rawHtml: string,
|
||||
compPath: string,
|
||||
): {
|
||||
headContent: string;
|
||||
bodyContent: string;
|
||||
htmlAttrs: string;
|
||||
bodyAttrs: string;
|
||||
} {
|
||||
const { document: doc } = parseHTML(rawHtml);
|
||||
|
||||
const rewriteTargets = [doc.head, doc.body].filter(Boolean);
|
||||
for (const target of rewriteTargets) {
|
||||
rewriteRelativePaths(target, compPath);
|
||||
}
|
||||
|
||||
const headContent = doc.head?.innerHTML ?? "";
|
||||
const bodyContent = doc.body?.innerHTML ?? "";
|
||||
|
||||
const htmlEl = doc.documentElement;
|
||||
const htmlAttrs = extractElementAttrs(htmlEl);
|
||||
const bodyAttrs = doc.body ? extractElementAttrs(doc.body) : "";
|
||||
|
||||
return { headContent, bodyContent, htmlAttrs, bodyAttrs };
|
||||
}
|
||||
|
||||
function extractElementAttrs(el: Element): string {
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < el.attributes.length; i++) {
|
||||
const attr = el.attributes[i]!;
|
||||
if (attr.value === "") {
|
||||
parts.push(attr.name);
|
||||
} else {
|
||||
parts.push(`${attr.name}="${attr.value}"`);
|
||||
}
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a standalone HTML page for a sub-composition.
|
||||
*
|
||||
* Uses the project's own index.html `<head>` so all dependencies (GSAP, fonts,
|
||||
* Lottie, reset styles, runtime) are preserved — instead of building a minimal
|
||||
* page from scratch that would miss important scripts/styles.
|
||||
*
|
||||
* Three dispatch modes, tried in order:
|
||||
* 1. `<template>` wrapper → extract template content (existing compositions)
|
||||
* 2. Full HTML document → parse and extract head/body separately (registry blocks)
|
||||
* 3. Raw fragment → wrap in a minimal document
|
||||
*
|
||||
* For full-doc mode, the composition's own `<head>` content (styles, scripts,
|
||||
* links, meta) is appended AFTER the project's index.html head. When both
|
||||
* declare the same dependency (e.g. GSAP CDN), the composition's copy wins
|
||||
* by last-write-wins script execution order — this is intentional so the
|
||||
* composition can pin a specific version.
|
||||
*/
|
||||
export function buildSubCompositionHtml(
|
||||
projectDir: string,
|
||||
@@ -25,35 +121,34 @@ export function buildSubCompositionHtml(
|
||||
|
||||
const rawComp = readFileSync(compFile, "utf-8");
|
||||
|
||||
// Extract content from <template> wrapper (compositions are always templates)
|
||||
let compHeadContent = "";
|
||||
let rewrittenContent: string;
|
||||
let htmlAttrs = "";
|
||||
let bodyAttrs = "";
|
||||
|
||||
const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
|
||||
const content = templateMatch?.[1] ?? rawComp;
|
||||
const { document: contentDoc } = parseHTML(
|
||||
`<!DOCTYPE html><html><head></head><body>${content}</body></html>`,
|
||||
);
|
||||
|
||||
rewriteAssetPaths(
|
||||
contentDoc.querySelectorAll("[src], [href]"),
|
||||
compPath,
|
||||
(el: Element, attr: string) => el.getAttribute(attr),
|
||||
(el: Element, attr: string, value: string) => {
|
||||
el.setAttribute(attr, value);
|
||||
},
|
||||
);
|
||||
rewriteInlineStyleAssetUrls(
|
||||
contentDoc.querySelectorAll("[style]"),
|
||||
compPath,
|
||||
(el: Element) => el.getAttribute("style"),
|
||||
(el: Element, value: string) => {
|
||||
el.setAttribute("style", value);
|
||||
},
|
||||
);
|
||||
for (const styleEl of contentDoc.querySelectorAll("style")) {
|
||||
styleEl.textContent = rewriteCssAssetUrls(styleEl.textContent || "", compPath);
|
||||
if (templateMatch) {
|
||||
const content = templateMatch[1];
|
||||
const { document: contentDoc } = parseHTML(
|
||||
`<!DOCTYPE html><html><head></head><body>${content}</body></html>`,
|
||||
);
|
||||
rewriteRelativePaths(contentDoc, compPath);
|
||||
rewrittenContent = contentDoc.body.innerHTML || content!;
|
||||
} else if (isFullHtmlDocument(rawComp)) {
|
||||
const parts = extractFullDocumentParts(rawComp, compPath);
|
||||
compHeadContent = parts.headContent;
|
||||
rewrittenContent = parts.bodyContent;
|
||||
htmlAttrs = parts.htmlAttrs;
|
||||
bodyAttrs = parts.bodyAttrs;
|
||||
} else {
|
||||
const { document: contentDoc } = parseHTML(
|
||||
`<!DOCTYPE html><html><head></head><body>${rawComp}</body></html>`,
|
||||
);
|
||||
rewriteRelativePaths(contentDoc, compPath);
|
||||
rewrittenContent = contentDoc.body.innerHTML || rawComp;
|
||||
}
|
||||
|
||||
const rewrittenContent = contentDoc.body.innerHTML || content;
|
||||
|
||||
// Use the project's index.html <head> to preserve all dependencies
|
||||
const indexPath = join(projectDir, "index.html");
|
||||
let headContent = "";
|
||||
@@ -69,6 +164,11 @@ export function buildSubCompositionHtml(
|
||||
headContent = `<base href="${baseHref}">\n${headContent}`;
|
||||
}
|
||||
|
||||
// Append the sub-composition's own <head> content so its CSS, scripts,
|
||||
// links, and meta tags are preserved. Placed after the project head so
|
||||
// the composition's deps take precedence (last-write-wins for scripts).
|
||||
if (compHeadContent) headContent += `\n${compHeadContent}`;
|
||||
|
||||
// Ensure runtime is present (might differ from the one in index.html)
|
||||
if (
|
||||
!headContent.includes("hyperframe.runtime") &&
|
||||
@@ -82,12 +182,15 @@ export function buildSubCompositionHtml(
|
||||
headContent += `\n<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`;
|
||||
}
|
||||
|
||||
const htmlOpen = htmlAttrs ? `<html ${htmlAttrs}>` : "<html>";
|
||||
const bodyOpen = bodyAttrs ? `<body ${bodyAttrs}>` : "<body>";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
${htmlOpen}
|
||||
<head>
|
||||
${headContent}
|
||||
</head>
|
||||
<body>
|
||||
${bodyOpen}
|
||||
<script>window.__timelines=window.__timelines||{};</script>
|
||||
${rewrittenContent}
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user