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 = new Set(), ): string { - return css.replace(CSS_IMPORT_RE, (full, _q1, urlPath, _q2, barePath, mediaQuery) => { - const importPath = urlPath ?? barePath; - if (!importPath || !isRelativeUrl(importPath)) return full; - const resolved = resolve(cssFileDir, importPath); - const normalizedBase = resolve(projectDir) + sep; - if (!resolved.startsWith(normalizedBase) || visited.has(resolved)) return full; - const content = safeReadFile(resolved); - if (content == null) return full; - visited.add(resolved); - const inlined = resolveCssImports(content, dirname(resolved), projectDir, visited); - const trimmedMedia = (mediaQuery || "").trim(); - if (trimmedMedia) return `@media ${trimmedMedia} {\n${inlined}\n}\n`; - return inlined + "\n"; - }); + const placeholders: string[] = []; + const withPlaceholders = css.replace( + CSS_IMPORT_RE, + (full, _q1, urlPath, _q2, barePath, mediaQuery) => { + const importPath = urlPath ?? barePath; + if (!importPath || !isRelativeUrl(importPath)) return full; + const resolved = resolve(cssFileDir, importPath); + const normalizedBase = resolve(projectDir) + sep; + if (!resolved.startsWith(normalizedBase) || visited.has(resolved)) return full; + const content = safeReadFile(resolved); + if (content == null) return full; + visited.add(resolved); + const inlined = inlineCssFile(content, dirname(resolved), projectDir, visited); + const trimmedMedia = (mediaQuery || "").trim(); + const block = trimmedMedia ? `@media ${trimmedMedia} {\n${inlined}\n}\n` : inlined + "\n"; + const idx = placeholders.length; + placeholders.push(block); + return `/*__hf_import_${idx}__*/`; + }, + ); + let rebased = rebaseCssUrls(withPlaceholders, cssFileDir, projectDir); + for (let i = 0; i < placeholders.length; i++) { + rebased = rebased.replace(`/*__hf_import_${i}__*/`, placeholders[i]!); + } + return rebased; } function safeReadFileBuffer(filePath: string): Buffer | null { @@ -553,7 +581,7 @@ export async function bundleToSingleHtml( if (!cssPath) continue; const css = safeReadFile(cssPath); if (css == null) continue; - localCssChunks.push(resolveCssImports(css, dirname(cssPath), projectDir)); + localCssChunks.push(inlineCssFile(css, dirname(cssPath), projectDir)); if (!cssAnchorPlaced) { const anchor = document.createElement("style"); anchor.setAttribute("data-hf-bundled-local-css", "1");