From cbd8a77d162be059126698f7e20680c5430dd3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 4 Aug 2026 04:09:50 +0200 Subject: [PATCH] fix(studio): resolve sibling asset paths in sub-composition previews (#2983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2956 ## What A composition in a subdirectory that references a **sibling** file (``, 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 ``, 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 ` + +
+`, + }); + + 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 and must not be re-pointed at the block dir. + const dir = makeTempProject({ + "index.html": ``, + "assets/logo.png": `png`, + "blocks/hero.html": `
`, + }); + + const html = buildSubCompositionHtml( + dir, + "blocks/hero.html", + "/api/runtime.js", + "/api/projects/demo/preview/", + ); + + expect(html).toContain('src="assets/logo.png"'); + }); }); diff --git a/packages/studio-server/src/helpers/subComposition.ts b/packages/studio-server/src/helpers/subComposition.ts index 51602c2f0..d70ece7b5 100644 --- a/packages/studio-server/src/helpers/subComposition.ts +++ b/packages/studio-server/src/helpers/subComposition.ts @@ -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*(?:])/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 ``. + * + * 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 but their rules may live in // a