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:
Miguel Ángel
2026-08-04 04:09:50 +02:00
committed by GitHub
parent 70cc4f59a1
commit cbd8a77d16
2 changed files with 122 additions and 24 deletions
@@ -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;
}