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 wrapper (compositions are always templates)
const templateMatch = rawComp.match(/]*>([\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 to preserve all dependencies
const indexPath = join(projectDir, "index.html");
@@ -58,7 +75,7 @@ ${headContent}
-${content}
+${rewrittenContent}