Skip to content

Commit c2da86a

Browse files
fengmk2claude
andcommitted
feat(migration): auto-merge tsdown config into vite.config.ts
Add functionality to automatically import tsdown.config.ts and add `lib: libConfig` to the vite defineConfig when migrating. - Add merge_tsdown_config function in Rust - Reuse generate_merge_rule for adding lib config (consistent with lint/fmt) - Add NAPI binding for mergeTsdownConfig - Update migrator to call mergeTsdownConfigFile instead of notifyTsdownConfig - Create vite.config.ts if it doesn't exist before merging tsdown config - Support vite.config.ts files without any import statements - Use ast-grep for consistent import addition (add_lib_config_import) - Always show documentation link for manual tsdown config merging - Add idempotency checks to prevent duplicates when running migration multiple times - Add unit tests with complete content comparison for tsdown config merging Note: ast-grep patterns match the call expression without the trailing semicolon. A code formatter (e.g., `vite fmt`) can add semicolons back. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 425e702 commit c2da86a

15 files changed

Lines changed: 618 additions & 31 deletions

File tree

crates/vite_migration/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ mod vite_config;
77
pub use file_walker::{WalkResult, find_ts_files};
88
pub use import_rewriter::{BatchRewriteResult, rewrite_imports_in_directory};
99
pub use package::rewrite_scripts;
10-
pub use vite_config::{MergeResult, merge_json_config};
10+
pub use vite_config::{MergeResult, merge_json_config, merge_tsdown_config};

crates/vite_migration/src/vite_config.rs

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,71 @@ fn escape_single_quotes(s: &str) -> String {
400400
s.replace('\\', "\\\\").replace('\'', "\\'")
401401
}
402402

