fix(producer): rewrite relative asset paths when inlining sub-compositions (#166)

## Summary

- **Fixes**: `<img src="../icon.svg">` and similar `../` relative asset references in sub-compositions resolve to 404 after inlining into the root document
- **Unifies**: Both `hyperframes preview` (core bundler) and `hyperframes render` (producer) now use the same shared logic — no duplication

## Root cause

When inlining a sub-composition at `compositions/scene.html` into root `index.html`, a relative path like `../icon.svg` is correct from `compositions/` (it points to project root) but after inlining, `../` escapes the project directory.

## Fix

Extracts path rewriting into a shared `rewriteSubCompPaths.ts` utility in `@hyperframes/core`, used by both the bundler and the producer. **Only rewrites paths starting with** **`../`** — plain relative paths like `assets/foo.svg` are already correct from the root perspective and must not be rewritten (this was the regression cause in `overlay-montage-prod`: sub-composition asset refs like `assets/notch.svg` were incorrectly being rewritten to `compositions/assets/notch.svg`).

## Regression fix

The earlier version of this fix (now in history) rewrote ALL relative paths including `assets/foo.svg`, breaking the `overlay-montage-prod` regression test. This PR fixes that by scoping rewrites to `../`\-prefixed paths only.

## Test plan

- [x] `../icon.svg` in sub-composition renders correctly in both preview and render
- [x] `assets/foo.svg` (no `../`) in sub-composition still resolves correctly — not rewritten
- [x] `overlay-montage-prod` regression test passes
- [x] All other regression shards pass
This commit is contained in:
Miguel Ángel
2026-04-01 00:13:55 +02:00
committed by GitHub
parent e6a38c9e0f
commit 7265d0adfd
4 changed files with 119 additions and 0 deletions
+15
View File
@@ -3,6 +3,7 @@ import { join, resolve, isAbsolute, sep } from "path";
import * as cheerio from "cheerio";
import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import { rewriteAssetPaths } from "./rewriteSubCompPaths";
import { validateHyperframeHtmlContract } from "./staticGuard";
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
@@ -432,6 +433,20 @@ export async function bundleToSingleHtml(
$content(s).remove();
});
// Rewrite relative asset paths before inlining so ../foo.svg from
// compositions/ resolves correctly when the content moves to root.
const $assetEls = $innerRoot.length
? $innerRoot.find("[src], [href]")
: $content("[src], [href]");
rewriteAssetPaths(
$assetEls.toArray(),
src,
(el, attr) => $content(el).attr(attr),
(el, attr, val) => {
$content(el).attr(attr, val);
},
);
if ($innerRoot.length) {
const innerCompId = $innerRoot.attr("data-composition-id");
const innerW = $innerRoot.attr("data-width");
@@ -0,0 +1,92 @@
/**
* Rewrite relative asset paths in sub-composition content so they resolve
* correctly after the content is inlined into the root document.
*
* A sub-composition at "compositions/scene.html" referencing "../icon.svg"
* means the project root — but after inlining into root index.html, the
* "../" escapes the project directory and causes 404s. This function
* resolves each relative path against the sub-composition's directory,
* then normalizes it to be relative to the project root.
*
* Used by both the core bundler (preview) and the producer compiler (render)
* to ensure consistent behavior.
*/
import { join, resolve, dirname } from "path";
/** Attributes that may contain relative asset paths. */
const PATH_ATTRS = ["src", "href"] as const;
/** Protocols and prefixes that should never be rewritten. */
function isAbsoluteOrSpecial(val: string): boolean {
return (
!val ||
val.startsWith("http://") ||
val.startsWith("https://") ||
val.startsWith("//") ||
val.startsWith("data:") ||
val.startsWith("#")
);
}
/**
* 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.
*/
function needsRewrite(val: string): boolean {
return val.startsWith("../") || val === "..";
}
/**
* 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")
* @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 {
if (isAbsoluteOrSpecial(relativePath)) return relativePath;
if (!needsRewrite(relativePath)) return relativePath;
const compDir = dirname(compSrcPath);
if (!compDir || compDir === ".") return relativePath;
const resolved = join(compDir, relativePath);
const normalized = resolve("/", resolved).slice(1);
return normalized;
}
/**
* Rewrite all relative `src` and `href` attributes on elements within a
* DOM tree, adjusting paths from the sub-composition's directory context
* to the project root.
*
* @param elements - Iterable of DOM elements to scan (e.g. from querySelectorAll)
* @param compSrcPath - The `data-composition-src` value
* @param getAttr - Function to read an attribute from an element
* @param setAttr - Function to set an attribute on an element
*/
export function rewriteAssetPaths<T>(
elements: Iterable<T>,
compSrcPath: string,
getAttr: (el: T, attr: string) => string | null | undefined,
setAttr: (el: T, attr: string, value: string) => void,
): void {
const compDir = dirname(compSrcPath);
if (!compDir || compDir === ".") return;
for (const el of elements) {
for (const attr of PATH_ATTRS) {
const val = (getAttr(el, attr) || "").trim();
if (isAbsoluteOrSpecial(val)) continue;
if (!needsRewrite(val)) continue;
const rewritten = join(compDir, val);
const normalized = resolve("/", rewritten).slice(1);
if (normalized !== val) {
setAttr(el, attr, normalized);
}
}
}
}
+1
View File
@@ -120,6 +120,7 @@ export type {
HyperframeLinterOptions,
} from "./lint/types";
export { lintHyperframeHtml } from "./lint/hyperframeLinter";
export { rewriteAssetPaths, rewriteAssetPath } from "./compiler/rewriteSubCompPaths";
// Inline scripts
export {