diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 93945b411..b91258370 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -725,4 +725,80 @@ describe("bundleToSingleHtml", () => { expect(bundled).toContain('url("fonts/brand.woff2")'); expect(bundled).not.toContain('url("../fonts/brand.woff2")'); }); + + it("resolves CSS @import statements when inlining stylesheets", async () => { + const dir = makeTempProject({ + "index.html": ` + + +
+ +`, + "styles/canvas.css": `@import url('./tokens.css');\nbody { margin: 0; }`, + "styles/tokens.css": `:root { --brand: #ff5728; }`, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("--brand: #ff5728"); + expect(bundled).not.toContain("@import"); + expect(bundled).toContain("margin: 0"); + }); + + it("resolves nested CSS @import chains", async () => { + const dir = makeTempProject({ + "index.html": ` + + +
+ +`, + "styles/main.css": `@import url('./base.css');\n.main { color: red; }`, + "styles/base.css": `@import url('../tokens.css');\n.base { display: flex; }`, + "tokens.css": `:root { --tk-teal: #1a3540; }`, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("--tk-teal: #1a3540"); + expect(bundled).toContain("display: flex"); + expect(bundled).toContain("color: red"); + expect(bundled).not.toContain("@import"); + }); + + it("wraps @import with media query in @media block", async () => { + const dir = makeTempProject({ + "index.html": ` + + +
+ +`, + "print.css": `@import url('./print-tokens.css') print;\nbody { font-size: 12pt; }`, + "print-tokens.css": `.print-only { display: block; }`, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("@media print"); + expect(bundled).toContain("display: block"); + expect(bundled).not.toContain("@import"); + }); + + it("preserves @import for absolute URLs", async () => { + const dir = makeTempProject({ + "index.html": ` + + +
+ +`, + "app.css": `@import url('https://fonts.googleapis.com/css2?family=Inter');\nbody { margin: 0; }`, + }); + + const bundled = await bundleToSingleHtml(dir); + + expect(bundled).toContain("@import url('https://fonts.googleapis.com/css2?family=Inter')"); + expect(bundled).toContain("margin: 0"); + }); }); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 3e1377d84..f433fed89 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, isAbsolute, sep } from "path"; +import { join, resolve, dirname, isAbsolute, sep } from "path"; import { transformSync } from "esbuild"; import { compileHtml, type MediaDurationProber } from "./htmlCompiler"; import { @@ -90,6 +90,31 @@ function safeReadFile(filePath: string): string | null { } } +const CSS_IMPORT_RE = + /@import\s+(?:url\(\s*(["']?)([^)"']+)\1\s*\)|(["'])([^"']+)\3)\s*([^;]*);\s*/g; + +function resolveCssImports( + 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"; + }); +} + function safeReadFileBuffer(filePath: string): Buffer | null { if (!existsSync(filePath)) return null; try { @@ -525,9 +550,10 @@ export async function bundleToSingleHtml( const href = el.getAttribute("href"); if (!href || !isRelativeUrl(href)) continue; const cssPath = safePath(projectDir, href); - const css = cssPath ? safeReadFile(cssPath) : null; + if (!cssPath) continue; + const css = safeReadFile(cssPath); if (css == null) continue; - localCssChunks.push(css); + localCssChunks.push(resolveCssImports(css, dirname(cssPath), projectDir)); if (!cssAnchorPlaced) { const anchor = document.createElement("style"); anchor.setAttribute("data-hf-bundled-local-css", "1");