Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions packages/core/src/studio-api/helpers/subComposition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
157 changes: 130 additions & 27 deletions packages/core/src/studio-api/helpers/subComposition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = "";
Expand All @@ -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") &&
Expand All @@ -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>
Expand Down
Loading