From 7265d0adfdd090a46742edd39bfb34af3b6cd336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 1 Apr 2026 00:13:55 +0200 Subject: [PATCH] fix(producer): rewrite relative asset paths when inlining sub-compositions (#166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Fixes**: `` 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 --- packages/core/src/compiler/htmlBundler.ts | 15 +++ .../core/src/compiler/rewriteSubCompPaths.ts | 92 +++++++++++++++++++ packages/core/src/index.ts | 1 + .../producer/src/services/htmlCompiler.ts | 11 +++ 4 files changed, 119 insertions(+) create mode 100644 packages/core/src/compiler/rewriteSubCompPaths.ts diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index dbe96e95e..c88e40c30 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -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"); diff --git a/packages/core/src/compiler/rewriteSubCompPaths.ts b/packages/core/src/compiler/rewriteSubCompPaths.ts new file mode 100644 index 000000000..dec8b814c --- /dev/null +++ b/packages/core/src/compiler/rewriteSubCompPaths.ts @@ -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( + elements: Iterable, + 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); + } + } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8c2524624..5e0c796be 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -120,6 +120,7 @@ export type { HyperframeLinterOptions, } from "./lint/types"; export { lintHyperframeHtml } from "./lint/hyperframeLinter"; +export { rewriteAssetPaths, rewriteAssetPath } from "./compiler/rewriteSubCompPaths"; // Inline scripts export { diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 3c2ff6afb..cf0841330 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -19,6 +19,7 @@ import { clampDurations, type ResolvedDuration, type UnresolvedElement, + rewriteAssetPaths, } from "@hyperframes/core"; import { extractVideoMetadata, extractAudioMetadata } from "../utils/ffprobe.js"; import { @@ -578,6 +579,16 @@ function inlineSubCompositions( scriptEl.remove(); } + // Rewrite relative asset paths before inlining so ../foo.svg from + // compositions/ resolves correctly when the content moves to root. + const rewriteTarget = innerRoot || contentDoc; + rewriteAssetPaths( + rewriteTarget.querySelectorAll("[src], [href]"), + srcPath, + (el, attr) => (el.getAttribute(attr) || "").trim(), + (el, attr, val) => el.setAttribute(attr, val), + ); + if (innerRoot) { const innerW = innerRoot.getAttribute("data-width"); const innerH = innerRoot.getAttribute("data-height");