You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
596 lines
18 KiB
596 lines
18 KiB
/**
|
|
* i18n 移除脚本 - 将国际化的文本替换为中文,移除 i18n 相关代码
|
|
* 执行 npm run i18n-remove 运行此脚本
|
|
*
|
|
* 此操作不可逆,会执行以下变更:
|
|
* - 读取 src/locales/zh-CN 的翻译映射
|
|
* - 替换 intl.formatMessage / useIntl().formatMessage 调用为中文硬编码
|
|
* - 替换 <FormattedMessage> 组件为中文文本
|
|
* - 移除 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. 替换 <FormattedMessage ... /> (使用精确匹配)
|
|
// 只替换 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',
|
|
);
|
|
content = content.replace(
|
|
/\n\s*const\s+intl\s*=\s*(?:useIntl|getIntl)\(\)\s*;?\n/g,
|
|
'\n',
|
|
);
|
|
}
|
|
|
|
// ── 4. 移除 SelectLang 使用
|
|
content = content.replace(
|
|
/\n?\s*\{SelectLang\s*&&\s*<SelectLang\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*<Lang\s*\/>/g, '');
|
|
content = content.replace(
|
|
/const Lang = \(\) => \{\s*\n\s*const \{ styles \} = useStyles\(\);\s*\n\s*return \(\s*\n\s*<div className=\{styles\.lang\}>\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}</`;
|
|
},
|
|
);
|
|
|
|
// ── 11. 清理残留
|
|
content = content.replace(/^\s*;\s*$/gm, '');
|
|
content = content.replace(/\n{3,}/g, '\n\n');
|
|
content = `${content.trimEnd()}\n`;
|
|
|
|
if (content !== original) {
|
|
fs.writeFileSync(filePath, content);
|
|
const relPath = path.relative('.', filePath);
|
|
console.log(`✓ 已处理: ${relPath}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 替换 intl.formatMessage(...) 和 useIntl().formatMessage(...)
|
|
* 使用括号匹配来精确找到完整的函数调用
|
|
*/
|
|
function replaceFormatMessageCalls(content, localeMap) {
|
|
const patterns = FORMAT_MESSAGE_PATTERNS;
|
|
|
|
for (const pattern of patterns) {
|
|
let searchFrom = 0;
|
|
while (true) {
|
|
const idx = content.indexOf(pattern, searchFrom);
|
|
if (idx === -1) break;
|
|
|
|
const openParenIdx = idx + pattern.length - 1; // position of (
|
|
const closeParenIdx = findClosingParen(content, openParenIdx);
|
|
|
|
if (closeParenIdx === -1) {
|
|
searchFrom = idx + 1;
|
|
continue;
|
|
}
|
|
|
|
const fullCall = content.slice(idx, closeParenIdx + 1);
|
|
|
|
// 检查 id 是否为字符串字面量(支持含转义引号的值)
|
|
const idMatch = fullCall.match(new RegExp(`id:\\s*${QSTR}`));
|
|
if (!idMatch) {
|
|
searchFrom = closeParenIdx + 1;
|
|
continue;
|
|
}
|
|
|
|
const id = idMatch[1] || idMatch[2];
|
|
const dmMatch = fullCall.match(new RegExp(`defaultMessage:\\s*${QSTR}`));
|
|
const defaultMsg = dmMatch ? dmMatch[1] || dmMatch[2] : '';
|
|
|
|
// 检查是否有第二参数(ICU 格式化参数),如果有则跳过
|
|
// 第一参数结束位置: 找到第一个 },{ 或 },\s*{
|
|
const firstArgEnd = fullCall.indexOf('},');
|
|
if (firstArgEnd !== -1 && firstArgEnd < fullCall.length - 2) {
|
|
// 有第二参数 — 无法静态替换,跳过
|
|
console.log(` ⚠ 跳过含 ICU 参数的调用: ${id}`);
|
|
searchFrom = closeParenIdx + 1;
|
|
continue;
|
|
}
|
|
|
|
const zhText = toStrLiteral(resolveText(id, defaultMsg, localeMap));
|
|
content =
|
|
content.slice(0, idx) + zhText + content.slice(closeParenIdx + 1);
|
|
|
|
searchFrom = idx + zhText.length;
|
|
}
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* 替换 <FormattedMessage id="xxx" defaultMessage="yyy" />
|
|
* 只替换 id 为字符串字面量的组件
|
|
*/
|
|
function replaceFormattedMessageComponents(content, localeMap) {
|
|
let searchFrom = 0;
|
|
const pattern = '<FormattedMessage';
|
|
|
|
while (true) {
|
|
const idx = content.indexOf(pattern, searchFrom);
|
|
if (idx === -1) break;
|
|
|
|
const endPattern = '/>';
|
|
let endIdx = content.indexOf(endPattern, idx);
|
|
if (endIdx === -1) break;
|
|
endIdx += endPattern.length; // include />
|
|
|
|
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 = [
|
|
'getIntl',
|
|
'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();
|
|
|