fix(core): resolve CSS @import when inlining stylesheets into bundle

The bundler inlines local CSS files by reading their content and
concatenating into a <style> block. @import statements inside those
files were left unresolved — their paths were relative to the original
CSS file location, but after inlining they resolve against the HTML
document, causing 404s for tokens, fonts, and variables.

Recursively resolve relative @import statements during CSS inlining,
with circular-import protection and @media wrapping for conditional
imports. Absolute URLs (CDN, Google Fonts) are preserved as-is.
This commit is contained in:
Miguel Ángel
2026-05-17 17:12:08 -04:00
parent 1fca35b625
commit 59ee7c2e6a
2 changed files with 105 additions and 3 deletions
@@ -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": `<!doctype html>
<html><body>
<link rel="stylesheet" href="styles/canvas.css">
<div data-composition-id="root" data-width="320" data-height="180"></div>
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
</body></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": `<!doctype html>
<html><body>
<link rel="stylesheet" href="styles/main.css">
<div data-composition-id="root" data-width="320" data-height="180"></div>
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
</body></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": `<!doctype html>
<html><body>
<link rel="stylesheet" href="print.css">
<div data-composition-id="root" data-width="320" data-height="180"></div>
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
</body></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": `<!doctype html>
<html><body>
<link rel="stylesheet" href="app.css">
<div data-composition-id="root" data-width="320" data-height="180"></div>
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
</body></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");
});
});
+29 -3
View File
@@ -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<string> = 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");