Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,4 +725,177 @@ 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");
});

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')");
});
});
60 changes: 57 additions & 3 deletions packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync, existsSync } from "fs";
import { join, resolve, isAbsolute, sep } from "path";
import { join, resolve, relative, dirname, isAbsolute, sep } from "path";
import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import {
Expand Down Expand Up @@ -90,6 +90,59 @@ function safeReadFile(filePath: string): string | null {
}
}

const CSS_IMPORT_RE =
/@import\s+(?:url\(\s*(["']?)([^)"']+)\1\s*\)|(["'])([^"']+)\3)\s*([^;]*);\s*/g;

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 {
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 {
if (!existsSync(filePath)) return null;
try {
Expand Down Expand Up @@ -525,9 +578,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(inlineCssFile(css, dirname(cssPath), projectDir));
if (!cssAnchorPlaced) {
const anchor = document.createElement("style");
anchor.setAttribute("data-hf-bundled-local-css", "1");
Expand Down
22 changes: 0 additions & 22 deletions packages/producer/dist/benchmark.d.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/producer/dist/benchmark.d.ts.map

This file was deleted.

6 changes: 0 additions & 6 deletions packages/producer/dist/config.d.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/producer/dist/config.d.ts.map

This file was deleted.

22 changes: 0 additions & 22 deletions packages/producer/dist/hyperframe.manifest.json

This file was deleted.

Loading
Loading