fix(preview): rewrite sub-composition asset urls in styles (#174)

## Summary
- rewrite CSS `url(...)` asset paths from sub-compositions before styles are hoisted into bundled/master preview output
- rewrite standalone sub-composition preview HTML so `src`/`href` paths keep resolving correctly under the preview root `<base>`
- add regression tests for bundled CSS asset rewriting and standalone sub-composition preview rewriting

## Root cause
Standalone composition previews reused the project `<head>` with a preview-root `<base href="/api/projects/:id/preview/">`, but the sub-composition body still contained `../...` asset references. Those escaped the preview route and 404ed. Separately, bundled preview already rewrote `<img src="../...">` paths but left hoisted CSS asset references like `@font-face src: url("../font.woff2")` untouched.

## Validation
- `pnpm --filter @hyperframes/core exec vitest run src/compiler/htmlBundler.test.ts src/studio-api/helpers/subComposition.test.ts`
- `pnpm --filter @hyperframes/core exec tsc --noEmit`
- `pnpm --filter @hyperframes/producer exec tsc --noEmit` *(blocked by pre-existing `packages/producer/src/services/deterministicFonts.ts` importing missing generated file `./fontData.generated.js` in the worktree install)*
- browser verification with `agent-browser` against the local preview server using `/Users/miguel07code/dev/test-hyperframes/heygen-promo`

## Browser proof
Verified the fixed preview routes in-browser after seeking to visible frames:
- standalone composition preview
- bundled master preview
This commit is contained in:
Miguel Ángel
2026-04-01 18:13:38 +02:00
committed by GitHub
parent 1a5badc803
commit 1681350ac4
25 changed files with 1923 additions and 34 deletions
@@ -195,4 +195,36 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain('data-height="600"');
expect(bundled).toContain("Sized content");
});
it("rewrites CSS url(...) asset paths from sub-compositions when styles are hoisted", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div
data-composition-id="hero"
data-composition-src="compositions/hero.html"
data-start="0"
data-duration="2"></div>
</div>
<script>window.__timelines={};</script>
</body></html>`,
"compositions/hero.html": `<template id="hero-template">
<div data-composition-id="hero" data-width="1920" data-height="1080">
<style>
@font-face {
font-family: "Brand Sans";
src: url("../fonts/brand.woff2") format("woff2");
}
</style>
<p>Hello</p>
</div>
</template>`,
});
const bundled = await bundleToSingleHtml(dir);
expect(bundled).toContain('url("fonts/brand.woff2")');
expect(bundled).not.toContain('url("../fonts/brand.woff2")');
});
});
+2 -2
View File
@@ -3,7 +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 { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
import { validateHyperframeHtmlContract } from "./staticGuard";
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
@@ -414,7 +414,7 @@ export async function bundleToSingleHtml(
: $content("[data-composition-id]").first();
$content("style").each((_, s) => {
compStyleChunks.push($content(s).html() || "");
compStyleChunks.push(rewriteCssAssetUrls($content(s).html() || "", src));
$content(s).remove();
});
$content("script").each((_, s) => {
@@ -16,6 +16,7 @@ import { join, resolve, dirname } from "path";
/** Attributes that may contain relative asset paths. */
const PATH_ATTRS = ["src", "href"] as const;
const CSS_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
/** Protocols and prefixes that should never be rewritten. */
function isAbsoluteOrSpecial(val: string): boolean {
@@ -90,3 +91,17 @@ export function rewriteAssetPaths<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 {
if (!cssText) return cssText;
return cssText.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
const urlValue = (rawUrl || "").trim();
const rewritten = rewriteAssetPath(compSrcPath, urlValue);
if (rewritten === urlValue) return full;
return `url(${quote || ""}${rewritten}${quote || ""})`;
});
}
+5 -1
View File
@@ -120,7 +120,11 @@ export type {
HyperframeLinterOptions,
} from "./lint/types";
export { lintHyperframeHtml } from "./lint/hyperframeLinter";
export { rewriteAssetPaths, rewriteAssetPath } from "./compiler/rewriteSubCompPaths";
export {
rewriteAssetPaths,
rewriteAssetPath,
rewriteCssAssetUrls,
} from "./compiler/rewriteSubCompPaths";
// Inline scripts
export {
@@ -0,0 +1,49 @@
// @vitest-environment node
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { buildSubCompositionHtml } from "./subComposition";
function makeTempProject(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "hf-subcomp-preview-"));
for (const [rel, content] of Object.entries(files)) {
const full = join(dir, rel);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, content, "utf-8");
}
return dir;
}
describe("buildSubCompositionHtml", () => {
it("rewrites sub-composition asset paths against the project root preview base", () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head><title>Test</title></head><body></body></html>`,
"compositions/hero.html": `<template id="hero-template">
<div data-composition-id="hero" data-width="1920" data-height="1080">
<img src="../logo.png" alt="Logo" />
<style>
@font-face {
font-family: "Brand Sans";
src: url("../fonts/brand.woff2") format("woff2");
}
</style>
</div>
</template>`,
});
const html = buildSubCompositionHtml(
dir,
"compositions/hero.html",
"/api/runtime.js",
"/api/projects/demo/preview/",
);
expect(html).toContain('<base href="/api/projects/demo/preview/">');
expect(html).toContain('src="logo.png"');
expect(html).toContain('url("fonts/brand.woff2")');
expect(html).not.toContain('src="../logo.png"');
expect(html).not.toContain('url("../fonts/brand.woff2")');
});
});
@@ -1,5 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import * as cheerio from "cheerio";
import { rewriteAssetPaths, rewriteCssAssetUrls } from "../../compiler/rewriteSubCompPaths.js";
/**
* Build a standalone HTML page for a sub-composition.
@@ -22,6 +24,21 @@ export function buildSubCompositionHtml(
// Extract content from <template> wrapper (compositions are always templates)
const templateMatch = rawComp.match(/<template[^>]*>([\s\S]*)<\/template>/i);
const content = templateMatch?.[1] ?? rawComp;
const $content = cheerio.load(content, {}, false);
rewriteAssetPaths(
$content("[src], [href]").toArray(),
compPath,
(el, attr) => $content(el).attr(attr),
(el, attr, value) => {
$content(el).attr(attr, value);
},
);
$content("style").each((_, styleEl) => {
$content(styleEl).html(rewriteCssAssetUrls($content(styleEl).html() || "", compPath));
});
const rewrittenContent = $content.root().html() || content;
// Use the project's index.html <head> to preserve all dependencies
const indexPath = join(projectDir, "index.html");
@@ -58,7 +75,7 @@ ${headContent}
</head>
<body>
<script>window.__timelines=window.__timelines||{};</script>
${content}
${rewrittenContent}
</body>
</html>`;
}