diff --git a/packages/react/src/plugin-islands.ts b/packages/react/src/plugin-islands.ts index 1c7fe440c..b91c731b5 100644 --- a/packages/react/src/plugin-islands.ts +++ b/packages/react/src/plugin-islands.ts @@ -11,6 +11,7 @@ import { rebuildIslandCssMap, computePagesNeedingClientJS, configureIslandCssServer, + rewriteCssImportForVirtualModule, ISLANDS_RENDERER_ID, ISLANDS_CLIENT_ENTRY_ID, } from "void/pages-islands-plugin"; @@ -375,7 +376,7 @@ function generateIslandClientEntry( cssImportRe.lastIndex = 0; let match; while ((match = cssImportRe.exec(content)) !== null) { - lines.push(match[0].trim() + ";"); + lines.push(rewriteCssImportForVirtualModule(match[0], match[1], file)); } } } diff --git a/packages/solid/src/plugin-islands.ts b/packages/solid/src/plugin-islands.ts index c601aae7b..8d346a3e0 100644 --- a/packages/solid/src/plugin-islands.ts +++ b/packages/solid/src/plugin-islands.ts @@ -7,6 +7,7 @@ import { generateComponentManifest } from "./pages-manifest"; import { resolveSpecifier, needsIslandClientBundle, + rewriteCssImportForVirtualModule, fileHasCssImports, extractCssImports, rebuildIslandCssMap as rebuildIslandCssMapShared, @@ -397,7 +398,7 @@ function generateIslandClientEntry( cssImportRe.lastIndex = 0; let match; while ((match = cssImportRe.exec(content)) !== null) { - lines.push(match[0].trim() + ";"); + lines.push(rewriteCssImportForVirtualModule(match[0], match[1], file)); } } } diff --git a/packages/svelte/src/plugin-islands.ts b/packages/svelte/src/plugin-islands.ts index 382e8c30e..ea0c8824e 100644 --- a/packages/svelte/src/plugin-islands.ts +++ b/packages/svelte/src/plugin-islands.ts @@ -6,6 +6,7 @@ import type { PageScanResult } from "void/pages"; import { resolveSpecifier, needsIslandClientBundle, + rewriteCssImportForVirtualModule, fileHasCssImports, extractCssImports, rebuildIslandCssMap as rebuildIslandCssMapShared, @@ -441,7 +442,7 @@ function generateIslandClientEntry( let cssMatch; cssImportRe.lastIndex = 0; while ((cssMatch = cssImportRe.exec(scriptContent)) !== null) { - lines.push(cssMatch[0].trim() + ";"); + lines.push(rewriteCssImportForVirtualModule(cssMatch[0], cssMatch[1], file)); } } } diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 97d3eec63..2642ae2ad 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -515,8 +515,11 @@ async function runFrameworkDeploy( log.info("Wrapper generated"); } - // 8. Read wrangler compat settings (void.json > wrangler.jsonc > default) - const wranglerCompat = readWranglerCompat(root, voidConfig); + // 8. Read wrangler compat settings (void.json > wrangler.jsonc > build output > default) + // Use the original workerDir (not actualWorkerDir) because bundleEntry() may + // reassign actualWorkerDir to a temporary output — the plugin-generated + // wrangler.json with correct compat settings lives in the original build output. + const wranglerCompat = readWranglerCompat(root, voidConfig, workerDir); // 9. Resolve revalidate + prerender from void.json const revalidate = voidConfig.routing?.revalidate; @@ -837,8 +840,8 @@ async function runFullDeploy( persistDir: join(root, ".void"), }); - // 8. Read wrangler compat settings (void.json > wrangler.jsonc > default) - const wranglerCompat = readWranglerCompat(root, deployConfig); + // 8. Read wrangler compat settings (void.json > wrangler.jsonc > build output > default) + const wranglerCompat = readWranglerCompat(root, deployConfig, join(distDir, workerDirName)); // 9. Load .env vars for production (excludes .local files) const envVars = loadDeployEnv(root); diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 3d7723e32..0022db32e 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -19,12 +19,20 @@ export interface WranglerCompat { /** * Read compatibility settings from `wrangler.jsonc`/`wrangler.json` and - * `void.json`, with priority: void.json > wrangler.jsonc > built-in default. + * `void.json`, with priority: void.json > wrangler.jsonc > build output > built-in default. * * Always returns a `compatibilityDate` (falls back to `DEFAULT_COMPAT_DATE`). + * + * @param buildOutputDir - Optional path to the build output directory (e.g. + * `dist/ssr`) which may contain a plugin-generated `wrangler.json` with the + * correct compat settings for the built worker. */ -export function readWranglerCompat(root: string, config?: VoidConfig): WranglerCompat { - // 1. Read wrangler.jsonc / wrangler.json +export function readWranglerCompat( + root: string, + config?: VoidConfig, + buildOutputDir?: string, +): WranglerCompat { + // 1. Read wrangler.jsonc / wrangler.json from project root let raw: string | undefined; for (const name of ["wrangler.jsonc", "wrangler.json"]) { try { @@ -55,16 +63,37 @@ export function readWranglerCompat(root: string, config?: VoidConfig): WranglerC } } + // 1b. Read build output wrangler.json as fallback (generated by @cloudflare/vite-plugin) + let buildDate: string | undefined; + let buildFlags: string[] | undefined; + if (buildOutputDir) { + try { + const buildRaw = readFileSync(join(buildOutputDir, "wrangler.json"), "utf-8"); + const buildParsed = JSON.parse(buildRaw); + if (typeof buildParsed.compatibility_date === "string") { + buildDate = buildParsed.compatibility_date; + } + if ( + Array.isArray(buildParsed.compatibility_flags) && + buildParsed.compatibility_flags.every((f: unknown) => typeof f === "string") + ) { + buildFlags = buildParsed.compatibility_flags; + } + } catch { + // no build output wrangler.json + } + } + // 2. Override with void.json worker fields (higher priority) const voidDate = config?.worker?.compatibility_date; const voidFlags = config?.worker?.compatibility_flags; - // 3. Merge: void.json > wrangler.jsonc > default + // 3. Merge: void.json > project wrangler.jsonc > build output > default const result: WranglerCompat = { - compatibilityDate: voidDate ?? wranglerDate ?? DEFAULT_COMPAT_DATE, + compatibilityDate: voidDate ?? wranglerDate ?? buildDate ?? DEFAULT_COMPAT_DATE, }; - const flags = voidFlags ?? wranglerFlags; + const flags = voidFlags ?? wranglerFlags ?? buildFlags; if (flags && flags.length > 0) { result.compatibilityFlags = flags; } diff --git a/packages/void/src/pages/islands-plugin.ts b/packages/void/src/pages/islands-plugin.ts index 67ea683fa..0b86e1ff1 100644 --- a/packages/void/src/pages/islands-plugin.ts +++ b/packages/void/src/pages/islands-plugin.ts @@ -1,6 +1,6 @@ import { normalizePath, type ViteDevServer } from "vite"; import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { resolveEffectiveLayoutChain } from "./scan"; import type { PageScanResult } from "./types"; @@ -75,6 +75,22 @@ export function fileHasCssImports(filePath: string): boolean { } } +/** + * Rewrite a matched CSS import line so relative specifiers resolve from the + * virtual module. Absolute and bare specifiers are kept as-is. + */ +export function rewriteCssImportForVirtualModule( + matchedLine: string, + specifier: string, + sourceFilePath: string, +): string { + if (specifier.startsWith(".")) { + const absPath = normalizePath(resolve(dirname(sourceFilePath), specifier)); + return `import ${JSON.stringify(absPath)};`; + } + return matchedLine.trim() + ";"; +} + /** Extract CSS import statements from a file and return them as @import rules. */ export function extractCssImports(filePath: string): string { const code = readFileSync(filePath, "utf-8"); diff --git a/packages/void/test/unit/island-css-rewrite.test.ts b/packages/void/test/unit/island-css-rewrite.test.ts new file mode 100644 index 000000000..bf3e4a038 --- /dev/null +++ b/packages/void/test/unit/island-css-rewrite.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { rewriteCssImportForVirtualModule } from "../../src/pages/islands-plugin"; + +describe("rewriteCssImportForVirtualModule", () => { + it("rewrites relative CSS import to absolute path", () => { + const result = rewriteCssImportForVirtualModule( + 'import "./global.css"', + "./global.css", + "/project/pages/layout.tsx", + ); + expect(result).toBe('import "/project/pages/global.css";'); + }); + + it("rewrites parent-relative CSS import to absolute path", () => { + const result = rewriteCssImportForVirtualModule( + 'import "../styles/theme.css"', + "../styles/theme.css", + "/project/pages/blog/index.tsx", + ); + expect(result).toBe('import "/project/pages/styles/theme.css";'); + }); + + it("preserves bare specifiers (package imports)", () => { + const result = rewriteCssImportForVirtualModule( + 'import "@void/md/theme.css"', + "@void/md/theme.css", + "/project/pages/layout.tsx", + ); + expect(result).toBe('import "@void/md/theme.css";'); + }); + + it("preserves absolute specifiers", () => { + const result = rewriteCssImportForVirtualModule( + 'import "/absolute/path/style.css"', + "/absolute/path/style.css", + "/project/pages/layout.tsx", + ); + expect(result).toBe('import "/absolute/path/style.css";'); + }); + + it("trims whitespace from non-relative imports", () => { + const result = rewriteCssImportForVirtualModule( + ' import "some-lib/styles.css" ', + "some-lib/styles.css", + "/project/pages/layout.tsx", + ); + expect(result).toBe('import "some-lib/styles.css";'); + }); + + it("normalizes backslashes to forward slashes", () => { + // normalizePath ensures Windows-style paths become valid import specifiers + const result = rewriteCssImportForVirtualModule( + 'import "./card.css"', + "./card.css", + "/project/pages/components/deep/Card.tsx", + ); + // Should always use forward slashes regardless of OS + expect(result).not.toContain("\\"); + expect(result).toBe('import "/project/pages/components/deep/card.css";'); + }); +}); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 5b7f1c054..24f06d899 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -134,6 +134,102 @@ describe("readWranglerCompat", () => { setup("wrangler.json", '{ "compatibility_date": "2026-02" }'); expect(() => readWranglerCompat(tmpDir)).toThrow("YYYY-MM-DD format"); }); + + it("falls back to build output wrangler.json when no project config exists", () => { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + const buildDir = join(tmpDir, "dist", "ssr"); + mkdirSync(buildDir, { recursive: true }); + writeFileSync( + join(buildDir, "wrangler.json"), + JSON.stringify({ + compatibility_date: "2026-03-29", + compatibility_flags: ["nodejs_als"], + }), + ); + expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({ + compatibilityDate: "2026-03-29", + compatibilityFlags: ["nodejs_als"], + }); + }); + + it("project wrangler.json takes priority over build output", () => { + setup( + "wrangler.json", + JSON.stringify({ + compatibility_date: "2026-04-01", + compatibility_flags: ["nodejs_compat"], + }), + ); + const buildDir = join(tmpDir, "dist", "ssr"); + mkdirSync(buildDir, { recursive: true }); + writeFileSync( + join(buildDir, "wrangler.json"), + JSON.stringify({ + compatibility_date: "2026-03-29", + compatibility_flags: ["nodejs_als"], + }), + ); + expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({ + compatibilityDate: "2026-04-01", + compatibilityFlags: ["nodejs_compat"], + }); + }); + + it("void.json takes priority over both project and build output", () => { + setup( + "wrangler.json", + JSON.stringify({ compatibility_date: "2026-01-01", compatibility_flags: ["flag_a"] }), + ); + const buildDir = join(tmpDir, "dist", "ssr"); + mkdirSync(buildDir, { recursive: true }); + writeFileSync( + join(buildDir, "wrangler.json"), + JSON.stringify({ compatibility_date: "2026-02-01", compatibility_flags: ["flag_b"] }), + ); + const config: VoidConfig = { + worker: { compatibility_date: "2026-06-01", compatibility_flags: ["flag_c"] }, + }; + expect(readWranglerCompat(tmpDir, config, buildDir)).toEqual({ + compatibilityDate: "2026-06-01", + compatibilityFlags: ["flag_c"], + }); + }); + + it("build output date used when project has no date but build output does", () => { + setup("wrangler.json", '{ "name": "test" }'); + const buildDir = join(tmpDir, "dist", "ssr"); + mkdirSync(buildDir, { recursive: true }); + writeFileSync( + join(buildDir, "wrangler.json"), + JSON.stringify({ compatibility_date: "2026-03-29" }), + ); + expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({ + compatibilityDate: "2026-03-29", + }); + }); + + it("build output flags used when project has no flags but build output does", () => { + setup("wrangler.json", '{ "compatibility_date": "2026-04-01" }'); + const buildDir = join(tmpDir, "dist", "ssr"); + mkdirSync(buildDir, { recursive: true }); + writeFileSync( + join(buildDir, "wrangler.json"), + JSON.stringify({ compatibility_flags: ["nodejs_als"] }), + ); + expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({ + compatibilityDate: "2026-04-01", + compatibilityFlags: ["nodejs_als"], + }); + }); + + it("ignores missing build output directory gracefully", () => { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + expect(readWranglerCompat(tmpDir, undefined, join(tmpDir, "nonexistent"))).toEqual({ + compatibilityDate: DEFAULT_COMPAT_DATE, + }); + }); }); const noBindings: InferredBindings = { diff --git a/packages/vue/src/plugin-islands.ts b/packages/vue/src/plugin-islands.ts index 4fa53de03..b65ea45e2 100644 --- a/packages/vue/src/plugin-islands.ts +++ b/packages/vue/src/plugin-islands.ts @@ -6,6 +6,7 @@ import type { PageScanResult } from "void/pages"; import { resolveSpecifier, needsIslandClientBundle, + rewriteCssImportForVirtualModule, rebuildIslandCssMap as rebuildIslandCssMapShared, computePagesNeedingClientJS as computePagesNeedingClientJSShared, configureIslandCssServer, @@ -442,7 +443,7 @@ function generateIslandClientEntry( // Extract JS-style CSS imports (e.g. import "@void/md/theme.css") cssImportRe.lastIndex = 0; while ((match = cssImportRe.exec(content)) !== null) { - lines.push(match[0].trim() + ";"); + lines.push(rewriteCssImportForVirtualModule(match[0], match[1], file)); } } }