mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user