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:
@@ -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`);
|
||||
if (missing.length > 0) {
|
||||
console.log(`\nMissing ${missing.length} translations:\n`);
|
||||
|
||||
// 按文件分组显示
|
||||
const groupedByFile = new Map<string, typeof missingKeys>();
|
||||
for (const item of missingKeys) {
|
||||
const file = item.file;
|
||||
if (!groupedByFile.has(file)) {
|
||||
groupedByFile.set(file, []);
|
||||
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();
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
/**
|
||||
* 查找多余(未使用)的翻译键
|
||||
*
|
||||
* 扫描翻译文件中的所有键,与代码中实际使用的键对比,找出多余的键
|
||||
*
|
||||
* 查找多余的翻译键
|
||||
* 用法: npx tsx scripts/find-unused-translations.ts [locale]
|
||||
* 示例:
|
||||
* npx tsx scripts/find-unused-translations.ts # 检查所有语言
|
||||
* npx tsx scripts/find-unused-translations.ts en-US # 只检查 en-US
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
@@ -14,32 +8,75 @@ 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",
|
||||
];
|
||||
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 extractUsedKeys(content: string, bindings: Map<string, string>): Map<string, Set<string>> {
|
||||
const usedKeys = new Map<string, Set<string>>();
|
||||
|
||||
for (const [varName, namespace] of bindings) {
|
||||
const callPattern = new RegExp(
|
||||
`\\b${varName}\\s*\\(\\s*("[^"]*"|'[^']*'|\`[^\`]*\`)(?:\\s*,|\\s*\\))`,
|
||||
"g"
|
||||
);
|
||||
|
||||
let match;
|
||||
while ((match = callPattern.exec(content)) !== null) {
|
||||
const arg = match[1];
|
||||
const key = parseStringLiteral(arg);
|
||||
|
||||
if (key !== null) {
|
||||
if (!usedKeys.has(namespace)) {
|
||||
usedKeys.set(namespace, new Set());
|
||||
}
|
||||
usedKeys.get(namespace)!.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return usedKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归获取目录下所有文件
|
||||
*/
|
||||
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))) {
|
||||
@@ -50,142 +87,19 @@ function getAllFiles(dir: string, extensions: string[]): string[] {
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码中实际使用的翻译键
|
||||
*/
|
||||
interface UsedTranslations {
|
||||
// namespace -> Set of keys
|
||||
byNamespace: Map<string, Set<string>>;
|
||||
// 根级别的完整路径 (当使用 useTranslations() 无参数时)
|
||||
rootKeys: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从代码文件中提取使用的翻译键
|
||||
*/
|
||||
function extractUsedTranslationsFromFiles(files: string[]): UsedTranslations {
|
||||
const result: UsedTranslations = {
|
||||
byNamespace: new Map(),
|
||||
rootKeys: new Set(),
|
||||
};
|
||||
|
||||
for (const filePath of files) {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
// 匹配 useTranslations("namespace") 或 getTranslations("namespace")
|
||||
const namespacePattern = /(?:useTranslations|getTranslations)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
|
||||
|
||||
// 检查是否有根级别的翻译 (无参数调用)
|
||||
const hasRootTranslations = /(?:useTranslations|getTranslations)\s*\(\s*\)/.test(content);
|
||||
|
||||
// 收集该文件中的所有 namespace
|
||||
const namespaces: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = namespacePattern.exec(content)) !== null) {
|
||||
namespaces.push(match[1]);
|
||||
}
|
||||
|
||||
// 匹配 t("key") 或 t('key') 或 t(`key`) 调用
|
||||
const tCallPattern = /\bt\s*\(\s*["'`]([^"'`]+)["'`]\s*(?:,|\))/g;
|
||||
|
||||
const keysInFile = new Set<string>();
|
||||
|
||||
while ((match = tCallPattern.exec(content)) !== null) {
|
||||
keysInFile.add(match[1]);
|
||||
}
|
||||
|
||||
// 匹配带命名空间的 t 调用 t("namespace.key") - 跨 namespace 访问
|
||||
const crossNamespacePattern = /\bt\s*\(\s*["'`]([^.]+\.[^"'`]+)["'`]\s*(?:,|\))/g;
|
||||
|
||||
// 将键添加到对应的 namespace
|
||||
for (const ns of namespaces) {
|
||||
if (!result.byNamespace.has(ns)) {
|
||||
result.byNamespace.set(ns, new Set());
|
||||
}
|
||||
|
||||
for (const key of keysInFile) {
|
||||
// 检查是否是跨 namespace 访问 (包含点)
|
||||
const dotIndex = key.indexOf(".");
|
||||
if (dotIndex > 0) {
|
||||
const possibleNs = key.substring(0, dotIndex);
|
||||
const remainingKey = key.substring(dotIndex + 1);
|
||||
|
||||
// 如果第一部分是已知的 namespace,则是跨 namespace 访问
|
||||
if (result.byNamespace.has(possibleNs) || possibleNs !== ns) {
|
||||
// 添加到对应的 namespace
|
||||
if (!result.byNamespace.has(possibleNs)) {
|
||||
result.byNamespace.set(possibleNs, new Set());
|
||||
}
|
||||
result.byNamespace.get(possibleNs)!.add(remainingKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.byNamespace.get(ns)!.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有根级别翻译,添加到 rootKeys
|
||||
if (hasRootTranslations) {
|
||||
for (const key of keysInFile) {
|
||||
result.rootKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取翻译对象中所有的键路径(带完整路径)
|
||||
*/
|
||||
function getAllTranslationKeys(
|
||||
function flattenTranslations(
|
||||
obj: Record<string, unknown>,
|
||||
locale: string
|
||||
): { namespace: string; key: string; fullPath: string }[] {
|
||||
const result: { namespace: string; key: string; fullPath: string }[] = [];
|
||||
|
||||
// 获取顶层 namespace
|
||||
for (const ns of Object.keys(obj)) {
|
||||
const nsValue = obj[ns];
|
||||
|
||||
if (typeof nsValue === "object" && nsValue !== null) {
|
||||
// 递归获取该 namespace 下的所有键
|
||||
const keys = getNestedKeys(nsValue as Record<string, unknown>);
|
||||
|
||||
for (const key of keys) {
|
||||
result.push({
|
||||
namespace: ns,
|
||||
key: key,
|
||||
fullPath: `${ns}.${key}`,
|
||||
});
|
||||
}
|
||||
} else if (typeof nsValue === "string") {
|
||||
// 顶层直接是字符串值
|
||||
result.push({
|
||||
namespace: ns,
|
||||
key: "",
|
||||
fullPath: ns,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归获取嵌套对象的键路径
|
||||
*/
|
||||
function getNestedKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||
prefix = ""
|
||||
): string[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const value = obj[key];
|
||||
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
keys.push(...getNestedKeys(obj[key] as Record<string, unknown>, fullKey));
|
||||
} else if (typeof obj[key] === "string") {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
keys.push(...flattenTranslations(value as Record<string, unknown>, fullKey));
|
||||
} else if (typeof value === "string") {
|
||||
keys.push(fullKey);
|
||||
}
|
||||
}
|
||||
@@ -193,139 +107,114 @@ function getNestedKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查键是否被代码使用
|
||||
*/
|
||||
function isKeyUsed(
|
||||
namespace: string,
|
||||
key: string,
|
||||
used: UsedTranslations
|
||||
fullKey: string,
|
||||
usedKeys: Map<string, Set<string>>
|
||||
): boolean {
|
||||
// 检查该 namespace 下是否使用了该键
|
||||
const nsKeys = used.byNamespace.get(namespace);
|
||||
if (nsKeys) {
|
||||
// 检查精确匹配
|
||||
if (nsKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
const parts = fullKey.split(".");
|
||||
|
||||
// 检查前缀匹配 (父级键被使用时,子键也算被使用)
|
||||
for (const usedKey of nsKeys) {
|
||||
if (key.startsWith(usedKey + ".") || usedKey.startsWith(key + ".")) {
|
||||
return true;
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const namespace = parts.slice(0, i).join(".");
|
||||
const keyInNamespace = parts.slice(i).join(".");
|
||||
|
||||
const nsKeys = usedKeys.get(namespace);
|
||||
if (nsKeys) {
|
||||
if (nsKeys.has(keyInNamespace)) return true;
|
||||
|
||||
for (const usedKey of nsKeys) {
|
||||
if (keyInNamespace.startsWith(usedKey + ".")) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查根级别使用
|
||||
for (const rootKey of used.rootKeys) {
|
||||
if (rootKey === `${namespace}.${key}` || rootKey.startsWith(`${namespace}.${key}.`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查跨 namespace 使用 (t("namespace.key") 形式)
|
||||
for (const [ns, keys] of used.byNamespace) {
|
||||
for (const k of keys) {
|
||||
// 检查是否是跨 namespace 访问
|
||||
if (k.includes(".")) {
|
||||
const fullKey = `${ns}.${k}`;
|
||||
if (fullKey === `${namespace}.${key}`) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const rootKeys = usedKeys.get("__ROOT__");
|
||||
if (rootKeys && rootKeys.has(fullKey)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主函数
|
||||
*/
|
||||
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 allUsedKeys = new Map<string, Set<string>>();
|
||||
|
||||
console.log(`📁 扫描了 ${sourceFiles.length} 个文件`);
|
||||
for (const filePath of sourceFiles) {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const bindings = extractTranslationBindings(content);
|
||||
const usedKeys = extractUsedKeys(content, bindings);
|
||||
|
||||
// 提取代码中使用的翻译
|
||||
const usedTranslations = extractUsedTranslationsFromFiles(sourceFiles);
|
||||
|
||||
console.log(`📝 发现 ${usedTranslations.byNamespace.size} 个命名空间被使用`);
|
||||
let totalKeys = 0;
|
||||
for (const keys of usedTranslations.byNamespace.values()) {
|
||||
totalKeys += keys.size;
|
||||
for (const [ns, keys] of usedKeys) {
|
||||
if (!allUsedKeys.has(ns)) {
|
||||
allUsedKeys.set(ns, new Set());
|
||||
}
|
||||
for (const key of keys) {
|
||||
allUsedKeys.get(ns)!.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`📝 发现 ${totalKeys} 个翻译键被使用\n`);
|
||||
|
||||
// 检查每种语言
|
||||
console.log(`Scanned ${sourceFiles.length} files`);
|
||||
console.log(`Found ${allUsedKeys.size} namespaces used\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(e);
|
||||
console.log(`Failed to parse: ${translationPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取翻译文件中的所有键
|
||||
const allTranslationKeys = getAllTranslationKeys(translations, locale);
|
||||
const allKeys = flattenTranslations(translations);
|
||||
console.log(`Total ${allKeys.length} translation keys`);
|
||||
|
||||
console.log(`📊 翻译文件中共有 ${allTranslationKeys.length} 个翻译键`);
|
||||
|
||||
// 找出未使用的键
|
||||
const unusedKeys = allTranslationKeys.filter(
|
||||
item => !isKeyUsed(item.namespace, item.key, usedTranslations)
|
||||
);
|
||||
const unusedKeys = allKeys.filter(key => !isKeyUsed(key, allUsedKeys));
|
||||
|
||||
if (unusedKeys.length === 0) {
|
||||
console.log(`✅ 没有多余的翻译键`);
|
||||
console.log("No unused translations!");
|
||||
} else {
|
||||
console.log(`\n⚠️ 发现 ${unusedKeys.length} 个可能多余的翻译键:\n`);
|
||||
console.log(`\n${unusedKeys.length} potentially unused translations:\n`);
|
||||
|
||||
// 按 namespace 分组显示
|
||||
const groupedByNs = new Map<string, typeof unusedKeys>();
|
||||
for (const item of unusedKeys) {
|
||||
if (!groupedByNs.has(item.namespace)) {
|
||||
groupedByNs.set(item.namespace, []);
|
||||
const groupedByNs = new Map<string, string[]>();
|
||||
for (const key of unusedKeys) {
|
||||
const firstDot = key.indexOf(".");
|
||||
const ns = firstDot > 0 ? key.substring(0, firstDot) : key;
|
||||
const subKey = firstDot > 0 ? key.substring(firstDot + 1) : "";
|
||||
|
||||
if (!groupedByNs.has(ns)) {
|
||||
groupedByNs.set(ns, []);
|
||||
}
|
||||
groupedByNs.get(item.namespace)!.push(item);
|
||||
groupedByNs.get(ns)!.push(subKey || "(root)");
|
||||
}
|
||||
|
||||
for (const [ns, items] of groupedByNs) {
|
||||
console.log(`📦 ${ns}`);
|
||||
for (const item of items) {
|
||||
console.log(` ${item.key || "(root value)"}`);
|
||||
for (const [ns, keys] of groupedByNs) {
|
||||
console.log(`${ns}`);
|
||||
for (const key of keys) {
|
||||
console.log(` ${key}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log("💡 提示: 这些键可能被动态使用(如 t(`prefix.${var}`)),请人工确认后再删除。");
|
||||
console.log("Note: These may be used dynamically (e.g., t(`prefix.${var}`)). Review before deleting.");
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n✨ 检查完成!");
|
||||
console.log("\nDone!");
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
Reference in New Issue
Block a user