mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +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.
|
// The <style> sibling was not tagged.
|
||||||
expect(html).not.toMatch(/<style[^>]*data-composition-id/i);
|
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 { existsSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join, posix } from "node:path";
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
import {
|
import { CSS_URL_RE, isNonRelativeUrl, rewriteAssetPath } from "@hyperframes/core";
|
||||||
rewriteAssetPaths,
|
|
||||||
rewriteCssAssetUrls,
|
|
||||||
rewriteInlineStyleAssetUrls,
|
|
||||||
} from "@hyperframes/core";
|
|
||||||
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
|
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,25 +14,70 @@ function isFullHtmlDocument(html: string): boolean {
|
|||||||
return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
|
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
|
* Rewrite relative asset paths in a parsed DOM tree. Shared across all
|
||||||
* three dispatch branches (template, full-doc, fragment) to avoid drift.
|
* three dispatch branches (template, full-doc, fragment) to avoid drift.
|
||||||
*/
|
*/
|
||||||
function rewriteRelativePaths(root: ParentNode, compPath: string): void {
|
function rewriteRelativePaths(root: ParentNode, compPath: string, projectDir: string): void {
|
||||||
rewriteAssetPaths(
|
const resolvePath = (value: string) => resolvePreviewAssetPath(projectDir, compPath, value);
|
||||||
root.querySelectorAll("[src], [href]"),
|
for (const el of root.querySelectorAll("[src], [href]")) {
|
||||||
compPath,
|
for (const attr of ["src", "href"]) {
|
||||||
(el: Element, attr: string) => el.getAttribute(attr),
|
const value = (el.getAttribute(attr) || "").trim();
|
||||||
(el: Element, attr: string, value: string) => el.setAttribute(attr, value),
|
if (!value) continue;
|
||||||
);
|
const resolved = resolvePath(value);
|
||||||
rewriteInlineStyleAssetUrls(
|
if (resolved !== value) el.setAttribute(attr, resolved);
|
||||||
root.querySelectorAll("[style]"),
|
}
|
||||||
compPath,
|
}
|
||||||
(el: Element) => el.getAttribute("style"),
|
for (const el of root.querySelectorAll("[style]")) {
|
||||||
(el: Element, value: string) => el.setAttribute("style", value),
|
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")) {
|
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(
|
function extractFullDocumentParts(
|
||||||
rawHtml: string,
|
rawHtml: string,
|
||||||
compPath: string,
|
compPath: string,
|
||||||
|
projectDir: string,
|
||||||
): {
|
): {
|
||||||
headContent: string;
|
headContent: string;
|
||||||
bodyContent: string;
|
bodyContent: string;
|
||||||
@@ -120,7 +162,7 @@ function extractFullDocumentParts(
|
|||||||
|
|
||||||
const rewriteTargets = [doc.head, doc.body].filter(Boolean);
|
const rewriteTargets = [doc.head, doc.body].filter(Boolean);
|
||||||
for (const target of rewriteTargets) {
|
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
|
// Run on the whole document: ids live in <body> but their rules may live in
|
||||||
// a <head> <style>, so the scope must span both.
|
// a <head> <style>, so the scope must span both.
|
||||||
@@ -278,12 +320,12 @@ export function buildSubCompositionHtml(
|
|||||||
const { document: contentDoc } = parseHTML(
|
const { document: contentDoc } = parseHTML(
|
||||||
`<!DOCTYPE html><html><head></head><body>${templateInner}</body></html>`,
|
`<!DOCTYPE html><html><head></head><body>${templateInner}</body></html>`,
|
||||||
);
|
);
|
||||||
rewriteRelativePaths(contentDoc, compPath);
|
rewriteRelativePaths(contentDoc, compPath, projectDir);
|
||||||
fixDigitLeadingIdSelectors(contentDoc);
|
fixDigitLeadingIdSelectors(contentDoc);
|
||||||
promoteTemplateCompositionId(rawComp, contentDoc.body);
|
promoteTemplateCompositionId(rawComp, contentDoc.body);
|
||||||
rewrittenContent = contentDoc.body.innerHTML || templateInner;
|
rewrittenContent = contentDoc.body.innerHTML || templateInner;
|
||||||
} else if (isFullHtmlDocument(rawComp)) {
|
} else if (isFullHtmlDocument(rawComp)) {
|
||||||
const parts = extractFullDocumentParts(rawComp, compPath);
|
const parts = extractFullDocumentParts(rawComp, compPath, projectDir);
|
||||||
compHeadContent = parts.headContent;
|
compHeadContent = parts.headContent;
|
||||||
rewrittenContent = parts.bodyContent;
|
rewrittenContent = parts.bodyContent;
|
||||||
htmlAttrs = parts.htmlAttrs;
|
htmlAttrs = parts.htmlAttrs;
|
||||||
@@ -292,7 +334,7 @@ export function buildSubCompositionHtml(
|
|||||||
const { document: contentDoc } = parseHTML(
|
const { document: contentDoc } = parseHTML(
|
||||||
`<!DOCTYPE html><html><head></head><body>${rawComp}</body></html>`,
|
`<!DOCTYPE html><html><head></head><body>${rawComp}</body></html>`,
|
||||||
);
|
);
|
||||||
rewriteRelativePaths(contentDoc, compPath);
|
rewriteRelativePaths(contentDoc, compPath, projectDir);
|
||||||
fixDigitLeadingIdSelectors(contentDoc);
|
fixDigitLeadingIdSelectors(contentDoc);
|
||||||
rewrittenContent = contentDoc.body.innerHTML || rawComp;
|
rewrittenContent = contentDoc.body.innerHTML || rawComp;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user