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
@@ -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);
}
}