From 11141974948ebe9ddd46b91266085ddba3e5d76d Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 22 Aug 2026 20:55:29 +0800 Subject: [PATCH 01/18] feat(init): detect css modules and auto-install compatibility workarounds (#2992) --- packages/vinext/src/init-cloudflare.ts | 401 ++++++++++++++++++++++++- packages/vinext/src/init.ts | 94 +++++- tests/init-cloudflare.test.ts | 209 +++++++++++++ tests/init.test.ts | 90 ++++++ 4 files changed, 776 insertions(+), 18 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index f56591f78..8f3e2411e 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -28,6 +28,7 @@ export type CloudflarePlatformSetupContext = { isAppRouter: boolean; existingViteConfigPath?: string; prerender?: boolean; + hasCssModules?: boolean; today?: string; }; @@ -36,6 +37,7 @@ export type CloudflarePlatformSetupResult = { skippedViteConfig: boolean; generatedPlatformFiles: string[]; nextSteps: string[]; + preservedExistingGenerateScopedName: boolean; }; export function validateCloudflarePlatformSetup( @@ -62,7 +64,7 @@ export function validateCloudflarePlatformSetup( : "IMAGES"; if (context.existingViteConfigPath) { - updateViteConfigForCloudflare( + const cloudflareConfig = updateViteConfigForCloudflare( context.existingViteConfigPath, fs.readFileSync(context.existingViteConfigPath, "utf-8"), { @@ -73,6 +75,9 @@ export function validateCloudflarePlatformSetup( prerender: context.prerender, }, ); + if (context.hasCssModules) { + updateViteConfigForCssModules(context.existingViteConfigPath, cloudflareConfig); + } } } @@ -89,9 +94,10 @@ export function setupCloudflarePlatform( let generatedViteConfig = false; let skippedViteConfig = false; + let preservedExistingGenerateScopedName = false; if (context.existingViteConfigPath) { const currentConfig = fs.readFileSync(context.existingViteConfigPath, "utf-8"); - const updatedConfig = updateViteConfigForCloudflare( + let updatedConfig = updateViteConfigForCloudflare( context.existingViteConfigPath, currentConfig, { @@ -102,6 +108,14 @@ export function setupCloudflarePlatform( prerender: context.prerender, }, ); + if (context.hasCssModules) { + const cssUpdate = updateViteConfigForCssModules( + context.existingViteConfigPath, + updatedConfig, + ); + updatedConfig = cssUpdate.code; + preservedExistingGenerateScopedName = cssUpdate.preservedExistingGenerateScopedName; + } if (updatedConfig !== currentConfig) { fs.writeFileSync(context.existingViteConfigPath, updatedConfig, "utf-8"); generatedViteConfig = true; @@ -110,8 +124,20 @@ export function setupCloudflarePlatform( } } else { const configContent = context.isAppRouter - ? generateAppRouterViteConfig(projectInfo, cloudflare, imagesBinding, context.prerender) - : generatePagesRouterViteConfig(projectInfo, cloudflare, imagesBinding, context.prerender); + ? generateAppRouterViteConfig( + projectInfo, + cloudflare, + imagesBinding, + context.prerender, + context.hasCssModules, + ) + : generatePagesRouterViteConfig( + projectInfo, + cloudflare, + imagesBinding, + context.prerender, + context.hasCssModules, + ); fs.writeFileSync(path.join(context.root, "vite.config.ts"), configContent, "utf-8"); generatedViteConfig = true; } @@ -162,6 +188,7 @@ export function setupCloudflarePlatform( ' Set its "id" value, replacing "" if present.', ] : [], + preservedExistingGenerateScopedName, }; } @@ -504,26 +531,66 @@ function vinextExpression( : `${binding}({\n ${optionEntries.join(",\n ")},\n})`; } +function generateScopedNameMethodSource( + indent: string, + pathBinding: string, + createHashBinding: string, + rootExpression = "import.meta.dirname", +): string { + return `${indent}generateScopedName(name, filename) { +${indent} const relativePath = ${pathBinding} +${indent} .relative(${rootExpression}, filename.replace(/\\?.*$/, "")) +${indent} .replaceAll("\\\\", "/"); +${indent} const hash = ${createHashBinding}("sha256") +${indent} .update(relativePath) +${indent} .digest("hex") +${indent} .slice(0, 7); +${indent} return \`_\${name}_\${hash}\`; +${indent}}`; +} + +function cssModulesConfigSource( + indent: string, + pathBinding: string, + createHashBinding: string, + rootExpression = "import.meta.dirname", +): string { + return `\n${indent}css: { +${indent} modules: { +${generateScopedNameMethodSource(`${indent} `, pathBinding, createHashBinding, rootExpression)}, +${indent} }, +${indent}},`; +} + /** Generate vite.config.ts for App Router */ export function generateAppRouterViteConfig( info?: CloudflareProjectInfo, options: CloudflareInitOptions = DEFAULT_CLOUDFLARE_INIT_OPTIONS, imagesBinding = "IMAGES", prerender = false, + hasCssModules = false, ): string { const imports: string[] = [ `import { defineConfig } from "vite";`, `import vinext from "vinext";`, `import { cloudflare } from "@cloudflare/vite-plugin";`, + ...(hasCssModules + ? [ + `import { createHash } from "node:crypto";`, + `import path from "node:path";`, + `import { patchCssModules } from "vite-css-modules";`, + ] + : []), ...cacheImports(options), ]; - if (info?.nativeModulesToStub && info.nativeModulesToStub.length > 0) { + if (!hasCssModules && info?.nativeModulesToStub && info.nativeModulesToStub.length > 0) { imports.push(`import path from "node:path";`); } const plugins: string[] = []; + if (hasCssModules) plugins.push(` patchCssModules(),`); if (info?.hasMDX) { plugins.push(` // vinext auto-injects @mdx-js/rollup with plugins from next.config`); } @@ -553,12 +620,14 @@ export function generateAppRouterViteConfig( resolveBlock = `\n resolve: {\n alias: {\n${aliases.join("\n")}\n },\n },`; } + const cssModulesConfig = hasCssModules ? cssModulesConfigSource(" ", "path", "createHash") : ""; + return `${imports.join("\n")} export default defineConfig({ plugins: [ ${plugins.join("\n")} - ],${resolveBlock} + ],${resolveBlock}${cssModulesConfig} }); `; } @@ -569,15 +638,23 @@ export function generatePagesRouterViteConfig( options: CloudflareInitOptions = DEFAULT_CLOUDFLARE_INIT_OPTIONS, imagesBinding = "IMAGES", prerender = false, + hasCssModules = false, ): string { const imports: string[] = [ `import { defineConfig } from "vite";`, `import vinext from "vinext";`, `import { cloudflare } from "@cloudflare/vite-plugin";`, + ...(hasCssModules + ? [ + `import { createHash } from "node:crypto";`, + `import path from "node:path";`, + `import { patchCssModules } from "vite-css-modules";`, + ] + : []), ...cacheImports(options), ]; - if (info?.nativeModulesToStub && info.nativeModulesToStub.length > 0) { + if (!hasCssModules && info?.nativeModulesToStub && info.nativeModulesToStub.length > 0) { imports.push(`import path from "node:path";`); } @@ -596,13 +673,16 @@ export function generatePagesRouterViteConfig( resolveBlock = `\n resolve: {\n alias: {\n${aliases.join("\n")}\n },\n },`; } + const cssModulesPlugin = hasCssModules ? " patchCssModules(),\n" : ""; + const cssModulesConfig = hasCssModules ? cssModulesConfigSource(" ", "path", "createHash") : ""; + return `${imports.join("\n")} export default defineConfig({ plugins: [ - ${vinextExpression(options, "vinext", "imagesOptimizer", imagesBinding, prerender).replace(/\n/g, "\n ")}, +${cssModulesPlugin} ${vinextExpression(options, "vinext", "imagesOptimizer", imagesBinding, prerender).replace(/\n/g, "\n ")}, cloudflare(), - ],${resolveBlock} + ],${resolveBlock}${cssModulesConfig} }); `; } @@ -828,6 +908,18 @@ function findImportedBinding( return undefined; } +function findNamespaceImportedBinding(program: ESTree.Program, source: string): string | undefined { + for (const statement of program.body) { + if (statement.type !== "ImportDeclaration" || statement.source.value !== source) continue; + const namespace = statement.specifiers.find( + (specifier): specifier is ESTree.ImportNamespaceSpecifier => + specifier.type === "ImportNamespaceSpecifier", + ); + if (namespace) return namespace.local.name; + } + return undefined; +} + function ensureNamedImport( program: ESTree.Program, output: MagicString, @@ -963,11 +1055,38 @@ function insertObjectProperty( object: AstObject, source: string, code: string, + normalizeEmptyObject = false, ): void { const offset = object.end - 1; - const hasProperties = object.properties.length > 0; - const hasTrailingComma = /,\s*$/.test(code.slice(object.start + 1, offset)); - output.appendLeft(offset, `${hasProperties && !hasTrailingComma ? "," : ""}\n${source}\n`); + if (normalizeEmptyObject && object.properties.length === 0) { + let triviaStart = offset; + while (triviaStart > object.start + 1 && /\s/.test(code[triviaStart - 1])) { + triviaStart--; + } + const lineStart = code.lastIndexOf("\n", object.start - 1) + 1; + const closingIndent = code.slice(lineStart, object.start).match(/^\s*/)?.[0] ?? ""; + const replacement = `\n${source}\n${closingIndent}`; + if (triviaStart === offset) output.appendLeft(offset, replacement); + else output.overwrite(triviaStart, offset, replacement); + return; + } + let insertionOffset = offset; + while (insertionOffset > object.start && /[\t ]/.test(code[insertionOffset - 1])) { + insertionOffset--; + } + if (insertionOffset > object.start && code[insertionOffset - 1] !== "\n") { + insertionOffset = offset; + } + const lastProperty = object.properties.at(-1) as AstNode | undefined; + if (lastProperty) { + const suffix = code.slice(lastProperty.end, insertionOffset); + if (!endsWithCommaIgnoringWhitespaceAndComments(suffix)) { + output.appendLeft(lastProperty.end, ","); + } + output.appendLeft(insertionOffset, `${suffix.includes("\n") ? "" : "\n"}${source}\n`); + return; + } + output.appendLeft(insertionOffset, `\n${source}\n`); } function endsWithCommaIgnoringWhitespaceAndComments(code: string): boolean { @@ -1104,6 +1223,26 @@ function findPluginCall( ); } +function findPluginMemberCall( + config: AstObject, + objectBinding: string | undefined, + member: string, +): (ESTree.CallExpression & AstNode) | undefined { + if (!objectBinding) return undefined; + const plugins = findProperty(config, "plugins"); + if (!plugins || plugins.value.type !== "ArrayExpression") return undefined; + return plugins.value.elements.find( + (element): element is ESTree.CallExpression & AstNode => + element?.type === "CallExpression" && + element.callee.type === "MemberExpression" && + !element.callee.computed && + element.callee.object.type === "Identifier" && + element.callee.object.name === objectBinding && + element.callee.property.type === "Identifier" && + element.callee.property.name === member, + ); +} + function hasVinextCacheSlot( call: (ESTree.CallExpression & AstNode) | undefined, name: "data" | "cdn", @@ -1379,6 +1518,244 @@ function ensurePlugins( ); } +function objectPropertyIndent(object: AstObject, code: string): string { + const existing = object.properties.find((property) => property.type === "Property"); + if (existing) { + return ( + code + .slice(0, (existing as AstNode).start) + .split("\n") + .at(-1) + ?.match(/^\s*/)?.[0] ?? "" + ); + } + const objectIndent = code.slice(0, object.start).split("\n").at(-1)?.match(/^\s*/)?.[0] ?? ""; + return `${objectIndent} `; +} + +function ensurePluginFirst( + output: MagicString, + config: AstObject, + expression: string, + binding: string, + code: string, +): void { + const plugins = findProperty(config, "plugins"); + if (!plugins) { + const indent = objectPropertyIndent(config, code); + insertObjectProperty(output, config, `${indent}plugins: [${expression}],`, code, true); + return; + } + if (plugins.value.type !== "ArrayExpression") { + throw new Error( + "The Vite config's plugins option must be an array for vinext init to configure CSS Modules.", + ); + } + const array = plugins.value as ESTree.ArrayExpression & AstNode; + const alreadyConfigured = array.elements.some( + (element) => + element?.type === "CallExpression" && + element.callee.type === "Identifier" && + element.callee.name === binding, + ); + if (alreadyConfigured) return; + + const firstElement = array.elements.find((element) => element !== null); + if (!firstElement) { + const interior = code.slice(array.start + 1, array.end - 1); + if (interior.trim() === "") { + if (array.start + 1 === array.end - 1) output.appendLeft(array.end - 1, expression); + else output.overwrite(array.start + 1, array.end - 1, expression); + } else { + const propertyIndent = + code + .slice(0, (plugins as AstNode).start) + .split("\n") + .at(-1) + ?.match(/^\s*/)?.[0] ?? ""; + let triviaEnd = array.end - 1; + while (triviaEnd > array.start + 1 && /\s/.test(code[triviaEnd - 1])) triviaEnd--; + output.overwrite( + triviaEnd, + array.end - 1, + `\n${propertyIndent} ${expression}\n${propertyIndent}`, + ); + } + return; + } + const inline = !code.slice(array.start, array.end).includes("\n"); + if (inline) { + output.appendLeft((firstElement as AstNode).start, `${expression}, `); + return; + } + const indent = + code + .slice(0, (firstElement as AstNode).start) + .split("\n") + .at(-1) + ?.match(/^\s*/)?.[0] ?? ""; + output.appendLeft((firstElement as AstNode).start, `${expression},\n${indent}`); +} + +function ensureCssModulesScopedName( + output: MagicString, + config: AstObject, + code: string, + pathBinding: string, + createHashBinding: string, + rootExpression: string, +): boolean { + const css = findProperty(config, "css"); + if (!css) { + const indent = objectPropertyIndent(config, code); + insertObjectProperty( + output, + config, + `${indent}css: {\n${indent} modules: {\n${generateScopedNameMethodSource( + `${indent} `, + pathBinding, + createHashBinding, + rootExpression, + )},\n${indent} },\n${indent}},`, + code, + true, + ); + return false; + } + if (css.value.type !== "ObjectExpression") { + throw new Error( + "The Vite config's css option must be a static object for vinext init to configure CSS Modules.", + ); + } + const cssObject = css.value as AstObject; + const modules = findProperty(cssObject, "modules"); + if (!modules) { + const indent = objectPropertyIndent(cssObject, code); + insertObjectProperty( + output, + cssObject, + `${indent}modules: {\n${generateScopedNameMethodSource( + `${indent} `, + pathBinding, + createHashBinding, + rootExpression, + )},\n${indent}},`, + code, + true, + ); + return false; + } + if (modules.value.type !== "ObjectExpression") { + throw new Error( + "The Vite config's css.modules option must be a static object for vinext init to configure CSS Modules.", + ); + } + const modulesObject = modules.value as AstObject; + if (findProperty(modulesObject, "generateScopedName")) return true; + const indent = objectPropertyIndent(modulesObject, code); + insertObjectProperty( + output, + modulesObject, + `${generateScopedNameMethodSource(indent, pathBinding, createHashBinding, rootExpression)},`, + code, + true, + ); + return false; +} + +export type CssModulesConfigUpdate = { + code: string; + changed: boolean; + preservedExistingGenerateScopedName: boolean; +}; + +/** Add the issue #2992 CSS Modules workaround without replacing user config. */ +export function updateViteConfigForCssModules( + filePath: string, + code: string, +): CssModulesConfigUpdate { + const firstProgram = parseViteConfig(filePath, code); + const firstConfig = findConfigObject(firstProgram); + if (!firstConfig) { + throw new Error( + `Could not find a static Vite config object in ${path.basename(filePath)}. Use an object export or defineConfig({...}) so vinext init can configure CSS Modules.`, + ); + } + const commonJs = usesCommonJsViteConfig(filePath, code); + const bindings = collectTopLevelBindings(firstProgram); + const firstOutput = new MagicString(code); + + const patchNamespace = commonJs + ? findRequiredBinding(firstProgram, "vite-css-modules", "default") + : findNamespaceImportedBinding(firstProgram, "vite-css-modules"); + const existingMemberCall = findPluginMemberCall(firstConfig, patchNamespace, "patchCssModules"); + const existingPatch = commonJs + ? findRequiredBinding(firstProgram, "vite-css-modules", "patchCssModules") + : findImportedBinding(firstProgram, "vite-css-modules", "patchCssModules"); + if (!existingMemberCall) { + const patchLocal = existingPatch ?? allocateBinding(bindings, "patchCssModules"); + const patchBinding = commonJs + ? ensureNamedRequire( + firstProgram, + firstOutput, + "vite-css-modules", + "patchCssModules", + patchLocal, + ) + : ensureNamedImport( + firstProgram, + firstOutput, + "vite-css-modules", + "patchCssModules", + patchLocal, + ); + ensurePluginFirst(firstOutput, firstConfig, `${patchBinding}()`, patchBinding, code); + } + + const withPlugin = firstOutput.toString(); + const secondProgram = parseViteConfig(filePath, withPlugin); + const secondConfig = findConfigObject(secondProgram)!; + const secondBindings = collectTopLevelBindings(secondProgram); + const secondOutput = new MagicString(withPlugin); + const existingCreateHash = commonJs + ? findRequiredBinding(secondProgram, "node:crypto", "createHash") + : findImportedBinding(secondProgram, "node:crypto", "createHash"); + const createHashLocal = existingCreateHash ?? allocateBinding(secondBindings, "createHash"); + const createHashBinding = commonJs + ? ensureNamedRequire(secondProgram, secondOutput, "node:crypto", "createHash", createHashLocal) + : ensureNamedImport(secondProgram, secondOutput, "node:crypto", "createHash", createHashLocal); + const existingPath = commonJs + ? findRequiredBinding(secondProgram, "node:path", "default") + : secondProgram.body + .filter( + (statement): statement is ESTree.ImportDeclaration => + statement.type === "ImportDeclaration", + ) + .find((statement) => statement.source.value === "node:path") + ?.specifiers.find( + (specifier): specifier is ESTree.ImportDefaultSpecifier => + specifier.type === "ImportDefaultSpecifier", + )?.local.name; + const pathLocal = existingPath ?? allocateBinding(secondBindings, "path"); + const pathBinding = commonJs + ? ensureDefaultRequire(secondProgram, secondOutput, "node:path", pathLocal) + : ensureDefaultImport(secondProgram, secondOutput, "node:path", pathLocal); + const preservedExistingGenerateScopedName = ensureCssModulesScopedName( + secondOutput, + secondConfig, + withPlugin, + pathBinding, + createHashBinding, + commonJs ? "__dirname" : "import.meta.dirname", + ); + const updated = secondOutput.toString(); + return { + code: updated, + changed: updated !== code, + preservedExistingGenerateScopedName, + }; +} + function ensureNativeAliases( output: MagicString, config: AstObject, diff --git a/packages/vinext/src/init.ts b/packages/vinext/src/init.ts index 6e490c8b4..91ad88959 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -30,6 +30,7 @@ import { } from "./utils/project.js"; import { setupCloudflarePlatform, + updateViteConfigForCssModules, usesCommonJsViteConfig, validateCloudflarePlatformSetup, } from "./init-cloudflare.js"; @@ -139,15 +140,56 @@ type InitResult = { // ─── Vite Config Generation (minimal, non-Cloudflare) ──────────────────────── -export function generateViteConfig(_isAppRouter: boolean, prerender = false): string { +export function generateViteConfig( + _isAppRouter: boolean, + prerender = false, + hasCssModules = false, +): string { const vinextCall = prerender ? `vinext({ prerender: { routes: "*" } })` : "vinext()"; - return `import vinext from "vinext"; + const baseConfig = `import vinext from "vinext"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [${vinextCall}], }); `; + return hasCssModules + ? updateViteConfigForCssModules("vite.config.ts", baseConfig).code + : baseConfig; +} + +const CSS_MODULE_PATTERN = /\.module\.(?:css|scss|sass)$/; +const CSS_MODULE_SCAN_IGNORES = new Set([ + "node_modules", + ".next", + ".vinext", + ".wrangler", + "dist", + "out", + "build", + "coverage", +]); + +/** Detect project-owned CSS, SCSS, or Sass module files. */ +export function scanCssModuleFiles(root: string): boolean { + const walk = (current: string): boolean => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + return false; + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (entry.name.startsWith(".") || CSS_MODULE_SCAN_IGNORES.has(entry.name)) continue; + if (walk(path.join(current, entry.name))) return true; + } else if (entry.isFile() && CSS_MODULE_PATTERN.test(entry.name)) { + return true; + } + } + return false; + }; + return walk(root); } // ─── Script Addition ───────────────────────────────────────────────────────── @@ -219,6 +261,7 @@ export type InitDependencyGroups = { export function getInitDependencyGroups( isAppRouter: boolean, platform: InitPlatform, + hasCssModules = false, ): InitDependencyGroups { const dependencies = ["vinext"]; const devDependencies = ["vite", "@vitejs/plugin-react"]; @@ -230,11 +273,16 @@ export function getInitDependencyGroups( dependencies.push("@vinext/cloudflare"); devDependencies.push("@cloudflare/vite-plugin", "wrangler"); } + if (hasCssModules) devDependencies.push("vite-css-modules"); return { dependencies, devDependencies }; } -export function getInitDeps(isAppRouter: boolean, platform: InitPlatform): string[] { - const groups = getInitDependencyGroups(isAppRouter, platform); +export function getInitDeps( + isAppRouter: boolean, + platform: InitPlatform, + hasCssModules = false, +): string[] { + const groups = getInitDependencyGroups(isAppRouter, platform, hasCssModules); return [...groups.dependencies, ...groups.devDependencies]; } @@ -417,6 +465,7 @@ type PlatformSetupContext = { viteConfigExists: boolean; force: boolean; prerender?: boolean; + hasCssModules: boolean; today?: string; }; @@ -425,21 +474,37 @@ type PlatformSetupResult = { skippedViteConfig: boolean; generatedPlatformFiles: string[]; nextSteps: string[]; + preservedExistingGenerateScopedName: boolean; }; function setupNodePlatform(context: PlatformSetupContext): PlatformSetupResult { if (context.viteConfigExists && !context.force) { + if (context.hasCssModules && context.existingViteConfigPath) { + const currentConfig = fs.readFileSync(context.existingViteConfigPath, "utf-8"); + const update = updateViteConfigForCssModules(context.existingViteConfigPath, currentConfig); + if (update.changed) { + fs.writeFileSync(context.existingViteConfigPath, update.code, "utf-8"); + } + return { + generatedViteConfig: update.changed, + skippedViteConfig: !update.changed, + generatedPlatformFiles: [], + nextSteps: [], + preservedExistingGenerateScopedName: update.preservedExistingGenerateScopedName, + }; + } return { generatedViteConfig: false, skippedViteConfig: true, generatedPlatformFiles: [], nextSteps: [], + preservedExistingGenerateScopedName: false, }; } fs.writeFileSync( context.existingViteConfigPath ?? path.join(context.root, "vite.config.ts"), - generateViteConfig(context.isAppRouter, context.prerender), + generateViteConfig(context.isAppRouter, context.prerender, context.hasCssModules), "utf-8", ); return { @@ -447,6 +512,7 @@ function setupNodePlatform(context: PlatformSetupContext): PlatformSetupResult { skippedViteConfig: false, generatedPlatformFiles: [], nextSteps: [], + preservedExistingGenerateScopedName: false, }; } @@ -520,6 +586,7 @@ export async function init(options: InitOptions): Promise { const viteConfigExists = hasViteConfig(root); const isApp = detectProject(root).isAppRouter; + const hasCssModules = scanCssModuleFiles(root); const pmName = detectPackageManagerName(root); const shouldInstall = options.install ?? true; @@ -530,10 +597,16 @@ export async function init(options: InitOptions): Promise { isAppRouter: isApp, existingViteConfigPath, prerender: options.prerender, + hasCssModules, today: options._today, }, options.cloudflare!, ); + } else if (hasCssModules && existingViteConfigPath) { + updateViteConfigForCssModules( + existingViteConfigPath, + fs.readFileSync(existingViteConfigPath, "utf-8"), + ); } // ── Step 1: Compatibility check ──────────────────────────────────────── @@ -579,6 +652,7 @@ export async function init(options: InitOptions): Promise { viteConfigExists, force: options.force ?? false, prerender: options.prerender, + hasCssModules, today: options._today, }; const platformSetup = @@ -593,7 +667,7 @@ export async function init(options: InitOptions): Promise { // ── Step 6: Install dependencies last ────────────────────────────────── - const neededDeps = getInitDependencyGroups(isApp, platform); + const neededDeps = getInitDependencyGroups(isApp, platform, hasCssModules); const missingDependencies = neededDeps.dependencies.filter((dep) => !isDepInstalled(root, dep)); const missingDevDependencies = neededDeps.devDependencies.filter( (dep) => !isDepInstalled(root, dep), @@ -729,6 +803,14 @@ export async function init(options: InitOptions): Promise { ` ${terminalStyle.green("\u2713")} Added vinext output directories to .gitignore`, ); } + if (hasCssModules) { + console.log(` ${terminalStyle.green("\u2713")} Configured vite-css-modules for CSS Modules`); + } + if (platformSetup.preservedExistingGenerateScopedName) { + console.log( + ` ${terminalStyle.yellow("!")} Preserved existing css.modules.generateScopedName; verify that it produces identical class names in the SSR and client environments`, + ); + } const nextSteps = [...platformSetup.nextSteps]; if (dependencyInstallNeedsApproval) { diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 053e4d1be..e1f0b0a58 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -4,6 +4,7 @@ import { generateAppRouterViteConfig, generatePagesRouterViteConfig, getWranglerImagesBinding, + updateViteConfigForCssModules, updateViteConfigForCloudflare, updateWranglerConfigForCloudflare, } from "../packages/vinext/src/init-cloudflare.js"; @@ -18,6 +19,214 @@ function expectValidConfig(output: string): void { expect(parsed.errors.filter((diagnostic) => diagnostic.severity === "Error")).toEqual([]); } +describe("updateViteConfigForCssModules", () => { + it("generates the workaround for both Cloudflare router configs", () => { + for (const config of [ + generateAppRouterViteConfig(undefined, undefined, "IMAGES", false, true), + generatePagesRouterViteConfig(undefined, undefined, "IMAGES", false, true), + ]) { + expectValidConfig(config); + expect(config.indexOf("patchCssModules()")).toBeLessThan(config.indexOf("vinext(")); + expect(config).toContain("generateScopedName(name, filename)"); + expect(config.match(/from "node:path"/g)).toHaveLength(1); + } + }); + + it("incrementally adds the plugin first and preserves existing CSS options", () => { + const input = `import vinext from "vinext"; +export default { + plugins: [vinext()], + css: { modules: { localsConvention: "camelCase" } }, + server: { port: 4321 }, +}; +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain('import { patchCssModules } from "vite-css-modules"'); + expect(result.code.indexOf("patchCssModules()")).toBeLessThan(result.code.indexOf("vinext()")); + expect(result.code).toContain('localsConvention: "camelCase"'); + expect(result.code).toContain("server: { port: 4321 }"); + expect(result.code).toContain("generateScopedName(name, filename)"); + expect(result.preservedExistingGenerateScopedName).toBe(false); + }); + + it("supports CommonJS configs and is idempotent", () => { + const input = `const vinext = require("vinext"); +module.exports = { plugins: [vinext()] }; +`; + + const first = updateViteConfigForCssModules("vite.config.cjs", input); + const second = updateViteConfigForCssModules("vite.config.cjs", first.code); + + expect(first.code).toContain('require("vite-css-modules")'); + expect(first.code).toContain('require("node:crypto")'); + expect(first.code).toContain('require("node:path")'); + expect(first.code).toContain(".relative(__dirname,"); + expect(second.code).toBe(first.code); + expect(second.changed).toBe(false); + }); + + it("recognizes an existing namespace plugin call", () => { + const input = `import * as cssModules from "vite-css-modules"; +export default { plugins: [cssModules.patchCssModules({ generateSourceTypes: true })] }; +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expect(result.code.match(/patchCssModules/g)).toHaveLength(1); + expect(result.code).not.toContain("import { patchCssModules }"); + expect(result.code).toContain("generateScopedName(name, filename)"); + }); + + it("preserves an existing generateScopedName", () => { + const input = `export default { + plugins: [], + css: { modules: { generateScopedName: "custom_[hash]" } }, +}; +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expect(result.code).toContain('generateScopedName: "custom_[hash]"'); + expect(result.code).not.toContain("generateScopedName(name, filename)"); + expect(result.preservedExistingGenerateScopedName).toBe(true); + }); + + it("keeps the comma after an existing multiline css property", () => { + const input = `import { defineConfig } from "vite"; +export default defineConfig({ + css: { + modules: { + generateScopedName: "test" + } + } +}); +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain(' generateScopedName: "test"'); + expect(result.code).toContain(" },\n plugins: [patchCssModules()],"); + expect(result.code).not.toContain("\n,\n"); + expect(result.preservedExistingGenerateScopedName).toBe(true); + }); + + it.each([ + ["no trivia", "vite.config.ts", "export default defineConfig({});\n"], + ["inline whitespace", "vite.config.ts", "export default defineConfig({ });\n"], + ["multiline whitespace", "vite.config.ts", "export default defineConfig({\n \n\t\n});\n"], + ["CRLF whitespace", "vite.config.ts", "export default defineConfig({\r\n \r\n});\r\n"], + ["a plain ESM object", "vite.config.mjs", "export default {\n \n};\n"], + ["a CommonJS object", "vite.config.cjs", "module.exports = {\n \n};\n"], + ])("normalizes an otherwise empty config with $0", (_name, filePath, input) => { + const result = updateViteConfigForCssModules(filePath, input); + + expectValidConfig(result.code); + expect(result.code).toContain("{\n plugins: [patchCssModules()],\n css: {"); + expect(result.code).not.toMatch(/\n[\t ]*\n/); + expect(updateViteConfigForCssModules(filePath, result.code).code).toBe(result.code); + }); + + it.each([ + ["line comment", "export default defineConfig({\n // keep this comment\n});\n"], + ["inline block comment", "export default defineConfig({ /* keep this comment */ });\n"], + ])("preserves comments in an otherwise empty config with a $0", (_name, input) => { + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain("keep this comment"); + expect(result.code).toContain("plugins: [patchCssModules()],"); + expect(result.code).toContain("css: {"); + }); + + it("normalizes whitespace-only plugins, css, and modules containers", () => { + const inputs = [ + "export default defineConfig({\n plugins: [\n \n ]\n});\n", + "export default defineConfig({\n css: {\n \n }\n});\n", + "export default defineConfig({\n css: {\n modules: {\n \n }\n }\n});\n", + ]; + + for (const input of inputs) { + const result = updateViteConfigForCssModules("vite.config.ts", input); + expectValidConfig(result.code); + expect(result.code).toContain("plugins: [patchCssModules()]"); + expect(result.code).toContain("modules: {\n generateScopedName(name, filename)"); + expect(result.code).not.toMatch(/\n[\t ]*\n/); + } + }); + + it("preserves comments in an otherwise empty plugins array", () => { + const input = `export default defineConfig({ + plugins: [ + // keep plugin comment + ] +}); +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain("// keep plugin comment\n patchCssModules()"); + }); + + it.each([ + [ + "css object", + `export default defineConfig({ + css: { + // keep nested comment + } +}); +`, + ], + [ + "modules object", + `export default defineConfig({ + css: { + modules: { + /* keep nested comment */ + } + } +}); +`, + ], + ])("preserves comments in an otherwise empty $0", (_name, input) => { + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain("keep nested comment"); + expect(result.code).toContain("generateScopedName(name, filename)"); + expect(result.code).not.toMatch(/\n[\t ]*\n/); + }); + + it("puts a missing comma before trailing property comments", () => { + const input = `export default defineConfig({ + css: { modules: { generateScopedName: "test" } } // keep css comment +}); +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain( + 'css: { modules: { generateScopedName: "test" } }, // keep css comment\n plugins:', + ); + expect(result.preservedExistingGenerateScopedName).toBe(true); + }); + + it("rejects dynamic CSS Modules config", () => { + expect(() => + updateViteConfigForCssModules( + "vite.config.ts", + "export default { plugins: [], css: { modules: getModules() } };", + ), + ).toThrow("css.modules option must be a static object"); + }); +}); + describe("updateViteConfigForCloudflare", () => { it("updates an existing ESM App Router config without replacing user code", () => { const input = `import { defineConfig } from "vite"; diff --git a/tests/init.test.ts b/tests/init.test.ts index 306011364..df200283f 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -7,6 +7,7 @@ import { generateViteConfig, addScripts, getInitDeps, + scanCssModuleFiles, isDepInstalled, getReactUpgradeDeps, updateGitignore, @@ -259,6 +260,33 @@ describe("generateViteConfig", () => { const config = generateViteConfig(true, true); expect(config).toContain('vinext({ prerender: { routes: "*" } })'); }); + + it("adds the CSS Modules workaround when requested", () => { + const config = generateViteConfig(false, false, true); + expect(config).toContain('from "vite-css-modules"'); + expect(config.indexOf("patchCssModules()")).toBeLessThan(config.indexOf("vinext()")); + expect(config).toContain("generateScopedName(name, filename)"); + expect(config).toContain("import.meta.dirname"); + expect(config).toContain(".slice(0, 7)"); + }); +}); + +describe("scanCssModuleFiles", () => { + for (const extension of ["css", "scss", "sass"] as const) { + it(`detects nested .module.${extension} files`, () => { + writeFile(tmpDir, `src/components/card.module.${extension}`, ".card {}"); + expect(scanCssModuleFiles(tmpDir)).toBe(true); + }); + } + + it("ignores ordinary styles, generated directories, dependencies, and uppercase suffixes", () => { + writeFile(tmpDir, "src/styles.scss", ".card {}"); + writeFile(tmpDir, "node_modules/pkg/styles.module.css", ".card {}"); + writeFile(tmpDir, ".next/styles.module.scss", ".card {}"); + writeFile(tmpDir, "dist/styles.module.sass", ".card {}"); + writeFile(tmpDir, "src/styles.module.CSS", ".card {}"); + expect(scanCssModuleFiles(tmpDir)).toBe(false); + }); }); // ─── Unit Tests: addScripts ────────────────────────────────────────────────── @@ -403,6 +431,11 @@ describe("getInitDeps", () => { expect(deps).not.toContain("@cloudflare/vite-plugin"); expect(deps).not.toContain("wrangler"); }); + + it("adds vite-css-modules only when CSS Modules are detected", () => { + expect(getInitDeps(false, "node", true)).toContain("vite-css-modules"); + expect(getInitDeps(false, "node", false)).not.toContain("vite-css-modules"); + }); }); /** Helper: create a fake resolvable react package in node_modules */ @@ -1174,6 +1207,32 @@ describe("init — dependency installation", () => { expect(result.installedDeps).toContain("vite"); }); + it("installs and configures vite-css-modules when the project uses CSS Modules", async () => { + setupProject(tmpDir, { router: "pages" }); + writeFile(tmpDir, "components/card.module.scss", ".card { color: red; }"); + + const { result, execCalls, output } = await runInit(tmpDir, { platform: "node" }); + + expect(result.installedDeps).toContain("vite-css-modules"); + expect(execCalls.some(({ cmd }) => cmd.includes("vite-css-modules"))).toBe(true); + const config = readFile(tmpDir, "vite.config.ts"); + expect(config.indexOf("patchCssModules()")).toBeLessThan(config.indexOf("vinext()")); + expect(config).toContain("generateScopedName(name, filename)"); + expect(output).toContain("Configured vite-css-modules for CSS Modules"); + }); + + it("records vite-css-modules without installing when install is disabled", async () => { + setupProject(tmpDir, { router: "pages" }); + writeFile(tmpDir, "styles.module.sass", ".card\n color: red"); + + const { execCalls } = await runInit(tmpDir, { platform: "node", install: false }); + + expect(execCalls).toEqual([]); + expect(readPkg(tmpDir)).toMatchObject({ + devDependencies: { "vite-css-modules": "latest" }, + }); + }); + it("detects missing @vitejs/plugin-rsc for App Router", async () => { setupProject(tmpDir, { router: "app" }); @@ -1412,6 +1471,37 @@ describe("init — dependency installation", () => { // ─── Guard Rails ───────────────────────────────────────────────────────────── describe("init — guard rails", () => { + it("validates an existing CSS Modules config before mutating the project", async () => { + setupProject(tmpDir, { router: "pages" }); + writeFile(tmpDir, "styles.module.css", ".card {}"); + writeFile(tmpDir, "vite.config.ts", "export default { plugins: [], css: getCss() };\n"); + const packageJsonBefore = readFile(tmpDir, "package.json"); + + await expect(runInit(tmpDir, { platform: "node" })).rejects.toThrow( + "css option must be a static object", + ); + + expect(readFile(tmpDir, "package.json")).toBe(packageJsonBefore); + expect(fs.existsSync(path.join(tmpDir, ".gitignore"))).toBe(false); + }); + + it("preserves an existing scoped-name strategy and warns for CSS Modules", async () => { + setupProject(tmpDir, { router: "pages" }); + writeFile(tmpDir, "styles.module.css", ".card {}"); + writeFile( + tmpDir, + "vite.config.ts", + 'export default { plugins: [], css: { modules: { generateScopedName: "custom_[hash]" } } };', + ); + + const { output } = await runInit(tmpDir, { platform: "node" }); + + const config = readFile(tmpDir, "vite.config.ts"); + expect(config).toContain('generateScopedName: "custom_[hash]"'); + expect(config).toContain("patchCssModules()"); + expect(output).toContain("Preserved existing css.modules.generateScopedName"); + }); + it("skips vite.config.ts when it already exists (without --force)", async () => { setupProject(tmpDir, { router: "app" }); writeFile(tmpDir, "vite.config.ts", "export default {}"); From f0b72a8c89c227c384d21b5d8c802af5734232d6 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Thu, 27 Aug 2026 11:45:11 +0800 Subject: [PATCH 02/18] fix: format error --- packages/vinext/src/init-cloudflare.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index e199e2d49..8095436ab 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -746,13 +746,13 @@ export function generatePagesRouterViteConfig( export default defineConfig({ plugins: [ ${cssModulesPlugin} ${vinextExpression( - options, - "vinext", - "imagesOptimizer", - imagesBinding, - prerender, - versionMetadataBinding, - ).replace(/\n/g, "\n ")}, + options, + "vinext", + "imagesOptimizer", + imagesBinding, + prerender, + versionMetadataBinding, + ).replace(/\n/g, "\n ")}, cloudflare(), ],${resolveBlock}${cssModulesConfig} }); From d4b22bbaf5c9d7cbf157220f58ab25ae8427da7d Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Thu, 27 Aug 2026 11:59:41 +0800 Subject: [PATCH 03/18] test: add param `hasCssModules` to `generateAppRouterViteConfig()` --- tests/init-cloudflare.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 8e58eccff..ddb97128c 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -892,6 +892,7 @@ export default { plugins: [vinext({ imageOptimization: true })] }; }, "IMAGES", false, + false, "CUSTOM_VERSION", ), ).toContain('cdnAdapter({ versionMetadataBinding: "CUSTOM_VERSION" })'); From 82cf8b65b0d92310ed51af0cc541a9f0b8096e59 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Mon, 31 Aug 2026 12:50:11 +0800 Subject: [PATCH 04/18] fix(init): preserve default export mode option for `patchCssModules()` --- packages/vinext/src/init-cloudflare.ts | 14 +++++++++--- tests/init-cloudflare.test.ts | 31 +++++++++++++++++++------- tests/init.test.ts | 15 +++++++++---- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 8095436ab..e981c50cd 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -647,7 +647,9 @@ export function generateAppRouterViteConfig( const plugins: string[] = []; - if (hasCssModules) plugins.push(` patchCssModules(),`); + if (hasCssModules) { + plugins.push(` patchCssModules({ exportMode: "default" }),`); + } if (info?.hasMDX) { plugins.push(` // vinext auto-injects @mdx-js/rollup with plugins from next.config`); } @@ -738,7 +740,7 @@ export function generatePagesRouterViteConfig( resolveBlock = `\n resolve: {\n alias: {\n${aliases.join("\n")}\n },\n },`; } - const cssModulesPlugin = hasCssModules ? " patchCssModules(),\n" : ""; + const cssModulesPlugin = hasCssModules ? ' patchCssModules({ exportMode: "default" }),\n' : ""; const cssModulesConfig = hasCssModules ? cssModulesConfigSource(" ", "path", "createHash") : ""; return `${imports.join("\n")} @@ -1786,7 +1788,13 @@ export function updateViteConfigForCssModules( "patchCssModules", patchLocal, ); - ensurePluginFirst(firstOutput, firstConfig, `${patchBinding}()`, patchBinding, code); + ensurePluginFirst( + firstOutput, + firstConfig, + `${patchBinding}({ exportMode: "default" })`, + patchBinding, + code, + ); } const withPlugin = firstOutput.toString(); diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index ddb97128c..d24f53ebd 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -21,13 +21,17 @@ function expectValidConfig(output: string): void { } describe("updateViteConfigForCssModules", () => { - it("generates the workaround for both Cloudflare router configs", () => { + // Regression for https://github.com/cloudflare/vinext/issues/2992#issuecomment-5348417497 + it("uses Next-compatible default-only exports for both Cloudflare router configs", () => { for (const config of [ generateAppRouterViteConfig(undefined, undefined, "IMAGES", false, true), generatePagesRouterViteConfig(undefined, undefined, "IMAGES", false, true), ]) { expectValidConfig(config); - expect(config.indexOf("patchCssModules()")).toBeLessThan(config.indexOf("vinext(")); + const patchCall = 'patchCssModules({ exportMode: "default" })'; + expect(config).toContain(patchCall); + expect(config.indexOf(patchCall)).toBeLessThan(config.indexOf("vinext(")); + expect(config).not.toContain("patchCssModules()"); expect(config).toContain("generateScopedName(name, filename)"); expect(config.match(/from "node:path"/g)).toHaveLength(1); } @@ -46,7 +50,10 @@ export default { expectValidConfig(result.code); expect(result.code).toContain('import { patchCssModules } from "vite-css-modules"'); - expect(result.code.indexOf("patchCssModules()")).toBeLessThan(result.code.indexOf("vinext()")); + const patchCall = 'patchCssModules({ exportMode: "default" })'; + expect(result.code).toContain(patchCall); + expect(result.code.indexOf(patchCall)).toBeLessThan(result.code.indexOf("vinext()")); + expect(result.code).not.toContain("patchCssModules()"); expect(result.code).toContain('localsConvention: "camelCase"'); expect(result.code).toContain("server: { port: 4321 }"); expect(result.code).toContain("generateScopedName(name, filename)"); @@ -64,6 +71,8 @@ module.exports = { plugins: [vinext()] }; expect(first.code).toContain('require("vite-css-modules")'); expect(first.code).toContain('require("node:crypto")'); expect(first.code).toContain('require("node:path")'); + expect(first.code).toContain('patchCssModules({ exportMode: "default" })'); + expect(first.code).not.toContain("patchCssModules()"); expect(first.code).toContain(".relative(__dirname,"); expect(second.code).toBe(first.code); expect(second.changed).toBe(false); @@ -78,6 +87,8 @@ export default { plugins: [cssModules.patchCssModules({ generateSourceTypes: tru expect(result.code.match(/patchCssModules/g)).toHaveLength(1); expect(result.code).not.toContain("import { patchCssModules }"); + expect(result.code).toContain("cssModules.patchCssModules({ generateSourceTypes: true })"); + expect(result.code).not.toContain('exportMode: "default"'); expect(result.code).toContain("generateScopedName(name, filename)"); }); @@ -110,7 +121,7 @@ export default defineConfig({ expectValidConfig(result.code); expect(result.code).toContain(' generateScopedName: "test"'); - expect(result.code).toContain(" },\n plugins: [patchCssModules()],"); + expect(result.code).toContain(' },\n plugins: [patchCssModules({ exportMode: "default" })],'); expect(result.code).not.toContain("\n,\n"); expect(result.preservedExistingGenerateScopedName).toBe(true); }); @@ -126,7 +137,9 @@ export default defineConfig({ const result = updateViteConfigForCssModules(filePath, input); expectValidConfig(result.code); - expect(result.code).toContain("{\n plugins: [patchCssModules()],\n css: {"); + expect(result.code).toContain( + '{\n plugins: [patchCssModules({ exportMode: "default" })],\n css: {', + ); expect(result.code).not.toMatch(/\n[\t ]*\n/); expect(updateViteConfigForCssModules(filePath, result.code).code).toBe(result.code); }); @@ -139,7 +152,7 @@ export default defineConfig({ expectValidConfig(result.code); expect(result.code).toContain("keep this comment"); - expect(result.code).toContain("plugins: [patchCssModules()],"); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })],'); expect(result.code).toContain("css: {"); }); @@ -153,7 +166,7 @@ export default defineConfig({ for (const input of inputs) { const result = updateViteConfigForCssModules("vite.config.ts", input); expectValidConfig(result.code); - expect(result.code).toContain("plugins: [patchCssModules()]"); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); expect(result.code).toContain("modules: {\n generateScopedName(name, filename)"); expect(result.code).not.toMatch(/\n[\t ]*\n/); } @@ -170,7 +183,9 @@ export default defineConfig({ const result = updateViteConfigForCssModules("vite.config.ts", input); expectValidConfig(result.code); - expect(result.code).toContain("// keep plugin comment\n patchCssModules()"); + expect(result.code).toContain( + '// keep plugin comment\n patchCssModules({ exportMode: "default" })', + ); }); it.each([ diff --git a/tests/init.test.ts b/tests/init.test.ts index 1183fb465..fa703a5b5 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -261,10 +261,13 @@ describe("generateViteConfig", () => { expect(config).toContain('vinext({ prerender: { routes: "*" } })'); }); - it("adds the CSS Modules workaround when requested", () => { + it("adds the CSS Modules workaround with Next-compatible default-only exports", () => { const config = generateViteConfig(false, false, true); expect(config).toContain('from "vite-css-modules"'); - expect(config.indexOf("patchCssModules()")).toBeLessThan(config.indexOf("vinext()")); + const patchCall = 'patchCssModules({ exportMode: "default" })'; + expect(config).toContain(patchCall); + expect(config.indexOf(patchCall)).toBeLessThan(config.indexOf("vinext()")); + expect(config).not.toContain("patchCssModules()"); expect(config).toContain("generateScopedName(name, filename)"); expect(config).toContain("import.meta.dirname"); expect(config).toContain(".slice(0, 7)"); @@ -1217,7 +1220,10 @@ describe("init — dependency installation", () => { expect(result.installedDeps).toContain("vite-css-modules"); expect(execCalls.some(({ cmd }) => cmd.includes("vite-css-modules"))).toBe(true); const config = readFile(tmpDir, "vite.config.ts"); - expect(config.indexOf("patchCssModules()")).toBeLessThan(config.indexOf("vinext()")); + const patchCall = 'patchCssModules({ exportMode: "default" })'; + expect(config).toContain(patchCall); + expect(config.indexOf(patchCall)).toBeLessThan(config.indexOf("vinext()")); + expect(config).not.toContain("patchCssModules()"); expect(config).toContain("generateScopedName(name, filename)"); expect(output).toContain("Configured vite-css-modules for CSS Modules"); }); @@ -1499,7 +1505,8 @@ describe("init — guard rails", () => { const config = readFile(tmpDir, "vite.config.ts"); expect(config).toContain('generateScopedName: "custom_[hash]"'); - expect(config).toContain("patchCssModules()"); + expect(config).toContain('patchCssModules({ exportMode: "default" })'); + expect(config).not.toContain("patchCssModules()"); expect(output).toContain("Preserved existing css.modules.generateScopedName"); }); From e0c6bad553dfe5a2e5ee139b96207a597b2ffb96 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Mon, 31 Aug 2026 14:19:58 +0800 Subject: [PATCH 05/18] fix(init): reject initializing css compatibility workaround if there is element spreading --- packages/vinext/src/init-cloudflare.ts | 43 ++++++++++++++++-- tests/init-cloudflare.test.ts | 63 ++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index e981c50cd..dc69882f1 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -806,6 +806,25 @@ function findProperty(object: AstObject, name: string): AstProperty | undefined ); } +function findLastProperty(object: AstObject, name: string): AstProperty | undefined { + for (let index = object.properties.length - 1; index >= 0; index--) { + const property = object.properties[index]; + if (property.type === "Property" && propertyName(property) === name) return property; + } + return undefined; +} + +/** + * A missing property may be supplied by any spread, while an existing property + * may be overridden by a later spread because object composition is last-write-wins. + */ +function hasPotentialSpreadOverride(object: AstObject, property: AstProperty | undefined): boolean { + const propertyIndex = property ? object.properties.lastIndexOf(property) : -1; + return object.properties + .slice(propertyIndex + 1) + .some((candidate) => candidate.type === "SpreadElement"); +} + function unwrapObject(expression: ESTree.Expression): AstObject | undefined { if (expression.type === "ObjectExpression") return expression as AstObject; if (expression.type === "ParenthesizedExpression") return unwrapObject(expression.expression); @@ -1684,7 +1703,12 @@ function ensureCssModulesScopedName( createHashBinding: string, rootExpression: string, ): boolean { - const css = findProperty(config, "css"); + const css = findLastProperty(config, "css"); + if (hasPotentialSpreadOverride(config, css)) { + throw new Error( + "The Vite config's css option must be explicitly defined after any spread properties so vinext init can configure CSS Modules without replacing existing options.", + ); + } if (!css) { const indent = objectPropertyIndent(config, code); insertObjectProperty( @@ -1707,7 +1731,12 @@ function ensureCssModulesScopedName( ); } const cssObject = css.value as AstObject; - const modules = findProperty(cssObject, "modules"); + const modules = findLastProperty(cssObject, "modules"); + if (hasPotentialSpreadOverride(cssObject, modules)) { + throw new Error( + "The Vite config's css.modules option must be explicitly defined after any spread properties so vinext init can configure CSS Modules without replacing existing options.", + ); + } if (!modules) { const indent = objectPropertyIndent(cssObject, code); insertObjectProperty( @@ -1730,7 +1759,15 @@ function ensureCssModulesScopedName( ); } const modulesObject = modules.value as AstObject; - if (findProperty(modulesObject, "generateScopedName")) return true; + const generateScopedName = findLastProperty(modulesObject, "generateScopedName"); + if (generateScopedName) { + if (hasPotentialSpreadOverride(modulesObject, generateScopedName)) { + throw new Error( + "The Vite config's css.modules.generateScopedName option must appear after any spread properties so vinext init can verify it.", + ); + } + return true; + } const indent = objectPropertyIndent(modulesObject, code); insertObjectProperty( output, diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index d24f53ebd..07d944ee0 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -241,6 +241,69 @@ export default defineConfig({ ), ).toThrow("css.modules option must be a static object"); }); + + it.each([ + [ + "CSS is provided only through a root config spread", + `const sharedConfig = { css: { modules: { localsConvention: "camelCase" } } }; +export default { + plugins: [], + ...sharedConfig, +}; +`, + ], + [ + "a later spread can override the direct modules object", + `const sharedCss = { modules: { localsConvention: "camelCase" } }; +export default { + plugins: [], + css: { modules: {}, ...sharedCss }, +}; +`, + ], + [ + "modules are provided only through a spread", + `const sharedCss = { modules: { localsConvention: "camelCase" } }; +export default { + plugins: [], + css: { ...sharedCss }, +}; +`, + ], + [ + "a later modules spread can override generateScopedName", + `const sharedModules = { generateScopedName: "shared_[hash]" }; +export default { + plugins: [], + css: { + modules: { generateScopedName: "custom_[hash]", ...sharedModules }, + }, +}; +`, + ], + ])("rejects ambiguous spread-composed CSS config when $0", (_name, input) => { + expect(() => updateViteConfigForCssModules("vite.config.ts", input)).toThrow(/spread/i); + }); + + it("preserves spread-provided module options when explicit modules follow the CSS spread", () => { + const input = `const sharedCss = { modules: { localsConvention: "camelCase" } }; +export default { + plugins: [], + css: { + ...sharedCss, + modules: { ...sharedCss.modules }, + }, +}; +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain("...sharedCss.modules"); + expect(result.code.indexOf("...sharedCss.modules")).toBeLessThan( + result.code.indexOf("generateScopedName(name, filename)"), + ); + }); }); describe("updateViteConfigForCloudflare", () => { From c026d8c290fd8c7a6782dfcb9633411092c0d557 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 8 Sep 2026 23:38:02 +0100 Subject: [PATCH 06/18] fix(init): preserve CSS module config semantics --- packages/vinext/src/init-cloudflare.ts | 102 +++++++++++++------------ packages/vinext/src/init.ts | 2 +- tests/init-cloudflare.test.ts | 34 +++++++++ tests/init.test.ts | 12 +++ 4 files changed, 101 insertions(+), 49 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index dc69882f1..9de65ef5e 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -1322,7 +1322,7 @@ function findPluginMemberCall( member: string, ): (ESTree.CallExpression & AstNode) | undefined { if (!objectBinding) return undefined; - const plugins = findProperty(config, "plugins"); + const plugins = findLastProperty(config, "plugins"); if (!plugins || plugins.value.type !== "ArrayExpression") return undefined; return plugins.value.elements.find( (element): element is ESTree.CallExpression & AstNode => @@ -1638,7 +1638,12 @@ function ensurePluginFirst( binding: string, code: string, ): void { - const plugins = findProperty(config, "plugins"); + const plugins = findLastProperty(config, "plugins"); + if (hasPotentialSpreadOverride(config, plugins)) { + throw new Error( + "The Vite config's plugins option must be explicitly defined after any spread properties so vinext init can configure CSS Modules without replacing existing plugins.", + ); + } if (!plugins) { const indent = objectPropertyIndent(config, code); insertObjectProperty(output, config, `${indent}plugins: [${expression}],`, code, true); @@ -1699,9 +1704,7 @@ function ensureCssModulesScopedName( output: MagicString, config: AstObject, code: string, - pathBinding: string, - createHashBinding: string, - rootExpression: string, + generateScopedNameSource: (indent: string) => string, ): boolean { const css = findLastProperty(config, "css"); if (hasPotentialSpreadOverride(config, css)) { @@ -1714,11 +1717,8 @@ function ensureCssModulesScopedName( insertObjectProperty( output, config, - `${indent}css: {\n${indent} modules: {\n${generateScopedNameMethodSource( + `${indent}css: {\n${indent} modules: {\n${generateScopedNameSource( `${indent} `, - pathBinding, - createHashBinding, - rootExpression, )},\n${indent} },\n${indent}},`, code, true, @@ -1742,12 +1742,7 @@ function ensureCssModulesScopedName( insertObjectProperty( output, cssObject, - `${indent}modules: {\n${generateScopedNameMethodSource( - `${indent} `, - pathBinding, - createHashBinding, - rootExpression, - )},\n${indent}},`, + `${indent}modules: {\n${generateScopedNameSource(`${indent} `)},\n${indent}},`, code, true, ); @@ -1769,13 +1764,7 @@ function ensureCssModulesScopedName( return true; } const indent = objectPropertyIndent(modulesObject, code); - insertObjectProperty( - output, - modulesObject, - `${generateScopedNameMethodSource(indent, pathBinding, createHashBinding, rootExpression)},`, - code, - true, - ); + insertObjectProperty(output, modulesObject, `${generateScopedNameSource(indent)},`, code, true); return false; } @@ -1839,36 +1828,53 @@ export function updateViteConfigForCssModules( const secondConfig = findConfigObject(secondProgram)!; const secondBindings = collectTopLevelBindings(secondProgram); const secondOutput = new MagicString(withPlugin); - const existingCreateHash = commonJs - ? findRequiredBinding(secondProgram, "node:crypto", "createHash") - : findImportedBinding(secondProgram, "node:crypto", "createHash"); - const createHashLocal = existingCreateHash ?? allocateBinding(secondBindings, "createHash"); - const createHashBinding = commonJs - ? ensureNamedRequire(secondProgram, secondOutput, "node:crypto", "createHash", createHashLocal) - : ensureNamedImport(secondProgram, secondOutput, "node:crypto", "createHash", createHashLocal); - const existingPath = commonJs - ? findRequiredBinding(secondProgram, "node:path", "default") - : secondProgram.body - .filter( - (statement): statement is ESTree.ImportDeclaration => - statement.type === "ImportDeclaration", - ) - .find((statement) => statement.source.value === "node:path") - ?.specifiers.find( - (specifier): specifier is ESTree.ImportDefaultSpecifier => - specifier.type === "ImportDefaultSpecifier", - )?.local.name; - const pathLocal = existingPath ?? allocateBinding(secondBindings, "path"); - const pathBinding = commonJs - ? ensureDefaultRequire(secondProgram, secondOutput, "node:path", pathLocal) - : ensureDefaultImport(secondProgram, secondOutput, "node:path", pathLocal); const preservedExistingGenerateScopedName = ensureCssModulesScopedName( secondOutput, secondConfig, withPlugin, - pathBinding, - createHashBinding, - commonJs ? "__dirname" : "import.meta.dirname", + (indent) => { + const existingCreateHash = commonJs + ? findRequiredBinding(secondProgram, "node:crypto", "createHash") + : findImportedBinding(secondProgram, "node:crypto", "createHash"); + const createHashLocal = existingCreateHash ?? allocateBinding(secondBindings, "createHash"); + const createHashBinding = commonJs + ? ensureNamedRequire( + secondProgram, + secondOutput, + "node:crypto", + "createHash", + createHashLocal, + ) + : ensureNamedImport( + secondProgram, + secondOutput, + "node:crypto", + "createHash", + createHashLocal, + ); + const existingPath = commonJs + ? findRequiredBinding(secondProgram, "node:path", "default") + : secondProgram.body + .filter( + (statement): statement is ESTree.ImportDeclaration => + statement.type === "ImportDeclaration", + ) + .find((statement) => statement.source.value === "node:path") + ?.specifiers.find( + (specifier): specifier is ESTree.ImportDefaultSpecifier => + specifier.type === "ImportDefaultSpecifier", + )?.local.name; + const pathLocal = existingPath ?? allocateBinding(secondBindings, "path"); + const pathBinding = commonJs + ? ensureDefaultRequire(secondProgram, secondOutput, "node:path", pathLocal) + : ensureDefaultImport(secondProgram, secondOutput, "node:path", pathLocal); + return generateScopedNameMethodSource( + indent, + pathBinding, + createHashBinding, + commonJs ? "__dirname" : "import.meta.dirname", + ); + }, ); const updated = secondOutput.toString(); return { diff --git a/packages/vinext/src/init.ts b/packages/vinext/src/init.ts index 91ad88959..b9a8f9c2c 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -602,7 +602,7 @@ export async function init(options: InitOptions): Promise { }, options.cloudflare!, ); - } else if (hasCssModules && existingViteConfigPath) { + } else if (hasCssModules && existingViteConfigPath && !options.force) { updateViteConfigForCssModules( existingViteConfigPath, fs.readFileSync(existingViteConfigPath, "utf-8"), diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 07d944ee0..7973467e6 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -103,6 +103,8 @@ export default { plugins: [cssModules.patchCssModules({ generateSourceTypes: tru expect(result.code).toContain('generateScopedName: "custom_[hash]"'); expect(result.code).not.toContain("generateScopedName(name, filename)"); + expect(result.code).not.toContain('from "node:crypto"'); + expect(result.code).not.toContain('from "node:path"'); expect(result.preservedExistingGenerateScopedName).toBe(true); }); @@ -243,6 +245,23 @@ export default defineConfig({ }); it.each([ + [ + "plugins are provided only through a root config spread", + `const sharedConfig = { plugins: [vinext()] }; +export default { + ...sharedConfig, +}; +`, + ], + [ + "a later spread can override the direct plugins array", + `const sharedConfig = { plugins: [vinext()] }; +export default { + plugins: [], + ...sharedConfig, +}; +`, + ], [ "CSS is provided only through a root config spread", `const sharedConfig = { css: { modules: { localsConvention: "camelCase" } } }; @@ -285,6 +304,21 @@ export default { expect(() => updateViteConfigForCssModules("vite.config.ts", input)).toThrow(/spread/i); }); + it("updates the effective plugins array when duplicate properties exist", () => { + const input = `export default { + plugins: [firstPlugin()], + plugins: [vinext()], +}; +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expect(result.code).toContain("plugins: [firstPlugin()]"); + expect(result.code).toContain( + 'plugins: [patchCssModules({ exportMode: "default" }), vinext()]', + ); + }); + it("preserves spread-provided module options when explicit modules follow the CSS spread", () => { const input = `const sharedCss = { modules: { localsConvention: "camelCase" } }; export default { diff --git a/tests/init.test.ts b/tests/init.test.ts index fa703a5b5..fa53b40dc 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -1492,6 +1492,18 @@ describe("init — guard rails", () => { expect(fs.existsSync(path.join(tmpDir, ".gitignore"))).toBe(false); }); + it("lets --force replace an unsupported existing CSS Modules config", async () => { + setupProject(tmpDir, { router: "pages" }); + writeFile(tmpDir, "styles.module.css", ".card {}"); + writeFile(tmpDir, "vite.config.ts", "export default { plugins: [], css: getCss() };\n"); + + await expect(runInit(tmpDir, { platform: "node", force: true })).resolves.toBeDefined(); + + const config = readFile(tmpDir, "vite.config.ts"); + expect(config).toContain('patchCssModules({ exportMode: "default" })'); + expect(config).not.toContain("getCss()"); + }); + it("preserves an existing scoped-name strategy and warns for CSS Modules", async () => { setupProject(tmpDir, { router: "pages" }); writeFile(tmpDir, "styles.module.css", ".card {}"); From ce628a8b1b548aef9e9d90b370185c8a6d00df61 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 8 Sep 2026 23:40:10 +0100 Subject: [PATCH 07/18] refactor(init): use native CSS module scan --- packages/vinext/src/init.ts | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/vinext/src/init.ts b/packages/vinext/src/init.ts index b9a8f9c2c..8dda98204 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -172,24 +172,19 @@ const CSS_MODULE_SCAN_IGNORES = new Set([ /** Detect project-owned CSS, SCSS, or Sass module files. */ export function scanCssModuleFiles(root: string): boolean { - const walk = (current: string): boolean => { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(current, { withFileTypes: true }); - } catch { - return false; - } - for (const entry of entries) { - if (entry.isDirectory()) { - if (entry.name.startsWith(".") || CSS_MODULE_SCAN_IGNORES.has(entry.name)) continue; - if (walk(path.join(current, entry.name))) return true; - } else if (entry.isFile() && CSS_MODULE_PATTERN.test(entry.name)) { - return true; - } - } + try { + return fs + .globSync("**/*.module.{css,scss,sass}", { + cwd: root, + withFileTypes: true, + exclude: (entry) => + entry.isDirectory() && + (entry.name.startsWith(".") || CSS_MODULE_SCAN_IGNORES.has(entry.name)), + }) + .some((entry) => CSS_MODULE_PATTERN.test(entry.name)); + } catch { return false; - }; - return walk(root); + } } // ─── Script Addition ───────────────────────────────────────────────────────── From e691ce82835f39f34b3094bfbb51df6fb814d420 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 8 Sep 2026 23:58:12 +0100 Subject: [PATCH 08/18] fix(init): handle typed Vite configs --- packages/vinext/src/init-cloudflare.ts | 33 +++++++++++++++++++------- tests/init-cloudflare.test.ts | 33 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 9de65ef5e..dab15d660 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import MagicString from "magic-string"; import type { ESTree } from "vite"; import type { CloudflareInitOptions } from "./init-platform.js"; +import { unwrapExpression } from "./plugins/ast-utils.js"; import { detectProject } from "./utils/project.js"; const require = createRequire(import.meta.url); @@ -826,9 +827,8 @@ function hasPotentialSpreadOverride(object: AstObject, property: AstProperty | u } function unwrapObject(expression: ESTree.Expression): AstObject | undefined { - if (expression.type === "ObjectExpression") return expression as AstObject; - if (expression.type === "ParenthesizedExpression") return unwrapObject(expression.expression); - return undefined; + const unwrapped = unwrapExpression(expression); + return unwrapped?.type === "ObjectExpression" ? (unwrapped as AstObject) : undefined; } function findVariableObject(program: ESTree.Program, name: string): AstObject | undefined { @@ -987,10 +987,17 @@ function findImportedBinding( imported: string, ): string | undefined { for (const statement of program.body) { - if (statement.type !== "ImportDeclaration" || statement.source.value !== source) continue; + if ( + statement.type !== "ImportDeclaration" || + statement.importKind === "type" || + statement.source.value !== source + ) { + continue; + } for (const specifier of statement.specifiers) { if ( specifier.type === "ImportSpecifier" && + specifier.importKind !== "type" && specifier.imported.type === "Identifier" && specifier.imported.name === imported ) { @@ -1003,7 +1010,13 @@ function findImportedBinding( function findNamespaceImportedBinding(program: ESTree.Program, source: string): string | undefined { for (const statement of program.body) { - if (statement.type !== "ImportDeclaration" || statement.source.value !== source) continue; + if ( + statement.type !== "ImportDeclaration" || + statement.importKind === "type" || + statement.source.value !== source + ) { + continue; + } const namespace = statement.specifiers.find( (specifier): specifier is ESTree.ImportNamespaceSpecifier => specifier.type === "ImportNamespaceSpecifier", @@ -1025,7 +1038,9 @@ function ensureNamedImport( const declaration = program.body.find( (statement): statement is ESTree.ImportDeclaration => - statement.type === "ImportDeclaration" && statement.source.value === source, + statement.type === "ImportDeclaration" && + statement.importKind !== "type" && + statement.source.value === source, ); if (declaration) { const named = declaration.specifiers.filter( @@ -1053,7 +1068,9 @@ function ensureDefaultImport( ): string { const declaration = program.body.find( (statement): statement is ESTree.ImportDeclaration => - statement.type === "ImportDeclaration" && statement.source.value === source, + statement.type === "ImportDeclaration" && + statement.importKind !== "type" && + statement.source.value === source, ); const existing = declaration?.specifiers.find( (specifier): specifier is ESTree.ImportDefaultSpecifier => @@ -1857,7 +1874,7 @@ export function updateViteConfigForCssModules( : secondProgram.body .filter( (statement): statement is ESTree.ImportDeclaration => - statement.type === "ImportDeclaration", + statement.type === "ImportDeclaration" && statement.importKind !== "type", ) .find((statement) => statement.source.value === "node:path") ?.specifiers.find( diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 7973467e6..c3bd51182 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -92,6 +92,39 @@ export default { plugins: [cssModules.patchCssModules({ generateSourceTypes: tru expect(result.code).toContain("generateScopedName(name, filename)"); }); + it("does not reuse type-only imports as runtime bindings", () => { + const input = `import type { patchCssModules } from "vite-css-modules"; +import { type createHash } from "node:crypto"; +import type path from "node:path"; +export default { plugins: [] } satisfies UserConfig; +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain( + 'import { patchCssModules as patchCssModules2 } from "vite-css-modules"', + ); + expect(result.code).toContain("type createHash, createHash as createHash2"); + expect(result.code).toContain('import path2 from "node:path"'); + expect(result.code).toContain('plugins: [patchCssModules2({ exportMode: "default" })]'); + expect(result.code).toContain('const hash = createHash2("sha256")'); + expect(result.code).toContain("const relativePath = path2"); + }); + + it.each([ + ["satisfies", "{ plugins: [] } satisfies UserConfig"], + ["as", "{ plugins: [] } as UserConfig"], + ["type assertion", "{ plugins: [] }"], + ["non-null assertion", "{ plugins: [] }!"], + ])("unwraps a config object behind a TypeScript $0 expression", (_name, config) => { + const result = updateViteConfigForCssModules("vite.config.ts", `export default ${config};\n`); + + expectValidConfig(result.code); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); + expect(result.code).toContain("generateScopedName(name, filename)"); + }); + it("preserves an existing generateScopedName", () => { const input = `export default { plugins: [], From 8951dd5c2e6b3328acbf12d3ee5cfa0326aa9f62 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 00:15:37 +0100 Subject: [PATCH 09/18] fix(init): harden CSS module config updates --- packages/vinext/src/init-cloudflare.ts | 60 ++++++++++++++++++++++---- packages/vinext/src/init.ts | 1 + tests/init-cloudflare.test.ts | 42 +++++++++++++++--- tests/init.test.ts | 17 +++++++- 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index dab15d660..8d23e242e 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -29,6 +29,7 @@ export type CloudflarePlatformSetupContext = { root: string; isAppRouter: boolean; existingViteConfigPath?: string; + force?: boolean; prerender?: boolean; hasCssModules?: boolean; today?: string; @@ -82,7 +83,11 @@ export function validateCloudflarePlatformSetup( }, ); if (context.hasCssModules) { - updateViteConfigForCssModules(context.existingViteConfigPath, cloudflareConfig); + updateViteConfigForCssModules( + context.existingViteConfigPath, + cloudflareConfig, + context.force, + ); } } } @@ -127,6 +132,7 @@ export function setupCloudflarePlatform( const cssUpdate = updateViteConfigForCssModules( context.existingViteConfigPath, updatedConfig, + context.force, ); updatedConfig = cssUpdate.code; preservedExistingGenerateScopedName = cssUpdate.preservedExistingGenerateScopedName; @@ -593,8 +599,10 @@ function generateScopedNameMethodSource( pathBinding: string, createHashBinding: string, rootExpression = "import.meta.dirname", + typescript = true, ): string { - return `${indent}generateScopedName(name, filename) { + const parameters = typescript ? "name: string, filename: string" : "name, filename"; + return `${indent}generateScopedName(${parameters}) { ${indent} const relativePath = ${pathBinding} ${indent} .relative(${rootExpression}, filename.replace(/\\?.*$/, "")) ${indent} .replaceAll("\\\\", "/"); @@ -815,6 +823,14 @@ function findLastProperty(object: AstObject, name: string): AstProperty | undefi return undefined; } +function hasNullishValue(property: AstProperty): boolean { + const value = property.value as AstNode & { name?: string; value?: unknown }; + return ( + (value.type === "Identifier" && value.name === "undefined") || + (value.type === "Literal" && value.value === null) + ); +} + /** * A missing property may be supplied by any spread, while an existing property * may be overridden by a later spread because object composition is last-write-wins. @@ -1406,12 +1422,7 @@ function hasVinextPrerender(call: (ESTree.CallExpression & AstNode) | undefined) } function isUsableImageOptimizer(property: AstProperty | undefined): boolean { - if (!property) return false; - const value = property.value as AstNode & { name?: string; value?: unknown }; - return !( - (value.type === "Identifier" && value.name === "undefined") || - (value.type === "Literal" && value.value === null) - ); + return Boolean(property && !hasNullishValue(property)); } function isImagesOptimizerCall( @@ -1722,6 +1733,7 @@ function ensureCssModulesScopedName( config: AstObject, code: string, generateScopedNameSource: (indent: string) => string, + force = false, ): boolean { const css = findLastProperty(config, "css"); if (hasPotentialSpreadOverride(config, css)) { @@ -1743,6 +1755,17 @@ function ensureCssModulesScopedName( return false; } if (css.value.type !== "ObjectExpression") { + if (force) { + const indent = objectPropertyIndent(config, code); + output.overwrite( + (css.value as AstNode).start, + (css.value as AstNode).end, + `{\n${indent} modules: {\n${generateScopedNameSource( + `${indent} `, + )},\n${indent} },\n${indent}}`, + ); + return false; + } throw new Error( "The Vite config's css option must be a static object for vinext init to configure CSS Modules.", ); @@ -1766,6 +1789,15 @@ function ensureCssModulesScopedName( return false; } if (modules.value.type !== "ObjectExpression") { + if (force) { + const indent = objectPropertyIndent(cssObject, code); + output.overwrite( + (modules.value as AstNode).start, + (modules.value as AstNode).end, + `{\n${generateScopedNameSource(`${indent} `)},\n${indent}}`, + ); + return false; + } throw new Error( "The Vite config's css.modules option must be a static object for vinext init to configure CSS Modules.", ); @@ -1778,7 +1810,14 @@ function ensureCssModulesScopedName( "The Vite config's css.modules.generateScopedName option must appear after any spread properties so vinext init can verify it.", ); } - return true; + if (!hasNullishValue(generateScopedName)) return true; + const indent = objectPropertyIndent(modulesObject, code); + output.overwrite( + (generateScopedName as AstNode).start, + (generateScopedName as AstNode).end, + generateScopedNameSource(indent), + ); + return false; } const indent = objectPropertyIndent(modulesObject, code); insertObjectProperty(output, modulesObject, `${generateScopedNameSource(indent)},`, code, true); @@ -1795,6 +1834,7 @@ export type CssModulesConfigUpdate = { export function updateViteConfigForCssModules( filePath: string, code: string, + force = false, ): CssModulesConfigUpdate { const firstProgram = parseViteConfig(filePath, code); const firstConfig = findConfigObject(firstProgram); @@ -1890,8 +1930,10 @@ export function updateViteConfigForCssModules( pathBinding, createHashBinding, commonJs ? "__dirname" : "import.meta.dirname", + [".ts", ".mts", ".cts"].includes(path.extname(filePath)), ); }, + force, ); const updated = secondOutput.toString(); return { diff --git a/packages/vinext/src/init.ts b/packages/vinext/src/init.ts index 8dda98204..547116ec9 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -591,6 +591,7 @@ export async function init(options: InitOptions): Promise { root, isAppRouter: isApp, existingViteConfigPath, + force: options.force, prerender: options.prerender, hasCssModules, today: options._today, diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index c3bd51182..aea12901d 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -32,7 +32,7 @@ describe("updateViteConfigForCssModules", () => { expect(config).toContain(patchCall); expect(config.indexOf(patchCall)).toBeLessThan(config.indexOf("vinext(")); expect(config).not.toContain("patchCssModules()"); - expect(config).toContain("generateScopedName(name, filename)"); + expect(config).toContain("generateScopedName(name: string, filename: string)"); expect(config.match(/from "node:path"/g)).toHaveLength(1); } }); @@ -56,7 +56,7 @@ export default { expect(result.code).not.toContain("patchCssModules()"); expect(result.code).toContain('localsConvention: "camelCase"'); expect(result.code).toContain("server: { port: 4321 }"); - expect(result.code).toContain("generateScopedName(name, filename)"); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); expect(result.preservedExistingGenerateScopedName).toBe(false); }); @@ -89,7 +89,7 @@ export default { plugins: [cssModules.patchCssModules({ generateSourceTypes: tru expect(result.code).not.toContain("import { patchCssModules }"); expect(result.code).toContain("cssModules.patchCssModules({ generateSourceTypes: true })"); expect(result.code).not.toContain('exportMode: "default"'); - expect(result.code).toContain("generateScopedName(name, filename)"); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); }); it("does not reuse type-only imports as runtime bindings", () => { @@ -122,7 +122,18 @@ export default { plugins: [] } satisfies UserConfig; expectValidConfig(result.code); expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); + }); + + it("keeps inserted scoped-name parameters JavaScript-safe", () => { + const result = updateViteConfigForCssModules( + "vite.config.mjs", + "export default { plugins: [] };\n", + ); + + expectValidConfig(result.code); expect(result.code).toContain("generateScopedName(name, filename)"); + expect(result.code).not.toContain("name: string"); }); it("preserves an existing generateScopedName", () => { @@ -135,12 +146,27 @@ export default { plugins: [] } satisfies UserConfig; const result = updateViteConfigForCssModules("vite.config.ts", input); expect(result.code).toContain('generateScopedName: "custom_[hash]"'); - expect(result.code).not.toContain("generateScopedName(name, filename)"); + expect(result.code).not.toContain("generateScopedName(name: string, filename: string)"); expect(result.code).not.toContain('from "node:crypto"'); expect(result.code).not.toContain('from "node:path"'); expect(result.preservedExistingGenerateScopedName).toBe(true); }); + it.each(["undefined", "null"])("replaces a nullish %s scoped-name setting", (value) => { + const input = `export default { + plugins: [], + css: { modules: { generateScopedName: ${value} } }, +}; +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); + expect(result.code).not.toContain(`generateScopedName: ${value}`); + expect(result.preservedExistingGenerateScopedName).toBe(false); + }); + it("keeps the comma after an existing multiline css property", () => { const input = `import { defineConfig } from "vite"; export default defineConfig({ @@ -202,7 +228,9 @@ export default defineConfig({ const result = updateViteConfigForCssModules("vite.config.ts", input); expectValidConfig(result.code); expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); - expect(result.code).toContain("modules: {\n generateScopedName(name, filename)"); + expect(result.code).toContain( + "modules: {\n generateScopedName(name: string, filename: string)", + ); expect(result.code).not.toMatch(/\n[\t ]*\n/); } }); @@ -249,7 +277,7 @@ export default defineConfig({ expectValidConfig(result.code); expect(result.code).toContain("keep nested comment"); - expect(result.code).toContain("generateScopedName(name, filename)"); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); expect(result.code).not.toMatch(/\n[\t ]*\n/); }); @@ -368,7 +396,7 @@ export default { expectValidConfig(result.code); expect(result.code).toContain("...sharedCss.modules"); expect(result.code.indexOf("...sharedCss.modules")).toBeLessThan( - result.code.indexOf("generateScopedName(name, filename)"), + result.code.indexOf("generateScopedName(name: string, filename: string)"), ); }); }); diff --git a/tests/init.test.ts b/tests/init.test.ts index fa53b40dc..c42d10df1 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -268,7 +268,7 @@ describe("generateViteConfig", () => { expect(config).toContain(patchCall); expect(config.indexOf(patchCall)).toBeLessThan(config.indexOf("vinext()")); expect(config).not.toContain("patchCssModules()"); - expect(config).toContain("generateScopedName(name, filename)"); + expect(config).toContain("generateScopedName(name: string, filename: string)"); expect(config).toContain("import.meta.dirname"); expect(config).toContain(".slice(0, 7)"); }); @@ -1224,7 +1224,7 @@ describe("init — dependency installation", () => { expect(config).toContain(patchCall); expect(config.indexOf(patchCall)).toBeLessThan(config.indexOf("vinext()")); expect(config).not.toContain("patchCssModules()"); - expect(config).toContain("generateScopedName(name, filename)"); + expect(config).toContain("generateScopedName(name: string, filename: string)"); expect(output).toContain("Configured vite-css-modules for CSS Modules"); }); @@ -1504,6 +1504,19 @@ describe("init — guard rails", () => { expect(config).not.toContain("getCss()"); }); + it("lets Cloudflare --force replace unsupported CSS Modules options", async () => { + setupProject(tmpDir, { router: "pages" }); + writeFile(tmpDir, "styles.module.css", ".card {}"); + writeFile(tmpDir, "vite.config.ts", "export default { plugins: [], css: getCss() };\n"); + + await expect(runInit(tmpDir, { force: true })).resolves.toBeDefined(); + + const config = readFile(tmpDir, "vite.config.ts"); + expect(config).toContain('patchCssModules({ exportMode: "default" })'); + expect(config).toContain("generateScopedName(name: string, filename: string)"); + expect(config).not.toContain("getCss()"); + }); + it("preserves an existing scoped-name strategy and warns for CSS Modules", async () => { setupProject(tmpDir, { router: "pages" }); writeFile(tmpDir, "styles.module.css", ".card {}"); From bc86aace0c82b917a85282e1aba6028ee510694e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 00:29:54 +0100 Subject: [PATCH 10/18] fix(init): preserve valid CSS module projects --- packages/vinext/src/init-cloudflare.ts | 36 ++++++++++++++++++++-- packages/vinext/src/init.ts | 20 +++++-------- tests/init-cloudflare.test.ts | 41 ++++++++++++++++++++++++-- tests/init.test.ts | 9 ++++-- 4 files changed, 87 insertions(+), 19 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 8d23e242e..8d8c3580b 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -847,7 +847,13 @@ function unwrapObject(expression: ESTree.Expression): AstObject | undefined { return unwrapped?.type === "ObjectExpression" ? (unwrapped as AstObject) : undefined; } -function findVariableObject(program: ESTree.Program, name: string): AstObject | undefined { +function findVariableObject( + program: ESTree.Program, + name: string, + seen = new Set(), +): AstObject | undefined { + if (seen.has(name)) return undefined; + seen.add(name); for (const statement of program.body) { if (statement.type !== "VariableDeclaration") continue; for (const declaration of statement.declarations) { @@ -858,7 +864,23 @@ function findVariableObject(program: ESTree.Program, name: string): AstObject | ) { continue; } - return unwrapObject(declaration.init); + const direct = unwrapObject(declaration.init); + if (direct) return direct; + const initializer = unwrapExpression(declaration.init); + if (initializer?.type === "Identifier") { + return findVariableObject(program, initializer.name, seen); + } + if (initializer?.type !== "CallExpression" || initializer.arguments.length === 0) { + return undefined; + } + const firstArgument = initializer.arguments[0]; + if (firstArgument.type === "SpreadElement") return undefined; + const argumentObject = unwrapObject(firstArgument); + if (argumentObject) return argumentObject; + if (firstArgument.type === "Identifier") { + return findVariableObject(program, firstArgument.name, seen); + } + return undefined; } } return undefined; @@ -907,6 +929,9 @@ function findConfigObject(program: ESTree.Program): AstObject | undefined { if (firstArgument.type === "SpreadElement") return undefined; const argumentObject = unwrapObject(firstArgument); if (argumentObject) return argumentObject; + if (firstArgument.type === "Identifier") { + return findVariableObject(program, firstArgument.name); + } if ( firstArgument.type !== "ArrowFunctionExpression" && firstArgument.type !== "FunctionExpression" @@ -1810,7 +1835,12 @@ function ensureCssModulesScopedName( "The Vite config's css.modules.generateScopedName option must appear after any spread properties so vinext init can verify it.", ); } - if (!hasNullishValue(generateScopedName)) return true; + const value = generateScopedName.value as AstNode & { value?: unknown }; + const usesHashTemplate = + value.type === "Literal" && + typeof value.value === "string" && + /\[hash(?::[^\]]*)?\]/i.test(value.value); + if (!hasNullishValue(generateScopedName) && !usesHashTemplate) return true; const indent = objectPropertyIndent(modulesObject, code); output.overwrite( (generateScopedName as AstNode).start, diff --git a/packages/vinext/src/init.ts b/packages/vinext/src/init.ts index 547116ec9..cbee5516e 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -17,7 +17,7 @@ */ import fs from "node:fs"; -import path from "pathslash"; +import path, { toSlash } from "pathslash"; import { spawn, spawnSync } from "node:child_process"; import { detectProject, @@ -159,27 +159,23 @@ export default defineConfig({ } const CSS_MODULE_PATTERN = /\.module\.(?:css|scss|sass)$/; -const CSS_MODULE_SCAN_IGNORES = new Set([ - "node_modules", - ".next", - ".vinext", - ".wrangler", - "dist", - "out", - "build", - "coverage", -]); +const CSS_MODULE_SCAN_IGNORES = new Set(["node_modules", ".next", ".vinext", ".wrangler"]); +const CSS_MODULE_ROOT_SCAN_IGNORES = new Set(["dist", "out", "build", "coverage"]); /** Detect project-owned CSS, SCSS, or Sass module files. */ export function scanCssModuleFiles(root: string): boolean { try { + const canonicalRoot = path.resolve(root); return fs .globSync("**/*.module.{css,scss,sass}", { cwd: root, withFileTypes: true, exclude: (entry) => entry.isDirectory() && - (entry.name.startsWith(".") || CSS_MODULE_SCAN_IGNORES.has(entry.name)), + (entry.name.startsWith(".") || + CSS_MODULE_SCAN_IGNORES.has(entry.name) || + (toSlash(entry.parentPath) === canonicalRoot && + CSS_MODULE_ROOT_SCAN_IGNORES.has(entry.name))), }) .some((entry) => CSS_MODULE_PATTERN.test(entry.name)); } catch { diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index aea12901d..0a38f6ee3 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -139,19 +139,56 @@ export default { plugins: [] } satisfies UserConfig; it("preserves an existing generateScopedName", () => { const input = `export default { plugins: [], - css: { modules: { generateScopedName: "custom_[hash]" } }, + css: { modules: { generateScopedName: "custom_[local]" } }, }; `; const result = updateViteConfigForCssModules("vite.config.ts", input); - expect(result.code).toContain('generateScopedName: "custom_[hash]"'); + expect(result.code).toContain('generateScopedName: "custom_[local]"'); expect(result.code).not.toContain("generateScopedName(name: string, filename: string)"); expect(result.code).not.toContain('from "node:crypto"'); expect(result.code).not.toContain('from "node:path"'); expect(result.preservedExistingGenerateScopedName).toBe(true); }); + it("replaces an environment-dependent hash-template scoped name", () => { + const input = `export default { + plugins: [], + css: { modules: { generateScopedName: "[name]__[local]___[hash:base64:5]" } }, +}; +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); + expect(result.code).not.toContain("[hash:base64:5]"); + expect(result.preservedExistingGenerateScopedName).toBe(false); + }); + + it.each([ + [ + "through an exported variable", + `const options = { plugins: [] }; +const config = defineConfig(options); +export default config; +`, + ], + [ + "as the direct defineConfig argument", + `const options = { plugins: [] }; +export default defineConfig(options); +`, + ], + ])("resolves a static config object $0", (_name, input) => { + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); + }); + it.each(["undefined", "null"])("replaces a nullish %s scoped-name setting", (value) => { const input = `export default { plugins: [], diff --git a/tests/init.test.ts b/tests/init.test.ts index c42d10df1..845509dc4 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -290,6 +290,11 @@ describe("scanCssModuleFiles", () => { writeFile(tmpDir, "src/styles.module.CSS", ".card {}"); expect(scanCssModuleFiles(tmpDir)).toBe(false); }); + + it("does not treat nested source directories as root build output", () => { + writeFile(tmpDir, "app/build/components/card.module.css", ".card {}"); + expect(scanCssModuleFiles(tmpDir)).toBe(true); + }); }); // ─── Unit Tests: addScripts ────────────────────────────────────────────────── @@ -1523,13 +1528,13 @@ describe("init — guard rails", () => { writeFile( tmpDir, "vite.config.ts", - 'export default { plugins: [], css: { modules: { generateScopedName: "custom_[hash]" } } };', + 'export default { plugins: [], css: { modules: { generateScopedName: "custom_[local]" } } };', ); const { output } = await runInit(tmpDir, { platform: "node" }); const config = readFile(tmpDir, "vite.config.ts"); - expect(config).toContain('generateScopedName: "custom_[hash]"'); + expect(config).toContain('generateScopedName: "custom_[local]"'); expect(config).toContain('patchCssModules({ exportMode: "default" })'); expect(config).not.toContain("patchCssModules()"); expect(output).toContain("Preserved existing css.modules.generateScopedName"); From 6cea508dfb6ec2e46cf126a843b350d003747615 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 00:39:23 +0100 Subject: [PATCH 11/18] fix(init): resolve static Vite config wrappers --- packages/vinext/src/init-cloudflare.ts | 69 ++++++++++++++++++++++---- tests/init-cloudflare.test.ts | 44 +++++++++++++++- 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 8d8c3580b..00ecb6f0c 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -847,6 +847,35 @@ function unwrapObject(expression: ESTree.Expression): AstObject | undefined { return unwrapped?.type === "ObjectExpression" ? (unwrapped as AstObject) : undefined; } +function isDefineConfigCall(program: ESTree.Program, call: ESTree.CallExpression): boolean { + const callee = unwrapExpression(call.callee); + if (callee?.type === "Identifier") { + return ( + callee.name === "defineConfig" || + callee.name === findImportedBinding(program, "vite", "defineConfig") || + callee.name === findRequiredBinding(program, "vite", "defineConfig") + ); + } + if ( + callee?.type !== "MemberExpression" || + callee.object.type !== "Identifier" || + !( + (!callee.computed && + callee.property.type === "Identifier" && + callee.property.name === "defineConfig") || + (callee.computed && + callee.property.type === "Literal" && + callee.property.value === "defineConfig") + ) + ) { + return false; + } + return ( + callee.object.name === findNamespaceImportedBinding(program, "vite") || + callee.object.name === findRequiredBinding(program, "vite", "default") + ); +} + function findVariableObject( program: ESTree.Program, name: string, @@ -870,7 +899,11 @@ function findVariableObject( if (initializer?.type === "Identifier") { return findVariableObject(program, initializer.name, seen); } - if (initializer?.type !== "CallExpression" || initializer.arguments.length === 0) { + if ( + initializer?.type !== "CallExpression" || + !isDefineConfigCall(program, initializer) || + initializer.arguments.length === 0 + ) { return undefined; } const firstArgument = initializer.arguments[0]; @@ -907,9 +940,18 @@ function findConfigObject(program: ESTree.Program): AstObject | undefined { } const direct = unwrapObject(expression.right); if (direct) return direct; - if (expression.right.type === "CallExpression" && expression.right.arguments.length > 0) { + if ( + expression.right.type === "CallExpression" && + isDefineConfigCall(program, expression.right) && + expression.right.arguments.length > 0 + ) { const firstArgument = expression.right.arguments[0]; - if (firstArgument.type !== "SpreadElement") return unwrapObject(firstArgument); + if (firstArgument.type === "SpreadElement") return undefined; + const argumentObject = unwrapObject(firstArgument); + if (argumentObject) return argumentObject; + if (firstArgument.type === "Identifier") { + return findVariableObject(program, firstArgument.name); + } } } return undefined; @@ -923,7 +965,13 @@ function findConfigObject(program: ESTree.Program): AstObject | undefined { const direct = unwrapObject(declaration); if (direct) return direct; if (declaration.type === "Identifier") return findVariableObject(program, declaration.name); - if (declaration.type !== "CallExpression" || declaration.arguments.length === 0) return undefined; + if ( + declaration.type !== "CallExpression" || + !isDefineConfigCall(program, declaration) || + declaration.arguments.length === 0 + ) { + return undefined; + } const firstArgument = declaration.arguments[0]; if (firstArgument.type === "SpreadElement") return undefined; @@ -1835,11 +1883,14 @@ function ensureCssModulesScopedName( "The Vite config's css.modules.generateScopedName option must appear after any spread properties so vinext init can verify it.", ); } - const value = generateScopedName.value as AstNode & { value?: unknown }; - const usesHashTemplate = - value.type === "Literal" && - typeof value.value === "string" && - /\[hash(?::[^\]]*)?\]/i.test(value.value); + const value = unwrapExpression(generateScopedName.value); + const staticValue = + value?.type === "Literal" && typeof value.value === "string" + ? value.value + : value?.type === "TemplateLiteral" && value.expressions.length === 0 + ? (value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw) + : undefined; + const usesHashTemplate = staticValue !== undefined && /\[hash(?::[^\]]*)?\]/i.test(staticValue); if (!hasNullishValue(generateScopedName) && !usesHashTemplate) return true; const indent = objectPropertyIndent(modulesObject, code); output.overwrite( diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 0a38f6ee3..ae993d30c 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -152,10 +152,14 @@ export default { plugins: [] } satisfies UserConfig; expect(result.preservedExistingGenerateScopedName).toBe(true); }); - it("replaces an environment-dependent hash-template scoped name", () => { + it.each([ + ['"[name]__[local]___[hash:base64:5]"', "string"], + ['"[name]__[local]___[hash:base64:5]" as const', "typed string"], + ["`[name]__[local]___[hash:base64:5]`", "template string"], + ])("replaces an environment-dependent hash-template $1", (scopedName) => { const input = `export default { plugins: [], - css: { modules: { generateScopedName: "[name]__[local]___[hash:base64:5]" } }, + css: { modules: { generateScopedName: ${scopedName} } }, }; `; @@ -189,6 +193,42 @@ export default defineConfig(options); expect(result.code).toContain("generateScopedName(name: string, filename: string)"); }); + it("resolves an identifier argument in a CommonJS defineConfig call", () => { + const input = `const { defineConfig } = require("vite"); +const options = { plugins: [] }; +module.exports = defineConfig(options); +`; + + const result = updateViteConfigForCssModules("vite.config.cjs", input); + + expectValidConfig(result.code); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); + expect(result.code).toContain("generateScopedName(name, filename)"); + }); + + it("recognizes a namespace-imported defineConfig call", () => { + const input = `import * as vite from "vite"; +const options = { plugins: [] }; +export default vite.defineConfig(options); +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); + }); + + it("rejects config objects hidden behind arbitrary factory calls", () => { + const input = `const base = { plugins: [] }; +const config = mergeConfig(base, overrides); +export default config; +`; + + expect(() => updateViteConfigForCssModules("vite.config.ts", input)).toThrow( + "Could not find a static Vite config object", + ); + }); + it.each(["undefined", "null"])("replaces a nullish %s scoped-name setting", (value) => { const input = `export default { plugins: [], From ac098776b69c8b721c60289734cca2706ea9a193 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 00:50:38 +0100 Subject: [PATCH 12/18] fix(init): verify static Vite config bindings --- packages/vinext/src/init-cloudflare.ts | 19 +++++----- tests/init-cloudflare.test.ts | 49 ++++++++++++++++++-------- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 00ecb6f0c..8503215de 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -823,11 +823,11 @@ function findLastProperty(object: AstObject, name: string): AstProperty | undefi return undefined; } -function hasNullishValue(property: AstProperty): boolean { - const value = property.value as AstNode & { name?: string; value?: unknown }; +function isNullishValue(value: ESTree.Node): boolean { + const candidate = value as AstNode & { name?: string; value?: unknown }; return ( - (value.type === "Identifier" && value.name === "undefined") || - (value.type === "Literal" && value.value === null) + (candidate.type === "Identifier" && candidate.name === "undefined") || + (candidate.type === "Literal" && candidate.value === null) ); } @@ -850,10 +850,11 @@ function unwrapObject(expression: ESTree.Expression): AstObject | undefined { function isDefineConfigCall(program: ESTree.Program, call: ESTree.CallExpression): boolean { const callee = unwrapExpression(call.callee); if (callee?.type === "Identifier") { + const imported = findImportedBinding(program, "vite", "defineConfig"); + const required = findRequiredBinding(program, "vite", "defineConfig"); return ( - callee.name === "defineConfig" || - callee.name === findImportedBinding(program, "vite", "defineConfig") || - callee.name === findRequiredBinding(program, "vite", "defineConfig") + (imported !== undefined && callee.name === imported) || + (required !== undefined && callee.name === required) ); } if ( @@ -1495,7 +1496,7 @@ function hasVinextPrerender(call: (ESTree.CallExpression & AstNode) | undefined) } function isUsableImageOptimizer(property: AstProperty | undefined): boolean { - return Boolean(property && !hasNullishValue(property)); + return Boolean(property && !isNullishValue(property.value)); } function isImagesOptimizerCall( @@ -1891,7 +1892,7 @@ function ensureCssModulesScopedName( ? (value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw) : undefined; const usesHashTemplate = staticValue !== undefined && /\[hash(?::[^\]]*)?\]/i.test(staticValue); - if (!hasNullishValue(generateScopedName) && !usesHashTemplate) return true; + if (value && !isNullishValue(value) && !usesHashTemplate) return true; const indent = objectPropertyIndent(modulesObject, code); output.overwrite( (generateScopedName as AstNode).start, diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index ae993d30c..b572aefba 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -20,6 +20,10 @@ function expectValidConfig(output: string): void { expect(parsed.errors.filter((diagnostic) => diagnostic.severity === "Error")).toEqual([]); } +function withDefineConfig(code: string): string { + return `import { defineConfig } from "vite";\n${code}`; +} + describe("updateViteConfigForCssModules", () => { // Regression for https://github.com/cloudflare/vinext/issues/2992#issuecomment-5348417497 it("uses Next-compatible default-only exports for both Cloudflare router configs", () => { @@ -186,7 +190,7 @@ export default defineConfig(options); `, ], ])("resolves a static config object $0", (_name, input) => { - const result = updateViteConfigForCssModules("vite.config.ts", input); + const result = updateViteConfigForCssModules("vite.config.ts", withDefineConfig(input)); expectValidConfig(result.code); expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); @@ -229,20 +233,34 @@ export default config; ); }); - it.each(["undefined", "null"])("replaces a nullish %s scoped-name setting", (value) => { - const input = `export default { + it("rejects a locally defined function named defineConfig", () => { + const input = `const base = { plugins: [] }; +const defineConfig = (value) => ({ ...value, ...overrides }); +export default defineConfig(base); +`; + + expect(() => updateViteConfigForCssModules("vite.config.ts", input)).toThrow( + "Could not find a static Vite config object", + ); + }); + + it.each(["undefined", "null", "undefined as string | undefined", "null as any"])( + "replaces a nullish %s scoped-name setting", + (value) => { + const input = `export default { plugins: [], css: { modules: { generateScopedName: ${value} } }, }; `; - const result = updateViteConfigForCssModules("vite.config.ts", input); + const result = updateViteConfigForCssModules("vite.config.ts", input); - expectValidConfig(result.code); - expect(result.code).toContain("generateScopedName(name: string, filename: string)"); - expect(result.code).not.toContain(`generateScopedName: ${value}`); - expect(result.preservedExistingGenerateScopedName).toBe(false); - }); + expectValidConfig(result.code); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); + expect(result.code).not.toContain(`generateScopedName: ${value}`); + expect(result.preservedExistingGenerateScopedName).toBe(false); + }, + ); it("keeps the comma after an existing multiline css property", () => { const input = `import { defineConfig } from "vite"; @@ -272,7 +290,8 @@ export default defineConfig({ ["a plain ESM object", "vite.config.mjs", "export default {\n \n};\n"], ["a CommonJS object", "vite.config.cjs", "module.exports = {\n \n};\n"], ])("normalizes an otherwise empty config with $0", (_name, filePath, input) => { - const result = updateViteConfigForCssModules(filePath, input); + const source = input.includes("defineConfig(") ? withDefineConfig(input) : input; + const result = updateViteConfigForCssModules(filePath, source); expectValidConfig(result.code); expect(result.code).toContain( @@ -286,7 +305,7 @@ export default defineConfig({ ["line comment", "export default defineConfig({\n // keep this comment\n});\n"], ["inline block comment", "export default defineConfig({ /* keep this comment */ });\n"], ])("preserves comments in an otherwise empty config with a $0", (_name, input) => { - const result = updateViteConfigForCssModules("vite.config.ts", input); + const result = updateViteConfigForCssModules("vite.config.ts", withDefineConfig(input)); expectValidConfig(result.code); expect(result.code).toContain("keep this comment"); @@ -302,7 +321,7 @@ export default defineConfig({ ]; for (const input of inputs) { - const result = updateViteConfigForCssModules("vite.config.ts", input); + const result = updateViteConfigForCssModules("vite.config.ts", withDefineConfig(input)); expectValidConfig(result.code); expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); expect(result.code).toContain( @@ -320,7 +339,7 @@ export default defineConfig({ }); `; - const result = updateViteConfigForCssModules("vite.config.ts", input); + const result = updateViteConfigForCssModules("vite.config.ts", withDefineConfig(input)); expectValidConfig(result.code); expect(result.code).toContain( @@ -350,7 +369,7 @@ export default defineConfig({ `, ], ])("preserves comments in an otherwise empty $0", (_name, input) => { - const result = updateViteConfigForCssModules("vite.config.ts", input); + const result = updateViteConfigForCssModules("vite.config.ts", withDefineConfig(input)); expectValidConfig(result.code); expect(result.code).toContain("keep nested comment"); @@ -364,7 +383,7 @@ export default defineConfig({ }); `; - const result = updateViteConfigForCssModules("vite.config.ts", input); + const result = updateViteConfigForCssModules("vite.config.ts", withDefineConfig(input)); expectValidConfig(result.code); expect(result.code).toContain( From eea0a786ca258fbec57e19e5c859a39a2bdd2d7a Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 10:13:05 +0100 Subject: [PATCH 13/18] fix(init): preserve computed Vite config properties --- packages/vinext/src/init-cloudflare.ts | 33 +++++++++++++------ tests/init-cloudflare.test.ts | 45 ++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 8503215de..298783882 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -800,11 +800,13 @@ function parseViteConfig(filePath: string, code: string): ESTree.Program { } function propertyName(property: AstProperty): string | undefined { - if (property.computed) return undefined; - if (property.key.type === "Identifier") return property.key.name; + if (!property.computed && property.key.type === "Identifier") return property.key.name; if (property.key.type === "Literal" && typeof property.key.value === "string") { return property.key.value; } + if (property.key.type === "TemplateLiteral" && property.key.expressions.length === 0) { + return property.key.quasis[0]?.value.cooked ?? property.key.quasis[0]?.value.raw; + } return undefined; } @@ -832,14 +834,18 @@ function isNullishValue(value: ESTree.Node): boolean { } /** - * A missing property may be supplied by any spread, while an existing property - * may be overridden by a later spread because object composition is last-write-wins. + * A missing property may be supplied by any spread or dynamic computed key, + * while an existing property may be overridden by either one appearing later. */ function hasPotentialSpreadOverride(object: AstObject, property: AstProperty | undefined): boolean { const propertyIndex = property ? object.properties.lastIndexOf(property) : -1; return object.properties .slice(propertyIndex + 1) - .some((candidate) => candidate.type === "SpreadElement"); + .some( + (candidate) => + candidate.type === "SpreadElement" || + (candidate.type === "Property" && candidate.computed && !propertyName(candidate)), + ); } function unwrapObject(expression: ESTree.Expression): AstObject | undefined { @@ -1743,7 +1749,7 @@ function ensurePluginFirst( const plugins = findLastProperty(config, "plugins"); if (hasPotentialSpreadOverride(config, plugins)) { throw new Error( - "The Vite config's plugins option must be explicitly defined after any spread properties so vinext init can configure CSS Modules without replacing existing plugins.", + "The Vite config's plugins option must be explicitly defined after any spread or dynamic computed properties so vinext init can configure CSS Modules without replacing existing plugins.", ); } if (!plugins) { @@ -1812,7 +1818,7 @@ function ensureCssModulesScopedName( const css = findLastProperty(config, "css"); if (hasPotentialSpreadOverride(config, css)) { throw new Error( - "The Vite config's css option must be explicitly defined after any spread properties so vinext init can configure CSS Modules without replacing existing options.", + "The Vite config's css option must be explicitly defined after any spread or dynamic computed properties so vinext init can configure CSS Modules without replacing existing options.", ); } if (!css) { @@ -1848,7 +1854,7 @@ function ensureCssModulesScopedName( const modules = findLastProperty(cssObject, "modules"); if (hasPotentialSpreadOverride(cssObject, modules)) { throw new Error( - "The Vite config's css.modules option must be explicitly defined after any spread properties so vinext init can configure CSS Modules without replacing existing options.", + "The Vite config's css.modules option must be explicitly defined after any spread or dynamic computed properties so vinext init can configure CSS Modules without replacing existing options.", ); } if (!modules) { @@ -1881,7 +1887,7 @@ function ensureCssModulesScopedName( if (generateScopedName) { if (hasPotentialSpreadOverride(modulesObject, generateScopedName)) { throw new Error( - "The Vite config's css.modules.generateScopedName option must appear after any spread properties so vinext init can verify it.", + "The Vite config's css.modules.generateScopedName option must appear after any spread or dynamic computed properties so vinext init can verify it.", ); } const value = unwrapExpression(generateScopedName.value); @@ -1936,7 +1942,14 @@ export function updateViteConfigForCssModules( const existingPatch = commonJs ? findRequiredBinding(firstProgram, "vite-css-modules", "patchCssModules") : findImportedBinding(firstProgram, "vite-css-modules", "patchCssModules"); - if (!existingMemberCall) { + if (existingMemberCall) { + const plugins = findLastProperty(firstConfig, "plugins"); + if (hasPotentialSpreadOverride(firstConfig, plugins)) { + throw new Error( + "The Vite config's plugins option must be explicitly defined after any spread or dynamic computed properties so vinext init can verify the existing CSS Modules plugin.", + ); + } + } else { const patchLocal = existingPatch ?? allocateBinding(bindings, "patchCssModules"); const patchBinding = commonJs ? ensureNamedRequire( diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index b572aefba..f7390b84a 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -96,6 +96,18 @@ export default { plugins: [cssModules.patchCssModules({ generateSourceTypes: tru expect(result.code).toContain("generateScopedName(name: string, filename: string)"); }); + it("rejects an existing namespace plugin call that a later spread can override", () => { + const input = `import * as cssModules from "vite-css-modules"; +export default { + plugins: [cssModules.patchCssModules()], + ...sharedConfig, + css: { modules: {} }, +}; +`; + + expect(() => updateViteConfigForCssModules("vite.config.ts", input)).toThrow(/spread/i); + }); + it("does not reuse type-only imports as runtime bindings", () => { const input = `import type { patchCssModules } from "vite-css-modules"; import { type createHash } from "node:crypto"; @@ -476,6 +488,39 @@ export default { ); }); + it("updates statically computed config properties without duplicating them", () => { + const input = `import vinext from "vinext"; +export default { + ["plugins"]: [vinext()], + [\`css\`]: { ["modules"]: { localsConvention: "camelCase" } }, +}; +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code.match(/plugins/g)).toHaveLength(1); + expect(result.code.match(/modules/g)).toHaveLength(2); + expect(result.code).toContain( + '["plugins"]: [patchCssModules({ exportMode: "default" }), vinext()]', + ); + expect(result.code).toContain('localsConvention: "camelCase"'); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); + }); + + it("rejects dynamic computed config properties that could override the workaround", () => { + const input = `export default { + plugins: [], + css: { modules: {} }, + [configKey]: sharedConfig, +}; +`; + + expect(() => updateViteConfigForCssModules("vite.config.ts", input)).toThrow( + /dynamic computed/i, + ); + }); + it("preserves spread-provided module options when explicit modules follow the CSS spread", () => { const input = `const sharedCss = { modules: { localsConvention: "camelCase" } }; export default { From 510fd2bc9d25298de58489ab40773d5925dda4a1 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 10:34:41 +0100 Subject: [PATCH 14/18] fix(init): harden CSS config rewrites --- packages/vinext/src/init-cloudflare.ts | 60 +++++++++++++++++++++----- packages/vinext/src/init.ts | 2 +- tests/init-cloudflare.test.ts | 39 +++++++++++++++++ tests/init.test.ts | 3 ++ 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 298783882..a020e71ea 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -4,7 +4,7 @@ import { createRequire } from "node:module"; import MagicString from "magic-string"; import type { ESTree } from "vite"; import type { CloudflareInitOptions } from "./init-platform.js"; -import { unwrapExpression } from "./plugins/ast-utils.js"; +import { forEachAstChild, unwrapExpression } from "./plugins/ast-utils.js"; import { detectProject } from "./utils/project.js"; const require = createRequire(import.meta.url); @@ -947,6 +947,9 @@ function findConfigObject(program: ESTree.Program): AstObject | undefined { } const direct = unwrapObject(expression.right); if (direct) return direct; + if (expression.right.type === "Identifier") { + return findVariableObject(program, expression.right.name); + } if ( expression.right.type === "CallExpression" && isDefineConfigCall(program, expression.right) && @@ -1037,7 +1040,7 @@ function collectPatternBindings(pattern: ESTree.Node, bindings: Set): vo } } -function collectTopLevelBindings(program: ESTree.Program): Set { +function collectAllBindings(program: ESTree.Program): Set { const bindings = new Set(); for (const statement of program.body) { if (statement.type === "ImportDeclaration") { @@ -1062,9 +1065,42 @@ function collectTopLevelBindings(program: ESTree.Program): Set { bindings.add(declaration.id.name); } } + const collectNestedBindings = (node: ESTree.Node): void => { + if (node.type === "VariableDeclarator") { + collectPatternBindings(node.id, bindings); + } else if ( + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ) { + if (node.type !== "ArrowFunctionExpression" && node.id) bindings.add(node.id.name); + for (const parameter of node.params) collectPatternBindings(parameter, bindings); + } else if (node.type === "ClassExpression" && node.id) { + bindings.add(node.id.name); + } else if (node.type === "CatchClause" && node.param) { + collectPatternBindings(node.param, bindings); + } else if (node.type === "TSImportEqualsDeclaration") { + bindings.add(node.id.name); + } + forEachAstChild(node, collectNestedBindings); + }; + forEachAstChild(program, collectNestedBindings); return bindings; } +function overwritePropertyValue( + output: MagicString, + property: AstProperty, + name: string, + value: string, +): void { + if (property.shorthand) { + output.overwrite((property as AstNode).start, (property as AstNode).end, `${name}: ${value}`); + } else { + output.overwrite((property.value as AstNode).start, (property.value as AstNode).end, value); + } +} + function allocateBinding(bindings: Set, preferred: string): string { if (!bindings.has(preferred)) { bindings.add(preferred); @@ -1837,9 +1873,10 @@ function ensureCssModulesScopedName( if (css.value.type !== "ObjectExpression") { if (force) { const indent = objectPropertyIndent(config, code); - output.overwrite( - (css.value as AstNode).start, - (css.value as AstNode).end, + overwritePropertyValue( + output, + css, + "css", `{\n${indent} modules: {\n${generateScopedNameSource( `${indent} `, )},\n${indent} },\n${indent}}`, @@ -1871,9 +1908,10 @@ function ensureCssModulesScopedName( if (modules.value.type !== "ObjectExpression") { if (force) { const indent = objectPropertyIndent(cssObject, code); - output.overwrite( - (modules.value as AstNode).start, - (modules.value as AstNode).end, + overwritePropertyValue( + output, + modules, + "modules", `{\n${generateScopedNameSource(`${indent} `)},\n${indent}}`, ); return false; @@ -1932,7 +1970,7 @@ export function updateViteConfigForCssModules( ); } const commonJs = usesCommonJsViteConfig(filePath, code); - const bindings = collectTopLevelBindings(firstProgram); + const bindings = collectAllBindings(firstProgram); const firstOutput = new MagicString(code); const patchNamespace = commonJs @@ -1978,7 +2016,7 @@ export function updateViteConfigForCssModules( const withPlugin = firstOutput.toString(); const secondProgram = parseViteConfig(filePath, withPlugin); const secondConfig = findConfigObject(secondProgram)!; - const secondBindings = collectTopLevelBindings(secondProgram); + const secondBindings = collectAllBindings(secondProgram); const secondOutput = new MagicString(withPlugin); const preservedExistingGenerateScopedName = ensureCssModulesScopedName( secondOutput, @@ -2120,7 +2158,7 @@ export function updateViteConfigForCloudflare( const output = new MagicString(code); const commonJs = usesCommonJsViteConfig(filePath, code); - const bindings = collectTopLevelBindings(program); + const bindings = collectAllBindings(program); const existingVinextBinding = commonJs ? findRequiredBinding(program, "vinext", "default") : program.body diff --git a/packages/vinext/src/init.ts b/packages/vinext/src/init.ts index cbee5516e..43ff61f3c 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -264,7 +264,7 @@ export function getInitDependencyGroups( dependencies.push("@vinext/cloudflare"); devDependencies.push("@cloudflare/vite-plugin", "wrangler"); } - if (hasCssModules) devDependencies.push("vite-css-modules"); + if (hasCssModules) devDependencies.push("vite-css-modules", "postcss"); return { dependencies, devDependencies }; } diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index f7390b84a..6a4ca0a94 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -222,6 +222,35 @@ module.exports = defineConfig(options); expect(result.code).toContain("generateScopedName(name, filename)"); }); + it("resolves a direct CommonJS identifier export", () => { + const input = `const config = { plugins: [] }; +module.exports = config; +`; + + const result = updateViteConfigForCssModules("vite.config.cjs", input); + + expectValidConfig(result.code); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); + expect(result.code).toContain("generateScopedName(name, filename)"); + }); + + it("avoids callback-local binding collisions", () => { + const input = `import { defineConfig } from "vite"; +export default defineConfig((patchCssModules) => { + const path = "user-path"; + const createHash = "user-hash"; + return { plugins: [] }; +}); +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + expectValidConfig(result.code); + expect(result.code).toContain("patchCssModules2({"); + expect(result.code).toContain("const relativePath = path2"); + expect(result.code).toContain('createHash2("sha256")'); + }); + it("recognizes a namespace-imported defineConfig call", () => { const input = `import * as vite from "vite"; const options = { plugins: [] }; @@ -413,6 +442,16 @@ export default defineConfig({ ).toThrow("css.modules option must be a static object"); }); + it.each([ + ["css", "const css = getCss();\nexport default { plugins: [], css };"], + ["modules", "const modules = getModules();\nexport default { plugins: [], css: { modules } };"], + ])("keeps forced shorthand %s replacement syntactically valid", (_name, input) => { + const result = updateViteConfigForCssModules("vite.config.ts", input, true); + + expectValidConfig(result.code); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); + }); + it.each([ [ "plugins are provided only through a root config spread", diff --git a/tests/init.test.ts b/tests/init.test.ts index 845509dc4..05712a807 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -442,7 +442,9 @@ describe("getInitDeps", () => { it("adds vite-css-modules only when CSS Modules are detected", () => { expect(getInitDeps(false, "node", true)).toContain("vite-css-modules"); + expect(getInitDeps(false, "node", true)).toContain("postcss"); expect(getInitDeps(false, "node", false)).not.toContain("vite-css-modules"); + expect(getInitDeps(false, "node", false)).not.toContain("postcss"); }); }); @@ -1223,6 +1225,7 @@ describe("init — dependency installation", () => { const { result, execCalls, output } = await runInit(tmpDir, { platform: "node" }); expect(result.installedDeps).toContain("vite-css-modules"); + expect(result.installedDeps).toContain("postcss"); expect(execCalls.some(({ cmd }) => cmd.includes("vite-css-modules"))).toBe(true); const config = readFile(tmpDir, "vite.config.ts"); const patchCall = 'patchCssModules({ exportMode: "default" })'; From ba59f326e59cca70b9c99552183bdde1bf8a0988 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 12:48:26 +0100 Subject: [PATCH 15/18] fix(init): handle hidden CSS modules and callback configs --- packages/vinext/src/init-cloudflare.ts | 30 ++++++++++++++------------ packages/vinext/src/init.ts | 8 +++---- tests/init-cloudflare.test.ts | 25 +++++++++++++++++++++ tests/init.test.ts | 5 +++++ 4 files changed, 50 insertions(+), 18 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index a020e71ea..e7e6b3733 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -883,6 +883,19 @@ function isDefineConfigCall(program: ESTree.Program, call: ESTree.CallExpression ); } +function findCallbackObject(expression: ESTree.Expression): AstObject | undefined { + const callback = unwrapExpression(expression); + if (callback?.type !== "ArrowFunctionExpression" && callback?.type !== "FunctionExpression") { + return undefined; + } + if (!callback.body) return undefined; + if (callback.body.type !== "BlockStatement") return unwrapObject(callback.body); + const returnStatement = callback.body.body.find( + (statement): statement is ESTree.ReturnStatement => statement.type === "ReturnStatement", + ); + return returnStatement?.argument ? unwrapObject(returnStatement.argument) : undefined; +} + function findVariableObject( program: ESTree.Program, name: string, @@ -920,7 +933,7 @@ function findVariableObject( if (firstArgument.type === "Identifier") { return findVariableObject(program, firstArgument.name, seen); } - return undefined; + return findCallbackObject(firstArgument); } } return undefined; @@ -962,6 +975,7 @@ function findConfigObject(program: ESTree.Program): AstObject | undefined { if (firstArgument.type === "Identifier") { return findVariableObject(program, firstArgument.name); } + return findCallbackObject(firstArgument); } } return undefined; @@ -990,19 +1004,7 @@ function findConfigObject(program: ESTree.Program): AstObject | undefined { if (firstArgument.type === "Identifier") { return findVariableObject(program, firstArgument.name); } - if ( - firstArgument.type !== "ArrowFunctionExpression" && - firstArgument.type !== "FunctionExpression" - ) { - return undefined; - } - - if (!firstArgument.body) return undefined; - if (firstArgument.body.type !== "BlockStatement") return unwrapObject(firstArgument.body); - const returnStatement = firstArgument.body.body.find( - (statement): statement is ESTree.ReturnStatement => statement.type === "ReturnStatement", - ); - return returnStatement?.argument ? unwrapObject(returnStatement.argument) : undefined; + return findCallbackObject(firstArgument); } function importInsertionOffset(program: ESTree.Program): number { diff --git a/packages/vinext/src/init.ts b/packages/vinext/src/init.ts index 43ff61f3c..33bdadb44 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -159,7 +159,8 @@ export default defineConfig({ } const CSS_MODULE_PATTERN = /\.module\.(?:css|scss|sass)$/; -const CSS_MODULE_SCAN_IGNORES = new Set(["node_modules", ".next", ".vinext", ".wrangler"]); +const CSS_MODULE_GLOBS = ["**/*.module.{css,scss,sass}", "**/.*/**/*.module.{css,scss,sass}"]; +const CSS_MODULE_SCAN_IGNORES = new Set(["node_modules", ".git", ".next", ".vinext", ".wrangler"]); const CSS_MODULE_ROOT_SCAN_IGNORES = new Set(["dist", "out", "build", "coverage"]); /** Detect project-owned CSS, SCSS, or Sass module files. */ @@ -167,13 +168,12 @@ export function scanCssModuleFiles(root: string): boolean { try { const canonicalRoot = path.resolve(root); return fs - .globSync("**/*.module.{css,scss,sass}", { + .globSync(CSS_MODULE_GLOBS, { cwd: root, withFileTypes: true, exclude: (entry) => entry.isDirectory() && - (entry.name.startsWith(".") || - CSS_MODULE_SCAN_IGNORES.has(entry.name) || + (CSS_MODULE_SCAN_IGNORES.has(entry.name) || (toSlash(entry.parentPath) === canonicalRoot && CSS_MODULE_ROOT_SCAN_IGNORES.has(entry.name))), }) diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 6a4ca0a94..b5de53ef8 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -222,6 +222,31 @@ module.exports = defineConfig(options); expect(result.code).toContain("generateScopedName(name, filename)"); }); + it.each([ + [ + "ES module", + `import { defineConfig } from "vite"; +const config = defineConfig(() => ({ plugins: [] })); +export default config; +`, + "vite.config.ts", + ], + [ + "CommonJS", + `const { defineConfig } = require("vite"); +const config = defineConfig(() => ({ plugins: [] })); +module.exports = config; +`, + "vite.config.cjs", + ], + ])("resolves a callback in a variable-bound $s defineConfig call", (_name, input, fileName) => { + const result = updateViteConfigForCssModules(fileName, input); + + expectValidConfig(result.code); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); + expect(result.code).toContain("generateScopedName(name"); + }); + it("resolves a direct CommonJS identifier export", () => { const input = `const config = { plugins: [] }; module.exports = config; diff --git a/tests/init.test.ts b/tests/init.test.ts index 05712a807..6e8f91d51 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -295,6 +295,11 @@ describe("scanCssModuleFiles", () => { writeFile(tmpDir, "app/build/components/card.module.css", ".card {}"); expect(scanCssModuleFiles(tmpDir)).toBe(true); }); + + it("detects CSS Modules in project-owned hidden source directories", () => { + writeFile(tmpDir, "components/.internal/card.module.css", ".card {}"); + expect(scanCssModuleFiles(tmpDir)).toBe(true); + }); }); // ─── Unit Tests: addScripts ────────────────────────────────────────────────── From 42fab6a58a6efe38865647029bb0645887499a46 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 13:00:46 +0100 Subject: [PATCH 16/18] fix(init): alias shadowed config helpers --- packages/vinext/src/init-cloudflare.ts | 163 +++++++++++++++++++------ tests/init-cloudflare.test.ts | 36 ++++++ 2 files changed, 164 insertions(+), 35 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index e7e6b3733..56bf15b79 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -773,6 +773,7 @@ ${cssModulesPlugin} ${vinextExpression( type AstNode = ESTree.Node & { start: number; end: number }; type AstObject = ESTree.ObjectExpression & AstNode; type AstProperty = Extract; +const configObjectLocalBindings = new WeakMap>(); function parseViteConfig(filePath: string, code: string): ESTree.Program { let parseSync: typeof import("vite").parseSync; @@ -889,11 +890,37 @@ function findCallbackObject(expression: ESTree.Expression): AstObject | undefine return undefined; } if (!callback.body) return undefined; - if (callback.body.type !== "BlockStatement") return unwrapObject(callback.body); - const returnStatement = callback.body.body.find( - (statement): statement is ESTree.ReturnStatement => statement.type === "ReturnStatement", - ); - return returnStatement?.argument ? unwrapObject(returnStatement.argument) : undefined; + const object = + callback.body.type !== "BlockStatement" + ? unwrapObject(callback.body) + : (() => { + const returnStatement = callback.body.body.find( + (statement): statement is ESTree.ReturnStatement => + statement.type === "ReturnStatement", + ); + return returnStatement?.argument ? unwrapObject(returnStatement.argument) : undefined; + })(); + if (!object) return undefined; + + const bindings = new Set(); + if (callback.type === "FunctionExpression" && callback.id) bindings.add(callback.id.name); + for (const parameter of callback.params) collectPatternBindings(parameter, bindings); + if (callback.body.type === "BlockStatement") { + for (const statement of callback.body.body) { + if (statement.type === "VariableDeclaration") { + for (const declaration of statement.declarations) { + collectPatternBindings(declaration.id, bindings); + } + } else if ( + (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") && + statement.id + ) { + bindings.add(statement.id.name); + } + } + } + configObjectLocalBindings.set(object, bindings); + return object; } function findVariableObject( @@ -1090,6 +1117,10 @@ function collectAllBindings(program: ESTree.Program): Set { return bindings; } +function hasLocalBindingAtObject(object: AstObject, name: string): boolean { + return configObjectLocalBindings.get(object)?.has(name) ?? false; +} + function overwritePropertyValue( output: MagicString, property: AstProperty, @@ -1294,6 +1325,34 @@ function ensureDefaultRequire( return binding; } +function aliasShadowedBinding( + program: ESTree.Program, + output: MagicString, + config: AstObject, + bindings: Set, + binding: string, + commonJs: boolean, +): string { + if (!hasLocalBindingAtObject(config, binding)) return binding; + for (const statement of program.body) { + if (statement.type !== "VariableDeclaration") continue; + for (const declaration of statement.declarations) { + if ( + declaration.id.type === "Identifier" && + declaration.init?.type === "Identifier" && + declaration.init.name === binding && + !hasLocalBindingAtObject(config, declaration.id.name) + ) { + return declaration.id.name; + } + } + } + const alias = allocateBinding(bindings, binding); + const offset = commonJs ? requireInsertionOffset(program) : importInsertionOffset(program); + output.appendLeft(offset, `\nconst ${alias} = ${binding};`); + return alias; +} + function insertObjectProperty( output: MagicString, object: AstObject, @@ -1990,22 +2049,30 @@ export function updateViteConfigForCssModules( ); } } else { - const patchLocal = existingPatch ?? allocateBinding(bindings, "patchCssModules"); - const patchBinding = commonJs - ? ensureNamedRequire( + const patchBinding = existingPatch + ? aliasShadowedBinding( firstProgram, firstOutput, - "vite-css-modules", - "patchCssModules", - patchLocal, + firstConfig, + bindings, + existingPatch, + commonJs, ) - : ensureNamedImport( - firstProgram, - firstOutput, - "vite-css-modules", - "patchCssModules", - patchLocal, - ); + : commonJs + ? ensureNamedRequire( + firstProgram, + firstOutput, + "vite-css-modules", + "patchCssModules", + allocateBinding(bindings, "patchCssModules"), + ) + : ensureNamedImport( + firstProgram, + firstOutput, + "vite-css-modules", + "patchCssModules", + allocateBinding(bindings, "patchCssModules"), + ); ensurePluginFirst( firstOutput, firstConfig, @@ -2028,22 +2095,30 @@ export function updateViteConfigForCssModules( const existingCreateHash = commonJs ? findRequiredBinding(secondProgram, "node:crypto", "createHash") : findImportedBinding(secondProgram, "node:crypto", "createHash"); - const createHashLocal = existingCreateHash ?? allocateBinding(secondBindings, "createHash"); - const createHashBinding = commonJs - ? ensureNamedRequire( + const createHashBinding = existingCreateHash + ? aliasShadowedBinding( secondProgram, secondOutput, - "node:crypto", - "createHash", - createHashLocal, + secondConfig, + secondBindings, + existingCreateHash, + commonJs, ) - : ensureNamedImport( - secondProgram, - secondOutput, - "node:crypto", - "createHash", - createHashLocal, - ); + : commonJs + ? ensureNamedRequire( + secondProgram, + secondOutput, + "node:crypto", + "createHash", + allocateBinding(secondBindings, "createHash"), + ) + : ensureNamedImport( + secondProgram, + secondOutput, + "node:crypto", + "createHash", + allocateBinding(secondBindings, "createHash"), + ); const existingPath = commonJs ? findRequiredBinding(secondProgram, "node:path", "default") : secondProgram.body @@ -2056,10 +2131,28 @@ export function updateViteConfigForCssModules( (specifier): specifier is ESTree.ImportDefaultSpecifier => specifier.type === "ImportDefaultSpecifier", )?.local.name; - const pathLocal = existingPath ?? allocateBinding(secondBindings, "path"); - const pathBinding = commonJs - ? ensureDefaultRequire(secondProgram, secondOutput, "node:path", pathLocal) - : ensureDefaultImport(secondProgram, secondOutput, "node:path", pathLocal); + const pathBinding = existingPath + ? aliasShadowedBinding( + secondProgram, + secondOutput, + secondConfig, + secondBindings, + existingPath, + commonJs, + ) + : commonJs + ? ensureDefaultRequire( + secondProgram, + secondOutput, + "node:path", + allocateBinding(secondBindings, "path"), + ) + : ensureDefaultImport( + secondProgram, + secondOutput, + "node:path", + allocateBinding(secondBindings, "path"), + ); return generateScopedNameMethodSource( indent, pathBinding, diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index b5de53ef8..680339bb8 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -276,6 +276,42 @@ export default defineConfig((patchCssModules) => { expect(result.code).toContain('createHash2("sha256")'); }); + it.each([ + [ + "ES module imports", + `import { defineConfig } from "vite"; +import { patchCssModules } from "vite-css-modules"; +import { createHash } from "node:crypto"; +import path from "node:path"; +export default defineConfig((patchCssModules, createHash, path) => ({ plugins: [] })); +`, + "vite.config.ts", + ], + [ + "CommonJS requires", + `const { defineConfig } = require("vite"); +const { patchCssModules } = require("vite-css-modules"); +const { createHash } = require("node:crypto"); +const path = require("node:path"); +module.exports = defineConfig((patchCssModules, createHash, path) => ({ plugins: [] })); +`, + "vite.config.cjs", + ], + ])("aliases $s shadowed by config callback bindings", (_name, input, fileName) => { + const result = updateViteConfigForCssModules(fileName, input); + const repeated = updateViteConfigForCssModules(fileName, result.code); + + expectValidConfig(result.code); + expect(result.code).toContain("const patchCssModules2 = patchCssModules;"); + expect(result.code).toContain("const createHash2 = createHash;"); + expect(result.code).toContain("const path2 = path;"); + expect(result.code).toContain('patchCssModules2({ exportMode: "default" })'); + expect(result.code).toContain('createHash2("sha256")'); + expect(result.code).toMatch(/path2\s*\.relative\(/); + expect(repeated.code).toBe(result.code); + expect(repeated.changed).toBe(false); + }); + it("recognizes a namespace-imported defineConfig call", () => { const input = `import * as vite from "vite"; const options = { plugins: [] }; From a96c2841cfe5f6b5b926abc6dbff09f771440092 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 13:09:24 +0100 Subject: [PATCH 17/18] fix(init): place CommonJS aliases after requires --- packages/vinext/src/init-cloudflare.ts | 16 +++++++++++++++- tests/init-cloudflare.test.ts | 14 +++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 56bf15b79..ad3e6b312 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -1348,7 +1348,21 @@ function aliasShadowedBinding( } } const alias = allocateBinding(bindings, binding); - const offset = commonJs ? requireInsertionOffset(program) : importInsertionOffset(program); + let offset = importInsertionOffset(program); + if (commonJs) { + offset = requireInsertionOffset(program); + for (const statement of program.body) { + if (statement.type !== "VariableDeclaration") continue; + const statementBindings = new Set(); + for (const declaration of statement.declarations) { + collectPatternBindings(declaration.id, statementBindings); + } + if (statementBindings.has(binding)) { + offset = (statement as AstNode).end; + break; + } + } + } output.appendLeft(offset, `\nconst ${alias} = ${binding};`); return alias; } diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 680339bb8..07d3a36ab 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -289,7 +289,8 @@ export default defineConfig((patchCssModules, createHash, path) => ({ plugins: [ ], [ "CommonJS requires", - `const { defineConfig } = require("vite"); + `"use strict"; +const { defineConfig } = require("vite"); const { patchCssModules } = require("vite-css-modules"); const { createHash } = require("node:crypto"); const path = require("node:path"); @@ -308,6 +309,17 @@ module.exports = defineConfig((patchCssModules, createHash, path) => ({ plugins: expect(result.code).toContain('patchCssModules2({ exportMode: "default" })'); expect(result.code).toContain('createHash2("sha256")'); expect(result.code).toMatch(/path2\s*\.relative\(/); + if (fileName.endsWith(".cjs")) { + expect(result.code.indexOf("const patchCssModules2")).toBeGreaterThan( + result.code.indexOf('require("vite-css-modules")'), + ); + expect(result.code.indexOf("const createHash2")).toBeGreaterThan( + result.code.indexOf('require("node:crypto")'), + ); + expect(result.code.indexOf("const path2")).toBeGreaterThan( + result.code.indexOf('require("node:path")'), + ); + } expect(repeated.code).toBe(result.code); expect(repeated.changed).toBe(false); }); From 1645f3bad2efd0dbff54a8d3dad97a98d7a67c01 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 9 Sep 2026 13:18:13 +0100 Subject: [PATCH 18/18] fix(init): detect dot-prefixed CSS modules --- packages/vinext/src/init.ts | 7 ++++++- tests/init.test.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/init.ts b/packages/vinext/src/init.ts index 33bdadb44..8a09290c8 100644 --- a/packages/vinext/src/init.ts +++ b/packages/vinext/src/init.ts @@ -159,7 +159,12 @@ export default defineConfig({ } const CSS_MODULE_PATTERN = /\.module\.(?:css|scss|sass)$/; -const CSS_MODULE_GLOBS = ["**/*.module.{css,scss,sass}", "**/.*/**/*.module.{css,scss,sass}"]; +const CSS_MODULE_GLOBS = [ + "**/*.module.{css,scss,sass}", + "**/.*.module.{css,scss,sass}", + "**/.*/**/*.module.{css,scss,sass}", + "**/.*/**/.*.module.{css,scss,sass}", +]; const CSS_MODULE_SCAN_IGNORES = new Set(["node_modules", ".git", ".next", ".vinext", ".wrangler"]); const CSS_MODULE_ROOT_SCAN_IGNORES = new Set(["dist", "out", "build", "coverage"]); diff --git a/tests/init.test.ts b/tests/init.test.ts index 6e8f91d51..d91296534 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -300,6 +300,14 @@ describe("scanCssModuleFiles", () => { writeFile(tmpDir, "components/.internal/card.module.css", ".card {}"); expect(scanCssModuleFiles(tmpDir)).toBe(true); }); + + it.each([".card.module.css", "components/.card.module.scss"])( + "detects a dot-prefixed CSS Module file at %s", + (file) => { + writeFile(tmpDir, file, ".card {}"); + expect(scanCssModuleFiles(tmpDir)).toBe(true); + }, + ); }); // ─── Unit Tests: addScripts ──────────────────────────────────────────────────