#!/usr/bin/env node /** * i18n 国际化检查脚本 * * 功能: * 1. 检查 zh-cn / en-us 语言包文件结构是否一致 * 2. 检查同文件下中英文 key 是否一致 * 3. 统计 key 总数和差异数 * * 用法: * node scripts/i18n-check.js * * 退出码: * 0 - 检查通过(无差异 或 只有警告级差异) * 1 - 存在错误级差异 */ const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const LANG_DIR = path.join(ROOT, 'lang'); const LOCALES = ['zh-cn', 'en-us']; // 递归获取目录下所有 .js 文件(相对路径) function getAllLangFiles(dir) { const result = []; function walk(current, rel) { const entries = fs.readdirSync(current, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(current, entry.name); const relPath = rel ? path.join(rel, entry.name) : entry.name; if (entry.isDirectory()) { walk(fullPath, relPath); } else if (entry.name.endsWith('.js')) { result.push(relPath.replace(/\\/g, '/')); } } } walk(dir, ''); return result.sort(); } // 解析语言包 js 文件,提取所有 key(支持嵌套,用 . 连接) // 注意: 用正则粗略提取,不做完整 AST 解析,对本项目语言包格式足够 function extractKeys(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); const keys = new Set(); // 简单的对象 key 提取:匹配 key: 'value' 或 key: { 形式 // 用栈跟踪嵌套层级,记录路径 const stack = []; const lines = content.split('\n'); let braceDepth = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // 跳过注释 const trimmed = line.trim(); if (trimmed.startsWith('//')) continue; if (trimmed.startsWith('/*')) continue; if (trimmed.startsWith('*')) continue; // 匹配 key: 形式 // 匹配: identifier: 或 'key': 或 "key": const keyMatch = line.match(/^\s*['"]?(\w[\w]*)['"]?\s*:/); if (keyMatch) { const key = keyMatch[1]; // 跳过 export const lang = { 这种 if (stack.length === 0 && key === 'lang') { // 往下找第一个 { if (line.includes('{')) braceDepth++; if (braceDepth > 0) stack.push(''); continue; } // 计算当前路径 const pathParts = stack.filter(p => p); const fullKey = [...pathParts, key].join('.'); // 判断这是字符串值还是对象值 const afterColon = line.substring(line.indexOf(':') + 1); const isObject = afterColon.includes('{'); const isString = afterColon.match(/['"`]/); if (isObject && !isString) { // 对象 key,入栈 stack.push(key); keys.add(fullKey + '.__object__'); } else { // 叶子 key keys.add(fullKey); } } // 处理闭合 },出栈 const closeBraces = (line.match(/\}/g) || []).length; const openBraces = (line.match(/\{/g) || []).length; const netClose = closeBraces - openBraces; if (line.includes('{') && keyMatch && !line.includes('}')) { // 这行是 key: {,已经在上面处理了入栈 } // 简单处理:遇到 } 且栈非空就出栈 if (netClose > 0 && stack.length > 0) { for (let j = 0; j < netClose && stack.length > 0; j++) { stack.pop(); } } } // 去掉 __object__ 标记 const result = new Set(); for (const k of keys) { if (!k.endsWith('.__object__')) { result.add(k); } } return result; } // 更可靠的 key 提取:用 Node.js require + 递归遍历对象 function extractKeysByRequire(filePath) { try { // 清除 require 缓存 delete require.cache[require.resolve(filePath)]; const mod = require(filePath); const langObj = mod.lang || mod.default?.lang || mod.default; if (!langObj || typeof langObj !== 'object') return new Set(); const keys = new Set(); function walk(obj, prefix) { for (const key of Object.keys(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; const val = obj[key]; if (val !== null && typeof val === 'object' && !Array.isArray(val)) { walk(val, fullKey); } else if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') { keys.add(fullKey); } } } walk(langObj, ''); return keys; } catch (e) { console.error(` 解析失败: ${filePath} - ${e.message}`); return new Set(); } } function main() { console.log('========================================'); console.log(' i18n 国际化检查'); console.log('========================================\n'); const localeDirs = {}; const localeFiles = {}; for (const locale of LOCALES) { const dir = path.join(LANG_DIR, locale); if (!fs.existsSync(dir)) { console.error(`错误: 找不到语言目录 ${dir}`); process.exit(1); } localeDirs[locale] = dir; localeFiles[locale] = new Set(getAllLangFiles(dir)); } // ---- 检查 1: 文件结构一致性 ---- console.log('【1/3】文件结构检查\n'); const zhFiles = localeFiles['zh-cn']; const enFiles = localeFiles['en-us']; const onlyZh = [...zhFiles].filter(f => !enFiles.has(f)); const onlyEn = [...enFiles].filter(f => !zhFiles.has(f)); const commonFiles = [...zhFiles].filter(f => enFiles.has(f)); console.log(` 中文语言包文件数: ${zhFiles.size}`); console.log(` 英文语言包文件数: ${enFiles.size}`); console.log(` 共同文件数: ${commonFiles.length}`); if (onlyZh.length > 0) { console.log(`\n ⚠️ 只有中文没有英文的文件 (${onlyZh.length}):`); for (const f of onlyZh) { console.log(` - ${f}`); } } if (onlyEn.length > 0) { console.log(`\n ⚠️ 只有英文没有中文的文件 (${onlyEn.length}):`); for (const f of onlyEn) { console.log(` - ${f}`); } } if (onlyZh.length === 0 && onlyEn.length === 0) { console.log(' ✅ 文件结构完全一致'); } // ---- 检查 2: key 一致性 ---- console.log('\n【2/3】Key 一致性检查\n'); let totalZhKeys = 0; let totalEnKeys = 0; let filesWithDiff = 0; const diffDetails = []; for (const file of commonFiles) { const zhPath = path.join(localeDirs['zh-cn'], file); const enPath = path.join(localeDirs['en-us'], file); const zhKeys = extractKeysByRequire(zhPath); const enKeys = extractKeysByRequire(enPath); totalZhKeys += zhKeys.size; totalEnKeys += enKeys.size; const missingInEn = [...zhKeys].filter(k => !enKeys.has(k)); const missingInZh = [...enKeys].filter(k => !zhKeys.has(k)); if (missingInEn.length > 0 || missingInZh.length > 0) { filesWithDiff++; diffDetails.push({ file, missingInEn, missingInZh, zhCount: zhKeys.size, enCount: enKeys.size, }); } } console.log(` 中文 key 总数: ${totalZhKeys}`); console.log(` 英文 key 总数: ${totalEnKeys}`); console.log(` 有差异的文件数: ${filesWithDiff} / ${commonFiles.length}`); if (filesWithDiff > 0) { console.log(`\n ⚠️ Key 差异详情:`); for (const diff of diffDetails) { console.log(`\n 📄 ${diff.file}`); console.log(` 中文: ${diff.zhCount} 个, 英文: ${diff.enCount} 个`); if (diff.missingInEn.length > 0) { console.log(` 英文缺失 (${diff.missingInEn.length}):`); for (const k of diff.missingInEn.slice(0, 10)) { console.log(` - ${k}`); } if (diff.missingInEn.length > 10) { console.log(` ... 还有 ${diff.missingInEn.length - 10} 个`); } } if (diff.missingInZh.length > 0) { console.log(` 中文缺失 (${diff.missingInZh.length}):`); for (const k of diff.missingInZh.slice(0, 10)) { console.log(` - ${k}`); } if (diff.missingInZh.length > 10) { console.log(` ... 还有 ${diff.missingInZh.length - 10} 个`); } } } } else { console.log(' ✅ 所有共同文件的 key 完全一致'); } // ---- 检查 3: 硬编码中文扫描 ---- console.log('\n【3/3】硬编码中文扫描\n'); // 跳过的目录 const SKIP_DIRS = new Set([ 'node_modules', 'unpackage', 'static', 'lang', 'uni_modules', '.git', '.hbuilderx', 'dist', 'build', 'stub-app-projects', 'docs', 'hybrid', 'wxcomponents' ]); // 跳过的文件 const SKIP_FILE_PATTERNS = [ /vendor\.js$/, /\.min\.js$/, /\.pack\.js$/, /manifest\.json\.js$/, ]; // 扫描所有 vue / js 文件 const srcFiles = []; function scanSrcDir(dir, rel) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relPath = rel ? path.join(rel, entry.name) : entry.name; if (entry.isDirectory()) { if (SKIP_DIRS.has(entry.name)) continue; scanSrcDir(fullPath, relPath); } else if (entry.name.endsWith('.vue') || entry.name.endsWith('.js')) { // 跳过 vendor / min / pack 等非源码文件 let skip = false; for (const pat of SKIP_FILE_PATTERNS) { if (pat.test(entry.name)) { skip = true; break; } } if (!skip) { srcFiles.push({ fullPath, relPath: relPath.replace(/\\/g, '/') }); } } } } scanSrcDir(ROOT, ''); // 中文正则(基本汉字区) const CN_RE = /[\u4e00-\u9fa5]+/g; // 去掉行内注释(// 和 /* */ 块注释单行) function stripInlineComment(line, inBlockComment) { let result = line; let inString = false; let stringChar = ''; let i = 0; while (i < result.length) { const ch = result[i]; const next = result[i + 1]; if (inBlockComment) { // 块注释中,找 */ if (ch === '*' && next === '/') { inBlockComment = false; result = result.substring(0, i) + ' ' + result.substring(i + 2); i += 2; continue; } // 替换为空格保持位置 result = result.substring(0, i) + ' ' + result.substring(i + 1); i++; continue; } if (inString) { if (ch === '\\') { i += 2; continue; } // 转义 if (ch === stringChar) { inString = false; } i++; continue; } // 字符串开始 if (ch === "'" || ch === '"' || ch === '`') { inString = true; stringChar = ch; i++; continue; } // 行注释 if (ch === '/' && next === '/') { result = result.substring(0, i); break; } // 块注释开始 if (ch === '/' && next === '*') { inBlockComment = true; result = result.substring(0, i) + ' ' + result.substring(i + 2); i += 2; continue; } i++; } return { text: result, inBlockComment }; } // 判断一行是否是国际化调用(已经国际化) function isLangCall(line) { // 匹配 $lang(' 或 $lang(" 或 _t(' 或 _t(" 或 _getLang().lang(' 或 _getLang().lang(" return /\$lang\(\s*['"]/.test(line) || /_t\(\s*['"]/.test(line) || /_getLang\(\)\.lang\(\s*['"]/.test(line); } const hardcodeResults = []; let totalCnHits = 0; for (const { fullPath, relPath } of srcFiles) { try { const content = fs.readFileSync(fullPath, 'utf-8'); const lines = content.split('\n'); let fileHits = 0; let inBlockComment = false; let inHtmlComment = false; for (let i = 0; i < lines.length; i++) { let line = lines[i]; // HTML 注释块处理 () if (inHtmlComment) { const endIdx = line.indexOf('-->'); if (endIdx >= 0) { line = line.substring(endIdx + 3); inHtmlComment = false; } else { continue; // 整行在 HTML 注释中 } } const htmlStart = line.indexOf('', htmlStart); if (htmlEnd >= 0) { line = line.substring(0, htmlStart) + line.substring(htmlEnd + 3); } else { line = line.substring(0, htmlStart); inHtmlComment = true; } } // JS/CSS 块注释 + 行注释处理 const stripped = stripInlineComment(line, inBlockComment); inBlockComment = stripped.inBlockComment; line = stripped.text; if (isLangCall(line)) continue; const matches = line.match(CN_RE); if (matches) { fileHits += matches.length; } } if (fileHits > 0) { totalCnHits += fileHits; hardcodeResults.push({ file: relPath, count: fileHits }); } } catch (e) { // 跳过读不了的文件 } } // 按命中数排序 hardcodeResults.sort((a, b) => b.count - a.count); console.log(` 扫描文件数: ${srcFiles.length}`); console.log(` 含中文的文件数: ${hardcodeResults.length}`); console.log(` 中文字符串命中数: ${totalCnHits}`); if (hardcodeResults.length > 0) { console.log(`\n ⚠️ 疑似硬编码中文 TOP 20:`); const topN = hardcodeResults.slice(0, 20); for (const r of topN) { console.log(` ${String(r.count).padStart(4)} ${r.file}`); } if (hardcodeResults.length > 20) { console.log(` ... 还有 ${hardcodeResults.length - 20} 个文件`); } console.log(`\n 💡 提示:这是粗略扫描,包含注释/默认值等误报,仅供参考`); } else { console.log(' ✅ 未发现硬编码中文(粗略扫描)'); } // ---- 总结 ---- console.log('\n========================================'); console.log(' 检查完成'); console.log('========================================'); const hasError = filesWithDiff > 0 || onlyZh.length > 0; if (hasError) { console.log('\n ❌ 存在差异,请修复后再提交'); process.exit(1); } else { console.log('\n ✅ 全部通过'); process.exit(0); } } main();