feat: translations integrity validation

closes #7
This commit is contained in:
CDN 2025-02-03 22:22:07 +08:00
parent 25bd99abc5
commit 47cf6171c5
Signed by: CDN
GPG key ID: 0C656827F9F80080
14 changed files with 291 additions and 62 deletions

View file

@ -0,0 +1,183 @@
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);
});