import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join, relative } from 'node:path'; interface ReplaceConfig { /** List of components to replace */ components: string[]; /** Whether to run in dry-run mode (preview only, no actual modifications) */ dryRun?: boolean; /** File extension whitelist */ fileExtensions?: string[]; /** Source package name */ fromPackage: string; /** Directory to scan */ targetDir: string; /** Target package name */ toPackage: string; } /** * Recursively get all files in a directory */ function getAllFiles(dir: string, extensions: string[]): string[] { const files: string[] = []; function walk(currentPath: string) { const items = readdirSync(currentPath); for (const item of items) { const fullPath = join(currentPath, item); const stat = statSync(fullPath); if (stat.isDirectory()) { // Skip directories like node_modules if (!['node_modules', '.git', 'dist', 'build', '.next'].includes(item)) { walk(fullPath); } } else if (stat.isFile()) { const hasValidExtension = extensions.some((ext) => fullPath.endsWith(ext)); if (hasValidExtension) { files.push(fullPath); } } } } walk(dir); return files; } /** * Parse import statements and extract imported components */ function parseImportStatement(line: string, packageName: string) { // Match import { ... } from 'package' const importRegex = new RegExp( `import\\s+{([^}]+)}\\s+from\\s+['"]${packageName.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&')}['"]`, ); const match = line.match(importRegex); if (!match) return null; const importContent = match[1]; const components = importContent .split(',') .map((item) => { const trimmed = item.trim(); // Handle as aliases: ComponentName as AliasName const asMatch = trimmed.match(/^(\w+)(?:\s+as\s+(\w+))?/); return asMatch ? { alias: asMatch[2] || null, name: asMatch[1], raw: trimmed, } : null; }) .filter(Boolean) as Array<{ alias: string | null; name: string; raw: string }>; return { components, fullMatch: match[0], indentation: line.match(/^\s*/)?.[0] || '', }; } /** * Replace import statements in a file */ function replaceImportsInFile(filePath: string, config: ReplaceConfig): boolean { const content = readFileSync(filePath, 'utf8'); const lines = content.split('\n'); let modified = false; const newLines: string[] = []; for (const line of lines) { const parsed = parseImportStatement(line, config.fromPackage); if (!parsed) { newLines.push(line); continue; } // Find components to replace and components to keep const toReplace = parsed.components.filter((comp) => config.components.includes(comp.name)); const toKeep = parsed.components.filter((comp) => !config.components.includes(comp.name)); if (toReplace.length === 0) { // No components to replace newLines.push(line); continue; } modified = true; // Generate new import statement const { indentation } = parsed; // If there are components to keep, preserve the original import if (toKeep.length > 0) { const keepImports = toKeep.map((c) => c.raw).join(', '); newLines.push(`${indentation}import { ${keepImports} } from '${config.fromPackage}';`); } // Add new import const replaceImports = toReplace.map((c) => c.raw).join(', '); newLines.push(`${indentation}import { ${replaceImports} } from '${config.toPackage}';`); } if (modified) { const newContent = newLines.join('\n'); if (!config.dryRun) { writeFileSync(filePath, newContent, 'utf8'); } return true; } return false; } /** * Execute replacement */ function executeReplace(config: ReplaceConfig) { const extensions = config.fileExtensions || ['.ts', '.tsx', '.js', '.jsx']; const files = getAllFiles(config.targetDir, extensions); console.log(`\n🔍 扫描目录: ${config.targetDir}`); console.log(`📦 从 "${config.fromPackage}" 替换到 "${config.toPackage}"`); console.log(`🎯 目标组件: ${config.components.join(', ')}`); console.log(`📄 找到 ${files.length} 个文件\n`); if (config.dryRun) { console.log('🔔 [DRY RUN 模式] 仅预览,不会实际修改文件\n'); } let modifiedCount = 0; const modifiedFiles: string[] = []; for (const file of files) { const wasModified = replaceImportsInFile(file, config); if (wasModified) { modifiedCount++; modifiedFiles.push(relative(process.cwd(), file)); } } console.log('\n✅ 完成!'); console.log(`📝 修改了 ${modifiedCount} 个文件\n`); if (modifiedFiles.length > 0) { console.log('修改的文件:'); for (const file of modifiedFiles) { console.log(` - ${file}`); } } } // ============ Main function ============ /** * Parse configuration from command line arguments */ function parseArgs(): ReplaceConfig | null { const args = process.argv.slice(2); if (args.length === 0 || args.includes('--help') || args.includes('-h')) { console.log(` 使用方法: bun run scripts/replaceComponentImports.ts [选项] 选项: --components 要替换的组件列表(逗号分隔) --from 原始包名 --to 目标包名 --dir 要扫描的目录(默认: src) --ext <.ext1,.ext2,...> 文件扩展名(默认: .ts,.tsx,.js,.jsx) --dry-run 仅预览,不实际修改文件 --help, -h 显示帮助信息 示例: # 将 antd 的 Skeleton 和 Empty 替换为 @lobehub/ui bun run scripts/replaceComponentImports.ts \\ --components Skeleton,Empty \\ --from antd \\ --to @lobehub/ui \\ --dir src # 仅预览,不修改 bun run scripts/replaceComponentImports.ts \\ --components Skeleton,Empty \\ --from antd \\ --to @lobehub/ui \\ --dry-run `); return null; } const getArgValue = (flag: string): string | undefined => { const index = args.indexOf(flag); return index !== -1 && index + 1 < args.length ? args[index + 1] : undefined; }; const componentsStr = getArgValue('--components'); const fromPackage = getArgValue('--from'); const toPackage = getArgValue('--to'); const targetDir = getArgValue('--dir') || 'src'; const extStr = getArgValue('--ext'); const dryRun = args.includes('--dry-run'); if (!componentsStr || !fromPackage || !toPackage) { console.error('❌ 错误: 必须指定 --components, --from 和 --to 参数'); console.error('使用 --help 查看帮助信息'); process.exit(1); } return { components: componentsStr.split(',').map((c) => c.trim()), dryRun, fileExtensions: extStr ? extStr.split(',').map((e) => e.trim()) : undefined, fromPackage, targetDir, toPackage, }; } // Execute script const config = parseArgs(); if (config) { executeReplace(config); }