403+
/// Merge tsdown config into vite.config.ts by importing it
404+
///
405+
/// This function adds an import statement for the tsdown config file
406+
/// and adds `lib: tsdownConfig` to the defineConfig.
407+
///
408+
/// # Arguments
409+
///
410+
/// * `vite_config_path` - Path to the vite.config.ts or vite.config.js file
411+
/// * `tsdown_config_path` - Path to the tsdown.config.ts file (relative path like "./tsdown.config.ts")
412+
///
413+
/// # Returns
414+
///
415+
/// Returns a `MergeResult` with the updated content
416+
pub fn merge_tsdown_config(
417+
vite_config_path: &Path,
418+
tsdown_config_path: &str,
419+
) -> Result<MergeResult, Error> {
420+
let vite_config_content = std::fs::read_to_string(vite_config_path)?;
421+
merge_tsdown_config_content(&vite_config_content, tsdown_config_path)
422+
}
423+
424+
/// Merge tsdown config into vite config content
425+
///
426+
/// This adds:
427+
/// 1. An import statement: `import tsdownConfig from './tsdown.config.ts'`
428+
/// 2. The lib config in defineConfig: `lib: tsdownConfig`
429+
///
430+
/// This function is idempotent - running it multiple times will not create duplicates.
431+
fn merge_tsdown_config_content(
432+
vite_config_content: &str,
433+
tsdown_config_path: &str,
434+
) -> Result<MergeResult, Error> {
435+
let uses_function_callback = check_function_callback(vite_config_content)?;
436+
437+
// Check if already migrated (idempotency check)
438+
if vite_config_content.contains("import tsdownConfig from") {
439+
return Ok(MergeResult {
440+
content: vite_config_content.to_string(),
441+
updated: false,
442+
uses_function_callback,
443+
});
444+
}
445+
446+
// Step 1: Add import statement at the beginning
447+
// Use JavaScript extensions for TypeScript files (TypeScript module resolution convention)
448+
// .ts → .js, .mts → .mjs, .cts → .cjs
449+
let import_path = if tsdown_config_path.ends_with(".mts") {
450+
tsdown_config_path.replace(".mts", ".mjs")
451+
} else if tsdown_config_path.ends_with(".cts") {
452+
tsdown_config_path.replace(".cts", ".cjs")
453+
} else if tsdown_config_path.ends_with(".ts") {
454+
tsdown_config_path.replace(".ts", ".js")
455+
} else {
456+
tsdown_config_path.to_string()
457+
};
458+
let content_with_import =
459+
format!("import tsdownConfig from '{import_path}';\n\n{vite_config_content}");
460+
461+
// Step 2: Add lib: tsdownConfig to defineConfig
462+
let lib_rule = generate_merge_rule("tsdownConfig", "lib");
463+
let (final_content, _) = ast_grep::apply_rules(&content_with_import, &lib_rule)?;
464+
465+
Ok(MergeResult { content: final_content, updated: true, uses_function_callback })
466+
}
467+
403468
#[cfg(test)]
404469
mod tests {
405470
use std::io::Write;
@@ -1044,4 +1109,200 @@ export default defineConfig({
10441109
})"
10451110
);
10461111
}
1112+
1113+
#[test]
1114+
fn test_merge_tsdown_config_content_simple() {
1115+
let vite_config = r#"import { defineConfig } from '@voidzero-dev/vite-plus';
1116+
1117+
export default defineConfig({
1118+
plugins: [],
1119+
});"#;
1120+
1121+
let result = merge_tsdown_config_content(vite_config, "./tsdown.config.ts").unwrap();
1122+
assert!(result.updated);
1123+
assert!(!result.uses_function_callback);
1124+
// TypeScript files use .js extension in imports
1125+
assert_eq!(
1126+
result.content,
1127+
r#"import tsdownConfig from './tsdown.config.js';
1128+
1129+
import { defineConfig } from '@voidzero-dev/vite-plus';
1130+
1131+
export default defineConfig({
1132+
lib: tsdownConfig,
1133+
plugins: [],
1134+
});"#
1135+
);
1136+
}
1137+
1138+
#[test]
1139+
fn test_merge_tsdown_config_content_with_existing_imports() {
1140+
let vite_config = r#"import { defineConfig } from '@voidzero-dev/vite-plus';
1141+
import react from '@vitejs/plugin-react';
1142+
1143+
export default defineConfig({
1144+
plugins: [react()],
1145+
});"#;
1146+
1147+
let result = merge_tsdown_config_content(vite_config, "./tsdown.config.ts").unwrap();
1148+
assert!(result.updated);
1149+
assert!(!result.uses_function_callback);
1150+
assert_eq!(
1151+
result.content,
1152+
r#"import tsdownConfig from './tsdown.config.js';
1153+
1154+
import { defineConfig } from '@voidzero-dev/vite-plus';
1155+
import react from '@vitejs/plugin-react';
1156+
1157+
export default defineConfig({
1158+
lib: tsdownConfig,
1159+
plugins: [react()],
1160+
});"#
1161+
);
1162+
}
1163+
1164+
#[test]
1165+
fn test_merge_tsdown_config_content_function_callback() {
1166+
let vite_config = r#"import { defineConfig } from '@voidzero-dev/vite-plus';
1167+
1168+
export default defineConfig((env) => ({
1169+
plugins: [],
1170+
}));"#;
1171+
1172+
let result = merge_tsdown_config_content(vite_config, "./tsdown.config.ts").unwrap();
1173+
assert!(result.updated);
1174+
assert!(result.uses_function_callback);
1175+
assert_eq!(
1176+
result.content,
1177+
r#"import tsdownConfig from './tsdown.config.js';
1178+
1179+
import { defineConfig } from '@voidzero-dev/vite-plus';
1180+
1181+
export default defineConfig((env) => ({
1182+
lib: tsdownConfig,
1183+
plugins: [],
1184+
}));"#
1185+
);
1186+
}
1187+
1188+
#[test]
1189+
fn test_merge_tsdown_config_content_idempotent() {
1190+
// Already migrated config - import at the beginning
1191+
let already_migrated = r#"import tsdownConfig from './tsdown.config.js';
1192+
1193+
import { defineConfig } from '@voidzero-dev/vite-plus';
1194+
1195+
export default defineConfig({
1196+
lib: tsdownConfig,
1197+
plugins: [],
1198+
});"#;
1199+
1200+
let result = merge_tsdown_config_content(already_migrated, "./tsdown.config.ts").unwrap();
1201+
assert!(!result.updated, "Should not update already migrated config");
1202+
assert_eq!(result.content, already_migrated);
1203+
1204+
// Run migration twice and verify no duplicates
1205+
let fresh_config = r#"import { defineConfig } from '@voidzero-dev/vite-plus';
1206+
1207+
export default defineConfig({
1208+
plugins: [],
1209+
});"#;
1210+
1211+
let expected_migrated = r#"import tsdownConfig from './tsdown.config.js';
1212+
1213+
import { defineConfig } from '@voidzero-dev/vite-plus';
1214+
1215+
export default defineConfig({
1216+
lib: tsdownConfig,
1217+
plugins: [],
1218+
});"#;
1219+
1220+
let first_result = merge_tsdown_config_content(fresh_config, "./tsdown.config.ts").unwrap();
1221+
assert!(first_result.updated);
1222+
assert_eq!(first_result.content, expected_migrated);
1223+
1224+
// Run again on the result - should return unchanged
1225+
let second_result =
1226+
merge_tsdown_config_content(&first_result.content, "./tsdown.config.ts").unwrap();
1227+
assert!(!second_result.updated, "Second migration should not update");
1228+
assert_eq!(second_result.content, expected_migrated);
1229+
}
1230+
1231+
#[test]
1232+
fn test_merge_tsdown_config_content_no_imports() {
1233+
// vite.config.ts without any import statements
1234+
let vite_config = r#"export default {
1235+
server: { port: 3000 }
1236+
}"#;
1237+
1238+
let result = merge_tsdown_config_content(vite_config, "./tsdown.config.ts").unwrap();
1239+
assert!(result.updated);
1240+
assert!(!result.uses_function_callback);
1241+
assert_eq!(
1242+
result.content,
1243+
r#"import tsdownConfig from './tsdown.config.js';
1244+
1245+
export default {
1246+
lib: tsdownConfig,
1247+
server: { port: 3000 }
1248+
}"#
1249+
);
1250+
}
1251+
1252+
#[test]
1253+
fn test_merge_tsdown_config_content_no_false_positive_stdlib() {
1254+
// "stdlib:" should not be detected as "lib:" key
1255+
let vite_config = r#"import { defineConfig } from '@voidzero-dev/vite-plus';
1256+
1257+
export default defineConfig({
1258+
stdlib: 'some-value',
1259+
});"#;
1260+
1261+
let result = merge_tsdown_config_content(vite_config, "./tsdown.config.ts").unwrap();
1262+
assert!(result.updated);
1263+
assert!(result.content.contains("import tsdownConfig from './tsdown.config.js'"));
1264+
assert!(result.content.contains("lib: tsdownConfig"));
1265+
assert!(result.content.contains("stdlib: 'some-value'"));
1266+
}
1267+
1268+
#[test]
1269+
fn test_merge_tsdown_config_content_mts_extension() {
1270+
// .mts files should use .mjs extension in imports
1271+
let vite_config = r#"import { defineConfig } from '@voidzero-dev/vite-plus';
1272+
1273+
export default defineConfig({});"#;
1274+
1275+
let result = merge_tsdown_config_content(vite_config, "./tsdown.config.mts").unwrap();
1276+
assert!(result.updated);
1277+
assert!(result.content.contains("import tsdownConfig from './tsdown.config.mjs'"));
1278+
}
1279+
1280+
#[test]
1281+
fn test_merge_tsdown_config_content_cts_extension() {
1282+
// .cts files should use .cjs extension in imports
1283+
let vite_config = r#"import { defineConfig } from '@voidzero-dev/vite-plus';
1284+
1285+
export default defineConfig({});"#;
1286+
1287+
let result = merge_tsdown_config_content(vite_config, "./tsdown.config.cts").unwrap();
1288+
assert!(result.updated);
1289+
assert!(result.content.contains("import tsdownConfig from './tsdown.config.cjs'"));
1290+
}
1291+
1292+
#[test]
1293+
fn test_merge_tsdown_config_content_js_extension_unchanged() {
1294+
// .js, .mjs, .cjs files should keep their extensions unchanged
1295+
let vite_config = r#"import { defineConfig } from '@voidzero-dev/vite-plus';
1296+
1297+
export default defineConfig({});"#;
1298+
1299+
let result = merge_tsdown_config_content(vite_config, "./tsdown.config.js").unwrap();
1300+
assert!(result.content.contains("import tsdownConfig from './tsdown.config.js'"));
1301+
1302+
let result = merge_tsdown_config_content(vite_config, "./tsdown.config.mjs").unwrap();
1303+
assert!(result.content.contains("import tsdownConfig from './tsdown.config.mjs'"));
1304+
1305+
let result = merge_tsdown_config_content(vite_config, "./tsdown.config.cjs").unwrap();
1306+
assert!(result.content.contains("import tsdownConfig from './tsdown.config.cjs'"));
1307+
}
10471308
}

