mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): rebase url() paths when inlining CSS from subdirectories
When CSS files in subdirectories are inlined into the bundle's <style> block, their url() references (fonts, images, cursors) break because they resolve relative to the HTML document root instead of the CSS file's original directory. Rebase all relative url() paths to the project root during CSS inlining, for both <link>-referenced stylesheets and @import-resolved content. Uses a placeholder approach to avoid double-rebasing when nested @import chains each carry their own url() references. Preserves absolute URLs, data URIs, query strings, and hash fragments.
This commit is contained in:
@@ -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": `<!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": `@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 <link>-inlined CSS from subdirectories", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><body>
|
||||
<link rel="stylesheet" href="theme/styles.css">
|
||||
<div data-composition-id="root" data-width="320" data-height="180"></div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines.root = {}</script>
|
||||
</body></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": `<!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/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": `<!doctype html>
|
||||
<html><body>
|
||||
<link rel="stylesheet" href="styles/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>`,
|
||||
"styles/app.css": [
|
||||
`@font-face { src: url('https://cdn.example.com/font.woff2'); }`,
|
||||
`.icon { background: url('data:image/svg+xml,<svg/>'); }`,
|
||||
`.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,<svg/>')");
|
||||
expect(bundled).toContain("url('styles/img/bg.png')");
|
||||
});
|
||||
|
||||
it("preserves url() query strings and hash fragments during rebasing", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><body>
|
||||
<link rel="stylesheet" href="styles/icons.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/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')");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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";
|
||||
});
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user