/** * i18n 移除脚本 - 将国际化的文本替换为中文,移除 i18n 相关代码 * 执行 npm run i18n-remove 运行此脚本 * * 此操作不可逆,会执行以下变更: * - 读取 src/locales/zh-CN 的翻译映射 * - 替换 intl.formatMessage / useIntl().formatMessage 调用为中文硬编码 * - 替换 组件为中文文本 * - 移除 useIntl、FormattedMessage、SelectLang、getLocale 的 import 和使用 * - 从 config/config.ts 中移除 locale 配置 * - 将 routes.ts 中的 name 值替换为中文菜单名 * - 删除 src/locales/ 目录 */ const fs = require('node:fs'); const path = require('node:path'); const I18N_SYMBOLS = [ 'useIntl', 'getIntl', 'FormattedMessage', 'SelectLang', 'getLocale', 'getAllLocales', 'setLocale', ]; const FORMAT_MESSAGE_PATTERNS = [ 'intl.formatMessage(', 'useIntl().formatMessage(', 'getIntl().formatMessage(', ]; const QSTR = `'((?:[^'\\\\]|\\\\.)*)'|"((?:[^"\\\\]|\\\\.)*)"`; // ─── 工具函数 ─────────────────────────────────────────── function readDirRecursive(dir) { const results = []; if (!fs.existsSync(dir)) return results; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...readDirRecursive(full)); } else { results.push(full); } } return results; } function toStrLiteral(text) { return `'${text .replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/\u2028/g, '\\u2028') .replace(/\u2029/g, '\\u2029')}'`; } function resolveText(id, defaultMsg, localeMap) { return localeMap[id] || defaultMsg || id; } /** * 找到括号匹配的闭合位置 * @param {string} str - 源字符串 * @param {number} openIdx - 开括号 ( 的位置 * @returns {number} 闭括号 ) 的位置,-1 表示未找到 */ function findClosingParen(str, openIdx) { let depth = 0; let i = openIdx; while (i < str.length) { const ch = str[i]; if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth === 0) return i; } else if (ch === "'" || ch === '"' || ch === '`') { // skip string literal const quote = ch; i++; while (i < str.length && str[i] !== quote) { if (str[i] === '\\') i++; // skip escaped char i++; } } else if (ch === '/' && str[i + 1] === '/') { // skip single-line comment while (i < str.length && str[i] !== '\n') i++; } else if (ch === '/' && str[i + 1] === '*') { // skip block comment i += 2; while (i < str.length - 1 && !(str[i] === '*' && str[i + 1] === '/')) i++; i++; // skip past */ } i++; } return -1; } // ─── Step 1: 构建 zh-CN 翻译映射 ──────────────────────── function buildLocaleMap() { const localeDir = path.join('src', 'locales'); const zhCNDir = path.join(localeDir, 'zh-CN'); const files = readDirRecursive(zhCNDir).filter((f) => f.endsWith('.ts')); const mainFile = path.join(localeDir, 'zh-CN.ts'); const localeMap = {}; for (const file of files) { const content = fs.readFileSync(file, 'utf-8'); extractLocaleKeys(content, localeMap); } if (fs.existsSync(mainFile)) { const content = fs.readFileSync(mainFile, 'utf-8'); extractLocaleKeys(content, localeMap); } return localeMap; } function extractLocaleKeys(content, localeMap) { // Match key: 'value' or key: "value", supporting escaped quotes inside values const regex = /['"]([a-zA-Z0-9_.-]+)['"]\s*:\s*'((?:[^'\\]|\\.)*)'|['"]([a-zA-Z0-9_.-]+)['"]\s*:\s*"((?:[^"\\]|\\.)*)"/g; let match = regex.exec(content); while (match !== null) { const key = match[1] || match[3]; const value = match[2] || match[4]; localeMap[key] = value; match = regex.exec(content); } } // ─── Step 2: 获取 menu 映射 ───────────────────────────── function buildMenuMap(localeMap) { const menuMap = {}; for (const [key, value] of Object.entries(localeMap)) { if (key.startsWith('menu.')) { menuMap[key.slice(5)] = value; } } return menuMap; } // ─── Step 3: 替换路由文件中的 name ────────────────────── function replaceRoutes(menuMap) { const configDir = path.join('config'); if (!fs.existsSync(configDir)) { console.log('- config/ 目录不存在,跳过'); return; } const routeFiles = fs .readdirSync(configDir) .filter((f) => f.startsWith('routes') && f.endsWith('.ts')) .map((f) => path.join(configDir, f)); for (const routesPath of routeFiles) { let content = fs.readFileSync(routesPath, 'utf-8'); let modified = false; content = content.replace(/name:\s*['"]([^'"]+)['"]/g, (match, name) => { const zhValue = menuMap[name]; if (zhValue) { modified = true; return `name: ${toStrLiteral(zhValue)}`; } return match; }); if (modified) { fs.writeFileSync(routesPath, content); console.log( `✓ 已替换 ${path.relative('.', routesPath)} 中的路由名称为中文`, ); } else { console.log(`- ${path.relative('.', routesPath)} 无需替换`); } } } // ─── Step 4: 从 config/config.ts 移除 locale 配置 ──────── function removeLocaleConfig() { const configPath = path.join('config', 'config.ts'); if (!fs.existsSync(configPath)) { console.log('- config/config.ts 不存在,跳过'); return; } let content = fs.readFileSync(configPath, 'utf-8'); const original = content; // 移除 locale 块及其紧邻的 JSDoc 注释(只匹配国际化相关的注释) content = content.replace( /\n\s*\/\*\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\n\s*locale:\s*\{[^}]*\},?\n/g, '\n', ); // 移除无注释的 locale: {...} 配置块 content = content.replace(/\n\s*locale:\s*\{[^}]*\},?\n/g, '\n'); // 移除 layout 中的 locale: true content = content.replace(/\n\s*locale:\s*true,?\n/g, '\n'); if (content !== original) { fs.writeFileSync(configPath, content); console.log('✓ 已从 config/config.ts 移除 locale 配置'); } else { console.log('- config/config.ts 无需修改'); } } // ─── Step 5: 替换源文件中的 i18n 调用 ────────────────── function processSourceFiles(localeMap) { const srcDir = path.join('src'); const files = readDirRecursive(srcDir).filter((f) => { const ext = path.extname(f); return ( ['.tsx', '.ts', '.jsx', '.js'].includes(ext) && !f.includes('locales') && !f.includes('.umi') && !f.includes('.umi-production') && !f.includes('.umi-test') && !f.endsWith('.d.ts') ); }); for (const file of files) { processFile(file, localeMap); } } function processFile(filePath, localeMap) { let content = fs.readFileSync(filePath, 'utf-8'); const original = content; if ( !content.includes('formatMessage') && !content.includes('FormattedMessage') && !content.includes('useIntl') && !content.includes('getIntl') && !content.includes('SelectLang') && !content.includes('getLocale') && !content.includes('getAllLocales') && !content.includes('setLocale') ) { return; } // ── 1. 替换 formatMessage 调用(使用括号匹配精确处理) // 只替换 id 为字符串字面量且无第二参数的简单调用 content = replaceFormatMessageCalls(content, localeMap); // ── 2. 替换 (使用精确匹配) // 只替换 id 为字符串字面量的调用 content = replaceFormattedMessageComponents(content, localeMap); // ── 3. 移除 const intl = useIntl() / const intl = getIntl() 声明 // 只在文件中不再有其他 intl. 引用时移除 if (!content.match(/\bintl\./)) { content = content.replace( /\n\s*(?:\/\*\*[\s\S]*?\*\/\n\s*)?const\s+intl\s*=\s*(?:useIntl|getIntl)\(\)\s*;?\n/g, '\n', ); } // ── 4. 移除 SelectLang 使用 content = content.replace( /\n?\s*\{SelectLang\s*&&\s*\s*\}/g, '', ); // ── 5. 处理 getLocale() 调用 content = content.replace( /const\s+(\w+)\s*=\s*getLocale\(\)\s*;?/g, (_match, varName) => { return `const ${varName} = 'zh-CN';`; }, ); // ── 5b. 处理 getAllLocales() 调用 content = content.replace( /useMemo\(\(\)\s*=>\s*getAllLocales\(\),\s*\[\]\)/g, "['zh-CN']", ); content = content.replace( /const\s+(\w+)\s*=\s*getAllLocales\(\)\s*;?/g, (_match, varName) => { return `const ${varName} = ['zh-CN'];`; }, ); // ── 5c. 移除 setLocale() 调用(使用括号匹配处理嵌套括号) content = removeSetLocaleCalls(content); // ── 6. 移除 data-lang 属性 content = content.replace( /\s*data-lang(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}))?/g, '', ); // ── 7. 移除 Lang 组件调用和定义 content = content.replace(/\n?\s*/g, ''); content = content.replace( /const Lang = \(\) => \{\s*\n\s*const \{ styles \} = useStyles\(\);\s*\n\s*return \(\s*\n\s*
\s*\n\s*<\/div>\s*\n\s*\);\s*\n\s*\};\n?/g, '', ); // ── 8. 清理 import 语句 // 只移除文件中不再使用的 i18n 相关符号 content = content.replace( /import\s*\{([^}]*)\}\s*from\s*['"]@umijs\/max['"];?\n?/g, (match, imports) => { const codeWithoutImport = content.replace(match, ''); const items = imports .split(',') .map((s) => s.trim()) .filter((s) => { if (!s || !I18N_SYMBOLS.includes(s)) return true; const regex = new RegExp(`\\b${s}\\b`); return regex.test(codeWithoutImport); }); if (items.length === 0) { return ''; } return `import { ${items.join(', ')} } from '@umijs/max';\n`; }, ); // ── 9. 简化 JSX 文本子节点中的 {'中文'} → 中文 content = content.replace(/\{'([^']*)'\}/g, (match, text, offset, str) => { const before = str.slice(Math.max(0, offset - 30), offset); const charBeforeBrace = before.trimEnd().slice(-1); // 不简化: JSX 属性(=)、模板字符串($)、函数参数/数组/表达式((,[,,) if ( charBeforeBrace === '=' || charBeforeBrace === '$' || charBeforeBrace === '(' || charBeforeBrace === ',' || charBeforeBrace === '[' ) { return match; } return text; }); // ── 10. 简化 JSX 文本子节点中 FormattedMessage 产生的 '中文' → 中文 content = content.replace( />(\s*)'([^'<]*?)'(\s*)<\//g, (_match, ws1, text, ws2) => { return `>${ws1}${text}${ws2} * 只替换 id 为字符串字面量的组件 */ function replaceFormattedMessageComponents(content, localeMap) { let searchFrom = 0; const pattern = ' const fullTag = content.slice(idx, endIdx); const idMatch = fullTag.match(new RegExp(`id=${QSTR}`)); if (!idMatch) { searchFrom = endIdx; continue; } const id = idMatch[1] || idMatch[2]; const dmMatch = fullTag.match(new RegExp(`defaultMessage=${QSTR}`)); const defaultMsg = dmMatch ? dmMatch[1] || dmMatch[2] : ''; const zhText = resolveText(id, defaultMsg, localeMap); const replacement = toStrLiteral(zhText); content = content.slice(0, idx) + replacement + content.slice(endIdx); searchFrom = idx + replacement.length; } return content; } /** * 移除 setLocale() 调用,使用括号匹配处理嵌套括号 */ function removeSetLocaleCalls(content) { const pattern = 'setLocale('; let searchFrom = 0; while (true) { const idx = content.indexOf(pattern, searchFrom); if (idx === -1) break; const openParenIdx = idx + pattern.length - 1; const closeParenIdx = findClosingParen(content, openParenIdx); if (closeParenIdx === -1) { searchFrom = idx + 1; continue; } // Remove the entire call including trailing semicolon let endIdx = closeParenIdx + 1; if (content[endIdx] === ';') endIdx++; content = content.slice(0, idx) + content.slice(endIdx); searchFrom = idx; } return content; } // ─── Step 6: 删除 locales 目录 ───────────────────────── function deleteLocalesDir() { const localeDir = path.join('src', 'locales'); fs.rmSync(localeDir, { recursive: true, force: true }); console.log('✓ 已删除 src/locales/ 目录'); } // ─── 残留检查 ──────────────────────────────────────────── function checkResiduals() { const residualSymbols = [ 'useIntl', 'getIntl', 'FormattedMessage', 'getLocale', 'getAllLocales', 'setLocale', ]; const srcDir = path.join('src'); const files = readDirRecursive(srcDir).filter((f) => { const ext = path.extname(f); return ( ['.tsx', '.ts', '.jsx', '.js'].includes(ext) && !f.includes('.umi') && !f.includes('.umi-production') && !f.includes('.umi-test') && !f.endsWith('.d.ts') ); }); let found = false; for (const file of files) { const content = fs.readFileSync(file, 'utf-8'); for (const sym of residualSymbols) { const regex = new RegExp(`\\b${sym}\\b`); const match = regex.exec(content); if (match) { const relPath = path.relative('.', file); const line = content.slice(0, match.index).split('\n').length; console.log(` ⚠ ${relPath}:${line} 残留 ${sym} 调用,请手动检查`); found = true; } } } if (!found) { console.log('✓ 无残留 i18n 调用'); } } // ─── Step 7: 从 package.json 移除 i18n-remove 脚本 ────── function updatePackageJson() { const pkgPath = 'package.json'; const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); if (pkg.scripts?.['i18n-remove']) { delete pkg.scripts['i18n-remove']; fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); console.log('✓ 已从 package.json 移除 i18n-remove 脚本'); } } // ─── 主函数 ───────────────────────────────────────────── function main() { console.log('========================================'); console.log(' 开始执行 i18n 移除脚本'); console.log('========================================\n'); console.log('>>> 构建中文翻译映射...'); const localeMap = buildLocaleMap(); const menuMap = buildMenuMap(localeMap); console.log(`✓ 已加载 ${Object.keys(localeMap).length} 条翻译映射`); console.log('\n>>> 替换路由名称为中文...'); replaceRoutes(menuMap); console.log('\n>>> 移除 config/config.ts 中的 locale 配置...'); removeLocaleConfig(); console.log('\n>>> 替换源文件中的 i18n 调用...'); processSourceFiles(localeMap); console.log('\n>>> 删除 locales 目录...'); deleteLocalesDir(); console.log('\n>>> 更新 package.json...'); updatePackageJson(); console.log('\n>>> 检查残留 i18n 调用...'); checkResiduals(); console.log('\n========================================'); console.log(' i18n 移除完成!'); console.log(' 请运行 npm install 并检查代码'); console.log('========================================'); } main();