packages/global/binding/index.d.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,38 @@ export interface MergeJsonConfigResult {
159159
usesFunctionCallback: boolean;
160160
}
161161

162+
/**
163+
* Merge tsdown config into vite config by importing it
164+
*
165+
* This function adds an import statement for the tsdown config file
166+
* and adds `lib: libConfig` to the defineConfig.
167+
*
168+
* # Arguments
169+
*
170+
* * `vite_config_path` - Path to the vite.config.ts or vite.config.js file
171+
* * `tsdown_config_path` - Relative path to the tsdown.config.ts file (e.g., "./tsdown.config.ts")
172+
*
173+
* # Returns
174+
*
175+
* Returns a `MergeJsonConfigResult` containing:
176+
* - `content`: The updated vite config content
177+
* - `updated`: Whether any changes were made
178+
* - `usesFunctionCallback`: Whether the config uses a function callback
179+
*
180+
* # Example
181+
*
182+
* ```javascript
183+
* const result = mergeTsdownConfig('vite.config.ts', './tsdown.config.ts');
184+
* if (result.updated) {
185+
* fs.writeFileSync('vite.config.ts', result.content);
186+
* }
187+
* ```
188+
*/
189+
export declare function mergeTsdownConfig(
190+
viteConfigPath: string,
191+
tsdownConfigPath: string,
192+
): MergeJsonConfigResult;
193+
162194
/** Access modes for a path. */
163195
export interface PathAccess {
164196
/** Whether the path was read */

packages/global/binding/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,7 @@ const {
766766
detectWorkspace,
767767
downloadPackageManager,
768768
mergeJsonConfig,
769+
mergeTsdownConfig,
769770
rewriteImportsInDirectory,
770771
rewriteScripts,
771772
run,
@@ -774,6 +775,7 @@ const {
774775
export { detectWorkspace };
775776
export { downloadPackageManager };
776777
export { mergeJsonConfig };
778+
export { mergeTsdownConfig };
777779
export { rewriteImportsInDirectory };
778780
export { rewriteScripts };
779781
export { run };

packages/global/binding/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ use vite_path::current_dir;
1515

1616
use crate::cli::Args;
1717
pub use crate::{
18-
migration::{merge_json_config, rewrite_imports_in_directory, rewrite_scripts},
18+
migration::{
19+
merge_json_config, merge_tsdown_config, rewrite_imports_in_directory, rewrite_scripts,
20+
},
1921
package_manager::{detect_workspace, download_package_manager},
2022
};
2123

packages/global/binding/src/migration.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,47 @@ pub struct BatchRewriteResult {
102102
pub errors: Vec<BatchRewriteError>,
103103
}
104104

105+
/// Merge tsdown config into vite config by importing it
106+
///
107+
/// This function adds an import statement for the tsdown config file
108+
/// and adds `lib: libConfig` to the defineConfig.
109+
///
110+
/// # Arguments
111+
///
112+
/// * `vite_config_path` - Path to the vite.config.ts or vite.config.js file
113+
/// * `tsdown_config_path` - Relative path to the tsdown.config.ts file (e.g., "./tsdown.config.ts")
114+
///
115+
/// # Returns
116+
///
117+
/// Returns a `MergeJsonConfigResult` containing:
118+
/// - `content`: The updated vite config content
119+
/// - `updated`: Whether any changes were made
120+
/// - `usesFunctionCallback`: Whether the config uses a function callback
121+
///
122+
/// # Example
123+
///
124+
/// ```javascript
125+
/// const result = mergeTsdownConfig('vite.config.ts', './tsdown.config.ts');
126+
/// if (result.updated) {
127+
/// fs.writeFileSync('vite.config.ts', result.content);
128+
/// }
129+
/// ```
130+
#[napi]
131+
pub fn merge_tsdown_config(
132+
vite_config_path: String,
133+
tsdown_config_path: String,
134+
) -> Result<MergeJsonConfigResult> {
135+
let result =
136+
vite_migration::merge_tsdown_config(Path::new(&vite_config_path), &tsdown_config_path)
137+
.map_err(anyhow::Error::from)?;
138+
139+
Ok(MergeJsonConfigResult {
140+
content: result.content,
141+
updated: result.updated,
142+
uses_function_callback: result.uses_function_callback,
143+
})
144+
}
145+
105146
/// Rewrite imports in all TypeScript/JavaScript files under a directory
106147
///
107148
/// This function finds all TypeScript and JavaScript files in the specified directory

0 commit comments

Comments
 (0)