From fc4c0b5c52b1f1e236b8cff1b33813f695a70fb0 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 02:02:59 +0800 Subject: [PATCH 1/7] feat(migrate): upgrade pack config for tsdown 0.23 --- .../migration_from_tsdown/package.json | 2 +- .../snapshots/migration_from_tsdown.md | 10 +- .../snapshots/migration_from_tsdown_tsgo.md | 6 + .../migration_from_tsdown/tsdown.config.ts | 3 + .../migration_from_tsdown_json_config.md | 14 +- .../tsdown.config.json | 6 +- .../snapshots/migration_from_tsup_success.md | 2 +- .../migration_from_tsup_monorepo_success.md | 4 +- .../migration_pack_tsdown_023/package.json | 18 + .../public/asset.txt | 1 + .../migration_pack_tsdown_023/snapshots.toml | 24 + .../snapshots/migration_pack_tsdown_023.md | 113 ++++ .../migration_pack_tsdown_023_build.md | 53 ++ .../migration_pack_tsdown_023/src/index.ts | 1 + .../migration_pack_tsdown_023/tsconfig.json | 1 + .../migration_pack_tsdown_023/vite.config.ts | 26 + crates/vp_migration/src/import_rewriter.rs | 34 +- crates/vp_migration/src/lib.rs | 1 + crates/vp_migration/src/pack_config.rs | 523 ++++++++++++++++++ crates/vp_migration/src/package.rs | 39 +- crates/vp_migration/src/script_rewrite.rs | 61 ++ crates/vp_migration/src/vite_config.rs | 55 +- docs/guide/migrate-rules.md | 34 ++ 23 files changed, 992 insertions(+), 39 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/public/asset.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_build.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/src/index.ts create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/tsconfig.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/vite.config.ts create mode 100644 crates/vp_migration/src/pack_config.rs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/package.json index e1cf53b312..565828c9bc 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/package.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/package.json @@ -1,7 +1,7 @@ { "name": "migration-from-tsdown", "scripts": { - "build": "tsdown", + "build": "tsdown --public-dir public", "build:watch": "tsdown --watch", "build:dts": "tsdown --dts" }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown.md index 20c439d0cb..cc682f3edb 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown.md @@ -26,6 +26,9 @@ export default defineConfig({ outDir: 'dist', format: ['esm', 'cjs'], dts: true, + unbundle: true, + copy: 'public', + deps: { resolveDepSubpath: true, onlyBundle: false }, }); ``` @@ -56,7 +59,7 @@ check package.json { "name": "migration-from-tsdown", "scripts": { - "build": "vp pack", + "build": "vp pack --copy public", "build:watch": "vp pack --watch", "build:dts": "vp pack --dts", "prepare": "vp config" @@ -114,6 +117,9 @@ export default defineConfig({ outDir: 'dist', format: ['esm', 'cjs'], dts: true, + unbundle: true, + copy: 'public', + deps: { resolveDepSubpath: true, onlyBundle: false }, }); ``` @@ -144,7 +150,7 @@ check package.json { "name": "migration-from-tsdown", "scripts": { - "build": "vp pack", + "build": "vp pack --copy public", "build:watch": "vp pack --watch", "build:dts": "vp pack --dts", "prepare": "vp config" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown_tsgo.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown_tsgo.md index e7efe051de..35dddb6632 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown_tsgo.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/snapshots/migration_from_tsdown_tsgo.md @@ -29,6 +29,9 @@ export default defineConfig({ outDir: 'dist', format: ['esm', 'cjs'], dts: { generator: 'tsgo' }, + unbundle: true, + copy: 'public', + deps: { resolveDepSubpath: true, onlyBundle: false }, }); ``` @@ -52,5 +55,8 @@ export default defineConfig({ outDir: 'dist', format: ['esm', 'cjs'], dts: { generator: 'tsgo' }, + unbundle: true, + copy: 'public', + deps: { resolveDepSubpath: true, onlyBundle: false }, }); ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/tsdown.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/tsdown.config.ts index 5e58c4443e..58c96f6c34 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/tsdown.config.ts +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown/tsdown.config.ts @@ -5,4 +5,7 @@ export default defineConfig({ outDir: 'dist', format: ['esm', 'cjs'], dts: true, + bundle: false, + publicDir: 'public', + deps: { onlyAllowBundle: false }, }); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/snapshots/migration_from_tsdown_json_config.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/snapshots/migration_from_tsdown_json_config.md index 89494ff3d6..95b6526596 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/snapshots/migration_from_tsdown_json_config.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/snapshots/migration_from_tsdown_json_config.md @@ -9,7 +9,7 @@ VITE+ - The Unified Toolchain for the Web ◇ Migrated . to Vite+ • Node pnpm -• 2 config updates applied +• 2 config updates applied, 1 file had imports rewritten ``` ## `vpt stat-file tsdown.config.json --assert-not file` @@ -35,7 +35,11 @@ export default defineConfig({ "entry": "src/index.ts", "outDir": "dist", "format": ["esm", "cjs"], - "dts": true, + "dts": { generator: 'oxc', }, + unbundle: true, + copy: "public", + css: { inject: true }, + "deps": { resolveDepSubpath: true, onlyBundle: false }, "inputOptions": { "cwd": "./src" } @@ -117,7 +121,11 @@ export default defineConfig({ "entry": "src/index.ts", "outDir": "dist", "format": ["esm", "cjs"], - "dts": true, + "dts": { generator: 'oxc', }, + unbundle: true, + copy: "public", + css: { inject: true }, + "deps": { resolveDepSubpath: true, onlyBundle: false }, "inputOptions": { "cwd": "./src" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/tsdown.config.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/tsdown.config.json index ff9c868a87..0c25ce1461 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/tsdown.config.json +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsdown_json_config/tsdown.config.json @@ -2,7 +2,11 @@ "entry": "src/index.ts", "outDir": "dist", "format": ["esm", "cjs"], - "dts": true, + "dts": { "oxc": true, "cjsReexport": false }, + "bundle": false, + "publicDir": "public", + "injectStyle": true, + "deps": { "onlyAllowBundle": false }, "inputOptions": { "cwd": "./src" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md index 72f95eddaf..6c4af9a5ad 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup/snapshots/migration_from_tsup_success.md @@ -40,7 +40,7 @@ the converted config uses vite-plus pack ``` import { defineConfig } from 'vite-plus/pack'; -export default defineConfig({ +export default defineConfig({ deps: { resolveDepSubpath: true }, entry: ['src/index.ts'], dts: true, format: ['esm', 'cjs'], diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md index 5efd70577a..5c40a7ca05 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_from_tsup_monorepo/snapshots/migration_from_tsup_monorepo_success.md @@ -32,7 +32,7 @@ package a gets a converted config ``` import { defineConfig } from 'vite-plus/pack'; -export default defineConfig({ +export default defineConfig({ deps: { resolveDepSubpath: true }, entry: ['src/index.ts'], dts: true, format: 'cjs', @@ -74,7 +74,7 @@ package b gets a converted config ``` import { defineConfig } from 'vite-plus/pack'; -export default defineConfig({ +export default defineConfig({ deps: { resolveDepSubpath: true }, entry: ['src/index.ts'], dts: true, format: 'cjs', diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/package.json new file mode 100644 index 0000000000..45223447d3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/package.json @@ -0,0 +1,18 @@ +{ + "name": "migration-pack-tsdown-023", + "type": "module", + "private": true, + "scripts": { + "build": "vp pack --public-dir public" + }, + "devDependencies": { + "vite-plus": "0.2.0" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "11.16.0", + "onFail": "download" + } + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/public/asset.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/public/asset.txt new file mode 100644 index 0000000000..2fd4c6ebcc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/public/asset.txt @@ -0,0 +1 @@ +copied asset diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml new file mode 100644 index 0000000000..91392f38f8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml @@ -0,0 +1,24 @@ +[[case]] +name = "migration_pack_tsdown_023" +vp = "global" +comment = "Upgrade an existing Vite+ pack config without --full and preserve it on a second migration." +steps = [ + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, + { argv = ["vpt", "print-file", "package.json"] }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, +] + +[[case]] +name = "migration_pack_tsdown_023_build" +vp = "global" +comment = "Build a migrated library and check that static assets and declarations are emitted." +steps = [ + { argv = ["vpt", "write-file", "vite.config.ts", "export default { pack: { entry: 'src/index.ts', bundle: false, publicDir: 'public', removeNodeProtocol: true, dts: { oxc: true, cjsReexport: false } } };"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vp", "run", "build"] }, + { argv = ["vpt", "stat-file", "dist/index.mjs", "--assert", "file"] }, + { argv = ["vpt", "stat-file", "dist/index.d.mts", "--assert", "file"] }, + { argv = ["vpt", "print-file", "dist/public/asset.txt"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023.md new file mode 100644 index 0000000000..48d9dc17bc --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023.md @@ -0,0 +1,113 @@ +# migration_pack_tsdown_023 + +Upgrade an existing Vite+ pack config without --full and preserve it on a second migration. + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• 1 file had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file vite.config.ts` + +``` +import { defineConfig } from 'vite-plus'; + +export default defineConfig(() => ({ + // This is Vite's public directory, which must stay unchanged. + publicDir: 'vite-public', + pack: [ + { + entry: 'src/index.ts', + unbundle: true, + outExtensions: () => ({ js: '.mjs' }), + copy: 'public', + nodeProtocol: 'strip', + css: { inject: true }, + deps: { neverBundle: true, onlyBundle: [/^allowed/], resolveDepSubpath: true }, + + dts: { generator: 'oxc', }, + attw: { profile: 'strict' }, + }, + { + entry: 'src/index.ts', + deps: { onlyBundle: false, neverBundle: true, resolveDepSubpath: false }, + dts: { generator: 'tsgo', tsgo: { path: './tsgo' }, }, + attw: { profile: 'node16' }, + }, + ], +})); +``` + +## `vpt print-file package.json` + +``` +{ + "name": "migration-pack-tsdown-023", + "type": "module", + "private": true, + "scripts": { + "build": "vp pack --copy public" + }, + "devDependencies": { + "vite-plus": "" + }, + "devEngines": { + "packageManager": { + "name": "npm", + "version": "", + "onFail": "download" + } + }, + "overrides": { + "vite": "npm:@voidzero-dev/vite-plus-core@" + } +} +``` + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` + +## `vpt print-file vite.config.ts` + +``` +import { defineConfig } from 'vite-plus'; + +export default defineConfig(() => ({ + // This is Vite's public directory, which must stay unchanged. + publicDir: 'vite-public', + pack: [ + { + entry: 'src/index.ts', + unbundle: true, + outExtensions: () => ({ js: '.mjs' }), + copy: 'public', + nodeProtocol: 'strip', + css: { inject: true }, + deps: { neverBundle: true, onlyBundle: [/^allowed/], resolveDepSubpath: true }, + + dts: { generator: 'oxc', }, + attw: { profile: 'strict' }, + }, + { + entry: 'src/index.ts', + deps: { onlyBundle: false, neverBundle: true, resolveDepSubpath: false }, + dts: { generator: 'tsgo', tsgo: { path: './tsgo' }, }, + attw: { profile: 'node16' }, + }, + ], +})); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_build.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_build.md new file mode 100644 index 0000000000..02675c1831 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_build.md @@ -0,0 +1,53 @@ +# migration_pack_tsdown_023_build + +Build a migrated library and check that static assets and declarations are emitted. + +## `vpt write-file vite.config.ts 'export default { pack: { entry: '\''src/index.ts'\'', bundle: false, publicDir: '\''public'\'', removeNodeProtocol: true, dts: { oxc: true, cjsReexport: false } } };'` + + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• 1 file had imports rewritten +• Package manager settings configured +``` + +## `vp run build` + +``` +VITE+ - The Unified Toolchain for the Web + +$ vp pack --copy public ⊘ cache disabled +ℹ entry: src/index.ts +ℹ tsconfig: tsconfig.json +ℹ Build start +ℹ dist/index.mjs kB │ gzip: kB +ℹ dist/index.d.mts kB │ gzip: kB +ℹ 2 files, total: kB +✔ Build complete in +``` + +## `vpt stat-file dist/index.mjs --assert file` + +``` +dist/index.mjs: file +``` + +## `vpt stat-file dist/index.d.mts --assert file` + +``` +dist/index.d.mts: file +``` + +## `vpt print-file dist/public/asset.txt` + +``` +copied asset +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/src/index.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/src/index.ts new file mode 100644 index 0000000000..1b55ac618d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/src/index.ts @@ -0,0 +1 @@ +export const value: number = 42; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/tsconfig.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/tsconfig.json new file mode 100644 index 0000000000..6db6cf3ad8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"isolatedDeclarations":true}} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/vite.config.ts new file mode 100644 index 0000000000..5058b8c644 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig(() => ({ + // This is Vite's public directory, which must stay unchanged. + publicDir: 'vite-public', + pack: [ + { + entry: 'src/index.ts', + bundle: false, + outExtension: () => ({ js: '.mjs' }), + publicDir: 'public', + removeNodeProtocol: true, + injectStyle: true, + inlineOnly: [/^allowed/], + skipNodeModulesBundle: true, + dts: { oxc: true, cjsReexport: false }, + attw: true, + }, + { + entry: 'src/index.ts', + deps: { onlyAllowBundle: false, skipNodeModulesBundle: true, resolveDepSubpath: false }, + dts: { tsgo: { path: './tsgo' }, cjsReexport: true }, + attw: { profile: 'node16' }, + }, + ], +})); diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index 812a637b8a..eac5d5a0cc 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -2286,8 +2286,7 @@ fn rewrite_import( )?; let standalone = file_path.file_stem().is_some_and(|stem| stem == "tsdown.config"); if !skip_packages.skip_tsdown && (standalone || is_vite_config_file(file_path)) { - let rewritten = - crate::vite_config::rewrite_pack_dts_generators(&result.content, standalone); + let rewritten = crate::pack_config::rewrite_pack_config(&result.content, standalone); result.updated |= rewritten != result.content; result.content = rewritten; } @@ -2412,6 +2411,37 @@ mod tests { use super::*; + #[test] + fn migrate_pack_configs_in_workspace_packages() { + let temp = tempfile::tempdir().unwrap(); + let package = temp.path().join("packages/library"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write( + temp.path().join("package.json"), + r#"{"devDependencies":{"vite-plus":"0.2.0"}}"#, + ) + .unwrap(); + std::fs::write( + package.join("package.json"), + r#"{"devDependencies":{"vite-plus":"0.2.0"}}"#, + ) + .unwrap(); + let vite_config = package.join("vite.config.ts"); + let tsdown_config = package.join("tsdown.config.mts"); + let source_file = package.join("example.ts"); + std::fs::write(&vite_config, "export default { pack: { bundle: false } };").unwrap(); + std::fs::write(&tsdown_config, "export default { publicDir: 'public' };").unwrap(); + let example = "export default { pack: { bundle: false } };"; + std::fs::write(&source_file, example).unwrap(); + let result = rewrite_imports_in_directory(temp.path()).unwrap(); + assert!(result.errors.is_empty()); + assert_eq!(result.modified_files.len(), 2); + assert!(std::fs::read_to_string(vite_config).unwrap().contains("unbundle: true")); + assert!(std::fs::read_to_string(tsdown_config).unwrap().contains("copy: 'public'")); + assert_eq!(std::fs::read_to_string(source_file).unwrap(), example); + assert!(rewrite_imports_in_directory(temp.path()).unwrap().modified_files.is_empty()); + } + #[test] fn test_rewrite_import_content_vite() { let vite_config = r#"import { defineConfig } from 'vite' diff --git a/crates/vp_migration/src/lib.rs b/crates/vp_migration/src/lib.rs index 855f23cd9b..427db5ea50 100644 --- a/crates/vp_migration/src/lib.rs +++ b/crates/vp_migration/src/lib.rs @@ -10,6 +10,7 @@ mod ast_grep; mod eslint; mod file_walker; mod import_rewriter; +mod pack_config; mod package; mod prettier; mod script_rewrite; diff --git a/crates/vp_migration/src/pack_config.rs b/crates/vp_migration/src/pack_config.rs new file mode 100644 index 0000000000..d8fd9cc35c --- /dev/null +++ b/crates/vp_migration/src/pack_config.rs @@ -0,0 +1,523 @@ +use std::ops::Range; + +use ast_grep_core::{Doc, Node, tree_sitter::StrDoc}; +use ast_grep_language::{LanguageExt, SupportLang}; + +use crate::vite_config::{ + is_direct_recognized_config_object, pair_key_matches, rewrite_pack_dts_generators, +}; + +type Edit = (Range, String); + +/// Upgrade the configuration options removed in tsdown 0.23 without evaluating +/// user code. Only direct pack objects and standalone tsdown configs qualify. +pub(crate) fn rewrite_pack_config(content: &str, standalone: bool) -> String { + let grep = SupportLang::TypeScript.ast_grep(content); + let mut edits = Vec::new(); + for object in grep.root().dfs().filter(|node| node.kind() == "object") { + if !is_pack_object(&object, standalone) { + continue; + } + let source = object.text(); + let rewritten = rewrite_options(&source); + if rewritten != source { + edits.push((object.range(), rewritten)); + } + } + // Select the declaration generator after the other option edits. + rewrite_pack_dts_generators(&apply_edits(content, edits, 0), standalone) +} + +pub(crate) fn is_pack_object(object: &Node<'_, D>, standalone: bool) -> bool { + let mut value = object.clone(); + while let Some(parent) = value.parent() { + match parent.kind().as_ref() { + "array" | "parenthesized_expression" | "satisfies_expression" | "as_expression" => { + value = parent; + } + _ => break, + } + } + if standalone && is_top_config_value(&value) { + return true; + } + value.parent().is_some_and(|pair| { + pair.kind() == "pair" + && pair.field("key").is_some_and(|key| pair_key_matches(&key, "pack")) + && pair.parent().is_some_and(|object| is_top_config_value(&object)) + }) +} + +fn is_top_config_value(node: &Node<'_, D>) -> bool { + is_direct_recognized_config_object(node) + && !node.ancestors().any(|ancestor| ancestor.kind() == "object") +} + +pub(crate) fn property_comments(node: &Node<'_, D>) -> String { + let mut comments = String::new(); + for child in node.children().filter(|child| child.kind() == "comment") { + comments.push_str(&child.text()); + comments.push('\n'); + } + comments +} + +fn apply_edits(content: &str, mut edits: Vec, offset: usize) -> String { + edits.sort_by_key(|(range, _)| std::cmp::Reverse(range.start)); + let mut result = content.to_owned(); + for (range, replacement) in edits { + result.replace_range(range.start - offset..range.end - offset, &replacement); + } + result +} + +/// Edits only direct properties. Spreads, duplicate keys and computed keys make +/// property precedence unknown, so leave such objects for manual migration. +struct ObjectEditor<'a, D: Doc> { + node: Node<'a, D>, + edits: Vec, + additions: Vec, +} + +impl<'a, D: Doc> ObjectEditor<'a, D> { + fn property(&self, name: &str) -> Option> { + self.node.children().find(|child| { + child + .field("key") + .or_else(|| child.field("name")) + .is_some_and(|key| pair_key_matches(&key, name)) + || child.kind() == "shorthand_property_identifier" && child.text() == name + }) + } + + fn value(&self, name: &str) -> Option> { + let property = self.property(name)?; + property + .field("value") + .or_else(|| (property.kind() == "shorthand_property_identifier").then_some(property)) + } + + fn remove(&mut self, name: &str) { + let Some(property) = self.property(name) else { return }; + self.edits.push((property.range(), property_comments(&property))); + if let Some(next) = property.next_all().find(|node| node.kind() != "comment") + && next.kind() == "," + { + self.edits.push((next.range(), String::new())); + } + } + + fn rename(&mut self, old: &str, new: &str) { + let Some(property) = self.property(old) else { return }; + if self.property(new).is_some() { + return; + } + if let Some(key) = property.field("key") { + self.edits.push((key.range(), new.to_owned())); + } else if property.kind() == "shorthand_property_identifier" { + self.edits.push((property.range(), format!("{new}: {old}"))); + } + } + + fn set_default(&mut self, name: &str, value: &str) { + if self.property(name).is_none() { + self.additions.push(format!("{name}: {value}")); + } + } + + fn replace_value(&mut self, name: &str, replacement: String) { + let Some(value) = self.value(name) else { return }; + self.edits.push((value.range(), replacement)); + } + + fn finish(mut self) -> String { + if !self.additions.is_empty() { + let start = self.node.range().start + 1; + self.edits.push((start..start, format!(" {},", self.additions.join(", ")))); + } + apply_edits(&self.node.text(), self.edits, self.node.range().start) + } +} + +fn edit_object( + source: &str, + edit: impl FnOnce(&mut ObjectEditor<'_, StrDoc>), +) -> String { + let wrapped = format!("({source})"); + let grep = SupportLang::TypeScript.ast_grep(&wrapped); + let root = grep.root(); + let Some(node) = root.dfs().find(|node| node.kind() == "object") else { + return source.to_owned(); + }; + if !can_edit_object(&node) { + return source.to_owned(); + } + let mut editor = ObjectEditor { node, edits: Vec::new(), additions: Vec::new() }; + edit(&mut editor); + editor.finish() +} + +pub(crate) fn can_edit_object(node: &Node<'_, D>) -> bool { + let mut names = std::collections::HashSet::new(); + for child in node.children() { + if child.kind() == "spread_element" { + return false; + } + if let Some(key) = child.field("key").or_else(|| child.field("name")) { + if key.kind() == "computed_property_name" + || !names.insert(key.text().trim_matches(['\'', '"']).to_owned()) + { + return false; + } + } else if child.kind() == "shorthand_property_identifier" + && !names.insert(child.text().into_owned()) + { + return false; + } + } + true +} + +fn rewrite_options(source: &str) -> String { + // First update nested namespaces; subsequent moves see the new keys and + // cannot create duplicate deps/css objects or overwrite explicit settings. + let source = edit_object(source, |config| { + for name in ["deps", "dts", "attw"] { + let Some(value) = config.value(name) else { continue }; + if value.kind() != "object" { + continue; + } + let updated = edit_object(&value.text(), |options| match name { + "deps" => { + options.rename("onlyAllowBundle", "onlyBundle"); + if let Some(skip) = options.value("skipNodeModulesBundle") { + if skip.kind() == "false" { + options.remove("skipNodeModulesBundle"); + } else if skip.kind() == "true" && options.property("neverBundle").is_none() + { + options.rename("skipNodeModulesBundle", "neverBundle"); + } + } + options.set_default("resolveDepSubpath", "true"); + } + "dts" => { + if options + .value("cjsReexport") + .is_some_and(|value| matches!(value.kind().as_ref(), "true" | "false")) + { + options.remove("cjsReexport"); + } + } + "attw" => { + if options.value("enabled").is_none_or(|value| value.kind() != "false") { + options.set_default("profile", "'strict'"); + } + } + _ => unreachable!(), + }); + config.replace_value(name, updated); + } + }); + let source = edit_object(&source, |config| { + config.rename("outExtension", "outExtensions"); + config.rename("publicDir", "copy"); + for (old, new, replacement) in + [("bundle", "unbundle", "true"), ("removeNodeProtocol", "nodeProtocol", "'strip'")] + { + let Some(value) = config.value(old) else { continue }; + let active = if old == "bundle" { "false" } else { "true" }; + if value.kind() == active && config.property(new).is_none() { + config.rename(old, new); + config.replace_value(old, replacement.to_owned()); + } else if matches!(value.kind().as_ref(), "true" | "false") + && (value.kind() != active || old == "bundle") + { + config.remove(old); + } + } + if config.value("attw").is_some_and(|value| value.kind() == "true") { + config.replace_value("attw", "{ profile: 'strict' }".to_owned()); + } + }); + let source = move_option(&source, "injectStyle", "css", "inject", false); + let source = move_option(&source, "inlineOnly", "deps", "onlyBundle", false); + let source = move_option(&source, "skipNodeModulesBundle", "deps", "neverBundle", true); + edit_object(&source, |config| { + config.set_default("deps", "{ resolveDepSubpath: true }"); + }) +} + +fn move_option(source: &str, old: &str, group: &str, new: &str, boolean: bool) -> String { + edit_object(source, |config| { + let Some(value) = config.value(old) else { return }; + if boolean { + match value.kind().as_ref() { + "false" => { + config.remove(old); + return; + } + "true" => {} + _ => return, + } + } + if let Some(namespace) = config.value(group) { + if namespace.kind() != "object" { + return; + } + let mut moved = false; + let updated = edit_object(&namespace.text(), |options| { + if options.property(new).is_none() { + options.set_default(new, &value.text()); + moved = true; + } + }); + if moved { + config.replace_value(group, updated); + config.remove(old); + } + } else if config.property(group).is_none() { + let defaults = if group == "deps" { ", resolveDepSubpath: true" } else { "" }; + // Replace in place so comments on the old option stay attached. + if let Some(property) = config.property(old) { + config.edits.push(( + property.range(), + format!("{group}: {{ {new}: {}{defaults} }}", value.text()), + )); + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn migrate(options: &str) -> String { + let input = format!("export default defineConfig({{ pack: {options} }});"); + let actual = rewrite_pack_config(&input, false); + assert_eq!(rewrite_pack_config(&actual, false), actual, "migration must be idempotent"); + let grep = SupportLang::TypeScript.ast_grep(&actual); + assert!(!grep.root().dfs().any(|node| node.kind() == "ERROR"), "{actual}"); + actual + } + + #[test] + fn removed_options_and_previous_defaults() { + let actual = migrate( + r#"{ + bundle: false, + outExtension: ({ format }) => ({ js: `.${format}.js` }), + publicDir: ['public'], + removeNodeProtocol: true, + injectStyle: false, + inlineOnly: [/^allowed/], + skipNodeModulesBundle: true, + dts: { tsgo: true, cjsReexport: false, sourcemap: true }, + attw: true, + }"#, + ); + for expected in [ + "unbundle: true", + "outExtensions: ({ format })", + "copy: ['public']", + "nodeProtocol: 'strip'", + "inject: false", + "onlyBundle: [/^allowed/]", + "neverBundle: true", + "resolveDepSubpath: true", + "generator: 'tsgo'", + "sourcemap: true", + "profile: 'strict'", + ] { + assert!(actual.contains(expected), "missing {expected}: {actual}"); + } + for removed in [ + "bundle:", + "outExtension:", + "publicDir", + "removeNodeProtocol", + "injectStyle", + "inlineOnly", + "skipNodeModulesBundle", + "cjsReexport", + "tsgo:", + ] { + // unbundle contains bundle as a substring. + assert!(!actual.contains(&format!(" {removed}")), "{actual}"); + } + } + + #[test] + fn comments_inside_removed_properties_remain_valid() { + let actual = + migrate("{ dts: { cjsReexport: // removed option\ntrue, tsgo: /* compiler */ true } }"); + assert!(actual.contains("// removed option\n"), "{actual}"); + assert!(actual.contains("/* compiler */"), "{actual}"); + } + + #[test] + fn nested_define_config_calls_are_not_pack_configs() { + let input = "export default defineConfig({ plugins: [defineConfig({ bundle: false, dts: { tsgo: true } })] });"; + let actual = rewrite_pack_config(input, true); + assert!(actual.contains("plugins: [defineConfig({ bundle: false, dts: { tsgo: true } })]")); + assert_eq!(actual.matches("resolveDepSubpath").count(), 1); + assert_eq!(rewrite_pack_config(&actual, true), actual); + assert_eq!(rewrite_pack_config(input, false), input); + } + + #[test] + fn nested_options_and_explicit_defaults() { + let actual = migrate( + r#"{ + bundle: true, removeNodeProtocol: false, + deps: { onlyAllowBundle: false, skipNodeModulesBundle: true, resolveDepSubpath: false }, + css: { modules: true }, injectStyle: true, + dts: { oxc: true, cjsReexport: true }, + attw: { profile: 'node16', enabled: false }, + }"#, + ); + for expected in [ + "onlyBundle: false", + "neverBundle: true", + "resolveDepSubpath: false", + "modules: true", + "inject: true", + "generator: 'oxc'", + "profile: 'node16'", + "enabled: false", + ] { + assert!(actual.contains(expected), "{actual}"); + } + assert!(!actual.contains("skipNodeModulesBundle")); + assert!(!actual.contains("cjsReexport")); + assert!(!actual.contains("unbundle")); + assert!(!actual.contains("nodeProtocol")); + } + + #[test] + fn preserves_method_conflicts() { + let actual = migrate( + "{ outExtension: extensions, outExtensions() { return {}; }, publicDir: 'public', copy() { return []; }, deps: { onlyBundle() { return false; } }, inlineOnly: false }", + ); + for expected in [ + "outExtension: extensions", + "outExtensions()", + "publicDir: 'public'", + "copy()", + "onlyBundle()", + "inlineOnly: false", + ] { + assert!(actual.contains(expected), "{actual}"); + } + } + + #[test] + fn shorthand_and_comments() { + let actual = migrate( + "{ publicDir, outExtension, inlineOnly, deps: { /* deps */ }, dts: { cjsReexport: true /* keep */ }, /* tail */ }", + ); + for expected in [ + "copy: publicDir", + "outExtensions: outExtension", + "onlyBundle: inlineOnly", + "/* deps */", + "/* keep */", + "/* tail */", + ] { + assert!(actual.contains(expected), "{actual}"); + } + } + + #[test] + fn skips_ambiguous_objects_and_conflicts() { + for options in [ + "{ ...shared, bundle: false }", + "{ [key]: value, bundle: false }", + "{ bundle: false, 'bundle': true }", + "{ ...shared, dts: { tsgo: true } }", + "{ dts: { tsgo: true, tsgo: false }, deps: { resolveDepSubpath: true } }", + ] { + let input = format!("export default {{ pack: {options} }};"); + assert_eq!(rewrite_pack_config(&input, false), input); + } + let actual = migrate( + "{ publicDir: 'old', copy: 'new', injectStyle: true, css: cssOptions, inlineOnly: ['x'], deps: { onlyBundle: ['y'], resolveDepSubpath: false }, dts: { ...dtsOptions, cjsReexport: true }, attw: attwOptions }", + ); + for expected in [ + "publicDir: 'old'", + "copy: 'new'", + "injectStyle: true", + "css: cssOptions", + "inlineOnly: ['x']", + "onlyBundle: ['y']", + "cjsReexport: true", + "attw: attwOptions", + ] { + assert!(actual.contains(expected), "{actual}"); + } + } + + #[test] + fn scope_arrays_callbacks_and_json() { + for (input, standalone) in [ + ("export default defineConfig([{ bundle: false }, { publicDir: 'public' }]);", true), + ("export default defineConfig(() => ({ pack: [{ bundle: false }] }));", false), + ( + "export default defineConfig(async () => { return { pack: { bundle: false } }; });", + false, + ), + ("export default { pack: ({ bundle: false } satisfies PackConfig) };", false), + ("export default { pack: { \"bundle\": false, \"dts\": { \"tsgo\": true } } };", false), + ] { + let actual = rewrite_pack_config(input, standalone); + assert!(!actual.contains("bundle: false"), "{actual}"); + assert!(actual.contains("resolveDepSubpath: true"), "{actual}"); + assert_eq!(rewrite_pack_config(&actual, standalone), actual); + } + for input in [ + "export default { publicDir: 'vite-public', plugins: [plugin({ bundle: false })] };", + "export default { test: { pack: { bundle: false } } };", + "export default defineConfig({ plugins: [{ config() { return { pack: { bundle: false } }; } }] });", + "const config = { bundle: false }; export default config;", + ] { + assert_eq!(rewrite_pack_config(input, false), input); + } + } + + #[test] + fn generator_objects_keep_their_options() { + for (options, generator, expected) in [ + ( + "{ dts: { tsgo: { path: './tsgo' }, oxc: true } }", + "tsgo", + "tsgo: { path: './tsgo' }", + ), + ( + "{ dts: { oxc: { stripInternal: true }, tsgo: false } }", + "oxc", + "oxc: { stripInternal: true }", + ), + ( + "{ dts: { generator: 'tsc', tsgo: { path: './tsgo' }, oxc: true } }", + "tsc", + "tsgo: { path: './tsgo' }", + ), + ] { + let actual = migrate(options); + assert!(actual.contains(&format!("generator: '{generator}'")), "{actual}"); + assert!(actual.contains(expected), "{actual}"); + assert!(!actual.contains("oxc: true"), "{actual}"); + assert!(!actual.contains("tsgo: false"), "{actual}"); + } + } + + #[test] + fn preserve_old_defaults_without_removed_options() { + let actual = migrate("{ entry: 'src/index.ts', attw: { enabled: true } }"); + assert!(actual.contains("resolveDepSubpath: true")); + assert!(actual.contains("profile: 'strict'")); + let actual = migrate("{ deps: { resolveDepSubpath: false }, attw: { enabled: false } }"); + assert!(!actual.contains("'strict'")); + assert!(actual.contains("resolveDepSubpath: false")); + } +} diff --git a/crates/vp_migration/src/package.rs b/crates/vp_migration/src/package.rs index 0039008fa8..59a3ce07ca 100644 --- a/crates/vp_migration/src/package.rs +++ b/crates/vp_migration/src/package.rs @@ -4,8 +4,10 @@ use serde_json::{Map, Value}; use vp_error::Error; use crate::{ - ast_grep, eslint::rewrite_eslint_script, prettier::rewrite_prettier_script, - script_rewrite::rewrite_bunx_commands, + ast_grep, + eslint::rewrite_eslint_script, + prettier::rewrite_prettier_script, + script_rewrite::{rewrite_bunx_commands, rewrite_pack_flags}, }; // Marker to replace "cross-env " before ast-grep processing @@ -29,7 +31,7 @@ fn rewrite_script(script: &str, rules: &[RuleConfig]) -> String { // matches an active rule, then process ordinary commands. let rewritten_bunx = rewrite_bunx_commands(&preprocessed, |inner| ast_grep::apply_loaded_rules(inner, rules)); - let result = ast_grep::apply_loaded_rules(&rewritten_bunx, rules); + let result = rewrite_pack_flags(&ast_grep::apply_loaded_rules(&rewritten_bunx, rules)); // Step 3: Replace cross-env marker back with "cross-env " (only if we replaced it) @@ -108,6 +110,37 @@ pub fn rewrite_scripts(scripts_json: &str, rules_yaml: &str) -> Result vp --version / vp -v (global flags, not dev-specific) --- diff --git a/crates/vp_migration/src/script_rewrite.rs b/crates/vp_migration/src/script_rewrite.rs index 753ed60f4c..6436fcf1bb 100644 --- a/crates/vp_migration/src/script_rewrite.rs +++ b/crates/vp_migration/src/script_rewrite.rs @@ -37,6 +37,67 @@ pub fn rewrite_script(script: &str, config: &ScriptRewriteConfig) -> String { rewrite_direct_script(&rewritten_bunx, config) } +/// Rename the removed tsdown copy flag only in `vp pack` invocations. The +/// command rewrite runs first, so this also covers scripts that used tsdown. +pub(crate) fn rewrite_pack_flags(script: &str) -> String { + let script = rewrite_bunx_commands(script, rewrite_direct_pack_flags); + rewrite_direct_pack_flags(&script) +} + +fn rewrite_direct_pack_flags(script: &str) -> String { + let mut parser = brush_parser::Parser::new( + script.as_bytes(), + &brush_parser::ParserOptions::default(), + &brush_parser::SourceInfo::default(), + ); + let Ok(mut program) = parser.parse_program() else { return script.to_owned() }; + let changed = visit_simple_commands(&mut program, &mut |cmd| { + let words = collect_command_words(cmd); + let start = match words.first().map(|word| word.value.as_str()) { + Some("vp") => 0, + Some("cross-env" | "cross-env-shell") => { + let Some(index) = words.iter().skip(1).position(|word| !word.value.contains('=')) + else { + return false; + }; + index + 1 + } + _ => return false, + }; + if words.get(start).is_none_or(|word| word.value != "vp") + || words.get(start + 1).is_none_or(|word| word.value != "pack") + { + return false; + } + let mut changed = false; + for word in words.iter().skip(start + 2) { + if word.value == "--" { + break; + } + let replacement = if word.value == "--public-dir" { + "--copy".to_owned() + } else if let Some(value) = word.value.strip_prefix("--public-dir=") { + format!("--copy={value}") + } else { + continue; + }; + if let CommandWordPosition::Suffix(index) = word.position + && let Some(suffix) = &mut cmd.suffix + && let ast::CommandPrefixOrSuffixItem::Word(value) = &mut suffix.0[index] + { + value.value = replacement; + changed = true; + } + } + changed + }); + if changed { + collapse_newlines(&normalize_pipe_spacing(&program.to_string())) + } else { + script.to_owned() + } +} + fn rewrite_direct_script(script: &str, config: &ScriptRewriteConfig) -> String { let mut parser = brush_parser::Parser::new( script.as_bytes(), diff --git a/crates/vp_migration/src/vite_config.rs b/crates/vp_migration/src/vite_config.rs index f87f975b05..e0b34dca13 100644 --- a/crates/vp_migration/src/vite_config.rs +++ b/crates/vp_migration/src/vite_config.rs @@ -405,7 +405,7 @@ fn wrap_lazy_plugins_content( Ok(MergeResult { content, updated: true, uses_function_callback }) } -fn pair_key_matches(key_node: &Node<'_, D>, config_key: &str) -> bool { +pub(crate) fn pair_key_matches(key_node: &Node<'_, D>, config_key: &str) -> bool { let text = key_node.text(); match key_node.kind().as_ref() { "property_identifier" => text == config_key, @@ -414,7 +414,7 @@ fn pair_key_matches(key_node: &Node<'_, D>, config_key: &str) -> bool { } } -/// Convert the boolean declaration-generator options removed in tsdown 0.23. +/// Make declaration-generator selection explicit for tsdown 0.23. /// Only inspect config objects, leaving plugin options and computed values alone. pub(crate) fn rewrite_pack_dts_generators(content: &str, standalone: bool) -> String { let grep = SupportLang::TypeScript.ast_grep(content); @@ -427,33 +427,30 @@ pub(crate) fn rewrite_pack_dts_generators(content: &str, standalone: bool) -> St continue; } let Some(config) = node.parent() else { continue }; - let config = match config.parent() { - Some(parent) if parent.kind() == "array" => parent, - _ => config, - }; - let is_pack = config.parent().is_some_and(|pair| { - pair.kind() == "pair" - && pair.field("key").is_some_and(|key| pair_key_matches(&key, "pack")) - && pair.parent().is_some_and(|object| is_direct_recognized_config_object(&object)) - }); - if !(is_pack || standalone && is_direct_recognized_config_object(&config)) { + if !crate::pack_config::is_pack_object(&config, standalone) + || !crate::pack_config::can_edit_object(&config) + { continue; } let Some(options) = node.field("value").filter(|value| value.kind() == "object") else { continue; }; + if !crate::pack_config::can_edit_object(&options) { + continue; + } let children: Vec<_> = options.children().collect(); - // A spread, computed key, or non-boolean selector can affect precedence. + // A spread, computed key, or dynamic selector can affect precedence. if children.iter().any(|child| { child.kind() == "spread_element" + || child.kind() == "method_definition" || child.kind() == "shorthand_property_identifier" && matches!(child.text().as_ref(), "tsgo" | "oxc") || child.field("key").is_some_and(|key| key.kind() == "computed_property_name") || (child.field("key").is_some_and(|key| { pair_key_matches(&key, "tsgo") || pair_key_matches(&key, "oxc") - }) && child - .field("value") - .is_some_and(|value| !matches!(value.kind().as_ref(), "true" | "false"))) + }) && child.field("value").is_some_and(|value| { + !matches!(value.kind().as_ref(), "true" | "false" | "object") + })) }) { continue; } @@ -473,13 +470,22 @@ pub(crate) fn rewrite_pack_dts_generators(content: &str, standalone: bool) -> St continue; }; let Some(value) = child.field("value") else { continue }; - if !matches!(value.kind().as_ref(), "true" | "false") { + if !matches!(value.kind().as_ref(), "true" | "false" | "object") { continue; } - if value.kind() == "true" && (generator.is_none() || name == "tsgo") { + if value.kind() != "false" && (generator.is_none() || name == "tsgo") { generator = Some((index, name)); } - boolean_options.push(index); + if value.kind() != "object" { + boolean_options.push(index); + } + } + if !has_generator + && let Some((index, name)) = generator + && children[index].field("value").is_some_and(|value| value.kind() == "object") + { + let start = options.range().start + 1; + edits.push((start..start, format!(" generator: '{name}',"))); } for index in boolean_options { let child = &children[index]; @@ -487,10 +493,13 @@ pub(crate) fn rewrite_pack_dts_generators(content: &str, standalone: bool) -> St && let Some((selected, name)) = generator && selected == index { - edits.push((child.range(), format!("generator: '{name}'"))); + if let (Some(key), Some(value)) = (child.field("key"), child.field("value")) { + edits.push((key.range(), "generator".to_owned())); + edits.push((value.range(), format!("'{name}'"))); + } continue; } - edits.push((child.range(), String::new())); + edits.push((child.range(), crate::pack_config::property_comments(child))); if let Some(comma) = children[index + 1..].iter().find(|n| n.kind() != "comment") && comma.kind() == "," { @@ -587,7 +596,7 @@ static RE_NAMESPACE_LAZY_PLUGINS_IMPORT: LazyLock = /// returns inside nested functions (e.g. an inline plugin's `config()` hook) /// do NOT match, so destructive edits never touch them. Used by transforms /// that rewrite in place (`wrap_lazy_plugins`, `upsert_json_config`). -fn is_direct_recognized_config_object(object_node: &Node<'_, D>) -> bool { +pub(crate) fn is_direct_recognized_config_object(object_node: &Node<'_, D>) -> bool { let Some(parent) = object_node.parent() else { return false }; match parent.kind().as_ref() { "export_statement" => true, @@ -1028,12 +1037,10 @@ mod tests { for input in [ "export default { plugins: [plugin({ dts: { tsgo: true } })] };", "export default { test: { pack: { dts: { tsgo: true } } } };", - "export default { pack: { dts: { tsgo: { path: './tsgo' } } } };", "export default { pack: { dts: { tsgo: enabled } } };", "export default { pack: { dts: { tsgo: enabled, oxc: true } } };", "export default { pack: { dts: { tsgo, oxc: true } } };", "export default { pack: { dts: { tsgo: true, oxc } } };", - "export default { pack: { dts: { tsgo: { path: './tsgo' }, oxc: true } } };", "export default { pack: { dts: { ...options, tsgo: true } } };", "export default { pack: { dts: { [key]: value, tsgo: true } } };", ] { diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index a7eb4e2689..20429fa1ea 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -39,6 +39,40 @@ When a default upgrade skips setup actions that would apply, it prints a hint to run `vp migrate --full`. Fresh (non Vite+) projects always run the full migration. +## Pack Configuration + +`vp migrate` updates static `pack` objects in `vite.config.*` and exported +objects in `tsdown.config.*` for [tsdown 0.23](https://github.com/rolldown/tsdown/releases/tag/v0.23.0). +This also runs on existing Vite+ projects without `--full`, including workspace +packages. Arrays and direct objects returned by `defineConfig` callbacks are +supported. JSON tsdown configs receive the same updates after they merge into +`vite.config.ts`. + +| Previous option | Updated option | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `bundle: false` | `unbundle: true` | +| `bundle: true` | Removed; bundling remains the default | +| `outExtension` | `outExtensions` | +| `publicDir` | `copy` | +| `removeNodeProtocol: true` | `nodeProtocol: 'strip'` | +| `injectStyle` | `css.inject` | +| `inlineOnly` / `deps.onlyAllowBundle` | `deps.onlyBundle` | +| `skipNodeModulesBundle: true` / `deps.skipNodeModulesBundle: true` | `deps.neverBundle: true` | +| `dts.tsgo` / `dts.oxc` | Select with `dts.generator`; retain generator option objects and remove boolean flags | +| `dts.cjsReexport` | Removed; tsdown generates CJS declarations separately | +| `--public-dir` in `tsdown` or `vp pack` scripts | `--copy` | + +Migration preserves the previous defaults by setting `deps.resolveDepSubpath` +to `true` when absent. Enabled ATTW checks receive `profile: 'strict'` when +no profile is set. Explicit values, including `false`, remain unchanged. + +The transform does not evaluate configuration code. Objects with spreads, +computed keys, or duplicate keys, and conflicting old and new options require +manual review. Dynamic boolean selectors remain unchanged. Unrelated Vite and +plugin options remain unchanged. Run `vp pack` after migration to check the +result. Node.js requirements, TypeScript module resolution, and programmatic +`build()` return values require separate review. + ## Dependency Rules What happens to each toolchain dependency, at a glance: From d81228eb11dd99c312cfd97e7361b8c4cf88d133 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 11:27:20 +0800 Subject: [PATCH 2/7] fix(migrate): preserve legacy pack configuration behavior --- .../assets/asset.txt | 1 + .../concise-entry.txt | 1 + .../concise.config.txt | 9 + .../external-entry.txt | 2 + .../external.config.txt | 18 ++ .../migration_pack_tsdown_023/helper.txt | 1 + .../manual.config.txt | 8 + .../migration_pack_tsdown_023/snapshots.toml | 46 ++++ ...gration_pack_tsdown_023_concise_methods.md | 101 +++++++++ .../migration_pack_tsdown_023_external.md | 73 ++++++ .../migration_pack_tsdown_023_manual.md | 60 +++++ crates/vp_migration/src/import_rewriter.rs | 51 ++++- crates/vp_migration/src/pack_config.rs | 210 ++++++++++++++++-- crates/vp_migration/src/vite_config.rs | 1 + docs/guide/migrate-rules.md | 6 + packages/cli/binding/index.d.cts | 2 + packages/cli/binding/src/migration.rs | 10 + .../cli/src/migration/migrator/vite-config.ts | 4 + 18 files changed, 577 insertions(+), 27 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/assets/asset.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/concise-entry.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/concise.config.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external-entry.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external.config.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/helper.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/manual.config.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_concise_methods.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/assets/asset.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/assets/asset.txt new file mode 100644 index 0000000000..73395d2293 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/assets/asset.txt @@ -0,0 +1 @@ +method copy asset diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/concise-entry.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/concise-entry.txt new file mode 100644 index 0000000000..40ac43ceb9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/concise-entry.txt @@ -0,0 +1 @@ +export { helper } from './helper.js'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/concise.config.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/concise.config.txt new file mode 100644 index 0000000000..20576b7b9f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/concise.config.txt @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig(() => ({ + entry: 'src/index.ts', + bundle: false, + dts: { oxc: true }, + outExtension() { return { js: '.custom.js' }; }, + publicDir() { return ['assets']; }, +})); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external-entry.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external-entry.txt new file mode 100644 index 0000000000..848a6ed7ab --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external-entry.txt @@ -0,0 +1,2 @@ +export { foo } from 'foo'; +export { externalValue } from './external.js'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external.config.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external.config.txt new file mode 100644 index 0000000000..22893e4a0f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external.config.txt @@ -0,0 +1,18 @@ +export default { + pack: [ + { + entry: 'src/index.ts', + outDir: 'dist/top', + dts: false, + external: ['foo', './external.js'], + skipNodeModulesBundle: true, + }, + { + entry: 'src/index.ts', + outDir: 'dist/nested', + dts: false, + external: ['foo', './external.js'], + deps: { skipNodeModulesBundle: true }, + }, + ], +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/helper.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/helper.txt new file mode 100644 index 0000000000..6812cd906f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/helper.txt @@ -0,0 +1 @@ +export const helper: number = 42; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/manual.config.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/manual.config.txt new file mode 100644 index 0000000000..4dae6220dd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/manual.config.txt @@ -0,0 +1,8 @@ +const externalOptions = ['foo']; + +export default { + pack: [ + { external: externalOptions, skipNodeModulesBundle: true, bundle: false, dts: { tsgo: true } }, + { external: externalOptions, deps: { skipNodeModulesBundle: true }, bundle: false }, + ], +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml index 91392f38f8..d846e83ddf 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml @@ -22,3 +22,49 @@ steps = [ { argv = ["vpt", "stat-file", "dist/index.d.mts", "--assert", "file"] }, { argv = ["vpt", "print-file", "dist/public/asset.txt"] }, ] + +[[case]] +name = "migration_pack_tsdown_023_external" +vp = "global" +comment = "Preserve external matchers when either skipNodeModulesBundle form becomes deps.neverBundle." +steps = [ + { argv = ["vpt", "cp", "external.config.txt", "vite.config.ts"], snapshot = false }, + { argv = ["vpt", "cp", "external-entry.txt", "src/index.ts"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, + { argv = ["vp", "pack"], snapshot = false }, + { argv = ["vpt", "print-file", "dist/top/index.mjs"] }, + { argv = ["vpt", "print-file", "dist/nested/index.mjs"] }, + { argv = ["vp", "migrate", "--no-interactive"] }, +] + +[[case]] +name = "migration_pack_tsdown_023_concise_methods" +vp = "global" +comment = "Migrate a standalone concise arrow and method options, then check unbundled files and copied assets." +steps = [ + { argv = ["vpt", "cp", "concise.config.txt", "tsdown.config.ts"], snapshot = false }, + { argv = ["vpt", "write-file", "vite.config.ts", "import packConfig from './tsdown.config.js';\nexport default { pack: packConfig({}) };\n"], snapshot = false }, + { argv = ["vpt", "cp", "concise-entry.txt", "src/index.ts"], snapshot = false }, + { argv = ["vpt", "cp", "helper.txt", "src/helper.ts"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "tsdown.config.ts"] }, + { argv = ["vp", "pack"] }, + { argv = ["vpt", "stat-file", "dist/index.custom.js", "--assert", "file"] }, + { argv = ["vpt", "stat-file", "dist/helper.custom.js", "--assert", "file"] }, + { argv = ["vpt", "print-file", "dist/assets/asset.txt"] }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "tsdown.config.ts"] }, +] + +[[case]] +name = "migration_pack_tsdown_023_manual" +vp = "global" +comment = "Report unsupported external matchers without changing the pack options." +steps = [ + { argv = ["vpt", "cp", "manual.config.txt", "vite.config.ts"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_concise_methods.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_concise_methods.md new file mode 100644 index 0000000000..cef0834214 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_concise_methods.md @@ -0,0 +1,101 @@ +# migration_pack_tsdown_023_concise_methods + +Migrate a standalone concise arrow and method options, then check unbundled files and copied assets. + +## `vpt cp concise.config.txt tsdown.config.ts` + + +## `vpt write-file vite.config.ts 'import packConfig from '\''./tsdown.config.js'\''; +export default { pack: packConfig({}) }; +'` + + +## `vpt cp concise-entry.txt src/index.ts` + + +## `vpt cp helper.txt src/helper.ts` + + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• 1 file had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file tsdown.config.ts` + +``` +import { defineConfig } from 'vite-plus/pack'; + +export default defineConfig(() => ({ deps: { resolveDepSubpath: true }, + entry: 'src/index.ts', + unbundle: true, + dts: { generator: 'oxc' }, + outExtensions() { return { js: '.custom.js' }; }, + copy() { return ['assets']; }, +})); +``` + +## `vp pack` + +``` +VITE+ - The Unified Toolchain for the Web + +ℹ entry: src/index.ts +ℹ tsconfig: tsconfig.json +ℹ Build start +ℹ dist/index.d.custom.ts kB │ gzip: kB +ℹ dist/index.custom.js kB │ gzip: kB +ℹ dist/helper.d.custom.ts kB │ gzip: kB +ℹ dist/helper.custom.js kB │ gzip: kB +ℹ 4 files, total: kB +✔ Build complete in +``` + +## `vpt stat-file dist/index.custom.js --assert file` + +``` +dist/index.custom.js: file +``` + +## `vpt stat-file dist/helper.custom.js --assert file` + +``` +dist/helper.custom.js: file +``` + +## `vpt print-file dist/assets/asset.txt` + +``` +method copy asset +``` + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` + +## `vpt print-file tsdown.config.ts` + +``` +import { defineConfig } from 'vite-plus/pack'; + +export default defineConfig(() => ({ deps: { resolveDepSubpath: true }, + entry: 'src/index.ts', + unbundle: true, + dts: { generator: 'oxc' }, + outExtensions() { return { js: '.custom.js' }; }, + copy() { return ['assets']; }, +})); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external.md new file mode 100644 index 0000000000..1367541e17 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external.md @@ -0,0 +1,73 @@ +# migration_pack_tsdown_023_external + +Preserve external matchers when either skipNodeModulesBundle form becomes deps.neverBundle. + +## `vpt cp external.config.txt vite.config.ts` + + +## `vpt cp external-entry.txt src/index.ts` + + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• 1 file had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file vite.config.ts` + +``` +export default { + pack: [ + { + entry: 'src/index.ts', + outDir: 'dist/top', + dts: false, + inputOptions: { external: ['foo', './external.js'] }, + deps: { neverBundle: true, resolveDepSubpath: true }, + }, + { + entry: 'src/index.ts', + outDir: 'dist/nested', + dts: false, + inputOptions: { external: ['foo', './external.js'] }, + deps: { resolveDepSubpath: true, neverBundle: true }, + }, + ], +}; +``` + +## `vp pack` + + +## `vpt print-file dist/top/index.mjs` + +``` +import { foo } from "foo"; +import { externalValue } from "./external.js"; +export { externalValue, foo }; +``` + +## `vpt print-file dist/nested/index.mjs` + +``` +import { foo } from "foo"; +import { externalValue } from "./external.js"; +export { externalValue, foo }; +``` + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md new file mode 100644 index 0000000000..ec1ba42cea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md @@ -0,0 +1,60 @@ +# migration_pack_tsdown_023_manual + +Report unsupported external matchers without changing the pack options. + +## `vpt cp manual.config.txt vite.config.ts` + + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• Package manager settings configured +! Warnings: + - vite.config.ts: Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged. +``` + +## `vpt print-file vite.config.ts` + +``` +const externalOptions = ['foo']; + +export default { + pack: [ + { external: externalOptions, skipNodeModulesBundle: true, bundle: false, dts: { tsgo: true } }, + { external: externalOptions, deps: { skipNodeModulesBundle: true }, bundle: false }, + ], +}; +``` + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite → +! Warnings: + - vite.config.ts: Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged. +``` + +## `vpt print-file vite.config.ts` + +``` +const externalOptions = ['foo']; + +export default { + pack: [ + { external: externalOptions, skipNodeModulesBundle: true, bundle: false, dts: { tsgo: true } }, + { external: externalOptions, deps: { skipNodeModulesBundle: true }, bundle: false }, + ], +}; +``` diff --git a/crates/vp_migration/src/import_rewriter.rs b/crates/vp_migration/src/import_rewriter.rs index eac5d5a0cc..7b7493fb5e 100644 --- a/crates/vp_migration/src/import_rewriter.rs +++ b/crates/vp_migration/src/import_rewriter.rs @@ -2115,6 +2115,8 @@ struct RewriteResult { pub updated: bool, /// Whether an upstream `vitest` specifier was intentionally preserved. pub preserved_vitest: bool, + /// Pack configurations that need manual migration. + pub warnings: Vec, } /// Result of rewriting imports in multiple files @@ -2128,11 +2130,13 @@ pub struct BatchRewriteResult { pub preserved_vitest_files: Vec, /// Files that had errors (path, error message) pub errors: Vec<(PathBuf, String)>, + /// Pack configurations that need manual migration. + pub warnings: Vec<(PathBuf, String)>, } enum FileResult { - Modified, - Unchanged, + Modified(Vec), + Unchanged(Vec), Error(String), } @@ -2200,7 +2204,7 @@ pub fn rewrite_imports_in_directory_with_options( .map(|(file_path, package_context)| { let skip_packages = package_context.skip_packages; if skip_packages.all_skipped() { - return (file_path, FileResult::Unchanged, false); + return (file_path, FileResult::Unchanged(Vec::new()), false); } match rewrite_import( @@ -2213,10 +2217,18 @@ pub fn rewrite_imports_in_directory_with_options( if let Err(e) = std::fs::write(&file_path, &rewrite_result.content) { (file_path, FileResult::Error(e.to_string()), false) } else { - (file_path, FileResult::Modified, rewrite_result.preserved_vitest) + ( + file_path, + FileResult::Modified(rewrite_result.warnings), + rewrite_result.preserved_vitest, + ) } } else { - (file_path, FileResult::Unchanged, rewrite_result.preserved_vitest) + ( + file_path, + FileResult::Unchanged(rewrite_result.warnings), + rewrite_result.preserved_vitest, + ) } } Err(e) => (file_path, FileResult::Error(e.to_string()), false), @@ -2230,15 +2242,21 @@ pub fn rewrite_imports_in_directory_with_options( unchanged_files: Vec::new(), preserved_vitest_files: Vec::new(), errors: Vec::new(), + warnings: Vec::new(), }; for (file_path, file_result, preserved_vitest) in results { if preserved_vitest { batch_result.preserved_vitest_files.push(file_path.clone()); } + if let FileResult::Modified(warnings) | FileResult::Unchanged(warnings) = &file_result { + batch_result + .warnings + .extend(warnings.iter().map(|message| (file_path.clone(), message.clone()))); + } match file_result { - FileResult::Modified => batch_result.modified_files.push(file_path), - FileResult::Unchanged => batch_result.unchanged_files.push(file_path), + FileResult::Modified(_) => batch_result.modified_files.push(file_path), + FileResult::Unchanged(_) => batch_result.unchanged_files.push(file_path), FileResult::Error(msg) => batch_result.errors.push((file_path, msg)), } } @@ -2289,6 +2307,7 @@ fn rewrite_import( let rewritten = crate::pack_config::rewrite_pack_config(&result.content, standalone); result.updated |= rewritten != result.content; result.content = rewritten; + result.warnings = crate::pack_config::pack_config_warnings(&result.content, standalone); } Ok(result) } @@ -2342,6 +2361,7 @@ fn rewrite_import_content_full( content: content.to_string(), updated: false, preserved_vitest: false, + warnings: Vec::new(), }); } @@ -2400,7 +2420,7 @@ fn rewrite_import_content_full( &mut preserved_vitest, ); - Ok(RewriteResult { content: new_content, updated, preserved_vitest }) + Ok(RewriteResult { content: new_content, updated, preserved_vitest, warnings: Vec::new() }) } #[cfg(test)] @@ -2411,6 +2431,21 @@ mod tests { use super::*; + #[test] + fn reports_unchanged_pack_configs_that_need_manual_migration() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("vite.config.ts"); + let content = "export default { pack: { external: externalOptions, deps: { skipNodeModulesBundle: true }, bundle: false, dts: { tsgo: true } } };"; + std::fs::write(&file, content).unwrap(); + let result = rewrite_imports_in_directory(temp.path()).unwrap(); + assert!(result.modified_files.is_empty()); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].0, file); + assert!(result.warnings[0].1.contains("Migrate this pack config manually")); + assert_eq!(std::fs::read_to_string(file).unwrap(), content); + } + #[test] fn migrate_pack_configs_in_workspace_packages() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/vp_migration/src/pack_config.rs b/crates/vp_migration/src/pack_config.rs index d8fd9cc35c..2b64542c07 100644 --- a/crates/vp_migration/src/pack_config.rs +++ b/crates/vp_migration/src/pack_config.rs @@ -18,8 +18,11 @@ pub(crate) fn rewrite_pack_config(content: &str, standalone: bool) -> String { if !is_pack_object(&object, standalone) { continue; } + if external_skip_needs_manual_migration(&object) { + continue; + } let source = object.text(); - let rewritten = rewrite_options(&source); + let rewritten = rewrite_options(&object); if rewritten != source { edits.push((object.range(), rewritten)); } @@ -30,7 +33,12 @@ pub(crate) fn rewrite_pack_config(content: &str, standalone: bool) -> String { pub(crate) fn is_pack_object(object: &Node<'_, D>, standalone: bool) -> bool { let mut value = object.clone(); - while let Some(parent) = value.parent() { + loop { + // The recognizer needs the object *inside* a concise arrow's parentheses. + if standalone && is_top_config_value(&value) { + return true; + } + let Some(parent) = value.parent() else { break }; match parent.kind().as_ref() { "array" | "parenthesized_expression" | "satisfies_expression" | "as_expression" => { value = parent; @@ -38,9 +46,6 @@ pub(crate) fn is_pack_object(object: &Node<'_, D>, standalone: bool) -> _ => break, } } - if standalone && is_top_config_value(&value) { - return true; - } value.parent().is_some_and(|pair| { pair.kind() == "pair" && pair.field("key").is_some_and(|key| pair_key_matches(&key, "pack")) @@ -71,6 +76,23 @@ fn apply_edits(content: &str, mut edits: Vec, offset: usize) -> String { result } +fn find_property<'a, D: Doc>(node: &Node<'a, D>, name: &str) -> Option> { + node.children().find(|child| { + child + .field("key") + .or_else(|| child.field("name")) + .is_some_and(|key| pair_key_matches(&key, name)) + || child.kind() == "shorthand_property_identifier" && child.text() == name + }) +} + +fn property_value<'a, D: Doc>(node: &Node<'a, D>, name: &str) -> Option> { + let property = find_property(node, name)?; + property + .field("value") + .or_else(|| (property.kind() == "shorthand_property_identifier").then_some(property)) +} + /// Edits only direct properties. Spreads, duplicate keys and computed keys make /// property precedence unknown, so leave such objects for manual migration. struct ObjectEditor<'a, D: Doc> { @@ -81,20 +103,11 @@ struct ObjectEditor<'a, D: Doc> { impl<'a, D: Doc> ObjectEditor<'a, D> { fn property(&self, name: &str) -> Option> { - self.node.children().find(|child| { - child - .field("key") - .or_else(|| child.field("name")) - .is_some_and(|key| pair_key_matches(&key, name)) - || child.kind() == "shorthand_property_identifier" && child.text() == name - }) + find_property(&self.node, name) } fn value(&self, name: &str) -> Option> { - let property = self.property(name)?; - property - .field("value") - .or_else(|| (property.kind() == "shorthand_property_identifier").then_some(property)) + property_value(&self.node, name) } fn remove(&mut self, name: &str) { @@ -112,7 +125,7 @@ impl<'a, D: Doc> ObjectEditor<'a, D> { if self.property(new).is_some() { return; } - if let Some(key) = property.field("key") { + if let Some(key) = property.field("key").or_else(|| property.field("name")) { self.edits.push((key.range(), new.to_owned())); } else if property.kind() == "shorthand_property_identifier" { self.edits.push((property.range(), format!("{new}: {old}"))); @@ -178,10 +191,89 @@ pub(crate) fn can_edit_object(node: &Node<'_, D>) -> bool { true } -fn rewrite_options(source: &str) -> String { +const EXTERNAL_SKIP_WARNING: &str = "Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged."; + +pub(crate) fn pack_config_warnings(content: &str, standalone: bool) -> Vec { + let grep = SupportLang::TypeScript.ast_grep(content); + if grep.root().dfs().any(|node| { + node.kind() == "object" + && is_pack_object(&node, standalone) + && external_skip_needs_manual_migration(&node) + }) { + vec![EXTERNAL_SKIP_WARNING.to_owned()] + } else { + Vec::new() + } +} + +fn has_external_skip(config: &Node<'_, D>) -> bool { + if find_property(config, "external").is_none() { + return false; + } + property_value(config, "skipNodeModulesBundle").is_some_and(|value| value.kind() == "true") + || property_value(config, "deps").is_some_and(|deps| { + deps.kind() == "object" + && property_value(&deps, "skipNodeModulesBundle") + .is_some_and(|value| value.kind() == "true") + }) +} + +pub(crate) fn external_skip_needs_manual_migration(config: &Node<'_, D>) -> bool { + if !has_external_skip(config) { + return false; + } + if !can_edit_object(config) + || property_value(config, "external").is_none_or(|value| !is_static_external(&value)) + { + return true; + } + for (namespace, conflicts) in + [("deps", &["neverBundle", "dts"][..]), ("inputOptions", &["external"][..])] + { + if find_property(config, namespace).is_some() { + let Some(value) = property_value(config, namespace) else { return true }; + if value.kind() != "object" + || !can_edit_object(&value) + || conflicts.iter().any(|name| find_property(&value, name).is_some()) + { + return true; + } + } + } + false +} + +fn is_static_external(value: &Node<'_, D>) -> bool { + match value.kind().as_ref() { + // tsdown interprets a top-level '/pattern/' string as a regular + // expression. Leave that form, and dynamic matchers, for manual review. + "string" => { + let text = value.text(); + let regex_string = text.as_bytes().get(1) == Some(&b'/') + && text.as_bytes().get(text.len() - 2) == Some(&b'/'); + !(text.contains('\\') || regex_string) + } + "regex" => true, + // Array entries pass through tsdown without string-to-regexp conversion. + "array" => value.children().all(|child| { + matches!(child.kind().as_ref(), "[" | "]" | "," | "comment" | "string" | "regex") + }), + _ => false, + } +} + +fn rewrite_options(object: &Node<'_, D>) -> String { + // Rolldown can retain the original static matcher while tsdown's deps + // plugin handles neverBundle: true. Avoid overriding DTS-specific matchers + // or user inputOptions; those combinations are reported for manual review. + let source = if has_external_skip(object) { + move_option(&object.text(), "external", "inputOptions", "external", false) + } else { + object.text().into_owned() + }; // First update nested namespaces; subsequent moves see the new keys and // cannot create duplicate deps/css objects or overwrite explicit settings. - let source = edit_object(source, |config| { + let source = edit_object(&source, |config| { for name in ["deps", "dts", "attw"] { let Some(value) = config.value(name) else { continue }; if value.kind() != "object" { @@ -301,6 +393,86 @@ mod tests { actual } + #[test] + fn migrates_static_external_with_both_skip_forms() { + for external in + ["['foo']", "['foo', /^virtual:/, './local.js']", "'foo'", "/^virtual:/", "[]"] + { + for skip in ["skipNodeModulesBundle: true", "deps: { skipNodeModulesBundle: true }"] { + let actual = migrate(&format!("{{ external: {external}, {skip} }}")); + assert!( + actual.contains(&format!("inputOptions: {{ external: {external} }}")), + "{actual}" + ); + assert!(actual.contains("neverBundle: true"), "{actual}"); + assert!(!actual.contains("skipNodeModulesBundle"), "{actual}"); + assert!(pack_config_warnings(&actual, false).is_empty()); + } + } + let actual = migrate( + "{ external: ['foo'], skipNodeModulesBundle: true, inputOptions: { treeshake: false } }", + ); + assert!(actual.contains("external: ['foo'], treeshake: false"), "{actual}"); + } + + #[test] + fn unsafe_external_combinations_stay_unchanged_and_warn() { + for options in [ + "external: dynamicExternal", + "external", + "external: (id) => id === 'foo'", + "external: '/foo/'", + "external: ['foo'], inputOptions: customOptions", + "external: ['foo'], inputOptions: { ...customOptions }", + "external: ['foo'], inputOptions: { external: ['bar'] }", + "external: ['foo'], inputOptions: { external() {} }", + "external: ['foo'], ...otherOptions", + ] { + for skip in ["skipNodeModulesBundle: true", "deps: { skipNodeModulesBundle: true }"] { + let input = format!( + "export default {{ pack: {{ {options}, {skip}, bundle: false, dts: {{ tsgo: true }} }} }};" + ); + assert_eq!(rewrite_pack_config(&input, false), input); + assert_eq!(pack_config_warnings(&input, false), [EXTERNAL_SKIP_WARNING]); + } + } + let input = "export default { pack: { external: ['foo'], deps: { skipNodeModulesBundle: true, dts: { neverBundle: ['types'] } } } };"; + assert_eq!(rewrite_pack_config(input, false), input); + assert_eq!(pack_config_warnings(input, false), [EXTERNAL_SKIP_WARNING]); + for input in [ + "export default { pack: { external: ['foo'], skipNodeModulesBundle: false } };", + "export default { plugins: [plugin({ external, skipNodeModulesBundle: true })] };", + ] { + assert!(pack_config_warnings(input, false).is_empty()); + } + } + + #[test] + fn standalone_concise_arrows_migrate_unbundle_and_generator() { + for input in [ + "export default defineConfig(() => ({ bundle: false, dts: { tsgo: true } }));", + "export default defineConfig(async () => ({ bundle: false, dts: { tsgo: true } }));", + "export default defineConfig(() => ([{ bundle: false, dts: { tsgo: true } }]));", + "export default defineConfig(() => (({ bundle: false, dts: { tsgo: true } }) satisfies UserConfig));", + "export default defineConfig(() => { return { bundle: false, dts: { tsgo: true } }; });", + ] { + let actual = rewrite_pack_config(input, true); + assert!(actual.contains("unbundle: true"), "{actual}"); + assert!(actual.contains("generator: 'tsgo'"), "{actual}"); + assert!(!actual.contains("tsgo: true"), "{actual}"); + assert_eq!(rewrite_pack_config(&actual, true), actual); + } + } + + #[test] + fn renames_method_options_without_changing_bodies() { + let input = "export default defineConfig({ outExtension() { return { js: '.custom.js' }; }, async 'publicDir'() { /* assets */ return ['assets']; } });"; + let actual = rewrite_pack_config(input, true); + assert!(actual.contains("outExtensions() { return { js: '.custom.js' }; }"), "{actual}"); + assert!(actual.contains("async copy() { /* assets */ return ['assets']; }"), "{actual}"); + assert_eq!(rewrite_pack_config(&actual, true), actual); + } + #[test] fn removed_options_and_previous_defaults() { let actual = migrate( diff --git a/crates/vp_migration/src/vite_config.rs b/crates/vp_migration/src/vite_config.rs index e0b34dca13..9953f7458f 100644 --- a/crates/vp_migration/src/vite_config.rs +++ b/crates/vp_migration/src/vite_config.rs @@ -429,6 +429,7 @@ pub(crate) fn rewrite_pack_dts_generators(content: &str, standalone: bool) -> St let Some(config) = node.parent() else { continue }; if !crate::pack_config::is_pack_object(&config, standalone) || !crate::pack_config::can_edit_object(&config) + || crate::pack_config::external_skip_needs_manual_migration(&config) { continue; } diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 20429fa1ea..326ad6ed33 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -66,6 +66,12 @@ Migration preserves the previous defaults by setting `deps.resolveDepSubpath` to `true` when absent. Enabled ATTW checks receive `profile: 'strict'` when no profile is set. Explicit values, including `false`, remain unchanged. +When `external` accompanies either `skipNodeModulesBundle` form, static matchers +move to `inputOptions.external` before `deps.neverBundle` is set. This preserves +the original matching rules, including external file paths. Unsupported matchers, +conflicting `inputOptions`, and declaration-specific dependency rules leave the +pack object unchanged and produce a manual-migration warning. + The transform does not evaluate configuration code. Objects with spreads, computed keys, or duplicate keys, and conflicting old and new options require manual review. Dynamic boolean selectors remain unchanged. Unrelated Vite and diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 72642a9d46..8cd94c5e75 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3450,6 +3450,8 @@ export interface BatchRewriteResult { preservedVitestFiles: Array; /** Files that had errors */ errors: Array; + /** Pack configurations that need manual migration */ + warnings: Array; } /** Configuration options passed from JavaScript to Rust. */ diff --git a/packages/cli/binding/src/migration.rs b/packages/cli/binding/src/migration.rs index 4d19833e8c..81b7b14899 100644 --- a/packages/cli/binding/src/migration.rs +++ b/packages/cli/binding/src/migration.rs @@ -201,6 +201,8 @@ pub struct BatchRewriteResult { pub preserved_vitest_files: Vec, /// Files that had errors pub errors: Vec, + /// Pack configurations that need manual migration + pub warnings: Vec, } /// Merge tsdown config into vite config by importing it @@ -310,6 +312,14 @@ pub fn rewrite_imports_in_directory( .iter() .map(|p| p.to_string_lossy().to_string()) .collect(), + warnings: result + .warnings + .iter() + .map(|(p, m)| BatchRewriteError { + path: p.to_string_lossy().to_string(), + message: m.clone(), + }) + .collect(), errors: result .errors .iter() diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 86b8e29c49..7846ad2b87 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -528,6 +528,10 @@ export function rewriteAllImports( const preserved = result.preservedVitestFiles.length; const errors = result.errors.length; + for (const warning of result.warnings) { + warnMigration(`${displayRelative(warning.path)}: ${warning.message}`, report); + } + if (report) { report.rewrittenImportFileCount += modified; report.preservedUpstreamVitestImportFileCount += preserved; From 366685c133eff742ed8024f7b8975fd552cf242a Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 22:13:20 +0800 Subject: [PATCH 3/7] fix(migrate): link dependency migration docs in pack warning --- .../snapshots/migration_pack_tsdown_023_manual.md | 4 ++-- crates/vp_migration/src/pack_config.rs | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md index ec1ba42cea..4c86d46953 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md @@ -17,7 +17,7 @@ VITE+ - The Unified Toolchain for the Web vite → • Package manager settings configured ! Warnings: - - vite.config.ts: Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged. + - vite.config.ts: Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged. See https://tsdown.dev/options/dependencies#migration-from-deprecated-options ``` ## `vpt print-file vite.config.ts` @@ -43,7 +43,7 @@ VITE+ - The Unified Toolchain for the Web • Dependencies: vite → ! Warnings: - - vite.config.ts: Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged. + - vite.config.ts: Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged. See https://tsdown.dev/options/dependencies#migration-from-deprecated-options ``` ## `vpt print-file vite.config.ts` diff --git a/crates/vp_migration/src/pack_config.rs b/crates/vp_migration/src/pack_config.rs index 2b64542c07..90b5b408c9 100644 --- a/crates/vp_migration/src/pack_config.rs +++ b/crates/vp_migration/src/pack_config.rs @@ -191,7 +191,11 @@ pub(crate) fn can_edit_object(node: &Node<'_, D>) -> bool { true } -const EXTERNAL_SKIP_WARNING: &str = "Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged."; +const EXTERNAL_SKIP_WARNING: &str = concat!( + "Cannot safely combine external with skipNodeModulesBundle. ", + "Migrate this pack config manually; its options were left unchanged. ", + "See https://tsdown.dev/options/dependencies#migration-from-deprecated-options", +); pub(crate) fn pack_config_warnings(content: &str, standalone: bool) -> Vec { let grep = SupportLang::TypeScript.ast_grep(content); From e0f2205a38e807fbc4144e18870319ad02572a86 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 22:37:14 +0800 Subject: [PATCH 4/7] fix(migrate): resolve constant external matchers --- .../external-conflicts.config.txt | 18 +++ ...nfig.txt => external-constants.config.txt} | 0 .../external.config.txt | 17 ++ .../migration_pack_tsdown_023/snapshots.toml | 18 ++- .../migration_pack_tsdown_023_external.md | 33 ++++ ...tion_pack_tsdown_023_external_conflicts.md | 44 +++++ ...tion_pack_tsdown_023_external_constants.md | 54 +++++++ .../migration_pack_tsdown_023_manual.md | 60 ------- crates/vp_migration/src/pack_config.rs | 151 ++++++++++++++++++ docs/guide/migrate-rules.md | 5 +- 10 files changed, 335 insertions(+), 65 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external-conflicts.config.txt rename crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/{manual.config.txt => external-constants.config.txt} (100%) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external_conflicts.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external_constants.md delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external-conflicts.config.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external-conflicts.config.txt new file mode 100644 index 0000000000..b84cb43f00 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external-conflicts.config.txt @@ -0,0 +1,18 @@ +const externalOptions = ['foo']; + +export default { + pack: [ + { + external: externalOptions, + skipNodeModulesBundle: true, + inputOptions(options) { return { external: options.external }; }, + bundle: false, + dts: { tsgo: true }, + }, + { + external: externalOptions, + deps: { skipNodeModulesBundle: true, dts: { neverBundle: ['types'] } }, + bundle: false, + }, + ], +}; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/manual.config.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external-constants.config.txt similarity index 100% rename from crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/manual.config.txt rename to crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external-constants.config.txt diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external.config.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external.config.txt index 22893e4a0f..701e4cea58 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external.config.txt +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/external.config.txt @@ -1,3 +1,6 @@ +const externalOptions = ['foo', './external.js']; +const externalAlias = externalOptions; + export default { pack: [ { @@ -14,5 +17,19 @@ export default { external: ['foo', './external.js'], deps: { skipNodeModulesBundle: true }, }, + { + entry: 'src/index.ts', + outDir: 'dist/constant', + dts: false, + external: externalOptions, + skipNodeModulesBundle: true, + }, + { + entry: 'src/index.ts', + outDir: 'dist/alias', + dts: false, + external: externalAlias, + deps: { skipNodeModulesBundle: true }, + }, ], }; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml index d846e83ddf..0cae456379 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml @@ -35,6 +35,8 @@ steps = [ { argv = ["vp", "pack"], snapshot = false }, { argv = ["vpt", "print-file", "dist/top/index.mjs"] }, { argv = ["vpt", "print-file", "dist/nested/index.mjs"] }, + { argv = ["vpt", "print-file", "dist/constant/index.mjs"] }, + { argv = ["vpt", "print-file", "dist/alias/index.mjs"] }, { argv = ["vp", "migrate", "--no-interactive"] }, ] @@ -58,13 +60,23 @@ steps = [ ] [[case]] -name = "migration_pack_tsdown_023_manual" +name = "migration_pack_tsdown_023_external_constants" vp = "global" -comment = "Report unsupported external matchers without changing the pack options." +comment = "Migrate constant external references with both skip forms without a manual-migration warning." steps = [ - { argv = ["vpt", "cp", "manual.config.txt", "vite.config.ts"], snapshot = false }, + { argv = ["vpt", "cp", "external-constants.config.txt", "vite.config.ts"], snapshot = false }, { argv = ["vp", "migrate", "--no-interactive"] }, { argv = ["vpt", "print-file", "vite.config.ts"] }, { argv = ["vp", "migrate", "--no-interactive"] }, { argv = ["vpt", "print-file", "vite.config.ts"] }, ] + +[[case]] +name = "migration_pack_tsdown_023_external_conflicts" +vp = "global" +comment = "Report conflicting dependency rules while preserving the pack options." +steps = [ + { argv = ["vpt", "cp", "external-conflicts.config.txt", "vite.config.ts"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external.md index 1367541e17..75e52492f5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external.md @@ -25,6 +25,9 @@ VITE+ - The Unified Toolchain for the Web ## `vpt print-file vite.config.ts` ``` +const externalOptions = ['foo', './external.js']; +const externalAlias = externalOptions; + export default { pack: [ { @@ -41,6 +44,20 @@ export default { inputOptions: { external: ['foo', './external.js'] }, deps: { resolveDepSubpath: true, neverBundle: true }, }, + { + entry: 'src/index.ts', + outDir: 'dist/constant', + dts: false, + inputOptions: { external: externalOptions }, + deps: { neverBundle: true, resolveDepSubpath: true }, + }, + { + entry: 'src/index.ts', + outDir: 'dist/alias', + dts: false, + inputOptions: { external: externalAlias }, + deps: { resolveDepSubpath: true, neverBundle: true }, + }, ], }; ``` @@ -64,6 +81,22 @@ import { externalValue } from "./external.js"; export { externalValue, foo }; ``` +## `vpt print-file dist/constant/index.mjs` + +``` +import { foo } from "foo"; +import { externalValue } from "./external.js"; +export { externalValue, foo }; +``` + +## `vpt print-file dist/alias/index.mjs` + +``` +import { foo } from "foo"; +import { externalValue } from "./external.js"; +export { externalValue, foo }; +``` + ## `vp migrate --no-interactive` ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external_conflicts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external_conflicts.md new file mode 100644 index 0000000000..ede6b7f29d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external_conflicts.md @@ -0,0 +1,44 @@ +# migration_pack_tsdown_023_external_conflicts + +Report conflicting dependency rules while preserving the pack options. + +## `vpt cp external-conflicts.config.txt vite.config.ts` + + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• Package manager settings configured +! Warnings: + - vite.config.ts: Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged. See https://tsdown.dev/options/dependencies#migration-from-deprecated-options +``` + +## `vpt print-file vite.config.ts` + +``` +const externalOptions = ['foo']; + +export default { + pack: [ + { + external: externalOptions, + skipNodeModulesBundle: true, + inputOptions(options) { return { external: options.external }; }, + bundle: false, + dts: { tsgo: true }, + }, + { + external: externalOptions, + deps: { skipNodeModulesBundle: true, dts: { neverBundle: ['types'] } }, + bundle: false, + }, + ], +}; +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external_constants.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external_constants.md new file mode 100644 index 0000000000..2739fb9bff --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_external_constants.md @@ -0,0 +1,54 @@ +# migration_pack_tsdown_023_external_constants + +Migrate constant external references with both skip forms without a manual-migration warning. + +## `vpt cp external-constants.config.txt vite.config.ts` + + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• 1 file had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file vite.config.ts` + +``` +const externalOptions = ['foo']; + +export default { + pack: [ + { inputOptions: { external: externalOptions }, deps: { neverBundle: true, resolveDepSubpath: true }, unbundle: true, dts: { generator: 'tsgo' } }, + { inputOptions: { external: externalOptions }, deps: { resolveDepSubpath: true, neverBundle: true }, unbundle: true }, + ], +}; +``` + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` + +## `vpt print-file vite.config.ts` + +``` +const externalOptions = ['foo']; + +export default { + pack: [ + { inputOptions: { external: externalOptions }, deps: { neverBundle: true, resolveDepSubpath: true }, unbundle: true, dts: { generator: 'tsgo' } }, + { inputOptions: { external: externalOptions }, deps: { resolveDepSubpath: true, neverBundle: true }, unbundle: true }, + ], +}; +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md deleted file mode 100644 index 4c86d46953..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_manual.md +++ /dev/null @@ -1,60 +0,0 @@ -# migration_pack_tsdown_023_manual - -Report unsupported external matchers without changing the pack options. - -## `vpt cp manual.config.txt vite.config.ts` - - -## `vp migrate --no-interactive` - -``` -VITE+ - The Unified Toolchain for the Web - -◇ Updated . to Vite+ -• Node npm -• Dependencies: - vite-plus 0.2.0 → - vite → -• Package manager settings configured -! Warnings: - - vite.config.ts: Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged. See https://tsdown.dev/options/dependencies#migration-from-deprecated-options -``` - -## `vpt print-file vite.config.ts` - -``` -const externalOptions = ['foo']; - -export default { - pack: [ - { external: externalOptions, skipNodeModulesBundle: true, bundle: false, dts: { tsgo: true } }, - { external: externalOptions, deps: { skipNodeModulesBundle: true }, bundle: false }, - ], -}; -``` - -## `vp migrate --no-interactive` - -``` -VITE+ - The Unified Toolchain for the Web - -◇ Updated . to Vite+ -• Node npm -• Dependencies: - vite → -! Warnings: - - vite.config.ts: Cannot safely combine external with skipNodeModulesBundle. Migrate this pack config manually; its options were left unchanged. See https://tsdown.dev/options/dependencies#migration-from-deprecated-options -``` - -## `vpt print-file vite.config.ts` - -``` -const externalOptions = ['foo']; - -export default { - pack: [ - { external: externalOptions, skipNodeModulesBundle: true, bundle: false, dts: { tsgo: true } }, - { external: externalOptions, deps: { skipNodeModulesBundle: true }, bundle: false }, - ], -}; -``` diff --git a/crates/vp_migration/src/pack_config.rs b/crates/vp_migration/src/pack_config.rs index 90b5b408c9..0d01566d4e 100644 --- a/crates/vp_migration/src/pack_config.rs +++ b/crates/vp_migration/src/pack_config.rs @@ -262,10 +262,89 @@ fn is_static_external(value: &Node<'_, D>) -> bool { "array" => value.children().all(|child| { matches!(child.kind().as_ref(), "[" | "]" | "," | "comment" | "string" | "regex") }), + "identifier" | "shorthand_property_identifier" => { + constant_initializer(value).is_some_and(|initializer| is_static_external(&initializer)) + } + "as_expression" | "satisfies_expression" | "parenthesized_expression" => value + .children() + .find(|child| !matches!(child.kind().as_ref(), "(" | "comment")) + .is_some_and(|inner| is_static_external(&inner)), _ => false, } } +/// Follow a local const binding without replacing the reference or evaluating +/// its initializer. Stop at shadowing bindings and unsupported lexical scopes. +fn constant_initializer<'a, D: Doc>(reference: &Node<'a, D>) -> Option> { + let name = reference.text(); + let mentions_name = |pattern: &Node<'_, D>| pattern.dfs().any(|node| node.text() == name); + for scope in reference.ancestors() { + match scope.kind().as_ref() { + "program" | "statement_block" => { + for statement in scope.children() { + let declaration = statement.field("declaration").unwrap_or(statement); + if matches!( + declaration.kind().as_ref(), + "lexical_declaration" | "variable_declaration" + ) { + for declarator in declaration + .children() + .filter(|node| node.kind() == "variable_declarator") + { + let Some(binding) = declarator.field("name") else { continue }; + if !mentions_name(&binding) { + continue; + } + // Only earlier bindings qualify. This also prevents + // cycles when following aliases between constants. + return (binding.kind() == "identifier" + && declaration + .field("kind") + .is_some_and(|kind| kind.text() == "const") + && declarator.range().end < reference.range().start) + .then(|| declarator.field("value")) + .flatten(); + } + } else if declaration + .field("name") + .is_some_and(|binding| mentions_name(&binding)) + { + return None; + } + } + } + "arrow_function" + | "function_expression" + | "function_declaration" + | "generator_function" + | "generator_function_declaration" + | "method_definition" => { + if ["parameter", "parameters", "name"] + .iter() + .any(|field| scope.field(field).is_some_and(|pattern| mentions_name(&pattern))) + { + return None; + } + // A var declaration can shadow an outer constant even when it + // appears in a nested block of the callback. + if scope.dfs().filter(|node| node.kind() == "variable_declaration").any( + |declaration| { + declaration.children().any(|declarator| { + declarator.field("name").is_some_and(|binding| mentions_name(&binding)) + }) + }, + ) { + return None; + } + } + "catch_clause" | "for_statement" | "for_in_statement" | "switch_body" + | "with_statement" | "class" | "class_declaration" | "internal_module" => return None, + _ => {} + } + } + None +} + fn rewrite_options(object: &Node<'_, D>) -> String { // Rolldown can retain the original static matcher while tsdown's deps // plugin handles neverBundle: true. Avoid overriding DTS-specific matchers @@ -419,6 +498,78 @@ mod tests { assert!(actual.contains("external: ['foo'], treeshake: false"), "{actual}"); } + #[test] + fn migrates_constant_external_references_without_warnings() { + for declarations in [ + "const externalOptions = ['foo', './external.js'];", + "const externalOptions: string[] = (['foo']);", + "export const externalOptions = ['foo', './external.js'] as const;", + "const externalOptions = ['foo'] satisfies string[];", + "const patterns = ['foo']; const externalOptions = patterns;", + "const patterns = ['foo'], externalOptions = patterns;", + "const externalOptions = /^virtual:/;", + "const externalOptions = 'foo';", + ] { + for skip in ["skipNodeModulesBundle: true", "deps: { skipNodeModulesBundle: true }"] { + for (standalone, config) in [ + ( + false, + format!( + "export default {{ pack: {{ external: externalOptions, {skip} }} }};" + ), + ), + ( + false, + format!( + "export default defineConfig(() => ({{ pack: {{ external: externalOptions, {skip} }} }}));" + ), + ), + ( + true, + format!( + "export default defineConfig({{ external: externalOptions, {skip} }});" + ), + ), + ] { + let input = format!("{declarations}\n{config}"); + let actual = rewrite_pack_config(&input, standalone); + assert!(actual.starts_with(declarations), "{actual}"); + assert!( + actual.contains("inputOptions: { external: externalOptions }"), + "{actual}" + ); + assert!(actual.contains("neverBundle: true"), "{actual}"); + assert!(!actual.contains("skipNodeModulesBundle"), "{actual}"); + assert!(pack_config_warnings(&actual, standalone).is_empty()); + assert_eq!(rewrite_pack_config(&actual, standalone), actual); + } + } + } + let input = "export default defineConfig(() => { const external = ['foo']; return { pack: { external, skipNodeModulesBundle: true } }; });"; + let actual = rewrite_pack_config(input, false); + assert!(actual.contains("inputOptions: { external: external }"), "{actual}"); + assert!(!actual.contains("skipNodeModulesBundle"), "{actual}"); + assert!(pack_config_warnings(&actual, false).is_empty()); + } + + #[test] + fn does_not_confuse_unknown_or_shadowed_external_references_with_constants() { + for input in [ + "let externalOptions = ['foo']; export default { pack: { external: externalOptions, skipNodeModulesBundle: true } };", + "const externalOptions = getExternal(); export default { pack: { external: externalOptions, skipNodeModulesBundle: true } };", + "const externalOptions = '/foo/'; export default { pack: { external: externalOptions, skipNodeModulesBundle: true } };", + "const externalOptions = other; const other = externalOptions; export default { pack: { external: externalOptions, skipNodeModulesBundle: true } };", + "const externalOptions = ['foo']; export default defineConfig((externalOptions) => ({ pack: { external: externalOptions, skipNodeModulesBundle: true } }));", + "const externalOptions = ['foo']; export default defineConfig(({ externalOptions }) => ({ pack: { external: externalOptions, skipNodeModulesBundle: true } }));", + "const externalOptions = ['foo']; export default defineConfig(() => { let externalOptions = getExternal(); return { pack: { external: externalOptions, skipNodeModulesBundle: true } }; });", + "const externalOptions = ['foo']; export default defineConfig(() => { const externalOptions = '/foo/'; return { pack: { external: externalOptions, skipNodeModulesBundle: true } }; });", + "const externalOptions = ['foo']; export default defineConfig(() => { if (custom) { var externalOptions = getExternal(); } return { pack: { external: externalOptions, skipNodeModulesBundle: true } }; });", + ] { + assert_eq!(rewrite_pack_config(input, false), input); + assert_eq!(pack_config_warnings(input, false), [EXTERNAL_SKIP_WARNING]); + } + } + #[test] fn unsafe_external_combinations_stay_unchanged_and_warn() { for options in [ diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 326ad6ed33..b42e2f8a7c 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -67,8 +67,9 @@ to `true` when absent. Enabled ATTW checks receive `profile: 'strict'` when no profile is set. Explicit values, including `false`, remain unchanged. When `external` accompanies either `skipNodeModulesBundle` form, static matchers -move to `inputOptions.external` before `deps.neverBundle` is set. This preserves -the original matching rules, including external file paths. Unsupported matchers, +and references to local constants move to `inputOptions.external` before +`deps.neverBundle` is set. Constant declarations and references stay intact. +This preserves the original matching rules, including external file paths. Unsupported matchers, conflicting `inputOptions`, and declaration-specific dependency rules leave the pack object unchanged and produce a manual-migration warning. From bb7ec043fc44bce2bd75e2cc3f350e24f815be23 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 22:57:09 +0800 Subject: [PATCH 5/7] fix(migrate): move noExternal to deps.alwaysBundle --- .../no-external-entry.txt | 1 + .../no-external.config.txt | 28 ++++ .../pack-dependency/index.js | 1 + .../pack-dependency/package.json | 6 + .../migration_pack_tsdown_023/snapshots.toml | 22 +++ .../migration_pack_tsdown_023_no_external.md | 155 ++++++++++++++++++ crates/vp_migration/src/pack_config.rs | 89 +++++++++- docs/guide/migrate-rules.md | 5 + 8 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/no-external-entry.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/no-external.config.txt create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/pack-dependency/index.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/pack-dependency/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_no_external.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/no-external-entry.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/no-external-entry.txt new file mode 100644 index 0000000000..c54745e96c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/no-external-entry.txt @@ -0,0 +1 @@ +export { bundledValue } from '@fixture/pack-bundled'; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/no-external.config.txt b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/no-external.config.txt new file mode 100644 index 0000000000..e614018f4d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/no-external.config.txt @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite-plus'; + +const bundlePatterns = ['@fixture/pack-bundled']; + +export default defineConfig({ + pack: [ + { + entry: 'src/index.ts', outDir: 'dist/literal', dts: false, + noExternal: ['@fixture/pack-bundled'], + }, + { + entry: 'src/index.ts', outDir: 'dist/reference', dts: false, + noExternal: bundlePatterns, + }, + { + entry: 'src/index.ts', outDir: 'dist/callback', dts: false, + noExternal: (id) => bundlePatterns.includes(id), + }, + { + entry: 'src/index.ts', outDir: 'dist/method', dts: false, + noExternal(id) { return bundlePatterns.includes(id); }, + deps: { onlyBundle: bundlePatterns }, + }, + { + entry: 'src/index.ts', outDir: 'dist/control', dts: false, + }, + ], +}); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/pack-dependency/index.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/pack-dependency/index.js new file mode 100644 index 0000000000..b965a8a799 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/pack-dependency/index.js @@ -0,0 +1 @@ +export const bundledValue = 42; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/pack-dependency/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/pack-dependency/package.json new file mode 100644 index 0000000000..0c2c6966a7 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/pack-dependency/package.json @@ -0,0 +1,6 @@ +{ + "name": "@fixture/pack-bundled", + "version": "1.0.0", + "type": "module", + "exports": "./index.js" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml index 0cae456379..938000fb01 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml @@ -80,3 +80,25 @@ steps = [ { argv = ["vp", "migrate", "--no-interactive"] }, { argv = ["vpt", "print-file", "vite.config.ts"] }, ] + +[[case]] +name = "migration_pack_tsdown_023_no_external" +vp = "global" +comment = "Migrate noExternal patterns, references, and callbacks and confirm the dependency remains bundled." +steps = [ + { argv = ["vpt", "cp", "no-external.config.txt", "vite.config.ts"], snapshot = false }, + { argv = ["vpt", "cp", "no-external-entry.txt", "src/index.ts"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "dependencies", '{"@fixture/pack-bundled":"file:./pack-dependency"}'], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, + { argv = ["vp", "install"], snapshot = false, timeout = 120000 }, + { argv = ["vpt", "stat-file", "node_modules/@fixture/pack-bundled/index.js", "--assert", "file"] }, + { argv = ["vp", "pack", "--fail-on-warn"], snapshot = false }, + { argv = ["vpt", "print-file", "dist/literal/index.mjs"] }, + { argv = ["vpt", "print-file", "dist/reference/index.mjs"] }, + { argv = ["vpt", "print-file", "dist/callback/index.mjs"] }, + { argv = ["vpt", "print-file", "dist/method/index.mjs"] }, + { argv = ["vpt", "print-file", "dist/control/index.mjs"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_no_external.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_no_external.md new file mode 100644 index 0000000000..e6c74ae187 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_no_external.md @@ -0,0 +1,155 @@ +# migration_pack_tsdown_023_no_external + +Migrate noExternal patterns, references, and callbacks and confirm the dependency remains bundled. + +## `vpt cp no-external.config.txt vite.config.ts` + + +## `vpt cp no-external-entry.txt src/index.ts` + + +## `vpt json-edit package.json dependencies '{"@fixture/pack-bundled":"file:./pack-dependency"}'` + + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• 1 file had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file vite.config.ts` + +``` +import { defineConfig } from 'vite-plus'; + +const bundlePatterns = ['@fixture/pack-bundled']; + +export default defineConfig({ + pack: [ + { + entry: 'src/index.ts', outDir: 'dist/literal', dts: false, + deps: { alwaysBundle: ['@fixture/pack-bundled'], resolveDepSubpath: true }, + }, + { + entry: 'src/index.ts', outDir: 'dist/reference', dts: false, + deps: { alwaysBundle: bundlePatterns, resolveDepSubpath: true }, + }, + { + entry: 'src/index.ts', outDir: 'dist/callback', dts: false, + deps: { alwaysBundle: (id) => bundlePatterns.includes(id), resolveDepSubpath: true }, + }, + { + entry: 'src/index.ts', outDir: 'dist/method', dts: false, + + deps: { alwaysBundle(id) { return bundlePatterns.includes(id); }, resolveDepSubpath: true, onlyBundle: bundlePatterns }, + }, + { deps: { resolveDepSubpath: true }, + entry: 'src/index.ts', outDir: 'dist/control', dts: false, + }, + ], +}); +``` + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +This project is already using Vite+! Happy coding! +``` + +## `vpt print-file vite.config.ts` + +``` +import { defineConfig } from 'vite-plus'; + +const bundlePatterns = ['@fixture/pack-bundled']; + +export default defineConfig({ + pack: [ + { + entry: 'src/index.ts', outDir: 'dist/literal', dts: false, + deps: { alwaysBundle: ['@fixture/pack-bundled'], resolveDepSubpath: true }, + }, + { + entry: 'src/index.ts', outDir: 'dist/reference', dts: false, + deps: { alwaysBundle: bundlePatterns, resolveDepSubpath: true }, + }, + { + entry: 'src/index.ts', outDir: 'dist/callback', dts: false, + deps: { alwaysBundle: (id) => bundlePatterns.includes(id), resolveDepSubpath: true }, + }, + { + entry: 'src/index.ts', outDir: 'dist/method', dts: false, + + deps: { alwaysBundle(id) { return bundlePatterns.includes(id); }, resolveDepSubpath: true, onlyBundle: bundlePatterns }, + }, + { deps: { resolveDepSubpath: true }, + entry: 'src/index.ts', outDir: 'dist/control', dts: false, + }, + ], +}); +``` + +## `vp install` + + +## `vpt stat-file node_modules/@fixture/pack-bundled/index.js --assert file` + +``` +node_modules/@fixture/pack-bundled/index.js: file +``` + +## `vp pack --fail-on-warn` + + +## `vpt print-file dist/literal/index.mjs` + +``` +//#region pack-dependency/index.js +const bundledValue = 42; +//#endregion +export { bundledValue }; +``` + +## `vpt print-file dist/reference/index.mjs` + +``` +//#region pack-dependency/index.js +const bundledValue = 42; +//#endregion +export { bundledValue }; +``` + +## `vpt print-file dist/callback/index.mjs` + +``` +//#region pack-dependency/index.js +const bundledValue = 42; +//#endregion +export { bundledValue }; +``` + +## `vpt print-file dist/method/index.mjs` + +``` +//#region pack-dependency/index.js +const bundledValue = 42; +//#endregion +export { bundledValue }; +``` + +## `vpt print-file dist/control/index.mjs` + +``` +import { bundledValue } from "@fixture/pack-bundled"; +export { bundledValue }; +``` diff --git a/crates/vp_migration/src/pack_config.rs b/crates/vp_migration/src/pack_config.rs index 0d01566d4e..7caa44ceff 100644 --- a/crates/vp_migration/src/pack_config.rs +++ b/crates/vp_migration/src/pack_config.rs @@ -416,6 +416,7 @@ fn rewrite_options(object: &Node<'_, D>) -> String { }); let source = move_option(&source, "injectStyle", "css", "inject", false); let source = move_option(&source, "inlineOnly", "deps", "onlyBundle", false); + let source = move_option(&source, "noExternal", "deps", "alwaysBundle", false); let source = move_option(&source, "skipNodeModulesBundle", "deps", "neverBundle", true); edit_object(&source, |config| { config.set_default("deps", "{ resolveDepSubpath: true }"); @@ -424,8 +425,10 @@ fn rewrite_options(object: &Node<'_, D>) -> String { fn move_option(source: &str, old: &str, group: &str, new: &str, boolean: bool) -> String { edit_object(source, |config| { - let Some(value) = config.value(old) else { return }; + let Some(property) = config.property(old) else { return }; + let value = config.value(old); if boolean { + let Some(value) = &value else { return }; match value.kind().as_ref() { "false" => { config.remove(old); @@ -435,6 +438,20 @@ fn move_option(source: &str, old: &str, group: &str, new: &str, boolean: bool) - _ => return, } } + let replacement = if let Some(value) = value { + format!("{new}: {}", value.text()) + } else if property.kind() == "method_definition" + && !property.children().any(|child| matches!(child.kind().as_ref(), "get" | "set")) + { + let Some(name) = property.field("name") else { return }; + apply_edits( + &property.text(), + vec![(name.range(), new.to_owned())], + property.range().start, + ) + } else { + return; + }; if let Some(namespace) = config.value(group) { if namespace.kind() != "object" { return; @@ -442,7 +459,7 @@ fn move_option(source: &str, old: &str, group: &str, new: &str, boolean: bool) - let mut moved = false; let updated = edit_object(&namespace.text(), |options| { if options.property(new).is_none() { - options.set_default(new, &value.text()); + options.additions.push(replacement.clone()); moved = true; } }); @@ -453,12 +470,9 @@ fn move_option(source: &str, old: &str, group: &str, new: &str, boolean: bool) - } else if config.property(group).is_none() { let defaults = if group == "deps" { ", resolveDepSubpath: true" } else { "" }; // Replace in place so comments on the old option stay attached. - if let Some(property) = config.property(old) { - config.edits.push(( - property.range(), - format!("{group}: {{ {new}: {}{defaults} }}", value.text()), - )); - } + config + .edits + .push((property.range(), format!("{group}: {{ {replacement}{defaults} }}"))); } }) } @@ -498,6 +512,65 @@ mod tests { assert!(actual.contains("external: ['foo'], treeshake: false"), "{actual}"); } + #[test] + fn migrates_no_external_values_without_evaluation() { + for value in [ + "['foo', /^@vendor\\//]", + "'foo'", + "/^@vendor\\//", + "bundlePatterns", + "getBundlePatterns()", + "production ? ['foo'] : []", + "(id) => id === 'foo'", + "function (id) { return id === 'foo'; }", + ] { + for deps in ["", ", deps: { onlyBundle: ['foo'] }", ", deps: { neverBundle: true }"] { + let actual = migrate(&format!("{{ noExternal: {value}{deps} }}")); + assert!(actual.contains(&format!("alwaysBundle: {value}")), "{actual}"); + assert!(!actual.contains("noExternal"), "{actual}"); + assert!(actual.contains("resolveDepSubpath: true"), "{actual}"); + assert!(pack_config_warnings(&actual, false).is_empty()); + } + } + let actual = migrate("{ noExternal }"); + assert!(actual.contains("alwaysBundle: noExternal"), "{actual}"); + } + + #[test] + fn migrates_no_external_methods_in_standalone_callbacks() { + for deps in ["", ", deps: { onlyBundle: ['foo'] }"] { + let input = format!( + "export default defineConfig(() => ({{ noExternal(id) {{ /* match */ return id === 'foo'; }}{deps} }}));" + ); + let actual = rewrite_pack_config(&input, true); + assert!( + actual.contains("alwaysBundle(id) { /* match */ return id === 'foo'; }"), + "{actual}" + ); + assert!(!actual.contains("noExternal"), "{actual}"); + assert!(pack_config_warnings(&actual, true).is_empty()); + assert_eq!(rewrite_pack_config(&actual, true), actual); + } + } + + #[test] + fn preserves_no_external_conflicts_and_unknown_deps() { + for deps in [ + "customDeps", + "{ ...customDeps }", + "{ alwaysBundle: ['bar'] }", + "{ alwaysBundle(id) { return id === 'bar'; } }", + ] { + for option in ["noExternal: bundlePatterns", "noExternal(id) { return id === 'foo'; }"] + { + let actual = migrate(&format!("{{ {option}, deps: {deps} }}")); + assert!(actual.contains(option), "{actual}"); + assert!(!actual.contains("alwaysBundle: bundlePatterns"), "{actual}"); + assert!(!actual.contains("alwaysBundle(id) { return id === 'foo'; }"), "{actual}"); + } + } + } + #[test] fn migrates_constant_external_references_without_warnings() { for declarations in [ diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index b42e2f8a7c..48efaca562 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -57,6 +57,7 @@ supported. JSON tsdown configs receive the same updates after they merge into | `removeNodeProtocol: true` | `nodeProtocol: 'strip'` | | `injectStyle` | `css.inject` | | `inlineOnly` / `deps.onlyAllowBundle` | `deps.onlyBundle` | +| `noExternal` | `deps.alwaysBundle` | | `skipNodeModulesBundle: true` / `deps.skipNodeModulesBundle: true` | `deps.neverBundle: true` | | `dts.tsgo` / `dts.oxc` | Select with `dts.generator`; retain generator option objects and remove boolean flags | | `dts.cjsReexport` | Removed; tsdown generates CJS declarations separately | @@ -66,6 +67,10 @@ Migration preserves the previous defaults by setting `deps.resolveDepSubpath` to `true` when absent. Enabled ATTW checks receive `profile: 'strict'` when no profile is set. Explicit values, including `false`, remain unchanged. +`noExternal` moves to `deps.alwaysBundle`, preserving matcher expressions, +references, and callback methods. Existing `deps.alwaysBundle` values remain +unchanged. + When `external` accompanies either `skipNodeModulesBundle` form, static matchers and references to local constants move to `inputOptions.external` before `deps.neverBundle` is set. Constant declarations and references stay intact. From a795c1deef59f7864f80a9b037fca9f84945fe3b Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 23:24:25 +0800 Subject: [PATCH 6/7] test(ecosystem): remove vinext pack migration workaround --- ecosystem-ci/patch-project.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index ab08264943..df86e933fd 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -105,18 +105,6 @@ if (project === 'vinext') { } await writeFile(workspacePath, patched, 'utf-8'); - // tsdown 0.23 replaces skipNodeModulesBundle with neverBundle. - const cloudflareConfigPath = join(repoRoot, 'packages/cloudflare/vite.config.ts'); - const cloudflareConfig = await readFile(cloudflareConfigPath, 'utf-8'); - const patchedCloudflareConfig = cloudflareConfig.replace( - 'skipNodeModulesBundle: true', - 'neverBundle: true', - ); - if (patchedCloudflareConfig === cloudflareConfig) { - throw new Error(`vinext patch: skipNodeModulesBundle not found in ${cloudflareConfigPath}`); - } - await writeFile(cloudflareConfigPath, patchedCloudflareConfig, 'utf-8'); - // The single in-process `integration` project runs serially and its ISR // revalidation test sits right at the 30s ceiling under CI load (observed // 26.8s on green main runs, 30.0s here) — a borderline timeout, not a real From 8443d3170f495a4dd368b97757f6f7e928c77ed0 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 6 Sep 2026 23:32:27 +0800 Subject: [PATCH 7/7] chore(core): remove obsolete tsdown ansis patches --- packages/core/build.ts | 138 ++--------------------------------------- 1 file changed, 5 insertions(+), 133 deletions(-) diff --git a/packages/core/build.ts b/packages/core/build.ts index 94b4a2fbef..5b806bf105 100644 --- a/packages/core/build.ts +++ b/packages/core/build.ts @@ -498,81 +498,6 @@ async function bundleTsdown() { await copyFile(join(tsdownSourceDir, 'client.d.ts'), join(projectDir, 'dist/tsdown/client.d.ts')); } -// Ensure a bundled chunk has the given ansis color helpers (e.g. `bold`, `red`). -// Rolldown can inline ansis into the logger chunk or keep it in a shared chunk. -// For the latter layout, add imports for any missing helpers by resolving their -// minified aliases from the shared chunk's own `export { ... }` map. -async function ensureAnsisImports( - content: string, - names: string[], - distDir: string, -): Promise { - // Scan every relative chunk import in the branded logger chunk. Which shared - // chunk holds the ansis colors depends on rolldown's chunking and has moved - // between versions (e.g. `main-*.js` → `ansis-*.js`), so we don't assume a - // fixed chunk name: instead we append each missing color to whichever imported - // chunk actually re-exports it. - const importRe = /import \{([^}]*)\} from "(\.\/[^"]+\.js)";/g; - const imports = [...content.matchAll(importRe)]; - // Every binding already in scope across all imports (its local name). - const localNames = new Set(); - for (const [, bindings] of imports) { - for (const binding of bindings.split(',')) { - const trimmed = binding.trim(); - if (!trimmed) { - continue; - } - const aliased = trimmed.match(/\bas\s+([A-Za-z0-9_$]+)$/); - localNames.add(aliased ? aliased[1] : trimmed); - } - } - // Rolldown can also inline ansis into the logger chunk. Detect its destructured - // declarations so we do not try to import a binding that is already local. - const isLocallyDeclared = (name: string) => - new RegExp(`\\b(?:const|let|var)\\s+(?:${name}\\b|\\{[^}]*\\b${name}\\b)`).test(content); - const missing = names.filter((name) => !localNames.has(name) && !isLocallyDeclared(name)); - if (missing.length === 0) { - return content; - } - if (imports.length === 0) { - throw new Error('ensureAnsisImports: no relative chunk import found in branded logger chunk'); - } - - // Group missing colors by the imported chunk that re-exports them. Chunks - // re-export colors as ` as ` (e.g. `bold as i`); the consumer - // side imports ` as `, so capture the alias here. - const additionsBySpecifier = new Map(); - for (const name of missing) { - let resolved = false; - for (const [, , specifier] of imports) { - const chunkContent = await readFile(join(distDir, specifier.slice(2)), 'utf-8'); - const exportAlias = chunkContent.match(new RegExp(`\\b${name} as ([A-Za-z0-9_$]+)`)); - if (!exportAlias) { - continue; - } - const additions = additionsBySpecifier.get(specifier) ?? []; - additions.push(`${exportAlias[1]} as ${name}`); - additionsBySpecifier.set(specifier, additions); - resolved = true; - break; - } - if (!resolved) { - throw new Error(`ensureAnsisImports: \`${name}\` is not re-exported from any imported chunk`); - } - } - - let result = content; - for (const [fullImport, bindings, specifier] of imports) { - const additions = additionsBySpecifier.get(specifier); - if (!additions) { - continue; - } - const newImport = `import { ${bindings.trim().replace(/,$/, '')}, ${additions.join(', ')} } from "${specifier}";`; - result = result.replace(fullImport, newImport); - } - return result; -} - async function brandTsdown() { const tsdownDistDir = join(projectDir, 'dist/tsdown'); const buildFiles = await glob(toPosixPath(join(tsdownDistDir, 'build-*.js')), { absolute: true }); @@ -636,47 +561,9 @@ async function brandTsdown() { throw new Error('brandTsdown: build error message patterns not found in any build chunk'); } - // `ansisColors` lists the bare ansis identifiers a replacement relies on, so we - // only try to import colors for the patches that actually applied. tsdown 0.23 - // dropped ansis for a `node:util` styleText proxy, and those patches need no - // imports at all (the proxy is declared in the same chunk). - const loggerPatches: { - search: string | RegExp; - replacement: string; - ansisColors?: string[]; - }[] = [ - { - search: 'output("warn", `\\n${bgYellow` WARN `} ${message}\\n`);', - replacement: 'output("warn", `${bold(yellow`warn:`)} ${message}`);', - ansisColors: ['bold', 'red'], - }, - { - search: 'output("warn", `${bgYellow` WARN `} ${message}\\n`);', - replacement: 'output("warn", `${bold(yellow`warn:`)} ${message}`);', - ansisColors: ['bold', 'red'], - }, - { - search: 'output("error", `\\n${bgRed` ERROR `} ${format(msgs)}\\n`);', - replacement: - 'output("error", `${bold(red`error:`)} ${format(msgs).replace(/^([A-Za-z]*Error):\\s*/, "")}`);', - ansisColors: ['bold', 'red'], - }, - { - search: 'output("error", `${bgRed` ERROR `} ${format(msgs)}\\n`);', - replacement: - 'output("error", `${bold(red`error:`)} ${format(msgs).replace(/^([A-Za-z]*Error):\\s*/, "")}`);', - ansisColors: ['bold', 'red'], - }, - { - search: 'output("error", `${bold(red`error:`)} ${format(msgs)}`);', - replacement: - 'output("error", `${bold(red`error:`)} ${format(msgs).replace(/^([A-Za-z]*Error):\\s*/, "")}`);', - ansisColors: ['bold', 'red'], - }, - // tsdown >= 0.23 formats with a `node:util` styleText proxy instead of ansis. - // The proxy binding is local to the logger chunk but rolldown may rename it - // (`styleText` → `styleText$1`) to avoid clashing with the `node:util` - // import, so capture whatever name it ended up with and reuse it. + // The styleText proxy is local to the logger chunk, but Rolldown may rename + // it to avoid a binding conflict. Capture and reuse its emitted name. + const loggerPatches = [ { search: /output\("warn", `\\n\$\{(styleText\$?\w*)\.bgYellow\(" WARN "\)\} \$\{message\}\\n`\);/g, @@ -694,31 +581,16 @@ async function brandTsdown() { for (const candidateFile of loggerCandidateFiles) { let content = await readFile(candidateFile, 'utf-8'); let changed = false; - const neededColors = new Set(); - for (const { search, replacement, ansisColors } of loggerPatches) { - const matched = typeof search === 'string' ? content.includes(search) : search.test(content); - if (!matched) { + for (const { search, replacement } of loggerPatches) { + if (!search.test(content)) { continue; } content = content.replaceAll(search, replacement); - for (const color of ansisColors ?? []) { - neededColors.add(color); - } changed = true; } if (!changed) { continue; } - // The branded ansis logger output uses `bold(...)` and `red` (see - // loggerPatches), but tsdown's logger module only imported the other ansis - // colors it needed (`bgRed`, `bgYellow`, `yellow`, ...). Those identifiers - // only happened to be in scope when rolldown co-located them in this chunk; - // newer chunking splits them out, leaving `bold`/`red` undefined at runtime. - // Ensure the branded chunk imports them from the same shared chunk it - // already pulls colors from. - if (neededColors.size > 0) { - content = await ensureAnsisImports(content, [...neededColors], tsdownDistDir); - } await writeFile(candidateFile, content, 'utf-8'); console.log(`Branded tsdown logger prefixes in ${candidateFile}`); loggerPatched = true;