From 238e3d60f872b6658d99b8fca7e2140f095289e4 Mon Sep 17 00:00:00 2001 From: Slinetrac Date: Fri, 31 Oct 2025 17:08:45 +0800 Subject: [PATCH] feat: add script to clean up unused i18n keys --- scripts/check-unused-i18n.js | 102 -------- scripts/cleanup-unused-i18n.mjs | 412 ++++++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+), 102 deletions(-) delete mode 100644 scripts/check-unused-i18n.js create mode 100644 scripts/cleanup-unused-i18n.mjs diff --git a/scripts/check-unused-i18n.js b/scripts/check-unused-i18n.js deleted file mode 100644 index 95958df4..00000000 --- a/scripts/check-unused-i18n.js +++ /dev/null @@ -1,102 +0,0 @@ -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const LOCALES_DIR = path.resolve(__dirname, "../src/locales"); -const SRC_DIRS = [ - path.resolve(__dirname, "../src"), - path.resolve(__dirname, "../src-tauri"), -]; -const exts = [".js", ".ts", ".tsx", ".jsx", ".vue", ".rs"]; - -// 递归获取所有文件 -function getAllFiles(dir, exts) { - let files = []; - fs.readdirSync(dir).forEach((file) => { - const full = path.join(dir, file); - if (fs.statSync(full).isDirectory()) { - files = files.concat(getAllFiles(full, exts)); - } else if (exts.includes(path.extname(full))) { - files.push(full); - } - }); - return files; -} - -// 读取所有源码内容为一个大字符串 -function getAllSourceContent() { - const files = SRC_DIRS.flatMap((dir) => getAllFiles(dir, exts)); - return files.map((f) => fs.readFileSync(f, "utf8")).join("\n"); -} - -// 白名单 key,不检查这些 key 是否被使用 -const WHITELIST_KEYS = [ - "theme.light", - "theme.dark", - "theme.system", - "Already Using Latest Core Version", -]; - -// 主流程 -function processI18nFile(i18nPath, lang, allSource) { - const i18n = JSON.parse(fs.readFileSync(i18nPath, "utf8")); - const keys = Object.keys(i18n); - - const used = {}; - const unused = []; - - let checked = 0; - const total = keys.length; - keys.forEach((key) => { - if (WHITELIST_KEYS.includes(key)) { - used[key] = i18n[key]; - } else { - // 只查找一次 - const regex = new RegExp(`["'\`]${key}["'\`]`); - if (regex.test(allSource)) { - used[key] = i18n[key]; - } else { - unused.push(key); - } - } - checked++; - if (checked % 20 === 0 || checked === total) { - const percent = ((checked / total) * 100).toFixed(1); - process.stdout.write( - `\r[${lang}] Progress: ${checked}/${total} (${percent}%)`, - ); - if (checked === total) process.stdout.write("\n"); - } - }); - - // 输出未使用的 key - console.log(`\n[${lang}] Unused keys:`, unused); - - // 备份原文件 - const oldPath = i18nPath + ".old"; - fs.renameSync(i18nPath, oldPath); - - // 写入精简后的 i18n 文件(保留原文件名) - fs.writeFileSync(i18nPath, JSON.stringify(used, null, 2), "utf8"); - console.log( - `[${lang}] Cleaned i18n file written to src/locales/${path.basename(i18nPath)}`, - ); - console.log(`[${lang}] Original file backed up as ${path.basename(oldPath)}`); -} - -function main() { - // 支持 zhtw.json、zh-tw.json、zh_CN.json 等 - const files = fs - .readdirSync(LOCALES_DIR) - .filter((f) => /^[a-z0-9\-_]+\.json$/i.test(f) && !f.endsWith(".old")); - const allSource = getAllSourceContent(); - files.forEach((file) => { - const lang = path.basename(file, ".json"); - processI18nFile(path.join(LOCALES_DIR, file), lang, allSource); - }); -} - -main(); diff --git a/scripts/cleanup-unused-i18n.mjs b/scripts/cleanup-unused-i18n.mjs new file mode 100644 index 00000000..5f426730 --- /dev/null +++ b/scripts/cleanup-unused-i18n.mjs @@ -0,0 +1,412 @@ +#!/usr/bin/env node + +import fs from "fs"; +import path from "path"; +import process from "process"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DEFAULT_SOURCE_DIRS = [ + path.resolve(__dirname, "../src"), + path.resolve(__dirname, "../src-tauri"), +]; +const LOCALES_DIR = path.resolve(__dirname, "../src/locales"); +const IGNORE_DIR_NAMES = new Set([ + ".git", + ".idea", + ".turbo", + ".next", + ".cache", + ".pnpm", + "node_modules", + "dist", + "build", + "coverage", + "out", + "target", + "gen", + "packages", + "release", + "logs", + "__pycache__", +]); +const SUPPORTED_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".vue", + ".rs", + ".json", +]); + +const WHITELIST_KEYS = new Set([ + "theme.light", + "theme.dark", + "theme.system", + "Already Using Latest Core Version", +]); + +function printUsage() { + console.log(`Usage: pnpm node scripts/cleanup-unused-i18n.mjs [options] + +Options: + --apply Write locale files with unused keys removed (default: report only) + --no-backup Skip creating \`.bak\` backups when applying changes + --report Write a JSON report to the given path + --src Include an additional source directory (repeatable) + --help Show this message +`); +} + +function parseArgs(argv) { + const options = { + apply: false, + backup: true, + reportPath: null, + extraSources: [], + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + switch (arg) { + case "--apply": + options.apply = true; + break; + case "--no-backup": + options.backup = false; + break; + case "--report": { + const next = argv[i + 1]; + if (!next) { + throw new Error("--report requires a file path"); + } + options.reportPath = path.resolve(process.cwd(), next); + i += 1; + break; + } + case "--src": + case "--source": { + const next = argv[i + 1]; + if (!next) { + throw new Error(`${arg} requires a directory path`); + } + options.extraSources.push(path.resolve(process.cwd(), next)); + i += 1; + break; + } + case "--help": + printUsage(); + process.exit(0); + break; + default: + if (arg.startsWith("--")) { + throw new Error(`Unknown option: ${arg}`); + } + throw new Error(`Unexpected positional argument: ${arg}`); + } + } + + return options; +} + +function getAllFiles(start, predicate) { + if (!fs.existsSync(start)) return []; + + const stack = [start]; + const files = []; + + while (stack.length) { + const current = stack.pop(); + if (!current) continue; + const stat = fs.statSync(current); + if (stat.isDirectory()) { + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isSymbolicLink()) { + continue; + } + if (IGNORE_DIR_NAMES.has(entry.name)) { + continue; + } + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(entryPath); + } else if (!predicate || predicate(entryPath)) { + files.push(entryPath); + } + } + } else if (!predicate || predicate(current)) { + files.push(current); + } + } + + return files; +} + +function loadSourceContents(sourceDirs) { + const sourceFiles = sourceDirs.flatMap((dir) => + getAllFiles(dir, (filePath) => + SUPPORTED_EXTENSIONS.has(path.extname(filePath)), + ), + ); + + return sourceFiles + .map((filePath) => fs.readFileSync(filePath, "utf8")) + .join("\n"); +} + +function flattenLocale(obj, parent = "") { + const entries = new Map(); + + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + return entries; + } + + for (const [key, value] of Object.entries(obj)) { + const currentPath = parent ? `${parent}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value)) { + const childEntries = flattenLocale(value, currentPath); + for (const [childKey, childValue] of childEntries) { + entries.set(childKey, childValue); + } + } else { + entries.set(currentPath, value); + } + } + + return entries; +} + +function removeKey(target, dottedKey) { + const parts = dottedKey.split("."); + const last = parts.pop(); + + if (!last) return; + + let current = target; + for (const part of parts) { + if ( + !current || + typeof current !== "object" || + Array.isArray(current) || + !(part in current) + ) { + return; + } + current = current[part]; + } + + if (current && typeof current === "object") { + delete current[last]; + } +} + +function cleanupEmptyBranches(target) { + if (!target || typeof target !== "object" || Array.isArray(target)) { + return false; + } + + for (const key of Object.keys(target)) { + if (cleanupEmptyBranches(target[key])) { + delete target[key]; + } + } + + return Object.keys(target).length === 0; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isKeyUsed(content, key) { + if (WHITELIST_KEYS.has(key)) return true; + if (!key) return false; + + const pattern = new RegExp(`(['"\`])${escapeRegExp(key)}\\1`); + return pattern.test(content); +} + +function writeReport(reportPath, data) { + const payload = JSON.stringify(data, null, 2); + fs.writeFileSync(reportPath, `${payload}\n`, "utf8"); +} + +function loadLocales() { + if (!fs.existsSync(LOCALES_DIR)) { + throw new Error(`Locales directory not found: ${LOCALES_DIR}`); + } + + return fs + .readdirSync(LOCALES_DIR) + .filter( + (file) => + /^[a-z0-9\-_]+\.json$/i.test(file) && + !file.endsWith(".bak") && + !file.endsWith(".old"), + ) + .map((file) => ({ + name: path.basename(file, ".json"), + path: path.join(LOCALES_DIR, file), + })); +} + +function ensureBackup(localePath) { + const backupPath = `${localePath}.bak`; + if (fs.existsSync(backupPath)) { + throw new Error( + `Backup file already exists for ${path.basename(localePath)}; ` + + "either remove it manually or rerun with --no-backup", + ); + } + fs.copyFileSync(localePath, backupPath); + return backupPath; +} + +function processLocale(locale, allSourceContent, options) { + const raw = fs.readFileSync(locale.path, "utf8"); + const data = JSON.parse(raw); + const flattened = flattenLocale(data); + + const unused = []; + for (const key of flattened.keys()) { + if (!isKeyUsed(allSourceContent, key)) { + unused.push(key); + } + } + + if (unused.length === 0) { + console.log(`[${locale.name}] No unused keys 🎉`); + return { + locale: locale.name, + file: locale.path, + totalKeys: flattened.size, + unusedKeys: [], + removed: [], + }; + } + + console.log( + `[${locale.name}] Found ${unused.length} unused keys (of ${flattened.size}):`, + ); + for (const key of unused) { + console.log(` - ${key}`); + } + + const removed = []; + if (options.apply) { + const updated = JSON.parse(JSON.stringify(data)); + for (const key of unused) { + removeKey(updated, key); + removed.push(key); + } + cleanupEmptyBranches(updated); + + if (options.backup) { + const backupPath = ensureBackup(locale.path); + console.log( + `[${locale.name}] Backup written to ${path.basename(backupPath)}`, + ); + } + + const serialized = JSON.stringify(updated, null, 2); + fs.writeFileSync(locale.path, `${serialized}\n`, "utf8"); + console.log( + `[${locale.name}] Updated locale file saved (${removed.length} keys removed)`, + ); + } + + return { + locale: locale.name, + file: locale.path, + totalKeys: flattened.size, + unusedKeys: unused, + removed, + }; +} + +function main() { + const argv = process.argv.slice(2); + + let options; + try { + options = parseArgs(argv); + } catch (error) { + console.error(`Error: ${error.message}`); + console.log(); + printUsage(); + process.exit(1); + } + + const sourceDirs = [ + ...new Set([...DEFAULT_SOURCE_DIRS, ...options.extraSources]), + ]; + + console.log("Scanning source directories:"); + for (const dir of sourceDirs) { + console.log(` - ${dir}`); + } + + const allSourceContent = loadSourceContents(sourceDirs); + const locales = loadLocales(); + + if (locales.length === 0) { + console.log("No locale files found."); + return; + } + + console.log(`\nChecking ${locales.length} locale files...\n`); + + const results = locales.map((locale) => + processLocale(locale, allSourceContent, options), + ); + + const totalUnused = results.reduce( + (count, result) => count + result.unusedKeys.length, + 0, + ); + + console.log("\nSummary:"); + for (const result of results) { + console.log( + ` • ${result.locale}: ${result.unusedKeys.length} unused / ${result.totalKeys} total`, + ); + } + console.log(`\nTotal unused keys: ${totalUnused}`); + if (options.apply) { + console.log( + "Files were updated in-place; review diffs before committing changes.", + ); + } else { + console.log( + "Run with --apply to write cleaned locale files. Backups will be created unless --no-backup is passed.", + ); + } + + if (options.reportPath) { + const payload = { + generatedAt: new Date().toISOString(), + options: { + apply: options.apply, + backup: options.backup, + sourceDirs, + }, + results, + }; + writeReport(options.reportPath, payload); + console.log(`Report written to ${options.reportPath}`); + } +} + +try { + main(); +} catch (error) { + console.error("Failed to complete i18n cleanup draft."); + console.error(error); + process.exit(1); +}