fix(core): resolve sub-composition sibling asset paths everywhere (#2994)

Extends the studio-preview fix to the render path and the asset-discovery
utilities, which share the same resolver and had the same defect.

`rewriteAssetPath` takes an optional `assetExists` probe. A plain relative ref
authored in a sub-composition (`_shared.css`, `clip.mp4`) is re-pointed at the
composition's own directory when that sibling exists on disk; project-root refs
with no sibling (the registry's `assets/logo.png` convention) stay as authored.
Callers that can see the filesystem supply the probe, so the module stays free
of node:fs.

Also fixes a second defect in the inliner: `<head>` <link> hrefs and external
script srcs are hoisted into the root document but never went through the
rewrite at all, so even the documented `../` form escaped the project and 404'd
at render time.

Wired into the preview bundler, the producer compiler, the studio preview
builder, the HEVC preview lint, the project lint's asset scans, publish proxy
baking, and media-treatment source resolution.
This commit is contained in:
Miguel Ángel
2026-08-03 21:13:35 -07:00
committed by GitHub
parent 91d14744a0
commit 4e7fcf7f2a
13 changed files with 299 additions and 80 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { relative, resolve } from "node:path";
import { join, relative, resolve } from "node:path";
import {
HF_COLOR_GRADING_ATTR,
getHfColorGradingCapabilities,
@@ -502,7 +502,7 @@ export function resolveMediaTreatmentSource(
if (!cleanSource) throw new Error("Selected media has no analyzable local src");
const projectRelative = cleanSource.startsWith("/")
? cleanSource
: rewriteAssetPath(compositionFile, cleanSource);
: rewriteAssetPath(compositionFile, cleanSource, (path) => existsSync(join(projectDir, path)));
const asset = resolveExistingLocalAsset(projectDir, projectRelative);
if (!asset) throw new Error(`Media file not found: ${source}`);
return asset.resolved;
+4 -1
View File
@@ -25,6 +25,7 @@
* structured manifest rather than silently shipping an unplayable asset.
*/
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { basename, dirname, resolve } from "node:path";
import { parseHTML } from "linkedom";
@@ -147,7 +148,9 @@ export async function bakeMediaProxies(
// (rewriteAssetPath to root-relative, then decodeUrlPathVariants via
// resolveLocalAssetCandidates) so percent-encoded and root-absolute
// srcs match the map keys the scan produced.
const rootRelativeSrc = rewriteAssetPath(entryPath, cleaned);
const rootRelativeSrc = rewriteAssetPath(entryPath, cleaned, (path) =>
existsSync(resolve(absProjectDir, path)),
);
for (const candidate of resolveLocalAssetCandidates(absProjectDir, rootRelativeSrc)) {
const archivePath = proxyByAbsolutePath.get(candidate);
if (archivePath) return archivePath;
@@ -898,6 +898,13 @@ export async function bundleToSingleHtml(
parseHtml: parseHTMLContent,
hostIdentityMap: hostIdentityByElement,
rewriteInlineStyles: true,
// A sub-composition's SIBLING assets (`_shared.css` next to it) must be
// re-pointed at its own directory when its content moves to the root
// document; project-root refs with no such sibling stay as authored.
assetExists: (path: string) => {
const resolved = resolveEntryPath(path);
return resolved !== null && existsSync(resolved);
},
flattenInnerRoot: prepareFlattenedInnerRoot,
readVariableDefaults: readDeclaredDefaults,
parseHostVariables: parseHostVariableValues,
@@ -553,3 +553,71 @@ describe("inlineSubCompositions recursive host discovery", () => {
]);
});
});
describe("inlineSubCompositions sub-composition asset paths", () => {
// Every asset ref a sub-composition in a subdirectory carries has to be
// re-pointed when its content moves into the project-root document. Hoisted
// <head> <link>/<script src> used to bypass the rewrite entirely (so even the
// documented `../` form escaped the project), and sibling refs (`_shared.css`)
// silently 404'd. Both render the frame unstyled.
const SUB_COMP = `<!doctype html>
<html><head>
<link rel="stylesheet" href="_shared.css">
<link rel="stylesheet" href="../shared/theme.css">
<script src="helper.js"></script>
<style>.badge { background-image: url("frame.png"); }</style>
</head><body>
<div data-composition-id="frame" data-width="1920" data-height="1080">
<img src="frame.png" alt="">
<div style="background-image: url('frame.png')"></div>
</div>
</body></html>`;
const PROJECT_FILES = [
"design/styleframes/_shared.css",
"design/styleframes/frame.png",
"design/styleframes/helper.js",
"design/shared/theme.css",
];
function inlineFrame() {
const { document } = parseHTML(`<!DOCTYPE html>
<html><body>
<div data-composition-id="main">
<div data-composition-id="frame" data-composition-src="design/styleframes/frame-01.html"
data-start="0" data-duration="4" data-track-index="0"></div>
</div>
</body></html>`);
const host = document.querySelector("[data-composition-src]")!;
const result = inlineSubCompositions(document, [host], {
resolveHtml: () => SUB_COMP,
parseHtml: (html) => parseHTML(html).document,
rewriteInlineStyles: true,
assetExists: (path: string) => PROJECT_FILES.includes(path),
});
return { document, result };
}
it("rewrites hoisted <link> hrefs against the sub-composition dir", () => {
const { result } = inlineFrame();
const hrefs = result.externalLinks.map((l) => l.href);
expect(hrefs).toContain("design/styleframes/_shared.css");
expect(hrefs).toContain("design/shared/theme.css");
expect(hrefs).not.toContain("_shared.css");
expect(hrefs).not.toContain("../shared/theme.css");
});
it("rewrites hoisted external script srcs", () => {
const { result } = inlineFrame();
expect(result.externalScriptSrcs).toContain("design/styleframes/helper.js");
});
it("rewrites sibling refs in markup, hoisted CSS, and inline styles", () => {
const { document, result } = inlineFrame();
expect(document.querySelector("img")?.getAttribute("src")).toBe("design/styleframes/frame.png");
expect(result.styles.join("\n")).toContain("design/styleframes/frame.png");
expect(document.querySelector("[style]")?.getAttribute("style")).toContain(
"design/styleframes/frame.png",
);
});
});
@@ -9,9 +9,11 @@
*/
import {
rewriteAssetPath,
rewriteAssetPaths,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
type AssetExists,
} from "./rewriteSubCompPaths";
import { queryByAttr } from "../utils/cssSelector";
import {
@@ -101,6 +103,15 @@ export interface InlineSubCompositionsOptions {
*/
scriptErrorLabel?: string;
/**
* Probe for "does this project-root-relative path exist?". Supplied by
* callers that can see the filesystem so a sub-composition's SIBLING asset
* refs (`<link href="_shared.css">` next to the composition) resolve against
* its own directory instead of 404ing at the project root. Omit it and plain
* relative paths pass through unchanged. See `AssetExists`.
*/
assetExists?: AssetExists;
/**
* Log a warning when a composition file cannot be resolved. `reason` is a
* short, human-readable explanation (e.g. "the file is empty (0 bytes or
@@ -169,6 +180,7 @@ export function inlineSubCompositions(
buildScopeSelector = defaultBuildScopeSelector,
scriptErrorLabel = "[HyperFrames] composition script error:",
onMissingComposition,
assetExists,
} = options;
const styles: string[] = [];
@@ -264,11 +276,17 @@ export function inlineSubCompositions(
}
}
// `<head>` <link>/<script src> are hoisted into the ROOT document, so they
// need the same directory rewrite the body's [src]/[href] pass applies —
// without it even the documented `../` form escapes the project and 404s.
const resolveSubAssetPath = (raw: string | null): string =>
rewriteAssetPath(src, (raw || "").trim(), assetExists);
// Scope one sub-composition <style> body. scopeRootSelectors keeps the
// sub-comp's html/body/:root rules from clobbering the host document (they
// are remapped to the composition box); see compositionScoping.
const scopeSubStyle = (raw: string): string => {
const css = rewriteCssAssetUrls(raw, src);
const css = rewriteCssAssetUrls(raw, src, assetExists);
return scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
@@ -286,7 +304,7 @@ export function inlineSubCompositions(
styles.push(scopeSubStyle(s.textContent || ""));
}
for (const s of [...compDoc.head.querySelectorAll("script")]) {
const externalSrc = (s.getAttribute("src") || "").trim();
const externalSrc = resolveSubAssetPath(s.getAttribute("src"));
if (externalSrc) {
if (!externalScriptSrcs.includes(externalSrc)) {
externalScriptSrcs.push(externalSrc);
@@ -297,7 +315,7 @@ export function inlineSubCompositions(
for (const link of [
...compDoc.head.querySelectorAll('link[rel="stylesheet"], link[rel="preconnect"]'),
]) {
const href = (link.getAttribute("href") || "").trim();
const href = resolveSubAssetPath(link.getAttribute("href"));
if (href && !seenLinkHrefs.has(href)) {
seenLinkHrefs.add(href);
const rel = (link.getAttribute("rel") || "").trim();
@@ -317,7 +335,7 @@ export function inlineSubCompositions(
// Extract scripts from content
for (const s of [...contentDoc.querySelectorAll("script")]) {
const externalSrc = (s.getAttribute("src") || "").trim();
const externalSrc = resolveSubAssetPath(s.getAttribute("src"));
if (externalSrc) {
if (!externalScriptSrcs.includes(externalSrc)) {
externalScriptSrcs.push(externalSrc);
@@ -352,6 +370,7 @@ export function inlineSubCompositions(
(el: Element, attr: string, val: string) => {
el.setAttribute(attr, val);
},
assetExists,
);
if (rewriteInlineStyles) {
@@ -365,6 +384,7 @@ export function inlineSubCompositions(
(el: Element, val: string) => {
el.setAttribute("style", val);
},
assetExists,
);
}
@@ -4,4 +4,5 @@ export {
rewriteAssetPaths,
rewriteInlineStyleAssetUrls,
rewriteCssAssetUrls,
type AssetExists,
} from "@hyperframes/parsers/asset-paths";
+52
View File
@@ -0,0 +1,52 @@
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { collectLocalVideoCandidates } from "./hevcPreviewLint.js";
function makeProject(files: string[]): string {
const dir = mkdtempSync(join(tmpdir(), "hf-hevc-lint-"));
for (const rel of files) {
const full = join(dir, rel);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, "video-bytes", "utf-8");
}
return dir;
}
// A sub-composition referencing a SIBLING video (`clip.mp4` next to it) resolves
// against its own directory, exactly as the preview and render paths now do.
// Resolving it against the project root instead either finds nothing (the file
// is silently skipped, so a real HEVC source is never reported) or finds a
// same-named file at the root and probes the WRONG one.
describe("collectLocalVideoCandidates", () => {
it("resolves a sibling video against the sub-composition dir", () => {
const dir = makeProject(["design/styleframes/clip.mp4"]);
const candidates = collectLocalVideoCandidates(dir, [
{ html: `<video src="clip.mp4"></video>`, compSrcPath: "design/styleframes/frame-01.html" },
]);
expect([...candidates.keys()]).toEqual([join(dir, "design/styleframes/clip.mp4")]);
});
it("prefers the sibling over a same-named file at the project root", () => {
const dir = makeProject(["clip.mp4", "design/styleframes/clip.mp4"]);
const candidates = collectLocalVideoCandidates(dir, [
{ html: `<video src="clip.mp4"></video>`, compSrcPath: "design/styleframes/frame-01.html" },
]);
expect([...candidates.keys()]).toEqual([join(dir, "design/styleframes/clip.mp4")]);
});
it("still resolves project-root refs that have no sibling", () => {
const dir = makeProject(["assets/clip.mp4"]);
const candidates = collectLocalVideoCandidates(dir, [
{ html: `<video src="assets/clip.mp4"></video>`, compSrcPath: "blocks/hero.html" },
]);
expect([...candidates.keys()]).toEqual([join(dir, "assets/clip.mp4")]);
});
});
+5 -1
View File
@@ -1,4 +1,6 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
import {
@@ -92,7 +94,9 @@ export function collectLocalVideoCandidates(
const src = cleanAssetUrl(rawSrc);
if (!src) continue;
if (isRemoteOrInlineUrl(src)) continue;
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
const rootRelative = compSrcPath
? rewriteAssetPath(compSrcPath, src, (path) => existsSync(join(projectDir, path)))
: src;
const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
if (!resolvedAsset) continue;
if (!candidates.has(resolvedAsset.resolved)) candidates.set(resolvedAsset.resolved, src);
+10 -3
View File
@@ -133,7 +133,10 @@ function resolveCssAssetCandidates(
return resolveLocalAssetCandidates(projectDir, join(dirname(cssRootRelativePath), url));
}
if (htmlCompSrcPath) {
return resolveLocalAssetCandidates(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
return resolveLocalAssetCandidates(
projectDir,
rewriteAssetPath(htmlCompSrcPath, url, (path) => existsSync(join(projectDir, path))),
);
}
return resolveLocalAssetCandidates(projectDir, url);
}
@@ -284,7 +287,9 @@ function lintAudioSrcNotFound(
const src = match[1]!;
if (/^(https?:|data:|blob:)/i.test(src)) continue;
if (isUnresolvedAssetPlaceholder(src)) continue;
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
const rootRelative = compSrcPath
? rewriteAssetPath(compSrcPath, src, (path) => existsSync(join(projectDir, path)))
: src;
if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) {
missingSrcs.push(src);
}
@@ -330,7 +335,9 @@ function lintMissingLocalAsset(
const src = cleanAssetUrl(rawSrc);
if (!src) continue;
if (isRemoteOrInlineUrl(src)) continue;
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
const rootRelative = compSrcPath
? rewriteAssetPath(compSrcPath, src, (path) => existsSync(join(projectDir, path)))
: src;
const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
if (resolvedAsset) continue;
@@ -55,4 +55,46 @@ describe("rewriteAssetPath", () => {
expect(elements[0]?.style).toBe(`background-image: url("cover.png")`);
});
// A sub-composition referencing a SIBLING file (`_shared.css`, no `../`) means
// a file in its own directory, but the inlined/preview document resolves it
// against the project root — so it 404s. `assetExists` lets a caller that can
// see the filesystem opt into browser semantics, while paths with no such
// sibling (the registry's project-root `assets/logo.png` convention) stay put.
describe("with an assetExists probe", () => {
const exists = (p: string) =>
[
"design/styleframes/_shared.css",
"design/styleframes/frame.png",
"assets/logo.png",
].includes(p);
it("resolves a sibling file against the sub-composition dir", () => {
expect(rewriteAssetPath("design/styleframes/frame-01.html", "_shared.css", exists)).toBe(
"design/styleframes/_shared.css",
);
});
it("keeps a query string and hash on the rewritten path", () => {
expect(rewriteAssetPath("design/styleframes/frame-01.html", "frame.png?v=2", exists)).toBe(
"design/styleframes/frame.png?v=2",
);
});
it("leaves project-root-relative paths alone when no sibling exists", () => {
expect(rewriteAssetPath("blocks/hero.html", "assets/logo.png", exists)).toBe(
"assets/logo.png",
);
});
it("still resolves `../` without consulting the probe", () => {
expect(rewriteAssetPath("compositions/scene.html", "../icon.svg", exists)).toBe("icon.svg");
});
it("is a no-op without the probe (unchanged default)", () => {
expect(rewriteAssetPath("design/styleframes/frame-01.html", "_shared.css")).toBe(
"_shared.css",
);
});
});
});
+52 -9
View File
@@ -24,28 +24,65 @@ const isAbsoluteOrSpecial = isNonRelativeUrl;
/**
* Returns true only for paths that traverse up with `../`.
* Plain relative paths like `assets/foo.svg` are already correct from the
* root perspective the browser resolves them against the served root, which
* is the project root, so they don't need rewriting.
* Plain relative paths like `assets/foo.svg` are ambiguous: two conventions are
* in the wild, and only the filesystem can tell them apart see `AssetExists`.
*/
function needsRewrite(val: string): boolean {
return val.startsWith("../") || val === "..";
}
/**
* Probe for "does this project-root-relative path exist?", supplied by callers
* that can see the filesystem (the studio preview builder, the bundler, the
* producer). Keeps this module free of `node:fs` so it stays browser-safe.
*
* It disambiguates the two conventions for a plain relative path authored in a
* sub-composition that lives in a subdirectory:
*
* 1. A SIBLING file `<link href="_shared.css">` next to the composition.
* This is what a browser resolves when the file is opened directly, and
* what an author means. Once the content moves into the project-root
* document it must become `design/styleframes/_shared.css` or it 404s.
* 2. A PROJECT-ROOT asset registry blocks are installed into a
* subdirectory but reference `assets/logo.png` at the root. Already
* correct in the root document; rewriting would break it.
*
* A sibling that exists on disk means (1); anything else is left as authored.
* Without a probe the behavior is unchanged plain relative paths pass through.
*/
export type AssetExists = (projectRelativePath: string) => boolean;
/** Split `foo.png?v=2#frag` into its path and its `?`/`#` suffix. */
function splitPathSuffix(value: string): [string, string] {
const marker = value.search(/[?#]/);
return marker === -1 ? [value, ""] : [value.slice(0, marker), value.slice(marker)];
}
/**
* Rewrite a single relative path from a sub-composition's context to the
* project root context.
*
* @param compSrcPath - The `data-composition-src` value (e.g. "compositions/scene.html")
* @param relativePath - The asset path to rewrite (e.g. "../icon.svg")
* @param assetExists - Optional filesystem probe; see `AssetExists`.
* @returns The rewritten path relative to project root (e.g. "icon.svg"), or
* the original path if no rewriting is needed.
*/
export function rewriteAssetPath(compSrcPath: string, relativePath: string): string {
export function rewriteAssetPath(
compSrcPath: string,
relativePath: string,
assetExists?: AssetExists,
): string {
if (isAbsoluteOrSpecial(relativePath)) return relativePath;
if (!needsRewrite(relativePath)) return relativePath;
const compDir = dirname(compSrcPath);
if (!compDir || compDir === ".") return relativePath;
if (!needsRewrite(relativePath)) {
if (!assetExists) return relativePath;
const [filePart, suffix] = splitPathSuffix(relativePath);
if (!filePart) return relativePath;
const sibling = resolve("/", join(compDir, filePart)).slice(1);
return assetExists(sibling) ? sibling + suffix : relativePath;
}
const resolved = join(compDir, relativePath);
const normalized = resolve("/", resolved).slice(1);
return normalized;
@@ -66,11 +103,12 @@ export function rewriteAssetPaths<T>(
compSrcPath: string,
getAttr: (el: T, attr: string) => string | null | undefined,
setAttr: (el: T, attr: string, value: string) => void,
assetExists?: AssetExists,
): void {
for (const el of elements) {
for (const attr of PATH_ATTRS) {
const val = (getAttr(el, attr) || "").trim();
const rewritten = rewriteAssetPath(compSrcPath, val);
const rewritten = rewriteAssetPath(compSrcPath, val, assetExists);
if (rewritten !== val) {
setAttr(el, attr, rewritten);
}
@@ -86,6 +124,7 @@ export function rewriteInlineStyleAssetUrls<T>(
compSrcPath: string,
getStyle: (el: T) => string | null | undefined,
setStyle: (el: T, value: string) => void,
assetExists?: AssetExists,
): void {
const compDir = dirname(compSrcPath);
if (!compDir || compDir === ".") return;
@@ -93,7 +132,7 @@ export function rewriteInlineStyleAssetUrls<T>(
for (const el of elements) {
const style = getStyle(el);
if (!style) continue;
const rewritten = rewriteCssAssetUrls(style, compSrcPath);
const rewritten = rewriteCssAssetUrls(style, compSrcPath, assetExists);
if (rewritten !== style) {
setStyle(el, rewritten);
}
@@ -104,11 +143,15 @@ export function rewriteInlineStyleAssetUrls<T>(
* Rewrite CSS url(...) references in a sub-composition's inline styles so
* ../foo.woff2 remains valid after the CSS is hoisted into the root document.
*/
export function rewriteCssAssetUrls(cssText: string, compSrcPath: string): string {
export function rewriteCssAssetUrls(
cssText: string,
compSrcPath: string,
assetExists?: AssetExists,
): string {
if (!cssText) return cssText;
return cssText.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
const urlValue = (rawUrl || "").trim();
const rewritten = rewriteAssetPath(compSrcPath, urlValue);
const rewritten = rewriteAssetPath(compSrcPath, urlValue, assetExists);
if (rewritten === urlValue) return full;
return `url(${quote || ""}${rewritten}${quote || ""})`;
});
@@ -968,6 +968,9 @@ function inlineSubCompositions(
return compHtml;
},
parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document,
// Mirrors the preview bundler: a sub-composition's SIBLING assets resolve
// against its own directory, project-root refs stay as authored.
assetExists: (path: string) => existsSync(resolve(projectDir, path)),
scriptErrorLabel: "[Compiler] Composition script failed",
// Preserve the authored root wrapper as a child of the host, matching
// the preview bundler's shape (htmlBundler.ts's prepareFlattenedInnerRoot,
@@ -1,7 +1,11 @@
import { existsSync, readFileSync } from "node:fs";
import { join, posix } from "node:path";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import { CSS_URL_RE, isNonRelativeUrl, rewriteAssetPath } from "@hyperframes/core";
import {
rewriteAssetPaths,
rewriteCssAssetUrls,
rewriteInlineStyleAssetUrls,
} from "@hyperframes/core";
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
/**
@@ -14,70 +18,35 @@ 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.
*
* The preview page borrows the project-root `<base>`, so a composition at
* `design/styleframes/frame-01.html` referencing its sibling `_shared.css` was
* 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. The `assetExists` probe re-points such
* refs at the composition's own directory; see `rewriteAssetPath`.
*/
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);
}
const assetExists = (path: string) => existsSync(join(projectDir, path));
rewriteAssetPaths(
root.querySelectorAll("[src], [href]"),
compPath,
(el: Element, attr: string) => el.getAttribute(attr),
(el: Element, attr: string, value: string) => el.setAttribute(attr, value),
assetExists,
);
rewriteInlineStyleAssetUrls(
root.querySelectorAll("[style]"),
compPath,
(el: Element) => el.getAttribute("style"),
(el: Element, value: string) => el.setAttribute("style", value),
assetExists,
);
for (const styleEl of root.querySelectorAll("style")) {
styleEl.textContent = rewriteCssUrls(styleEl.textContent || "", resolvePath);
styleEl.textContent = rewriteCssAssetUrls(styleEl.textContent || "", compPath, assetExists);
}
}