fix: rewrite translation check scripts with proper regex

- Fix regex to handle 'await getTranslations' pattern
- Add word boundary to prevent false matches like 'get("q")'
- Improve namespace detection for dotted namespaces
- Reduce false positives in both scripts
This commit is contained in:
2026-03-18 07:59:21 +08:00
parent de7c1321c2
commit 286add7fff
2 changed files with 286 additions and 443 deletions

View File

@@ -1,12 +1,6 @@
/**
* 查找缺失的翻译键
*
* 扫描代码中使用的翻译键,与翻译文件对比,找出缺失的键
*
* 用法: npx tsx scripts/find-missing-translations.ts [locale]
* 示例:
* npx tsx scripts/find-missing-translations.ts # 检查所有语言
* npx tsx scripts/find-missing-translations.ts en-US # 只检查 en-US
*/
import * as fs from "fs";
@@ -14,38 +8,92 @@ import * as path from "path";
const SRC_DIR = "./src";
const MESSAGES_DIR = "./messages";
const ALL_LOCALES = ["en-US", "zh-CN", "ja-JP", "ko-KR", "de-DE", "fr-FR", "it-IT", "ug-CN"];
// 所有支持的语言
const ALL_LOCALES = [
"en-US",
"zh-CN",
"ja-JP",
"ko-KR",
"de-DE",
"fr-FR",
"it-IT",
"ug-CN",
];
interface FileTranslationUsage {
interface TranslationUsage {
file: string;
line: number;
namespace: string;
keys: Set<string>;
key: string;
isDynamic: boolean;
}
function parseStringLiteral(s: string): string | null {
s = s.trim();
if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1);
if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1);
if (s.startsWith("`") && s.endsWith("`")) {
return s.includes("${") || s.includes("+") ? null : s.slice(1, -1);
}
return null;
}
function extractTranslationBindings(content: string): Map<string, string> {
const bindings = new Map<string, string>();
const patterns = [
/(?:const|let|var)\s+(\w+)\s*=\s*(?:await\s+)?(?:useTranslations|getTranslations)\s*\(\s*([^)]*)\s*\)/g,
/(?:const|let|var)\s*\{\s*(\w+)\s*\}\s*=\s*await\s+getTranslations\s*\(\s*([^)]*)\s*\)/g,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
const varName = match[1];
const arg = match[2].trim();
if (arg === "") {
bindings.set(varName, "__ROOT__");
} else {
const ns = parseStringLiteral(arg);
if (ns !== null) bindings.set(varName, ns);
}
}
}
return bindings;
}
function extractTranslationCalls(
content: string,
filePath: string,
bindings: Map<string, string>
): TranslationUsage[] {
const usages: TranslationUsage[] = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
for (const [varName, namespace] of bindings) {
const callPattern = new RegExp(
`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`,
"g"
);
let match;
while ((match = callPattern.exec(line)) !== null) {
const arg = match[1];
const key = parseStringLiteral(arg);
if (key !== null) {
usages.push({ file: filePath, line: lineNum, namespace, key, isDynamic: false });
} else {
usages.push({ file: filePath, line: lineNum, namespace, key: arg, isDynamic: true });
}
}
}
}
return usages;
}
/**
* 递归获取目录下所有文件
*/
function getAllFiles(dir: string, extensions: string[]): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...getAllFiles(fullPath, extensions));
} else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
@@ -56,236 +104,142 @@ function getAllFiles(dir: string, extensions: string[]): string[] {
return files;
}
/**
* 从代码文件中提取翻译使用情况
*/
function extractTranslationsFromFile(filePath: string): FileTranslationUsage[] {
const content = fs.readFileSync(filePath, "utf-8");
const usages: FileTranslationUsage[] = [];
function navigateToObject(obj: Record<string, unknown>, path: string): unknown {
if (typeof obj[path] !== "undefined") return obj[path];
// 匹配 useTranslations("namespace") 或 getTranslations("namespace")
const namespacePattern = /(?:useTranslations|getTranslations)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
// 匹配没有参数的 useTranslations() 或 getTranslations() - 使用根级别
const rootNamespacePattern = /(?:useTranslations|getTranslations)\s*\(\s*\)/g;
// 首先找到所有的 namespace 声明
const namespaces: { name: string; isRoot: boolean }[] = [];
let match;
while ((match = namespacePattern.exec(content)) !== null) {
namespaces.push({ name: match[1], isRoot: false });
let current: unknown = obj;
for (const part of path.split(".")) {
if (typeof current !== "object" || current === null) return undefined;
current = (current as Record<string, unknown>)[part];
}
// 检查是否有根级别的翻译
let hasRootTranslations = false;
while ((match = rootNamespacePattern.exec(content)) !== null) {
hasRootTranslations = true;
}
if (namespaces.length === 0 && !hasRootTranslations) {
return usages;
}
// 匹配 t("key") 或 t('key') 或 t(`key`) 调用
// 支持简单键、嵌套键、带插值的键
const tCallPattern = /\bt\s*\(\s*["'`]([^"'`]+)["'`]\s*(?:,|\))/g;
const allKeys = new Set<string>();
while ((match = tCallPattern.exec(content)) !== null) {
allKeys.add(match[1]);
}
// 匹配模板字面量动态键 t(`prefix.${variable}`)
const dynamicTCallPattern = /\bt\s*\(\s*`([^`]+)\$\{[^}]+\}([^`]*)`\s*(?:,|\))/g;
while ((match = dynamicTCallPattern.exec(content)) !== null) {
// 标记为动态键,前缀部分
const prefix = match[1];
if (prefix) {
allKeys.add(`[DYNAMIC PREFIX: ${prefix}]`);
}
}
if (allKeys.size === 0) {
return usages;
}
// 为每个 namespace 创建使用记录
for (const ns of namespaces) {
usages.push({
file: filePath,
namespace: ns.name,
keys: allKeys,
});
}
// 如果有根级别翻译,添加一个特殊的 namespace
if (hasRootTranslations) {
usages.push({
file: filePath,
namespace: "__ROOT__",
keys: allKeys,
});
}
return usages;
return current;
}
/**
* 获取翻译对象中所有的键路径
*/
function getAllKeysFromTranslation(obj: Record<string, unknown>, prefix = ""): Set<string> {
const keys = new Set<string>();
function keyExists(key: string, namespace: string, translations: Record<string, unknown>): boolean {
let targetObj: unknown;
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
keys.add(fullKey);
if (typeof obj[key] === "object" && obj[key] !== null) {
const nestedKeys = getAllKeysFromTranslation(
obj[key] as Record<string, unknown>,
fullKey
);
nestedKeys.forEach(k => keys.add(k));
}
}
return keys;
}
/**
* 检查键是否存在于翻译对象中
*/
function keyExistsInTranslation(key: string, namespace: string, translations: Record<string, unknown>): boolean {
// 处理根级别翻译
if (namespace === "__ROOT__") {
const parts = key.split(".");
let current: unknown = translations;
for (const part of parts) {
if (typeof current !== "object" || current === null) {
return false;
}
current = (current as Record<string, unknown>)[part];
}
return typeof current === "string";
targetObj = translations;
} else {
targetObj = navigateToObject(translations, namespace);
}
// 处理带命名空间的翻译
const nsTranslations = translations[namespace];
if (!nsTranslations || typeof nsTranslations !== "object") {
return false;
}
if (typeof targetObj !== "object" || targetObj === null) return false;
const parts = key.split(".");
let current: unknown = nsTranslations;
const target = targetObj as Record<string, unknown>;
for (const part of parts) {
if (typeof current !== "object" || current === null) {
return false;
}
if (typeof target[key] === "string") return true;
let current: unknown = target;
for (const part of key.split(".")) {
if (typeof current !== "object" || current === null) return false;
current = (current as Record<string, unknown>)[part];
}
return typeof current === "string";
}
/**
* 主函数
*/
function main() {
const targetLocale = process.argv[2];
const localesToCheck = targetLocale
? [targetLocale]
: ALL_LOCALES;
const localesToCheck = targetLocale ? [targetLocale] : ALL_LOCALES;
console.log("🔍 扫描代码中的翻译使用情况...\n");
console.log("Scanning source files...\n");
// 获取所有源代码文件
const sourceFiles = [
...getAllFiles(SRC_DIR, [".tsx", ".ts"]),
];
const sourceFiles = getAllFiles(SRC_DIR, [".tsx", ".ts"]);
const allUsages: TranslationUsage[] = [];
// 提取所有翻译使用
const allUsages: FileTranslationUsage[] = [];
for (const file of sourceFiles) {
allUsages.push(...extractTranslationsFromFile(file));
for (const filePath of sourceFiles) {
const content = fs.readFileSync(filePath, "utf-8");
const bindings = extractTranslationBindings(content);
const usages = extractTranslationCalls(content, filePath, bindings);
allUsages.push(...usages);
}
console.log(`📁 扫描了 ${sourceFiles.length} 个文件`);
console.log(`📝 发现 ${allUsages.length} 个翻译使用声明\n`);
const uniqueUsages = new Map<string, TranslationUsage>();
for (const usage of allUsages) {
const key = `${usage.file}:${usage.line}:${usage.namespace}:${usage.key}`;
uniqueUsages.set(key, usage);
}
const dedupedUsages = Array.from(uniqueUsages.values());
console.log(`Found ${sourceFiles.length} files, ${dedupedUsages.length} translation usages\n`);
// 检查每种语言
for (const locale of localesToCheck) {
console.log(`\n${"=".repeat(60)}`);
console.log(`🌐 检查语言: ${locale}`);
console.log(`Locale: ${locale}`);
console.log("=".repeat(60));
const translationPath = path.join(MESSAGES_DIR, `${locale}.json`);
if (!fs.existsSync(translationPath)) {
console.log(`❌ 翻译文件不存在: ${translationPath}`);
console.log(`File not found: ${translationPath}`);
continue;
}
let translations: Record<string, unknown>;
try {
const content = fs.readFileSync(translationPath, "utf-8");
translations = JSON.parse(content);
translations = JSON.parse(fs.readFileSync(translationPath, "utf-8"));
} catch (e) {
console.log(`❌ 翻译文件解析失败: ${translationPath}`);
console.log(`Failed to parse: ${translationPath}`);
console.log(e);
continue;
}
const missingKeys: { file: string; namespace: string; key: string }[] = [];
const missing: TranslationUsage[] = [];
const dynamic: TranslationUsage[] = [];
for (const usage of allUsages) {
for (const key of usage.keys) {
// 跳过动态键标记
if (key.startsWith("[DYNAMIC PREFIX:")) {
continue;
}
if (!keyExistsInTranslation(key, usage.namespace, translations)) {
missingKeys.push({
file: usage.file,
namespace: usage.namespace,
key: key,
});
}
for (const usage of dedupedUsages) {
if (usage.isDynamic) {
dynamic.push(usage);
} else if (!keyExists(usage.key, usage.namespace, translations)) {
missing.push(usage);
}
}
if (missingKeys.length === 0) {
console.log(`✅ 没有缺失的翻译键`);
if (missing.length === 0 && dynamic.length === 0) {
console.log("All translations exist!");
} else {
console.log(`\n❌ 发现 ${missingKeys.length} 个缺失的翻译键:\n`);
// 按文件分组显示
const groupedByFile = new Map<string, typeof missingKeys>();
for (const item of missingKeys) {
const file = item.file;
if (!groupedByFile.has(file)) {
groupedByFile.set(file, []);
if (missing.length > 0) {
console.log(`\nMissing ${missing.length} translations:\n`);
const byFile = new Map<string, TranslationUsage[]>();
for (const usage of missing) {
if (!byFile.has(usage.file)) byFile.set(usage.file, []);
byFile.get(usage.file)!.push(usage);
}
for (const [file, usages] of byFile) {
console.log(file);
for (const usage of usages) {
const ns = usage.namespace === "__ROOT__" ? "(root)" : usage.namespace;
console.log(` L${usage.line} [${ns}] ${usage.key}`);
}
console.log();
}
groupedByFile.get(file)!.push(item);
}
for (const [file, items] of groupedByFile) {
console.log(`📄 ${file}`);
for (const item of items) {
const nsDisplay = item.namespace === "__ROOT__" ? "(root)" : item.namespace;
console.log(` [${nsDisplay}] ${item.key}`);
if (dynamic.length > 0) {
console.log(`\n${dynamic.length} dynamic keys (manual review):\n`);
const byFile = new Map<string, TranslationUsage[]>();
for (const usage of dynamic) {
if (!byFile.has(usage.file)) byFile.set(usage.file, []);
byFile.get(usage.file)!.push(usage);
}
for (const [file, usages] of byFile) {
console.log(file);
for (const usage of usages) {
const ns = usage.namespace === "__ROOT__" ? "(root)" : usage.namespace;
console.log(` L${usage.line} [${ns}] ${usage.key}`);
}
}
console.log();
}
}
}
console.log("\n✨ 检查完成!");
console.log("\nDone!");
}
main();