From 1681350ac4406bc8d153933013b62c1fc82b44b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 1 Apr 2026 18:13:38 +0200 Subject: [PATCH] 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 `` - add regression tests for bundled CSS asset rewriting and standalone sub-composition preview rewriting ## Root cause Standalone composition previews reused the project `` with a preview-root ``, but the sub-composition body still contained `../...` asset references. Those escaped the preview route and 404ed. Separately, bundled preview already rewrote `` 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 --- bun.lock | 1 + .../core/src/compiler/htmlBundler.test.ts | 32 + packages/core/src/compiler/htmlBundler.ts | 4 +- .../core/src/compiler/rewriteSubCompPaths.ts | 15 + packages/core/src/index.ts | 6 +- .../studio-api/helpers/subComposition.test.ts | 49 ++ .../src/studio-api/helpers/subComposition.ts | 19 +- packages/producer/package.json | 1 + .../producer/src/services/htmlCompiler.ts | 61 +- .../heygen-promo-preview-assets/meta.json | 12 + .../output/compiled.html | 805 ++++++++++++++++++ .../output/output.mp4 | 3 + .../src/ABCSolarDisplay-Bold.woff2 | Bin 0 -> 105040 bytes .../src/TT_Norms_Pro_Bold.woff2 | Bin 0 -> 101116 bytes .../src/TT_Norms_Pro_Medium.woff2 | Bin 0 -> 97844 bytes .../src/TT_Norms_Pro_Normal.woff2 | Bin 0 -> 100160 bytes .../src/compositions/scene1-heygen-hero.html | 336 ++++++++ .../compositions/scene2-heygen-showcase.html | 255 ++++++ .../src/compositions/scene3-heygen-cta.html | 288 +++++++ .../src/feature-strip.png | Bin 0 -> 613605 bytes .../src/hero-prism.png | Bin 0 -> 280704 bytes .../src/heygen-homepage.png | Bin 0 -> 455746 bytes .../src/heygen-logo.png | Bin 0 -> 27230 bytes .../src/heygen-superpower.png | Bin 0 -> 270913 bytes .../src/index.html | 70 ++ 25 files changed, 1923 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/studio-api/helpers/subComposition.test.ts create mode 100644 packages/producer/tests/heygen-promo-preview-assets/meta.json create mode 100644 packages/producer/tests/heygen-promo-preview-assets/output/compiled.html create mode 100644 packages/producer/tests/heygen-promo-preview-assets/output/output.mp4 create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/ABCSolarDisplay-Bold.woff2 create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/TT_Norms_Pro_Bold.woff2 create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/TT_Norms_Pro_Medium.woff2 create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/TT_Norms_Pro_Normal.woff2 create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/compositions/scene1-heygen-hero.html create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/compositions/scene2-heygen-showcase.html create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/compositions/scene3-heygen-cta.html create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/feature-strip.png create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/hero-prism.png create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/heygen-homepage.png create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/heygen-logo.png create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/heygen-superpower.png create mode 100644 packages/producer/tests/heygen-promo-preview-assets/src/index.html diff --git a/bun.lock b/bun.lock index 4f9fcc0ee..62569e75c 100644 --- a/bun.lock +++ b/bun.lock @@ -120,6 +120,7 @@ "@hyperframes/engine": "workspace:^", "hono": "^4.6.0", "linkedom": "^0.18.12", + "postcss": "^8.4.0", "puppeteer": "^24.0.0", "puppeteer-core": "^24.39.1", }, diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 5f5d937f8..118cd9a45 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -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": ` + +
+
+
+ +`, + "compositions/hero.html": ``, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain('url("fonts/brand.woff2")'); + expect(bundled).not.toContain('url("../fonts/brand.woff2")'); + }); }); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index c88e40c30..e2fb81c6d 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -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) => { diff --git a/packages/core/src/compiler/rewriteSubCompPaths.ts b/packages/core/src/compiler/rewriteSubCompPaths.ts index dec8b814c..b8c10e721 100644 --- a/packages/core/src/compiler/rewriteSubCompPaths.ts +++ b/packages/core/src/compiler/rewriteSubCompPaths.ts @@ -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( } } } + +/** + * 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 || ""})`; + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e0c796be..abda0f8f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/studio-api/helpers/subComposition.test.ts b/packages/core/src/studio-api/helpers/subComposition.test.ts new file mode 100644 index 000000000..9d4378d73 --- /dev/null +++ b/packages/core/src/studio-api/helpers/subComposition.test.ts @@ -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 { + 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": ` +Test`, + "compositions/hero.html": ``, + }); + + const html = buildSubCompositionHtml( + dir, + "compositions/hero.html", + "/api/runtime.js", + "/api/projects/demo/preview/", + ); + + expect(html).toContain(''); + 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")'); + }); +}); diff --git a/packages/core/src/studio-api/helpers/subComposition.ts b/packages/core/src/studio-api/helpers/subComposition.ts index 00b11ea05..569cec201 100644 --- a/packages/core/src/studio-api/helpers/subComposition.ts +++ b/packages/core/src/studio-api/helpers/subComposition.ts @@ -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