mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
fix(studio): resolve sibling asset paths in sub-composition previews (#2983)
Fixes #2956 ## What A composition in a subdirectory that references a **sibling** file (`<link rel="stylesheet" href="_shared.css">`, not `../_shared.css`) is now resolved against the composition's own directory when building the standalone sub-composition preview page. ## Why The preview page borrows the project-root `<base href="/api/projects/:id/preview/">`, but the path rewriter only rewrote `../`-prefixed paths. So `design/styleframes/frame-01.html` referencing `_shared.css` was served unrewritten and the browser requested `/preview/_shared.css` → **404**. With its stylesheet missing, the frame renders unstyled: `body` has no background, and the thumbnail generator's transparent-body fallback paints it `#1c2028`. Result: dark navy thumbnail with unreadable dark text on every styleframe in the Board view. The report attributed this to project scale (~21 sibling files). It is not scale-related: a 2-file project reproduces identically, and the same file moved to the project root renders correctly. The trigger is **composition-in-a-subdirectory + relative sibling asset ref**. Reproduced before the fix (single `curl` against the thumbnail endpoint, plus a direct headless capture of the preview URL): ``` HTTP 404 http://localhost:5190/api/projects/big/preview/_shared.css body bg: rgb(28, 32, 40) ``` ## How `resolvePreviewAssetPath` in `packages/studio-server/src/helpers/subComposition.ts`, applied through the single rewrite pass all three dispatch branches (template / full-doc / fragment) already share, so `src`, `href`, inline `style` urls, and `<style>` blocks all get the same rule: 1. `../` paths keep resolving against the composition dir (unchanged, shared with the producer's inliner so preview and render agree). 2. Any other relative path is re-pointed at the composition's directory **only when that sibling file exists on disk**. The disk check is what keeps the two conventions apart: registry blocks are installed into a subdirectory but reference project-root assets (`assets/logo.png`), which are already correct under the root base and have no sibling on disk, so they are left untouched. Not changed: the `#1c2028` transparent-body fallback in the thumbnail generator. It is correct for genuinely transparent compositions; the illegibility was a downstream symptom of the 404. ## Test plan - [x] Unit tests added/updated — two tests in `subComposition.test.ts`: a red-first regression guard for the sibling `<link>` / `<img>` / `url()` case, and a guard that project-root-relative refs with no sibling on disk stay untouched. - [x] Manual testing performed — reproduced the dark thumbnail on a generated project (21-file and 2-file variants both reproduce), then confirmed the same URL renders the white-to-lavender gradient with legible text after the fix, with no 404 in the network log. - [x] `packages/studio-server` suite green: 29 files / 402 tests. Lint, format, typecheck clean. - [ ] Documentation updated — n/a
This commit is contained in:
@@ -359,4 +359,60 @@ describe("buildSubCompositionHtml", () => {
|
||||
// The <style> sibling was not tagged.
|
||||
expect(html).not.toMatch(/<style[^>]*data-composition-id/i);
|
||||
});
|
||||
|
||||
// Regression guard for the dark/illegible styleframe thumbnail: a composition
|
||||
// in a subdirectory that references a SIBLING file (`_shared.css`, not
|
||||
// `../_shared.css`) used to be served unrewritten under the project-root
|
||||
// <base>, so the browser requested /preview/_shared.css → 404. The frame then
|
||||
// rendered unstyled, and the thumbnailer's transparent-body fallback painted
|
||||
// it dark navy with unreadable text.
|
||||
it("resolves same-directory asset refs against the composition's own directory", () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body></body></html>`,
|
||||
"design/styleframes/_shared.css": `.stage { background: #fff; }`,
|
||||
"design/styleframes/frame-01.png": `png`,
|
||||
"design/styleframes/frame-01.html": `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="_shared.css" />
|
||||
<style>.stage { background-image: url("frame-01.png"); }</style>
|
||||
</head>
|
||||
<body><div class="stage"><img src="frame-01.png" alt="" /></div></body>
|
||||
</html>`,
|
||||
});
|
||||
|
||||
const html = buildSubCompositionHtml(
|
||||
dir,
|
||||
"design/styleframes/frame-01.html",
|
||||
"/api/runtime.js",
|
||||
"/api/projects/demo/preview/",
|
||||
);
|
||||
|
||||
expect(html).not.toBeNull();
|
||||
expect(html).toContain('href="design/styleframes/_shared.css"');
|
||||
expect(html).toContain('src="design/styleframes/frame-01.png"');
|
||||
expect(html).toContain('url("design/styleframes/frame-01.png")');
|
||||
expect(html).not.toContain('href="_shared.css"');
|
||||
});
|
||||
|
||||
it("leaves project-root-relative asset refs alone when no sibling file exists", () => {
|
||||
// Registry blocks are installed into a subdirectory but reference assets at
|
||||
// the project root (`assets/logo.png`). Those already resolve correctly
|
||||
// under the project-root <base> and must not be re-pointed at the block dir.
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html><html><head></head><body></body></html>`,
|
||||
"assets/logo.png": `png`,
|
||||
"blocks/hero.html": `<div data-composition-id="hero"><img src="assets/logo.png" alt="" /></div>`,
|
||||
});
|
||||
|
||||
const html = buildSubCompositionHtml(
|
||||
dir,
|
||||
"blocks/hero.html",
|
||||
"/api/runtime.js",
|
||||
"/api/projects/demo/preview/",
|
||||
);
|
||||
|
||||
expect(html).toContain('src="assets/logo.png"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { join, posix } from "node:path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import {
|
||||
rewriteAssetPaths,
|
||||
rewriteCssAssetUrls,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "@hyperframes/core";
|
||||
import { CSS_URL_RE, isNonRelativeUrl, rewriteAssetPath } from "@hyperframes/core";
|
||||
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
|
||||
|
||||
/**
|
||||
@@ -18,25 +14,70 @@ function isFullHtmlDocument(html: string): boolean {
|
||||
return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one relative asset reference authored inside a sub-composition into a
|
||||
* path that is correct under the preview's project-root `<base>`.
|
||||
*
|
||||
* The browser resolves a relative URL against the document the markup came
|
||||
* from, but this page borrows the project-root base — so a composition at
|
||||
* `design/styleframes/frame-01.html` referencing its sibling `_shared.css` used
|
||||
* to be served as-is and requested as `/preview/_shared.css` (404). The frame
|
||||
* then rendered unstyled, which the thumbnailer's transparent-body fallback
|
||||
* painted dark navy — the "illegible styleframe thumbnail" bug.
|
||||
*
|
||||
* Two rules, in order:
|
||||
* 1. `../` paths resolve against the composition dir (shared with the
|
||||
* producer's inliner, so preview and render agree).
|
||||
* 2. Any other relative path is re-pointed at the composition's own directory
|
||||
* ONLY when that sibling file actually exists. Registry blocks installed
|
||||
* into a subdirectory reference project-root assets (`assets/logo.png`)
|
||||
* that are already correct under the root base — those must stay put, and
|
||||
* a disk check is what tells the two conventions apart.
|
||||
*/
|
||||
function resolvePreviewAssetPath(projectDir: string, compPath: string, rawValue: string): string {
|
||||
const value = rawValue.trim();
|
||||
if (isNonRelativeUrl(value)) return value;
|
||||
if (value.startsWith("../") || value === "..") return rewriteAssetPath(compPath, value);
|
||||
const compDir = posix.dirname(compPath);
|
||||
if (!compDir || compDir === ".") return value;
|
||||
const filePart = value.split(/[?#]/)[0] ?? "";
|
||||
if (!filePart) return value;
|
||||
const sibling = posix.join(compDir, filePart);
|
||||
if (!existsSync(join(projectDir, sibling))) return value;
|
||||
return posix.join(compDir, value);
|
||||
}
|
||||
|
||||
function rewriteCssUrls(cssText: string, resolvePath: (value: string) => string): string {
|
||||
if (!cssText) return cssText;
|
||||
return cssText.replace(CSS_URL_RE, (full: string, quote: string, rawUrl: string) => {
|
||||
const url = (rawUrl || "").trim();
|
||||
const resolved = resolvePath(url);
|
||||
return resolved === url ? full : `url(${quote || ""}${resolved}${quote || ""})`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
);
|
||||
function rewriteRelativePaths(root: ParentNode, compPath: string, projectDir: string): void {
|
||||
const resolvePath = (value: string) => resolvePreviewAssetPath(projectDir, compPath, value);
|
||||
for (const el of root.querySelectorAll("[src], [href]")) {
|
||||
for (const attr of ["src", "href"]) {
|
||||
const value = (el.getAttribute(attr) || "").trim();
|
||||
if (!value) continue;
|
||||
const resolved = resolvePath(value);
|
||||
if (resolved !== value) el.setAttribute(attr, resolved);
|
||||
}
|
||||
}
|
||||
for (const el of root.querySelectorAll("[style]")) {
|
||||
const style = el.getAttribute("style");
|
||||
if (!style) continue;
|
||||
const resolved = rewriteCssUrls(style, resolvePath);
|
||||
if (resolved !== style) el.setAttribute("style", resolved);
|
||||
}
|
||||
for (const styleEl of root.querySelectorAll("style")) {
|
||||
styleEl.textContent = rewriteCssAssetUrls(styleEl.textContent || "", compPath);
|
||||
styleEl.textContent = rewriteCssUrls(styleEl.textContent || "", resolvePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +151,7 @@ function fixDigitLeadingIdSelectors(root: ParentNode): void {
|
||||
function extractFullDocumentParts(
|
||||
rawHtml: string,
|
||||
compPath: string,
|
||||
projectDir: string,
|
||||
): {
|
||||
headContent: string;
|
||||
bodyContent: string;
|
||||
@@ -120,7 +162,7 @@ function extractFullDocumentParts(
|
||||
|
||||
const rewriteTargets = [doc.head, doc.body].filter(Boolean);
|
||||
for (const target of rewriteTargets) {
|
||||
rewriteRelativePaths(target, compPath);
|
||||
rewriteRelativePaths(target, compPath, projectDir);
|
||||
}
|
||||
// Run on the whole document: ids live in <body> but their rules may live in
|
||||
// a <head> <style>, so the scope must span both.
|
||||
@@ -278,12 +320,12 @@ export function buildSubCompositionHtml(
|
||||
const { document: contentDoc } = parseHTML(
|
||||
`<!DOCTYPE html><html><head></head><body>${templateInner}</body></html>`,
|
||||
);
|
||||
rewriteRelativePaths(contentDoc, compPath);
|
||||
rewriteRelativePaths(contentDoc, compPath, projectDir);
|
||||
fixDigitLeadingIdSelectors(contentDoc);
|
||||
promoteTemplateCompositionId(rawComp, contentDoc.body);
|
||||
rewrittenContent = contentDoc.body.innerHTML || templateInner;
|
||||
} else if (isFullHtmlDocument(rawComp)) {
|
||||
const parts = extractFullDocumentParts(rawComp, compPath);
|
||||
const parts = extractFullDocumentParts(rawComp, compPath, projectDir);
|
||||
compHeadContent = parts.headContent;
|
||||
rewrittenContent = parts.bodyContent;
|
||||
htmlAttrs = parts.htmlAttrs;
|
||||
@@ -292,7 +334,7 @@ export function buildSubCompositionHtml(
|
||||
const { document: contentDoc } = parseHTML(
|
||||
`<!DOCTYPE html><html><head></head><body>${rawComp}</body></html>`,
|
||||
);
|
||||
rewriteRelativePaths(contentDoc, compPath);
|
||||
rewriteRelativePaths(contentDoc, compPath, projectDir);
|
||||
fixDigitLeadingIdSelectors(contentDoc);
|
||||
rewrittenContent = contentDoc.body.innerHTML || rawComp;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user