import fs from 'fs-extra'; import path from 'path'; import * as globModule from 'glob'; const glob = globModule.glob; import chalk from 'chalk'; interface TranslationMap { [key: string]: string | TranslationMap; } interface TranslationResult { missingKeys: string[]; unusedKeys: string[]; } // 递归获取所有翻译键 function getAllTranslationKeys(obj: TranslationMap, prefix = ''): string[] { return Object.entries(obj).reduce((acc, [key, value]) => { const currentKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'object') { return [...acc, ...getAllTranslationKeys(value, currentKey)]; } // 如果键以 translation. 开头,移除这个前缀 const finalKey = currentKey.startsWith('translation.') ? currentKey.substring('translation.'.length) : currentKey; if (!shouldIgnoreKey(finalKey)) { return [...acc, finalKey]; } return acc; }, []); } // 从源代码中提取翻译键的正则表达式 const translationKeyRegexes = [ /(?:t|i18n\.t)\(['"]([a-zA-Z][a-zA-Z0-9._-]+)['"](?:\s*,\s*{[^}]*})?\)/gi, // t('key') or t('key', {}) /(?:t|i18n\.t)\(\s*(?:[a-zA-Z][a-zA-Z0-9._]+\s*\?\s*)?['"]([a-zA-Z][a-zA-Z0-9._-]+)['"](?:\s*:\s*['"]([a-zA-Z][a-zA-Z0-9._-]+)['"])?(?:\s*,\s*{[^}]*})?\)/gi, // t(isOpen ? 'key1' : 'key2') or t('key') /useTranslation\(['"]([a-zA-Z][a-zA-Z0-9._-]+)['"]\)/gi, // useTranslation('key') /]*i18nKey=["']([a-zA-Z][a-zA-Z0-9._-]+)["']/gi, // ]; // 从文件内容中提取翻译键 function extractTranslationKeys(content: string): Set { const keys = new Set(); for (const regex of translationKeyRegexes) { let match; while ((match = regex.exec(content)) !== null) { // 如果有多个捕获组(三元运算符的情况),添加所有捕获的键 for (let i = 1; i < match.length; i++) { const key = match[i]; if (key && !shouldIgnoreKey(key)) { keys.add(key); } } } } return keys; } // 忽略的翻译键 const ignoredKeys = new Set([ 'content-type', 'page', 'tags' ]); // 忽略的翻译键前缀 const ignoredPrefixes = [ 'projects.status.', 'projects.tags.', 'social.links', 'updates.tags', 'data.' ]; // 检查键是否应该被忽略 function shouldIgnoreKey(key: string): boolean { if (ignoredKeys.has(key)) return true; return ignoredPrefixes.some(prefix => key.startsWith(prefix)); } // 比较翻译键 function compareTranslations(usedKeys: Set, availableKeys: Set): TranslationResult { const missingKeys = Array.from(usedKeys) .filter(key => !availableKeys.has(key)) .sort(); const unusedKeys = Array.from(availableKeys) .filter(key => !usedKeys.has(key)) .sort(); return { missingKeys, unusedKeys }; } async function main() { console.log(chalk.blue('Checking translations...')); // 获取所有支持的语言 const dataDir = path.resolve('data'); const langs = fs.readdirSync(dataDir) .filter(file => fs.statSync(path.join(dataDir, file)).isDirectory()) .filter(dir => /^[a-z]{2}-[A-Z]{2}$/.test(dir)); // 扫描源代码文件 const srcFiles = await glob('src/**/*.{ts,tsx,js,jsx}', { absolute: true }); const usedKeys = new Set(); // 从所有源代码文件中提取翻译键 for (const file of srcFiles) { const content = await fs.readFile(file, 'utf-8'); const keys = extractTranslationKeys(content); keys.forEach(key => usedKeys.add(key)); } // 检查每个语言的翻译 let hasWarnings = false; for (const lang of langs) { const indexPath = path.join(dataDir, lang, 'index.json'); if (fs.existsSync(indexPath)) { const content = await fs.readJson(indexPath); // 确保我们从 translations 对象开始 const translationsObj = content.translations || content; const availableKeys = new Set(getAllTranslationKeys(translationsObj)); const { missingKeys, unusedKeys } = compareTranslations(usedKeys, availableKeys); if (missingKeys.length > 0 || unusedKeys.length > 0) { hasWarnings = true; console.log(chalk.yellow(`\n[${lang}]`)); if (missingKeys.length > 0) { console.log(chalk.yellow(` Missing translations: (${missingKeys.length} keys)`)); // 按命名空间分组输出 const groupedKeys = missingKeys.reduce((acc, key) => { const namespace = key.split('.')[0]; if (!acc[namespace]) acc[namespace] = []; acc[namespace].push(key); return acc; }, {} as Record); Object.entries(groupedKeys) .sort(([a], [b]) => a.localeCompare(b)) .forEach(([namespace, keys]) => { console.log(chalk.cyan(`\n ${namespace}:`)); keys.forEach(key => { console.log(chalk.red(` ${key}`)); }); }); } if (unusedKeys.length > 0) { console.log(chalk.yellow(`\n Unused translations: (${unusedKeys.length} keys)`)); // 按命名空间分组输出 const groupedKeys = unusedKeys.reduce((acc, key) => { const namespace = key.split('.')[0]; if (!acc[namespace]) acc[namespace] = []; acc[namespace].push(key); return acc; }, {} as Record); Object.entries(groupedKeys) .sort(([a], [b]) => a.localeCompare(b)) .forEach(([namespace, keys]) => { console.log(chalk.cyan(`\n ${namespace}:`)); keys.forEach(key => { console.log(chalk.yellow(` ${key}`)); }); }); } } } } if (!hasWarnings) { console.log(chalk.green('\n✓ All translations are complete and clean!')); } else { console.log(chalk.yellow('\n⚠️ Please review the translation issues listed above.')); } } main().catch(error => { console.error(chalk.red('Error:'), error); process.exit(1); });