From b02ca859dea46223a3272008042a8382e7c654f5 Mon Sep 17 00:00:00 2001 From: alger Date: Wed, 4 Mar 2026 21:02:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(i18n):=20=E9=87=8D=E6=9E=84=E9=94=AE?= =?UTF-8?q?=E5=80=BC=E6=A3=80=E6=9F=A5=E5=B9=B6=E5=A2=9E=E5=8A=A0=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E5=91=8A=E8=AD=A6=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check_i18n.ts | 340 ++++++++++++++++++++++++++++++------------ 1 file changed, 244 insertions(+), 96 deletions(-) diff --git a/scripts/check_i18n.ts b/scripts/check_i18n.ts index 67f8c1c..3a8c555 100644 --- a/scripts/check_i18n.ts +++ b/scripts/check_i18n.ts @@ -1,103 +1,251 @@ import fs from 'fs'; import path from 'path'; +import { pathToFileURL } from 'url'; -async function main() { - const langDir = path.join(process.cwd(), 'src/i18n/lang'); - const sourceLang = 'zh-CN'; - const targetLangs = ['en-US', 'ja-JP', 'ko-KR', 'zh-Hant']; +type TranslationObject = Record; +type KeyValueMap = Map; +type KeyReference = { + file: string; + line: number; + key: string; +}; - const sourcePath = path.join(langDir, sourceLang); - const files = fs.readdirSync(sourcePath).filter((f) => f.endsWith('.ts')); +const SOURCE_LANG = 'zh-CN'; +const TARGET_LANGS = ['en-US', 'ja-JP', 'ko-KR', 'zh-Hant'] as const; +const CHECK_EXTENSIONS = new Set(['.ts', '.vue']); - function getFlatObject(obj: any, prefix = ''): Record { - const result: Record = {}; - for (const key in obj) { - const val = obj[key]; - const fullKey = prefix ? `${prefix}.${key}` : key; - if (typeof val === 'object' && val !== null && !Array.isArray(val)) { - Object.assign(result, getFlatObject(val, fullKey)); - } else { - result[fullKey] = String(val); - } - } - return result; - } - - const report: any = {}; - let hasMissing = false; - - for (const fileName of files) { - // Dynamic import might be tricky with ESM/TS in Bun without proper setup - // We'll read the file and extract the export default object using a simple regex/eval approach - // or just use Bun.file and eval if it's safe enough for this tool. - - const getContent = async (filePath: string) => { - if (!fs.existsSync(filePath)) return null; - const content = fs.readFileSync(filePath, 'utf-8'); - // Simple extraction of the default export object - const match = content.match(/export\s+default\s+([\s\S]+);/); - if (!match) return null; - try { - // This is a bit hacky but works for simple object literals in i18n files - // We replace potential comments and import statements if they existed, - // but here it's mostly just a JS object. - const objStr = match[1].trim(); - // Remove trailing comma if it exists before closing brace - // objStr = objStr.replace(/,\s*}/g, '}'); - - // Use a more robust way: wrap in () and eval - // Note: this assumes the i18n files don't have complex logic or external imports - return eval(`(${objStr})`); - } catch (e) { - console.error(`Error parsing ${filePath}:`, e); - return null; - } - }; - - const sourceObj = await getContent(path.join(sourcePath, fileName)); - if (!sourceObj) continue; - const sourceKeysMap = getFlatObject(sourceObj); - const sourceKeys = Object.keys(sourceKeysMap); - - for (const lang of targetLangs) { - if (!report[lang]) report[lang] = {}; - - const targetFilePath = path.join(langDir, lang, fileName); - const targetObj = await getContent(targetFilePath); - - const targetKeysMap = targetObj ? getFlatObject(targetObj) : {}; - const targetKeys = Object.keys(targetKeysMap); - - const missing = sourceKeys.filter((k) => !targetKeys.includes(k)); - - if (missing.length > 0) { - hasMissing = true; - report[lang][fileName] = missing.map((k) => ({ - key: k, - zh: sourceKeysMap[k] - })); - } - } - } - - if (hasMissing) { - console.error('发现国际化键值缺失:'); - for (const lang in report) { - const files = report[lang]; - if (Object.keys(files).length === 0) continue; - console.error(`\n语言: ${lang}`); - for (const fileName in files) { - console.error(` 文件: ${fileName}`); - files[fileName].forEach((item: any) => { - console.error(` - [${item.key}]: ${item.zh}`); - }); - } - } - process.exit(1); - } else { - console.log('所有国际化键值检查通过!'); - process.exit(0); - } +function isPlainObject(value: unknown): value is TranslationObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); } -main(); +function flattenTranslations( + input: TranslationObject, + prefix = '', + output: KeyValueMap = new Map() +): KeyValueMap { + Object.entries(input).forEach(([key, value]) => { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (isPlainObject(value)) { + flattenTranslations(value, fullKey, output); + return; + } + output.set(fullKey, String(value ?? '')); + }); + return output; +} + +async function loadTranslationFile(filePath: string): Promise { + if (!fs.existsSync(filePath)) { + return null; + } + + const moduleUrl = pathToFileURL(filePath).href; + const loaded = await import(moduleUrl); + const payload = loaded.default; + + if (!isPlainObject(payload)) { + throw new Error(`翻译文件默认导出必须是对象: ${filePath}`); + } + + return payload; +} + +function walkFiles(dirPath: string): string[] { + const results: string[] = []; + + if (!fs.existsSync(dirPath)) { + return results; + } + + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + results.push(...walkFiles(fullPath)); + continue; + } + + if (entry.isFile() && CHECK_EXTENSIONS.has(path.extname(entry.name))) { + results.push(fullPath); + } + } + + return results; +} + +function getLineNumber(content: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i += 1) { + if (content[i] === '\n') { + line += 1; + } + } + return line; +} + +function collectReferencesFromContent(content: string, file: string): KeyReference[] { + const references: KeyReference[] = []; + const patterns = [ + /\bt\(\s*['"`]([^'"`$]+)['"`]\s*[,)]/g, + /\bi18n\.global\.t\(\s*['"`]([^'"`$]+)['"`]\s*[,)]/g, + /\$t\(\s*['"`]([^'"`$]+)['"`]\s*[,)]/g + ]; + + for (const pattern of patterns) { + let match: RegExpExecArray | null = pattern.exec(content); + while (match) { + references.push({ + file, + line: getLineNumber(content, match.index), + key: match[1] + }); + match = pattern.exec(content); + } + } + + return references; +} + +function collectTranslationReferences(projectRoot: string): KeyReference[] { + const scanDirs = ['src/renderer', 'src/main', 'src/preload']; + const references: KeyReference[] = []; + + for (const scanDir of scanDirs) { + const absoluteDir = path.join(projectRoot, scanDir); + const files = walkFiles(absoluteDir); + + for (const file of files) { + const content = fs.readFileSync(file, 'utf-8'); + references.push(...collectReferencesFromContent(content, path.relative(projectRoot, file))); + } + } + + return references; +} + +async function main() { + const projectRoot = process.cwd(); + const langDir = path.join(projectRoot, 'src/i18n/lang'); + const sourceDir = path.join(langDir, SOURCE_LANG); + const fileNames = fs + .readdirSync(sourceDir) + .filter((file) => file.endsWith('.ts')) + .sort(); + + const missingByLang: Record> = {}; + const extraByLang: Record> = {}; + const sourceKeys = new Set(); + const sourceValues = new Map(); + let hasBlockingIssue = false; + const strictMode = process.env.I18N_STRICT === '1'; + + for (const fileName of fileNames) { + const moduleName = fileName.replace(/\.ts$/, ''); + const sourcePath = path.join(sourceDir, fileName); + const sourceObject = await loadTranslationFile(sourcePath); + if (!sourceObject) { + continue; + } + + const sourceMap = flattenTranslations(sourceObject, moduleName); + const sourceMapKeys = new Set(sourceMap.keys()); + + sourceMap.forEach((value, key) => { + sourceKeys.add(key); + sourceValues.set(key, value); + }); + + for (const lang of TARGET_LANGS) { + if (!missingByLang[lang]) { + missingByLang[lang] = {}; + } + if (!extraByLang[lang]) { + extraByLang[lang] = {}; + } + + const targetPath = path.join(langDir, lang, fileName); + const targetObject = await loadTranslationFile(targetPath); + const targetMap = targetObject + ? flattenTranslations(targetObject, moduleName) + : new Map(); + const targetMapKeys = new Set(targetMap.keys()); + + const missing = Array.from(sourceMapKeys).filter((key) => !targetMapKeys.has(key)); + const extra = Array.from(targetMapKeys).filter((key) => !sourceMapKeys.has(key)); + + if (missing.length > 0) { + missingByLang[lang][fileName] = missing; + hasBlockingIssue = true; + } + + if (extra.length > 0) { + extraByLang[lang][fileName] = extra; + } + } + } + + const allReferences = collectTranslationReferences(projectRoot); + const invalidReferences = allReferences.filter((item) => !sourceKeys.has(item.key)); + + const hasWarningIssue = + invalidReferences.length > 0 || + Object.values(extraByLang).some((item) => Object.keys(item).length > 0); + const shouldFail = hasBlockingIssue || (strictMode && hasWarningIssue); + + if (hasBlockingIssue || hasWarningIssue) { + console.error('发现国际化问题:'); + + for (const lang of TARGET_LANGS) { + const missingFiles = missingByLang[lang]; + const extraFiles = extraByLang[lang]; + const hasLangIssue = + Object.keys(missingFiles).length > 0 || Object.keys(extraFiles).length > 0; + + if (!hasLangIssue) { + continue; + } + + console.error(`\n语言: ${lang}`); + for (const fileName of Object.keys(missingFiles)) { + console.error(` 文件: ${fileName}`); + for (const key of missingFiles[fileName]) { + const sourceValue = sourceValues.get(key) ?? ''; + console.error(` - 缺失键 [${key}]:${sourceValue}`); + } + } + + for (const fileName of Object.keys(extraFiles)) { + console.error(` 文件: ${fileName}`); + for (const key of extraFiles[fileName]) { + console.error(` - 多余键 [${key}]`); + } + } + } + + if (invalidReferences.length > 0) { + console.error('\n代码中引用了不存在的 i18n key:'); + for (const item of invalidReferences) { + console.error(` - ${item.file}:${item.line} -> ${item.key}`); + } + } + + if (strictMode && hasWarningIssue && !hasBlockingIssue) { + console.error('\n当前为严格模式,告警将导致失败(I18N_STRICT=1)。'); + } + } + + if (shouldFail) { + process.exit(1); + } + + if (!hasBlockingIssue && !hasWarningIssue) { + console.log('所有国际化键值检查通过!'); + return; + } + + console.log('国际化检查通过(含告警,建议尽快修复)'); +} + +main().catch((error) => { + console.error('国际化检查执行失败:', error); + process.exit(1); +});