diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/package.json new file mode 100644 index 0000000000..e74d50f79f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/package.json @@ -0,0 +1,7 @@ +{ + "name": "migration-existing-vite-plus-esbuild-minify", + "packageManager": "pnpm@12.11.2", + "devDependencies": { + "vite-plus": "latest" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots.toml new file mode 100644 index 0000000000..eca66ecb79 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots.toml @@ -0,0 +1,12 @@ +[[case]] +name = "migration_existing_vite_plus_esbuild_minify" +vp = "global" +skip-platforms = ["windows"] +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], snapshot = false, comment = "Converge the project to a fully migrated Vite+ project; this pass rewrites the import and switches the minifier." }, + { argv = ["vpt", "replace-file-content", "vite.config.ts", "minify: true", "minify: 'esbuild'"], snapshot = false, comment = "Reintroduce the Vite 7-era esbuild minify as the project's ONLY pending change." }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "The static minify rewrite emits no warnings, so it must mark the migration as done instead of hitting the already-using-Vite+ fast exit." }, + { argv = ["vpt", "grep-file", "vite.config.ts", "minify: true"], comment = "The rewrite is applied to vite.config." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots/migration_existing_vite_plus_esbuild_minify.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots/migration_existing_vite_plus_esbuild_minify.md new file mode 100644 index 0000000000..2ca49198c5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots/migration_existing_vite_plus_esbuild_minify.md @@ -0,0 +1,35 @@ +# migration_existing_vite_plus_esbuild_minify + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +Converge the project to a fully migrated Vite+ project; this pass rewrites the import and switches the minifier. + + +## `vpt replace-file-content vite.config.ts 'minify: true' 'minify: '\''esbuild'\'''` + +Reintroduce the Vite 7-era esbuild minify as the project's ONLY pending change. + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +The static minify rewrite emits no warnings, so it must mark the migration as done instead of hitting the already-using-Vite+ fast exit. + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• 1 config update applied +``` + +## `vpt grep-file vite.config.ts 'minify: true'` + +The rewrite is applied to vite.config. + +``` +vite.config.ts: found "minify: true" +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/vite.config.ts new file mode 100644 index 0000000000..f21e144550 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + minify: 'esbuild', + sourcemap: true, + }, +}); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index c1bcb66131..d9921d6d22 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -52,6 +52,7 @@ const { injectLintTypeCheckDefaults, ensureSvelteRuneGlobals, mergeViteConfigFiles, + migrateEsbuildConfigInViteConfig, rewriteEslintPackageJson, collectInstalledPackageNames, sanitizeMigratedOxlintConfig, @@ -8664,6 +8665,7 @@ describe('existing Vite+ core migration finalization', () => { imports: true, tsdownConfig: false, taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }); @@ -8743,6 +8745,7 @@ export default defineConfig({ imports: true, tsdownConfig: true, taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }); expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toContain( @@ -8762,6 +8765,7 @@ export default defineConfig({ imports: false, tsdownConfig: false, taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }); }); @@ -8801,6 +8805,7 @@ export default defineConfig({ entry: 'src/index.ts' }); imports: true, tsdownConfig: false, taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }); expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toBe(originalViteConfig); @@ -8865,6 +8870,7 @@ export default defineConfig({ expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true, report)).toMatchObject({ taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }); expect(report.migratedTaskCacheConfigCount).toBe(2); @@ -8906,6 +8912,54 @@ export default defineConfig({ ); expect(result.taskCacheWarnings[0]).toContain('/config/run#cache'); }); + + it('reports the esbuild minify switch as a migration on an otherwise up-to-date project', () => { + // The rewrite is the project's ONLY pending change and the static case + // emits no warnings. `didMigrate` must therefore see this flag, or the + // "already using Vite+" fast exit would swallow the vite.config rewrite + // (no summary, no formatting pass). + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'test', devDependencies: { 'vite-plus': 'latest' } }, null, 2), + ); + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + build: { + minify: 'esbuild', + sourcemap: true, + }, +}); +`, + ); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + + expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true)).toEqual({ + dependencies: false, + scripts: false, + tsconfigTypes: false, + imports: false, + tsdownConfig: false, + taskCacheConfig: false, + esbuildMinifyConfig: true, + taskCacheWarnings: [], + }); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toContain('minify: true'); + + // Converged: a second run finds nothing left to migrate. + expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true)).toEqual({ + dependencies: false, + scripts: false, + tsconfigTypes: false, + imports: false, + tsdownConfig: false, + taskCacheConfig: false, + esbuildMinifyConfig: false, + taskCacheWarnings: [], + }); + }); }); describe('rewriteStandaloneProject — task cache settings', () => { @@ -9542,3 +9596,317 @@ describe('collectMigrationSetupPlan non-interactive editor conflicts', () => { expect(plan.editorConflictDecisions.get('workspace.xml')).toBe('skip'); }); }); + +describe('migrateEsbuildConfigInViteConfig', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-esbuild-config-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('rewrites static build.minify esbuild to true and preserves comments', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + build: { + /* keep me */ + minify: 'esbuild', + sourcemap: true, + }, +}); +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(true); + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toContain('minify: true'); + expect(after).toContain('/* keep me */'); + expect(after).not.toContain('esbuild'); + expect(report.esbuildMinifyMigrationCount).toBe(1); + expect(report.warnings).toHaveLength(0); + }); + + it('rewrites quoted minify keys', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `export default { + build: { + 'minify': 'esbuild', + }, +}; +`, + ); + const report = createMigrationReport(); + + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toContain(`'minify': true`); + expect(report.esbuildMinifyMigrationCount).toBe(1); + }); + + it('leaves unrelated minify options alone but diagnoses them', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { somePlugin } from 'some-plugin'; + +export default defineConfig({ + plugins: [somePlugin({ minify: 'esbuild' })], +}); +`, + ); + const report = createMigrationReport(); + + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toBe(`import { somePlugin } from 'some-plugin'; + +export default defineConfig({ + plugins: [somePlugin({ minify: 'esbuild' })], +}); +`); + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('esbuild'); + }); + + it('rewrites minify inside a defineConfig callback expression body', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig((config) => ({ + build: { + minify: 'esbuild', + sourcemap: true, + }, +})); +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(true); + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toContain('minify: true'); + expect(report.esbuildMinifyMigrationCount).toBe(1); + expect(report.warnings).toHaveLength(0); + }); + + it('rewrites direct return statements in callback block bodies', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig(() => { + return { build: { minify: 'esbuild' } }; +}); +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(true); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8')).toContain('minify: true'); + expect(report.esbuildMinifyMigrationCount).toBe(1); + }); + + it('rewrites direct returns of classic function callbacks', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig(function (config) { + return { build: { minify: 'esbuild' } }; +}); +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(true); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8')).toContain('minify: true'); + expect(report.esbuildMinifyMigrationCount).toBe(1); + }); + + it('diagnoses a dynamic minify selection in a callback instead of rewriting it', () => { + // Production-only esbuild selection: no span is collectable, but the + // dynamic walk must still flag the config so the Vite 8 failure is not + // silent. + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig(({ mode }) => ({ + build: { + minify: mode === 'legacy' ? 'esbuild' : true, + }, +})); +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(false); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8')).toContain( + `mode === 'legacy' ? 'esbuild' : true`, + ); + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('esbuild'); + }); + + it('ignores computed identifier keys that collide with option names', () => { + // `[build]` is an unrelated plugin option, not Vite's `build` key; the + // rewrite must not fire, but the raw occurrence is still diagnosed. + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `const build = 'plugin'; + +export default { + [build]: { + minify: 'esbuild', + }, +}; +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(false); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8')).toContain( + "minify: 'esbuild'", + ); + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('esbuild'); + }); + + it('treats computed string-literal keys as option names', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `export default { + ['build']: { + minify: 'esbuild', + }, +}; +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(true); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8')).toContain('minify: true'); + expect(report.esbuildMinifyMigrationCount).toBe(1); + }); + + it('diagnoses dynamic minify values without rewriting them', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +const useEsbuild = () => false; + +export default defineConfig({ + build: { minify: useEsbuild() ? 'esbuild' : true }, +}); +`, + ); + const report = createMigrationReport(); + + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toContain(`'esbuild' : true`); + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('installing `esbuild` separately'); + }); + + it('diagnoses transformWithEsbuild usage', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { transformWithEsbuild } from 'vite'; + +export default { + plugins: [ + { + name: 'esbuild-transform', + transform(code, id) { + return transformWithEsbuild(code, id); + }, + }, + ], +}; +`, + ); + const report = createMigrationReport(); + + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('transformWithOxc'); + }); + + it('ignores minify esbuild mentions inside comments', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +// The old config used minify: "esbuild" before we migrated. +export default defineConfig({ + build: { minify: true }, +}); +`, + ); + const report = createMigrationReport(); + + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(0); + }); + + it('does not warn for oxc-compatible configs', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + build: { minify: true }, +}); +`, + ); + const report = createMigrationReport(); + + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(0); + }); + + it('does nothing when no vite config exists', () => { + const report = createMigrationReport(); + + expect(() => migrateEsbuildConfigInViteConfig(tmpDir, true, report)).not.toThrow(); + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 29cc62b1a2..3dda3090bf 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -515,7 +515,8 @@ function showMigrationSummary(options: { report.removedConfigCount + report.tsdownImportCount + report.wrappedPluginConfigCount + - report.migratedTaskCacheConfigCount; + report.migratedTaskCacheConfigCount + + report.esbuildMinifyMigrationCount; log( `${styleText('magenta', '◇')} ${updatedExistingVitePlus ? 'Updated' : 'Migrated'} ${accent(projectLabel)} to Vite+ ${VITE_PLUS_VERSION}`, @@ -1182,7 +1183,8 @@ async function main() { coreMigrationResult.tsconfigTypes || coreMigrationResult.imports || coreMigrationResult.tsdownConfig || - coreMigrationResult.taskCacheConfig + coreMigrationResult.taskCacheConfig || + coreMigrationResult.esbuildMinifyConfig ) { didMigrate = true; } diff --git a/packages/cli/src/migration/migrator/core-finalization.ts b/packages/cli/src/migration/migrator/core-finalization.ts index d57f2b556a..3d92e917ae 100644 --- a/packages/cli/src/migration/migrator/core-finalization.ts +++ b/packages/cli/src/migration/migrator/core-finalization.ts @@ -12,6 +12,7 @@ import { migrateTaskCacheConfigInViteConfig, rewriteAllImports, rewriteTsconfigTypes, + migrateEsbuildConfigInViteConfig, } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; @@ -86,6 +87,7 @@ export type CoreMigrationFinalizationResult = { imports: boolean; tsdownConfig: boolean; taskCacheConfig: boolean; + esbuildMinifyConfig: boolean; // Kept out of the report so that review items alone do not make an // up-to-date project run the rest of the migration. taskCacheWarnings: string[]; @@ -156,6 +158,7 @@ export function finalizeCoreMigrationForExistingVitePlus( imports: false, tsdownConfig: false, taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }; @@ -189,6 +192,8 @@ export function finalizeCoreMigrationForExistingVitePlus( result.taskCacheConfig = migrateTaskCacheConfigInViteConfig(projectPath, silent, report, result.taskCacheWarnings) || result.taskCacheConfig; + result.esbuildMinifyConfig = + migrateEsbuildConfigInViteConfig(projectPath, silent, report) || result.esbuildMinifyConfig; } return result; diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index dc4a2c5550..50e1fa0b74 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -57,6 +57,7 @@ import { workspaceUsesVitestDirectly, workspaceUsesWebdriverio, workspaceUsesWebdriverioProvider, + migrateEsbuildConfigInViteConfig, wrapLazyPluginsInViteConfig, } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; @@ -345,6 +346,7 @@ export function rewriteStandaloneProject( dropDeadOxlintPluginsDependency(projectPath, workspaceInfo.packages, originalOxlintDependencies); wrapLazyPluginsInViteConfig(projectPath, silent, report); migrateTaskCacheConfigInViteConfig(projectPath, silent, report); + migrateEsbuildConfigInViteConfig(projectPath, silent, report); } /** @@ -510,10 +512,12 @@ export function rewriteMonorepo( ); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); migrateTaskCacheConfigInViteConfig(workspaceInfo.rootDir, silent, report); + migrateEsbuildConfigInViteConfig(workspaceInfo.rootDir, silent, report); for (const pkg of workspaceInfo.packages) { const projectPath = path.join(workspaceInfo.rootDir, pkg.path); wrapLazyPluginsInViteConfig(projectPath, silent, report); migrateTaskCacheConfigInViteConfig(projectPath, silent, report); + migrateEsbuildConfigInViteConfig(projectPath, silent, report); } } @@ -641,5 +645,6 @@ export function rewriteMonorepoProject( if (!deferLazyPluginWrapping) { wrapLazyPluginsInViteConfig(projectPath, silent, report); migrateTaskCacheConfigInViteConfig(projectPath, silent, report); + migrateEsbuildConfigInViteConfig(projectPath, silent, report); } } diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 96e6e6d46f..749ff0f59f 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -886,6 +886,235 @@ export function migrateTaskCacheConfigInViteConfig( return true; } +// Minimal shapes for the serialized oxc AST returned by +// analyzeMigrationSource. Unknown fields stay behind the index signature; +// specific members are declared where this module relies on them. +interface OxcSpan { + start: number; + end: number; +} + +interface OxcNode extends OxcSpan { + type: string; + [key: string]: unknown; +} + +interface OxcProperty { + key?: OxcNode; + value: OxcNode; + computed?: boolean; +} + +interface OxcObjectNode extends OxcNode { + properties?: OxcProperty[]; +} + +// Key name for object properties: bare identifiers and quoted strings both +// count (`minify:` and `'minify':` are the same option), and so do computed +// string literals (`['minify']`). Computed identifiers must not: a bound +// name that happens to equal an option name (`const build = 'plugin'` + +// `[build]: {...}`) is not that option. +function propertyName(prop: OxcProperty): string | undefined { + const { key } = prop; + if (!key) { + return undefined; + } + if (key.type === 'Identifier' && typeof key.name === 'string' && !prop.computed) { + return key.name; + } + if (key.type === 'Literal' && typeof key.value === 'string') { + return key.value; + } + return undefined; +} + +// Unwrap a default-exported declaration into the config objects it +// statically exposes: the object itself, defineConfig({...}) and +// defineConfig([{...}, ...]) arguments, TS wrappers (as / satisfies), and +// the objects that config factories (`defineConfig(() => ({...}))` and +// function equivalents) build in their direct returns. +function* staticConfigObjects(node: unknown): Generator { + if (Array.isArray(node)) { + for (const item of node) { + yield* staticConfigObjects(item); + } + return; + } + if (!node || typeof node !== 'object') { + return; + } + const n = node as OxcNode; + switch (n.type) { + case 'ObjectExpression': + yield n; + return; + case 'CallExpression': + case 'NewExpression': + yield* staticConfigObjects(n.arguments); + return; + case 'ArrayExpression': + yield* staticConfigObjects(n.elements); + return; + case 'TSAsExpression': + case 'TSSatisfiesExpression': + case 'TSNonNullExpression': + case 'ParenthesizedExpression': + yield* staticConfigObjects(n.expression); + return; + case 'ArrowFunctionExpression': + case 'FunctionExpression': + case 'FunctionDeclaration': + yield* functionConfigObjects(n); + return; + default: + return; + } +} + +// Config objects a function exposes: an expression body IS the return +// value; a block body contributes only its DIRECT return statements. +// Returns nested in conditionals stay with the raw-occurrence fallback +// diagnostics instead of being rewritten blind. +function* functionConfigObjects(fn: OxcNode): Generator { + const body = fn.body as OxcNode | undefined; + if (!body) { + return; + } + if (body.type !== 'BlockStatement') { + yield* staticConfigObjects(body); + return; + } + for (const statement of (body.body as unknown[] | undefined) ?? []) { + const node = statement as OxcNode; + if (node.type === 'ReturnStatement' && node.argument) { + yield* staticConfigObjects(node.argument); + } + } +} + +// Every node in the serialized oxc AST, for name-level scans. +function* walkAstNodes(node: unknown): Generator { + if (Array.isArray(node)) { + for (const item of node) { + yield* walkAstNodes(item); + } + return; + } + if (!node || typeof node !== 'object') { + return; + } + const n = node as OxcNode; + yield n; + for (const [key, value] of Object.entries(n)) { + if (key === 'start' || key === 'end' || key === 'type') { + continue; + } + yield* walkAstNodes(value); + } +} + +/** + * esbuild-specific config fails under Vite 8 unless esbuild is installed + * separately. The static case — `build.minify: 'esbuild'` in a + * default-exported config, including objects directly returned from a + * `defineConfig` callback — is rewritten in place to `minify: true` (the + * oxc minifier); span replacement keeps comments and unrelated options + * intact, and the switch is reported. Dynamic values (e.g. a ternary + * picking `'esbuild'` per mode), `minify` outside `build`, computed + * identifier keys, and `transformWithEsbuild` usage keep their code and + * get a diagnostic covering both the keep-esbuild and migrate-to-oxc + * paths. + */ +export function migrateEsbuildConfigInViteConfig( + projectPath: string, + silent = false, + report?: MigrationReport, +): boolean { + const configs = detectConfigs(projectPath); + if (!configs.viteConfig) { + return false; + } + const viteConfigPath = path.join(projectPath, configs.viteConfig); + const source = fs.readFileSync(viteConfigPath, 'utf8'); + const { ast, comments } = JSON.parse(analyzeMigrationSource(viteConfigPath, source)) as { + ast: { node: Program }; + comments: Array<{ start: number; end: number }>; + }; + + const minifyValueSpans: Array = []; + let hasDynamicMinify = false; + let referencesTransformWithEsbuild = false; + for (const statement of ast.node.body) { + if (statement.type !== 'ExportDefaultDeclaration') { + continue; + } + for (const configObject of staticConfigObjects(statement.declaration)) { + const buildProp = configObject.properties?.find((prop) => propertyName(prop) === 'build'); + if (!buildProp || buildProp.value.type !== 'ObjectExpression') { + continue; + } + // Guarded by the ObjectExpression check above. + const buildObject = buildProp.value as OxcObjectNode; + const minifyProp = buildObject.properties?.find((prop) => propertyName(prop) === 'minify'); + if (!minifyProp) { + continue; + } + const { value } = minifyProp; + if (value.type === 'Literal' && value.value === 'esbuild') { + minifyValueSpans.push({ start: value.start, end: value.end }); + } else if (value.type !== 'Literal') { + // Dynamic (non-literal) values can't be migrated blind. + hasDynamicMinify = true; + } + // Static non-esbuild literals (`true`, `false`, `'terser'`, ...) are + // valid user choices and stay untouched. + } + } + for (const node of walkAstNodes(ast.node)) { + if (node.type === 'Identifier' && node.name === 'transformWithEsbuild') { + referencesTransformWithEsbuild = true; + } + } + + // Raw occurrences outside comments that the static walk did not account + // for (spread configs, helper-built objects, non-default exports) are + // surfaced as diagnostics — never rewritten blind. + const rawOccurrences = [...source.matchAll(/\bminify\s*:\s*(['"])esbuild\1/g)].filter( + (match) => + !comments.some((comment) => match.index >= comment.start && match.index < comment.end), + ).length; + const hasUnaccountedMinify = rawOccurrences > minifyValueSpans.length; + + if (minifyValueSpans.length > 0) { + let content = source; + for (const span of minifyValueSpans.toSorted((first, second) => second.start - first.start)) { + content = content.slice(0, span.start) + 'true' + content.slice(span.end); + } + fs.writeFileSync(viteConfigPath, content); + if (report) { + report.esbuildMinifyMigrationCount += minifyValueSpans.length; + } + if (!silent) { + prompts.log.success( + `✔ Switched \`build.minify\` from \`esbuild\` to the oxc minifier (\`minify: true\`) in ${displayRelative(viteConfigPath)}`, + ); + } + } + if (hasDynamicMinify || hasUnaccountedMinify) { + warnMigration( + `${displayRelative(viteConfigPath)}: \`minify: "esbuild"\` requires installing \`esbuild\` separately under Vite 8. If you rely on esbuild minification, install it and keep the config; otherwise prefer \`minify: true\` (the oxc minifier).`, + report, + ); + } + if (referencesTransformWithEsbuild) { + warnMigration( + `${displayRelative(viteConfigPath)}: \`transformWithEsbuild\` requires installing \`esbuild\` separately under Vite 8. Migrate to \`transformWithOxc\` if you do not need exact esbuild behavior, or install \`esbuild\` to keep the current behavior.`, + report, + ); + } + return minifyValueSpans.length > 0; +} + /** * Rewrite imports in all TypeScript/JavaScript files under a directory * This rewrites vite/vitest imports to @voidzero-dev/vite-plus diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index 9893f56790..585f14e27b 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -19,6 +19,7 @@ export interface MigrationReport { tsdownImportCount: number; wrappedPluginConfigCount: number; migratedTaskCacheConfigCount: number; + esbuildMinifyMigrationCount: number; rewrittenImportFileCount: number; preservedUpstreamVitestImportFileCount: number; rewrittenImportErrors: Array<{ path: string; message: string }>; @@ -46,6 +47,7 @@ export function createMigrationReport(): MigrationReport { tsdownImportCount: 0, wrappedPluginConfigCount: 0, migratedTaskCacheConfigCount: 0, + esbuildMinifyMigrationCount: 0, rewrittenImportFileCount: 0, preservedUpstreamVitestImportFileCount: 0, rewrittenImportErrors: [],