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
3 changes: 2 additions & 1 deletion packages/react/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
rebuildIslandCssMap,
computePagesNeedingClientJS,
configureIslandCssServer,
rewriteCssImportForVirtualModule,
ISLANDS_RENDERER_ID,
ISLANDS_CLIENT_ENTRY_ID,
} from "void/pages-islands-plugin";
Expand Down Expand Up @@ -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));
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/solid/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { generateComponentManifest } from "./pages-manifest";
import {
resolveSpecifier,
needsIslandClientBundle,
rewriteCssImportForVirtualModule,
fileHasCssImports,
extractCssImports,
rebuildIslandCssMap as rebuildIslandCssMapShared,
Expand Down Expand Up @@ -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));
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion packages/svelte/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { PageScanResult } from "void/pages";
import {
resolveSpecifier,
needsIslandClientBundle,
rewriteCssImportForVirtualModule,
fileHasCssImports,
extractCssImports,
rebuildIslandCssMap as rebuildIslandCssMapShared,
Expand Down Expand Up @@ -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));
}
}
}
Expand Down
11 changes: 7 additions & 4 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
41 changes: 35 additions & 6 deletions packages/void/src/cli/wrangler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
18 changes: 17 additions & 1 deletion packages/void/src/pages/islands-plugin.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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");
Expand Down
61 changes: 61 additions & 0 deletions packages/void/test/unit/island-css-rewrite.test.ts
Original file line number Diff line number Diff line change
@@ -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";');
});
});
96 changes: 96 additions & 0 deletions packages/void/test/unit/wrangler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
3 changes: 2 additions & 1 deletion packages/vue/src/plugin-islands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { PageScanResult } from "void/pages";
import {
resolveSpecifier,
needsIslandClientBundle,
rewriteCssImportForVirtualModule,
rebuildIslandCssMap as rebuildIslandCssMapShared,
computePagesNeedingClientJS as computePagesNeedingClientJSShared,
configureIslandCssServer,
Expand Down Expand Up @@ -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));
}
}
}
Expand Down
Loading