diff --git a/scripts/find-missing-translations.ts b/scripts/find-missing-translations.ts new file mode 100644 index 0000000..583f666 --- /dev/null +++ b/scripts/find-missing-translations.ts @@ -0,0 +1,291 @@ +/** + * 查找缺失的翻译键 + * + * 扫描代码中使用的翻译键,与翻译文件对比,找出缺失的键 + * + * 用法: 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"; +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", +]; + +interface FileTranslationUsage { + file: string; + namespace: string; + keys: Set; +} + +/** + * 递归获取目录下所有文件 + */ +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) { + 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))) { + files.push(fullPath); + } + } + + return files; +} + +/** + * 从代码文件中提取翻译使用情况 + */ +function extractTranslationsFromFile(filePath: string): FileTranslationUsage[] { + const content = fs.readFileSync(filePath, "utf-8"); + const usages: FileTranslationUsage[] = []; + + // 匹配 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 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(); + + 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; +} + +/** + * 获取翻译对象中所有的键路径 + */ +function getAllKeysFromTranslation(obj: Record, prefix = ""): Set { + const keys = new Set(); + + 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, + fullKey + ); + nestedKeys.forEach(k => keys.add(k)); + } + } + + return keys; +} + +/** + * 检查键是否存在于翻译对象中 + */ +function keyExistsInTranslation(key: string, namespace: string, translations: Record): 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)[part]; + } + + return typeof current === "string"; + } + + // 处理带命名空间的翻译 + const nsTranslations = translations[namespace]; + if (!nsTranslations || typeof nsTranslations !== "object") { + return false; + } + + const parts = key.split("."); + let current: unknown = nsTranslations; + + for (const part of parts) { + if (typeof current !== "object" || current === null) { + return false; + } + current = (current as Record)[part]; + } + + return typeof current === "string"; +} + +/** + * 主函数 + */ +function main() { + const targetLocale = process.argv[2]; + const localesToCheck = targetLocale + ? [targetLocale] + : ALL_LOCALES; + + console.log("🔍 扫描代码中的翻译使用情况...\n"); + + // 获取所有源代码文件 + const sourceFiles = [ + ...getAllFiles(SRC_DIR, [".tsx", ".ts"]), + ]; + + // 提取所有翻译使用 + const allUsages: FileTranslationUsage[] = []; + for (const file of sourceFiles) { + allUsages.push(...extractTranslationsFromFile(file)); + } + + console.log(`📁 扫描了 ${sourceFiles.length} 个文件`); + console.log(`📝 发现 ${allUsages.length} 个翻译使用声明\n`); + + // 检查每种语言 + for (const locale of localesToCheck) { + console.log(`\n${"=".repeat(60)}`); + console.log(`🌐 检查语言: ${locale}`); + console.log("=".repeat(60)); + + const translationPath = path.join(MESSAGES_DIR, `${locale}.json`); + + if (!fs.existsSync(translationPath)) { + console.log(`❌ 翻译文件不存在: ${translationPath}`); + continue; + } + + let translations: Record; + try { + const content = fs.readFileSync(translationPath, "utf-8"); + translations = JSON.parse(content); + } catch (e) { + console.log(`❌ 翻译文件解析失败: ${translationPath}`); + console.log(e); + continue; + } + + const missingKeys: { file: string; namespace: string; key: string }[] = []; + + 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, + }); + } + } + } + + if (missingKeys.length === 0) { + console.log(`✅ 没有缺失的翻译键`); + } else { + console.log(`\n❌ 发现 ${missingKeys.length} 个缺失的翻译键:\n`); + + // 按文件分组显示 + const groupedByFile = new Map(); + for (const item of missingKeys) { + const file = item.file; + if (!groupedByFile.has(file)) { + groupedByFile.set(file, []); + } + 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}`); + } + console.log(); + } + } + } + + console.log("\n✨ 检查完成!"); +} + +main(); diff --git a/scripts/find-unused-translations.ts b/scripts/find-unused-translations.ts new file mode 100644 index 0000000..ed6dbeb --- /dev/null +++ b/scripts/find-unused-translations.ts @@ -0,0 +1,331 @@ +/** + * 查找多余(未使用)的翻译键 + * + * 扫描翻译文件中的所有键,与代码中实际使用的键对比,找出多余的键 + * + * 用法: 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"; +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", +]; + +/** + * 递归获取目录下所有文件 + */ +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) { + 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))) { + files.push(fullPath); + } + } + + return files; +} + +/** + * 代码中实际使用的翻译键 + */ +interface UsedTranslations { + // namespace -> Set of keys + byNamespace: Map>; + // 根级别的完整路径 (当使用 useTranslations() 无参数时) + rootKeys: Set; +} + +/** + * 从代码文件中提取使用的翻译键 + */ +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(); + + 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( + obj: Record, + 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); + + 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, prefix = ""): string[] { + const keys: string[] = []; + + for (const key of Object.keys(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (typeof obj[key] === "object" && obj[key] !== null) { + keys.push(...getNestedKeys(obj[key] as Record, fullKey)); + } else if (typeof obj[key] === "string") { + keys.push(fullKey); + } + } + + return keys; +} + +/** + * 检查键是否被代码使用 + */ +function isKeyUsed( + namespace: string, + key: string, + used: UsedTranslations +): boolean { + // 检查该 namespace 下是否使用了该键 + const nsKeys = used.byNamespace.get(namespace); + if (nsKeys) { + // 检查精确匹配 + if (nsKeys.has(key)) { + return true; + } + + // 检查前缀匹配 (父级键被使用时,子键也算被使用) + for (const usedKey of nsKeys) { + if (key.startsWith(usedKey + ".") || usedKey.startsWith(key + ".")) { + 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; + } + } + } + } + + return false; +} + +/** + * 主函数 + */ +function main() { + const targetLocale = process.argv[2]; + const localesToCheck = targetLocale + ? [targetLocale] + : ALL_LOCALES; + + console.log("🔍 扫描代码中使用的翻译键...\n"); + + // 获取所有源代码文件 + const sourceFiles = getAllFiles(SRC_DIR, [".tsx", ".ts"]); + + console.log(`📁 扫描了 ${sourceFiles.length} 个文件`); + + // 提取代码中使用的翻译 + const usedTranslations = extractUsedTranslationsFromFiles(sourceFiles); + + console.log(`📝 发现 ${usedTranslations.byNamespace.size} 个命名空间被使用`); + let totalKeys = 0; + for (const keys of usedTranslations.byNamespace.values()) { + totalKeys += keys.size; + } + console.log(`📝 发现 ${totalKeys} 个翻译键被使用\n`); + + // 检查每种语言 + for (const locale of localesToCheck) { + console.log(`\n${"=".repeat(60)}`); + console.log(`🌐 检查语言: ${locale}`); + console.log("=".repeat(60)); + + const translationPath = path.join(MESSAGES_DIR, `${locale}.json`); + + if (!fs.existsSync(translationPath)) { + console.log(`❌ 翻译文件不存在: ${translationPath}`); + continue; + } + + let translations: Record; + try { + const content = fs.readFileSync(translationPath, "utf-8"); + translations = JSON.parse(content); + } catch (e) { + console.log(`❌ 翻译文件解析失败: ${translationPath}`); + console.log(e); + continue; + } + + // 获取翻译文件中的所有键 + const allTranslationKeys = getAllTranslationKeys(translations, locale); + + console.log(`📊 翻译文件中共有 ${allTranslationKeys.length} 个翻译键`); + + // 找出未使用的键 + const unusedKeys = allTranslationKeys.filter( + item => !isKeyUsed(item.namespace, item.key, usedTranslations) + ); + + if (unusedKeys.length === 0) { + console.log(`✅ 没有多余的翻译键`); + } else { + console.log(`\n⚠️ 发现 ${unusedKeys.length} 个可能多余的翻译键:\n`); + + // 按 namespace 分组显示 + const groupedByNs = new Map(); + for (const item of unusedKeys) { + if (!groupedByNs.has(item.namespace)) { + groupedByNs.set(item.namespace, []); + } + groupedByNs.get(item.namespace)!.push(item); + } + + for (const [ns, items] of groupedByNs) { + console.log(`📦 ${ns}`); + for (const item of items) { + console.log(` ${item.key || "(root value)"}`); + } + console.log(); + } + + console.log("💡 提示: 这些键可能被动态使用(如 t(`prefix.${var}`)),请人工确认后再删除。"); + } + } + + console.log("\n✨ 检查完成!"); +} + +main();