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