183 lines
6.7 KiB
TypeScript
183 lines
6.7 KiB
TypeScript
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<string[]>((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')
|
||
/<Trans[^>]*i18nKey=["']([a-zA-Z][a-zA-Z0-9._-]+)["']/gi, // <Trans i18nKey="key" />
|
||
];
|
||
|
||
// 从文件内容中提取翻译键
|
||
function extractTranslationKeys(content: string): Set<string> {
|
||
const keys = new Set<string>();
|
||
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<string>, availableKeys: Set<string>): 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<string>();
|
||
|
||
// 从所有源代码文件中提取翻译键
|
||
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<string, string[]>);
|
||
|
||
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<string, string[]>);
|
||
|
||
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);
|
||
});
|