diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index c997d2555..ad3e6b312 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 { forEachAstChild, unwrapExpression } from "./plugins/ast-utils.js"; import { detectProject } from "./utils/project.js"; const require = createRequire(import.meta.url); @@ -28,7 +29,9 @@ export type CloudflarePlatformSetupContext = { root: string; isAppRouter: boolean; existingViteConfigPath?: string; + force?: boolean; prerender?: boolean; + hasCssModules?: boolean; today?: string; }; @@ -37,6 +40,7 @@ export type CloudflarePlatformSetupResult = { skippedViteConfig: boolean; generatedPlatformFiles: string[]; nextSteps: string[]; + preservedExistingGenerateScopedName: boolean; }; export function validateCloudflarePlatformSetup( @@ -66,7 +70,7 @@ export function validateCloudflarePlatformSetup( : DEFAULT_VERSION_METADATA_BINDING; if (context.existingViteConfigPath) { - updateViteConfigForCloudflare( + const cloudflareConfig = updateViteConfigForCloudflare( context.existingViteConfigPath, fs.readFileSync(context.existingViteConfigPath, "utf-8"), { @@ -78,6 +82,13 @@ export function validateCloudflarePlatformSetup( prerender: context.prerender, }, ); + if (context.hasCssModules) { + updateViteConfigForCssModules( + context.existingViteConfigPath, + cloudflareConfig, + context.force, + ); + } } } @@ -102,9 +113,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, { @@ -116,6 +128,15 @@ export function setupCloudflarePlatform( prerender: context.prerender, }, ); + if (context.hasCssModules) { + const cssUpdate = updateViteConfigForCssModules( + context.existingViteConfigPath, + updatedConfig, + context.force, + ); + updatedConfig = cssUpdate.code; + preservedExistingGenerateScopedName = cssUpdate.preservedExistingGenerateScopedName; + } if (updatedConfig !== currentConfig) { fs.writeFileSync(context.existingViteConfigPath, updatedConfig, "utf-8"); generatedViteConfig = true; @@ -129,6 +150,7 @@ export function setupCloudflarePlatform( cloudflare, imagesBinding, context.prerender, + context.hasCssModules, versionMetadataBinding, ) : generatePagesRouterViteConfig( @@ -136,6 +158,7 @@ export function setupCloudflarePlatform( cloudflare, imagesBinding, context.prerender, + context.hasCssModules, versionMetadataBinding, ); fs.writeFileSync(path.join(context.root, "vite.config.ts"), configContent, "utf-8"); @@ -185,6 +208,7 @@ export function setupCloudflarePlatform( ' Set its "id" value, replacing "" if present.', ] : [], + preservedExistingGenerateScopedName, }; } @@ -570,27 +594,71 @@ function vinextExpression( : `${binding}({\n ${optionEntries.join(",\n ")},\n})`; } +function generateScopedNameMethodSource( + indent: string, + pathBinding: string, + createHashBinding: string, + rootExpression = "import.meta.dirname", + typescript = true, +): string { + const parameters = typescript ? "name: string, filename: string" : "name, filename"; + return `${indent}generateScopedName(${parameters}) { +${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, versionMetadataBinding = DEFAULT_VERSION_METADATA_BINDING, ): 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({ exportMode: "default" }),`); + } if (info?.hasMDX) { plugins.push(` // vinext auto-injects @mdx-js/rollup with plugins from next.config`); } @@ -627,12 +695,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} }); `; } @@ -643,16 +713,24 @@ export function generatePagesRouterViteConfig( options: CloudflareInitOptions = DEFAULT_CLOUDFLARE_INIT_OPTIONS, imagesBinding = "IMAGES", prerender = false, + hasCssModules = false, versionMetadataBinding = DEFAULT_VERSION_METADATA_BINDING, ): 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";`); } @@ -671,20 +749,23 @@ export function generatePagesRouterViteConfig( resolveBlock = `\n resolve: {\n alias: {\n${aliases.join("\n")}\n },\n },`; } + const cssModulesPlugin = hasCssModules ? ' patchCssModules({ exportMode: "default" }),\n' : ""; + const cssModulesConfig = hasCssModules ? cssModulesConfigSource(" ", "path", "createHash") : ""; + return `${imports.join("\n")} export default defineConfig({ plugins: [ - ${vinextExpression( - options, - "vinext", - "imagesOptimizer", - imagesBinding, - prerender, - versionMetadataBinding, - ).replace(/\n/g, "\n ")}, +${cssModulesPlugin} ${vinextExpression( + options, + "vinext", + "imagesOptimizer", + imagesBinding, + prerender, + versionMetadataBinding, + ).replace(/\n/g, "\n ")}, cloudflare(), - ],${resolveBlock} + ],${resolveBlock}${cssModulesConfig} }); `; } @@ -692,6 +773,7 @@ export default defineConfig({ 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; @@ -719,11 +801,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; } @@ -734,13 +818,118 @@ function findProperty(object: AstObject, name: string): AstProperty | undefined ); } -function unwrapObject(expression: ESTree.Expression): AstObject | undefined { - if (expression.type === "ObjectExpression") return expression as AstObject; - if (expression.type === "ParenthesizedExpression") return unwrapObject(expression.expression); +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; } -function findVariableObject(program: ESTree.Program, name: string): AstObject | undefined { +function isNullishValue(value: ESTree.Node): boolean { + const candidate = value as AstNode & { name?: string; value?: unknown }; + return ( + (candidate.type === "Identifier" && candidate.name === "undefined") || + (candidate.type === "Literal" && candidate.value === null) + ); +} + +/** + * 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" || + (candidate.type === "Property" && candidate.computed && !propertyName(candidate)), + ); +} + +function unwrapObject(expression: ESTree.Expression): AstObject | undefined { + const unwrapped = unwrapExpression(expression); + 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") { + const imported = findImportedBinding(program, "vite", "defineConfig"); + const required = findRequiredBinding(program, "vite", "defineConfig"); + return ( + (imported !== undefined && callee.name === imported) || + (required !== undefined && callee.name === required) + ); + } + 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 findCallbackObject(expression: ESTree.Expression): AstObject | undefined { + const callback = unwrapExpression(expression); + if (callback?.type !== "ArrowFunctionExpression" && callback?.type !== "FunctionExpression") { + return undefined; + } + if (!callback.body) return 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( + 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) { @@ -751,7 +940,27 @@ 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" || + !isDefineConfigCall(program, initializer) || + 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 findCallbackObject(firstArgument); } } return undefined; @@ -778,9 +987,22 @@ 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 === "Identifier") { + return findVariableObject(program, expression.right.name); + } + 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 findCallbackObject(firstArgument); } } return undefined; @@ -794,25 +1016,22 @@ 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; const argumentObject = unwrapObject(firstArgument); if (argumentObject) return argumentObject; - if ( - firstArgument.type !== "ArrowFunctionExpression" && - firstArgument.type !== "FunctionExpression" - ) { - return undefined; + if (firstArgument.type === "Identifier") { + return findVariableObject(program, firstArgument.name); } - - 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 { @@ -850,7 +1069,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") { @@ -875,9 +1094,46 @@ 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 hasLocalBindingAtObject(object: AstObject, name: string): boolean { + return configObjectLocalBindings.get(object)?.has(name) ?? false; +} + +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); @@ -896,10 +1152,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 ) { @@ -910,6 +1173,24 @@ function findImportedBinding( return undefined; } +function findNamespaceImportedBinding(program: ESTree.Program, source: string): string | undefined { + for (const statement of program.body) { + 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", + ); + if (namespace) return namespace.local.name; + } + return undefined; +} + function ensureNamedImport( program: ESTree.Program, output: MagicString, @@ -922,7 +1203,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( @@ -950,7 +1233,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 => @@ -1040,16 +1325,85 @@ 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); + 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; +} + function insertObjectProperty( output: MagicString, 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 { @@ -1186,6 +1540,26 @@ function findPluginCall( ); } +function findPluginMemberCall( + config: AstObject, + objectBinding: string | undefined, + member: string, +): (ESTree.CallExpression & AstNode) | undefined { + if (!objectBinding) return undefined; + const plugins = findLastProperty(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 getVinextCacheSlot( call: (ESTree.CallExpression & AstNode) | undefined, name: "data" | "cdn", @@ -1239,12 +1613,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 && !isNullishValue(property.value)); } function isImagesOptimizerCall( @@ -1466,6 +1835,356 @@ 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 = findLastProperty(config, "plugins"); + if (hasPotentialSpreadOverride(config, 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 configure CSS Modules without replacing existing 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, + generateScopedNameSource: (indent: string) => string, + force = false, +): boolean { + 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 or dynamic computed properties so vinext init can configure CSS Modules without replacing existing options.", + ); + } + if (!css) { + const indent = objectPropertyIndent(config, code); + insertObjectProperty( + output, + config, + `${indent}css: {\n${indent} modules: {\n${generateScopedNameSource( + `${indent} `, + )},\n${indent} },\n${indent}},`, + code, + true, + ); + return false; + } + if (css.value.type !== "ObjectExpression") { + if (force) { + const indent = objectPropertyIndent(config, code); + overwritePropertyValue( + output, + css, + "css", + `{\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.", + ); + } + const cssObject = css.value as AstObject; + 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 or dynamic computed properties so vinext init can configure CSS Modules without replacing existing options.", + ); + } + if (!modules) { + const indent = objectPropertyIndent(cssObject, code); + insertObjectProperty( + output, + cssObject, + `${indent}modules: {\n${generateScopedNameSource(`${indent} `)},\n${indent}},`, + code, + true, + ); + return false; + } + if (modules.value.type !== "ObjectExpression") { + if (force) { + const indent = objectPropertyIndent(cssObject, code); + overwritePropertyValue( + output, + modules, + "modules", + `{\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.", + ); + } + const modulesObject = modules.value as AstObject; + 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 or dynamic computed properties so vinext init can verify it.", + ); + } + 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 (value && !isNullishValue(value) && !usesHashTemplate) 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); + 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, + force = false, +): 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 = collectAllBindings(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 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 patchBinding = existingPatch + ? aliasShadowedBinding( + firstProgram, + firstOutput, + firstConfig, + bindings, + existingPatch, + commonJs, + ) + : commonJs + ? ensureNamedRequire( + firstProgram, + firstOutput, + "vite-css-modules", + "patchCssModules", + allocateBinding(bindings, "patchCssModules"), + ) + : ensureNamedImport( + firstProgram, + firstOutput, + "vite-css-modules", + "patchCssModules", + allocateBinding(bindings, "patchCssModules"), + ); + ensurePluginFirst( + firstOutput, + firstConfig, + `${patchBinding}({ exportMode: "default" })`, + patchBinding, + code, + ); + } + + const withPlugin = firstOutput.toString(); + const secondProgram = parseViteConfig(filePath, withPlugin); + const secondConfig = findConfigObject(secondProgram)!; + const secondBindings = collectAllBindings(secondProgram); + const secondOutput = new MagicString(withPlugin); + const preservedExistingGenerateScopedName = ensureCssModulesScopedName( + secondOutput, + secondConfig, + withPlugin, + (indent) => { + const existingCreateHash = commonJs + ? findRequiredBinding(secondProgram, "node:crypto", "createHash") + : findImportedBinding(secondProgram, "node:crypto", "createHash"); + const createHashBinding = existingCreateHash + ? aliasShadowedBinding( + secondProgram, + secondOutput, + secondConfig, + secondBindings, + existingCreateHash, + commonJs, + ) + : 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 + .filter( + (statement): statement is ESTree.ImportDeclaration => + statement.type === "ImportDeclaration" && statement.importKind !== "type", + ) + .find((statement) => statement.source.value === "node:path") + ?.specifiers.find( + (specifier): specifier is ESTree.ImportDefaultSpecifier => + specifier.type === "ImportDefaultSpecifier", + )?.local.name; + 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, + createHashBinding, + commonJs ? "__dirname" : "import.meta.dirname", + [".ts", ".mts", ".cts"].includes(path.extname(filePath)), + ); + }, + force, + ); + const updated = secondOutput.toString(); + return { + code: updated, + changed: updated !== code, + preservedExistingGenerateScopedName, + }; +} + function ensureNativeAliases( output: MagicString, config: AstObject, @@ -1548,7 +2267,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 6e490c8b4..8a09290c8 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, @@ -30,6 +30,7 @@ import { } from "./utils/project.js"; import { setupCloudflarePlatform, + updateViteConfigForCssModules, usesCommonJsViteConfig, validateCloudflarePlatformSetup, } from "./init-cloudflare.js"; @@ -139,15 +140,52 @@ 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_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"]); + +/** Detect project-owned CSS, SCSS, or Sass module files. */ +export function scanCssModuleFiles(root: string): boolean { + try { + const canonicalRoot = path.resolve(root); + return fs + .globSync(CSS_MODULE_GLOBS, { + cwd: root, + withFileTypes: true, + exclude: (entry) => + entry.isDirectory() && + (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 { + return false; + } } // ─── Script Addition ───────────────────────────────────────────────────────── @@ -219,6 +257,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 +269,16 @@ export function getInitDependencyGroups( dependencies.push("@vinext/cloudflare"); devDependencies.push("@cloudflare/vite-plugin", "wrangler"); } + if (hasCssModules) devDependencies.push("vite-css-modules", "postcss"); 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 +461,7 @@ type PlatformSetupContext = { viteConfigExists: boolean; force: boolean; prerender?: boolean; + hasCssModules: boolean; today?: string; }; @@ -425,21 +470,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 +508,7 @@ function setupNodePlatform(context: PlatformSetupContext): PlatformSetupResult { skippedViteConfig: false, generatedPlatformFiles: [], nextSteps: [], + preservedExistingGenerateScopedName: false, }; } @@ -520,6 +582,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; @@ -529,11 +592,18 @@ export async function init(options: InitOptions): Promise { root, isAppRouter: isApp, existingViteConfigPath, + force: options.force, prerender: options.prerender, + hasCssModules, today: options._today, }, options.cloudflare!, ); + } else if (hasCssModules && existingViteConfigPath && !options.force) { + updateViteConfigForCssModules( + existingViteConfigPath, + fs.readFileSync(existingViteConfigPath, "utf-8"), + ); } // ── Step 1: Compatibility check ──────────────────────────────────────── @@ -579,6 +649,7 @@ export async function init(options: InitOptions): Promise { viteConfigExists, force: options.force ?? false, prerender: options.prerender, + hasCssModules, today: options._today, }; const platformSetup = @@ -593,7 +664,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 +800,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 921b574a0..07d3a36ab 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -4,6 +4,7 @@ import { generateAppRouterViteConfig, generatePagesRouterViteConfig, getWranglerImagesBinding, + updateViteConfigForCssModules, getWranglerVersionMetadataBinding, updateViteConfigForCloudflare, updateWranglerConfigForCloudflare, @@ -19,6 +20,640 @@ 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", () => { + for (const config of [ + generateAppRouterViteConfig(undefined, undefined, "IMAGES", false, true), + generatePagesRouterViteConfig(undefined, undefined, "IMAGES", false, true), + ]) { + expectValidConfig(config); + 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: string, filename: string)"); + 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"'); + 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: string, filename: string)"); + 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('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); + }); + + 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("cssModules.patchCssModules({ generateSourceTypes: true })"); + expect(result.code).not.toContain('exportMode: "default"'); + 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"; +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: 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", () => { + const input = `export default { + plugins: [], + css: { modules: { generateScopedName: "custom_[local]" } }, +}; +`; + + const result = updateViteConfigForCssModules("vite.config.ts", input); + + 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.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: ${scopedName} } }, +}; +`; + + 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", withDefineConfig(input)); + + expectValidConfig(result.code); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); + 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.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; +`; + + 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.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", + `"use strict"; +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\(/); + 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); + }); + + 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("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); + + 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({ + 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({ exportMode: "default" })],'); + 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 source = input.includes("defineConfig(") ? withDefineConfig(input) : input; + const result = updateViteConfigForCssModules(filePath, source); + + expectValidConfig(result.code); + 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); + }); + + 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", withDefineConfig(input)); + + expectValidConfig(result.code); + expect(result.code).toContain("keep this comment"); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })],'); + 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", withDefineConfig(input)); + expectValidConfig(result.code); + expect(result.code).toContain('plugins: [patchCssModules({ exportMode: "default" })]'); + expect(result.code).toContain( + "modules: {\n generateScopedName(name: string, filename: string)", + ); + 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", withDefineConfig(input)); + + expectValidConfig(result.code); + expect(result.code).toContain( + '// keep plugin comment\n patchCssModules({ exportMode: "default" })', + ); + }); + + 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", withDefineConfig(input)); + + expectValidConfig(result.code); + expect(result.code).toContain("keep nested comment"); + expect(result.code).toContain("generateScopedName(name: string, filename: string)"); + 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", withDefineConfig(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"); + }); + + 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", + `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" } } }; +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("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("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 { + 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: string, filename: string)"), + ); + }); +}); + describe("updateViteConfigForCloudflare", () => { it("updates an existing ESM App Router config without replacing user code", () => { const input = `import { defineConfig } from "vite"; @@ -683,6 +1318,7 @@ export default { plugins: [vinext({ imageOptimization: true })] }; }, "IMAGES", false, + false, "CUSTOM_VERSION", ), ).toContain('cdnAdapter({ versionMetadataBinding: "CUSTOM_VERSION" })'); diff --git a/tests/init.test.ts b/tests/init.test.ts index 280af250a..d91296534 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,54 @@ describe("generateViteConfig", () => { const config = generateViteConfig(true, true); expect(config).toContain('vinext({ prerender: { routes: "*" } })'); }); + + 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"'); + 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: string, filename: string)"); + 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); + }); + + 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); + }); + + it("detects CSS Modules in project-owned hidden source directories", () => { + 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 ────────────────────────────────────────────────── @@ -403,6 +452,13 @@ 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", true)).toContain("postcss"); + expect(getInitDeps(false, "node", false)).not.toContain("vite-css-modules"); + expect(getInitDeps(false, "node", false)).not.toContain("postcss"); + }); }); /** Helper: create a fake resolvable react package in node_modules */ @@ -1175,6 +1231,36 @@ 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(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" })'; + expect(config).toContain(patchCall); + expect(config.indexOf(patchCall)).toBeLessThan(config.indexOf("vinext()")); + expect(config).not.toContain("patchCssModules()"); + expect(config).toContain("generateScopedName(name: string, filename: string)"); + 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" }); @@ -1413,6 +1499,63 @@ 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("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("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 {}"); + writeFile( + tmpDir, + "vite.config.ts", + '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_[local]"'); + expect(config).toContain('patchCssModules({ exportMode: "default" })'); + expect(config).not.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 {}");