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