diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index b91258370..1e2ddf89d 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -801,4 +801,101 @@ describe("bundleToSingleHtml", () => { expect(bundled).toContain("@import url('https://fonts.googleapis.com/css2?family=Inter')"); expect(bundled).toContain("margin: 0"); }); + + it("rebases url() paths in @import-resolved CSS to project root", async () => { + const dir = makeTempProject({ + "index.html": ` +
+ + + +`, + "styles/canvas.css": `@import url('./tokens.css');\nbody { margin: 0; }`, + "styles/tokens.css": `@font-face { src: url('assets/fonts/brand.woff2') format('woff2'); }`, + "styles/assets/fonts/brand.woff2": "fake-font-data", + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("url('styles/assets/fonts/brand.woff2')"); + expect(bundled).not.toContain("url('assets/fonts/brand.woff2')"); + expect(bundled).not.toContain("@import"); + }); + + it("rebases url() paths in -inlined CSS from subdirectories", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "theme/styles.css": `.bg { background: url('./images/grain.png'); }`, + "theme/images/grain.png": "fake-image-data", + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("url('theme/images/grain.png')"); + expect(bundled).not.toContain("url('./images/grain.png')"); + }); + + it("rebases url() paths with ../ traversal in nested @import", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "styles/main.css": `@import url('./base/reset.css');`, + "styles/base/reset.css": `body { background: url('../../assets/bg.png'); }`, + "assets/bg.png": "fake-image", + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("url('assets/bg.png')"); + expect(bundled).not.toContain("url('../../assets/bg.png')"); + }); + + it("preserves absolute and data url() references during rebasing", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "styles/app.css": [ + `@font-face { src: url('https://cdn.example.com/font.woff2'); }`, + `.icon { background: url('data:image/svg+xml,'); }`, + `.local { background: url('./img/bg.png'); }`, + ].join("\n"), + "styles/img/bg.png": "fake", + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("url('https://cdn.example.com/font.woff2')"); + expect(bundled).toContain("url('data:image/svg+xml,')"); + expect(bundled).toContain("url('styles/img/bg.png')"); + }); + + it("preserves url() query strings and hash fragments during rebasing", async () => { + const dir = makeTempProject({ + "index.html": ` + + + + +`, + "styles/icons.css": `.icon { background: url('./sprite.png?v=2#section'); }`, + "styles/sprite.png": "fake-sprite", + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("url('styles/sprite.png?v=2#section')"); + }); }); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index f433fed89..1bbbe89a5 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -1,5 +1,5 @@ import { readFileSync, existsSync } from "fs"; -import { join, resolve, dirname, isAbsolute, sep } from "path"; +import { join, resolve, relative, dirname, isAbsolute, sep } from "path"; import { transformSync } from "esbuild"; import { compileHtml, type MediaDurationProber } from "./htmlCompiler"; import { @@ -93,26 +93,54 @@ function safeReadFile(filePath: string): string | null { const CSS_IMPORT_RE = /@import\s+(?:url\(\s*(["']?)([^)"']+)\1\s*\)|(["'])([^"']+)\3)\s*([^;]*);\s*/g; -function resolveCssImports( +const REBASE_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g; + +function rebaseCssUrls(css: string, cssFileDir: string, projectDir: string): string { + const resolvedRoot = resolve(projectDir); + const resolvedDir = resolve(cssFileDir); + if (resolvedDir === resolvedRoot) return css; + return css.replace(REBASE_URL_RE, (full, quote: string, urlValue: string) => { + if (!urlValue || !isRelativeUrl(urlValue)) return full; + const { basePath, suffix } = splitUrlSuffix(urlValue.trim()); + if (!basePath) return full; + const absolutePath = resolve(resolvedDir, basePath); + const rebased = relative(resolvedRoot, absolutePath); + if (rebased === basePath) return full; + return `url(${quote || ""}${rebased}${suffix}${quote || ""})`; + }); +} + +function inlineCssFile( css: string, cssFileDir: string, projectDir: string, visited: Set