#!/usr/bin/env node import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import yaml from "js-yaml"; import ts from "typescript"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const FRONTEND_LOCALES_DIR = path.resolve(__dirname, "../src/locales"); const BACKEND_LOCALES_DIR = path.resolve( __dirname, "../crates/clash-verge-i18n/locales", ); const DEFAULT_FRONTEND_SOURCE_DIRS = [path.resolve(__dirname, "../src")]; const DEFAULT_BACKEND_SOURCE_DIRS = [ path.resolve(__dirname, "../src-tauri"), path.resolve(__dirname, "../crates"), ]; const EXCLUDE_USAGE_DIRS = [FRONTEND_LOCALES_DIR, BACKEND_LOCALES_DIR]; const DEFAULT_BASELINE_LANG = "en"; 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 FRONTEND_EXTENSIONS = new Set([ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".json", ]); const BACKEND_EXTENSIONS = new Set([".rs"]); const TS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+$/; const TEMPLATE_PREFIX_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*\.$/; const IGNORED_KEY_PREFIXES = new Set([ "text", "primary", "secondary", "error", "warning", "success", "info", "background", "grey", "option", "action", "example", "chrome", "localhost", "www", "pac", "V2", "v2", "v1", ]); const NOTICE_METHOD_NAMES = new Set(["success", "error", "info", "warning"]); const NOTICE_SERVICE_IDENTIFIERS = new Set([ "@/services/notice-service", "./notice-service", "../services/notice-service", ]); const WHITELIST_KEYS = new Set([ "theme.light", "theme.dark", "theme.system", "_version", ]); const MAX_PREVIEW_ENTRIES = 40; const dynamicKeyCache = new Map(); const fileUsageCache = new Map(); function resetUsageCaches() { dynamicKeyCache.clear(); fileUsageCache.clear(); } 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) --align Align locale structure/order using the baseline locale --baseline Baseline locale file name for frontend/backend (default: ${DEFAULT_BASELINE_LANG}) --keep-extra Preserve keys that exist only in non-baseline locales when aligning --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: [], align: false, baseline: DEFAULT_BASELINE_LANG, keepExtra: false, }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; switch (arg) { case "--apply": options.apply = true; break; case "--align": options.align = true; break; case "--keep-extra": options.keepExtra = 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 "--baseline": { const next = argv[i + 1]; if (!next) { throw new Error("--baseline requires a locale name (e.g. en)"); } options.baseline = next.replace(/\.(json|ya?ml)$/i, ""); 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 collectSourceFiles(sourceDirs, options = {}) { const supportedExtensions = options.supportedExtensions ?? FRONTEND_EXTENSIONS; const seen = new Set(); const files = []; for (const dir of sourceDirs) { const resolved = getAllFiles(dir, (filePath) => { if (seen.has(filePath)) return false; if (!supportedExtensions.has(path.extname(filePath))) return false; if ( EXCLUDE_USAGE_DIRS.some((excluded) => filePath.startsWith(`${excluded}${path.sep}`), ) || EXCLUDE_USAGE_DIRS.includes(filePath) ) { return false; } return true; }); for (const filePath of resolved) { seen.add(filePath); files.push({ path: filePath, extension: path.extname(filePath).toLowerCase(), content: fs.readFileSync(filePath, "utf8"), }); } } files.sort((a, b) => a.path.localeCompare(b.path)); return files; } 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 diffLocaleKeys(baselineEntries, localeEntries) { const missing = []; const extra = []; for (const key of baselineEntries.keys()) { if (!localeEntries.has(key)) { missing.push(key); } } for (const key of localeEntries.keys()) { if (!baselineEntries.has(key)) { extra.push(key); } } missing.sort(); extra.sort(); return { missing, extra }; } function determineScriptKind(extension) { switch (extension) { case ".ts": return ts.ScriptKind.TS; case ".tsx": return ts.ScriptKind.TSX; case ".jsx": return ts.ScriptKind.JSX; case ".js": case ".mjs": case ".cjs": return ts.ScriptKind.JS; default: return ts.ScriptKind.TS; } } function getNamespaceFromKey(key) { if (!key || typeof key !== "string") return null; const [namespace] = key.split("."); return namespace ?? null; } function addTemplatePrefixCandidate( prefix, dynamicPrefixes, baselineNamespaces, ) { if (!prefix || typeof prefix !== "string") return; const normalized = prefix.trim(); if (!normalized) return; let candidate = normalized; if (!candidate.endsWith(".")) { const lastDotIndex = candidate.lastIndexOf("."); if (lastDotIndex === -1) { return; } candidate = candidate.slice(0, lastDotIndex + 1); } if (!TEMPLATE_PREFIX_PATTERN.test(candidate)) return; const namespace = getNamespaceFromKey(candidate); if (!namespace || IGNORED_KEY_PREFIXES.has(namespace)) return; if (!baselineNamespaces.has(namespace)) return; dynamicPrefixes.add(candidate); } function addKeyIfValid(key, usedKeys, baselineNamespaces, options = {}) { if (!key || typeof key !== "string") return false; if (!KEY_PATTERN.test(key)) return false; const namespace = getNamespaceFromKey(key); if (!namespace || IGNORED_KEY_PREFIXES.has(namespace)) return false; if (!options.forceNamespace && !baselineNamespaces.has(namespace)) { return false; } usedKeys.add(key); return true; } function collectImportSpecifiers(sourceFile) { const specifiers = new Map(); sourceFile.forEachChild((node) => { if (!ts.isImportDeclaration(node)) return; const moduleText = ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text; if (!moduleText || !node.importClause) return; if (node.importClause.name) { specifiers.set(node.importClause.name.text, moduleText); } const { namedBindings } = node.importClause; if (!namedBindings) return; if (ts.isNamespaceImport(namedBindings)) { specifiers.set(namedBindings.name.text, `${moduleText}.*`); return; } if (ts.isNamedImports(namedBindings)) { for (const element of namedBindings.elements) { specifiers.set(element.name.text, moduleText); } } }); return specifiers; } function getCallExpressionChain(expression) { const chain = []; let current = expression; while (current) { if (ts.isIdentifier(current)) { chain.unshift(current.text); break; } if (ts.isPropertyAccessExpression(current)) { chain.unshift(current.name.text); current = current.expression; continue; } if (ts.isElementAccessExpression(current)) { const argument = current.argumentExpression; if (ts.isStringLiteralLike(argument)) { chain.unshift(argument.text); current = current.expression; continue; } return []; } break; } return chain; } function classifyCallExpression(expression, importSpecifiers) { if (!expression) return null; const chain = getCallExpressionChain(expression); if (chain.length === 0) return null; const last = chain[chain.length - 1]; const root = chain[0]; if (last === "t") { return { type: "translation", forceNamespace: true }; } if ( NOTICE_METHOD_NAMES.has(last) && root === "showNotice" && (!importSpecifiers || NOTICE_SERVICE_IDENTIFIERS.has(importSpecifiers.get("showNotice") ?? "")) ) { return { type: "notice", forceNamespace: true }; } return null; } function resolveBindingValue(name, scopeStack) { if (!name) return null; for (let index = scopeStack.length - 1; index >= 0; index -= 1) { const scope = scopeStack[index]; if (scope && scope.has(name)) { return scope.get(name); } } return null; } function resolveKeyFromExpression( node, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, ) { if (!node) return null; if ( ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node) ) { return node.text; } if (ts.isTemplateExpression(node)) { addTemplatePrefixCandidate( node.head?.text ?? "", dynamicPrefixes, baselineNamespaces, ); for (const span of node.templateSpans) { const literalText = span.literal?.text ?? ""; const combined = (node.head?.text ?? "") + literalText; addTemplatePrefixCandidate(combined, dynamicPrefixes, baselineNamespaces); } return null; } if (ts.isBinaryExpression(node)) { if (node.operatorToken.kind === ts.SyntaxKind.PlusToken) { const left = resolveKeyFromExpression( node.left, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, ); const right = resolveKeyFromExpression( node.right, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, ); if (left && right) { return `${left}${right}`; } if (left) { addTemplatePrefixCandidate(left, dynamicPrefixes, baselineNamespaces); } return null; } return null; } if (ts.isParenthesizedExpression(node)) { return resolveKeyFromExpression( node.expression, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, ); } if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) { return resolveKeyFromExpression( node.expression, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, ); } if (ts.isIdentifier(node)) { return resolveBindingValue(node.text, scopeStack); } if (ts.isCallExpression(node)) { const classification = classifyCallExpression( node.expression, importSpecifiers, ); if (!classification) return null; const firstArg = node.arguments[0]; if (!firstArg) return null; return resolveKeyFromExpression( firstArg, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, ); } return null; } function collectUsedKeysFromTsFile( file, baselineNamespaces, usedKeys, dynamicPrefixes, ) { let sourceFile; try { sourceFile = ts.createSourceFile( file.path, file.content, ts.ScriptTarget.Latest, true, determineScriptKind(file.extension), ); } catch (error) { console.warn(`Warning: failed to parse ${file.path}: ${error.message}`); return; } const importSpecifiers = collectImportSpecifiers(sourceFile); const scopeStack = [new Map()]; const visit = (node) => { let scopePushed = false; if ( ts.isBlock(node) || ts.isModuleBlock(node) || node.kind === ts.SyntaxKind.CaseBlock || ts.isCatchClause(node) ) { scopeStack.push(new Map()); scopePushed = true; } if (ts.isTemplateExpression(node)) { resolveKeyFromExpression( node, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, ); } if (ts.isVariableDeclaration(node) && node.initializer) { const key = resolveKeyFromExpression( node.initializer, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, ); if (key && KEY_PATTERN.test(key)) { if (ts.isIdentifier(node.name)) { scopeStack[scopeStack.length - 1].set(node.name.text, key); } } } if (ts.isCallExpression(node)) { const classification = classifyCallExpression( node.expression, importSpecifiers, ); if (classification) { const [firstArg] = node.arguments; if (firstArg) { const key = resolveKeyFromExpression( firstArg, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, ); if ( !addKeyIfValid(key, usedKeys, baselineNamespaces, classification) ) { addTemplatePrefixCandidate( key ?? "", dynamicPrefixes, baselineNamespaces, ); } } } } if (ts.isJsxAttribute(node) && node.name?.text === "i18nKey") { const initializer = node.initializer; if (initializer && ts.isStringLiteralLike(initializer)) { addKeyIfValid(initializer.text, usedKeys, baselineNamespaces, { forceNamespace: false, }); } else if ( initializer && ts.isJsxExpression(initializer) && initializer.expression ) { const key = resolveKeyFromExpression( initializer.expression, scopeStack, dynamicPrefixes, baselineNamespaces, importSpecifiers, ); addKeyIfValid(key, usedKeys, baselineNamespaces, { forceNamespace: true, }); } } node.forEachChild(visit); if (scopePushed) { scopeStack.pop(); } }; visit(sourceFile); } function collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys) { const regex = /['"`]([A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+)['"`]/g; let match; while ((match = regex.exec(file.content))) { addKeyIfValid(match[1], usedKeys, baselineNamespaces, { forceNamespace: false, }); } } function readRustStringLiteral(source, startIndex) { const slice = source.slice(startIndex); if (slice.startsWith('"')) { const match = slice.match(/^"(?:\\.|[^"\\])*"/); if (!match) return null; return match[0].slice(1, -1); } if (slice.startsWith("r")) { const match = slice.match(/^r(#+)?"([\s\S]*?)"\1/); if (!match) return null; return match[2]; } return null; } function collectUsedKeysFromRustFile( file, baselineNamespaces, usedKeys, _dynamicPrefixes, ) { const pattern = /\b(?:[A-Za-z_][\w:]*::)?t!\s*\(/g; let match; while ((match = pattern.exec(file.content))) { let index = match.index + match[0].length; while (index < file.content.length && /\s/.test(file.content[index])) { index += 1; } const key = readRustStringLiteral(file.content, index); if (key) { addKeyIfValid(key, usedKeys, baselineNamespaces, { forceNamespace: true, }); } } collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys); } function collectUsedI18nKeys(sourceFiles, baselineNamespaces) { const usedKeys = new Set(); const dynamicPrefixes = new Set(); for (const file of sourceFiles) { if (TS_EXTENSIONS.has(file.extension)) { collectUsedKeysFromTsFile( file, baselineNamespaces, usedKeys, dynamicPrefixes, ); } else if (file.extension === ".rs") { collectUsedKeysFromRustFile( file, baselineNamespaces, usedKeys, dynamicPrefixes, ); } else { collectUsedKeysFromTextFile(file, baselineNamespaces, usedKeys); } } return { usedKeys, dynamicPrefixes }; } function alignToBaseline(baselineNode, localeNode, options) { const shouldCopyLocale = localeNode && typeof localeNode === "object" && !Array.isArray(localeNode); if ( baselineNode && typeof baselineNode === "object" && !Array.isArray(baselineNode) ) { const result = {}; const baselineKeys = Object.keys(baselineNode); for (const key of baselineKeys) { const baselineValue = baselineNode[key]; const localeValue = shouldCopyLocale ? localeNode[key] : undefined; if ( baselineValue && typeof baselineValue === "object" && !Array.isArray(baselineValue) ) { result[key] = alignToBaseline( baselineValue, localeValue && typeof localeValue === "object" ? localeValue : {}, options, ); } else if (localeValue === undefined) { result[key] = baselineValue; } else { result[key] = localeValue; } } if (options.keepExtra && shouldCopyLocale) { const extraKeys = Object.keys(localeNode) .filter((key) => !baselineKeys.includes(key)) .sort(); for (const key of extraKeys) { result[key] = localeNode[key]; } } return result; } return shouldCopyLocale ? localeNode : baselineNode; } function logPreviewEntries(label, items) { if (!items || items.length === 0) return; const preview = items.slice(0, MAX_PREVIEW_ENTRIES); for (const item of preview) { console.log(` · ${label}: ${item}`); } if (items.length > preview.length) { console.log( ` · ${label}: ... and ${items.length - preview.length} more`, ); } } function removeKey(target, dottedKey) { if ( !target || typeof target !== "object" || Array.isArray(target) || dottedKey === "" ) { return; } if (dottedKey in target) { delete target[dottedKey]; return; } const parts = dottedKey.split(".").filter((part) => part !== ""); if (parts.length === 0) return; let current = target; for (let index = 0; index < parts.length; index += 1) { const part = parts[index]; if ( !current || typeof current !== "object" || Array.isArray(current) || !(part in current) ) { return; } if (index === parts.length - 1) { delete current[part]; return; } current = current[part]; } } 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 findKeyInSources(key, sourceFiles) { if (fileUsageCache.has(key)) { return fileUsageCache.get(key); } const pattern = new RegExp(`(['"\`])${escapeRegExp(key)}\\1`); let found = false; for (const file of sourceFiles) { if (pattern.test(file.content)) { found = true; break; } } fileUsageCache.set(key, found); return found; } function isKeyUsed(key, usage, sourceFiles) { if (WHITELIST_KEYS.has(key)) return true; if (!key) return false; if (usage.usedKeys.has(key)) return true; if (dynamicKeyCache.has(key)) { return dynamicKeyCache.get(key); } const prefixes = getCandidatePrefixes(key); let used = prefixes.some((prefix) => usage.dynamicPrefixes.has(prefix)); if (!used) { used = findKeyInSources(key, sourceFiles); } dynamicKeyCache.set(key, used); return used; } function getCandidatePrefixes(key) { if (!key.includes(".")) return []; const parts = key.split("."); const prefixes = []; for (let index = 0; index < parts.length - 1; index += 1) { const prefix = `${parts.slice(0, index + 1).join(".")}.`; prefixes.push(prefix); } return prefixes; } function writeReport(reportPath, data) { const payload = JSON.stringify(data, null, 2); fs.writeFileSync(reportPath, `${payload}\n`, "utf8"); } function isPlainObject(value) { return value && typeof value === "object" && !Array.isArray(value); } function loadFrontendLocales() { if (!fs.existsSync(FRONTEND_LOCALES_DIR)) { throw new Error(`Locales directory not found: ${FRONTEND_LOCALES_DIR}`); } const entries = fs.readdirSync(FRONTEND_LOCALES_DIR, { withFileTypes: true }); const locales = []; for (const entry of entries) { if (entry.isFile()) { if ( /^[a-z0-9\-_]+\.json$/i.test(entry.name) && !entry.name.endsWith(".bak") && !entry.name.endsWith(".old") ) { const localePath = path.join(FRONTEND_LOCALES_DIR, entry.name); const name = path.basename(entry.name, ".json"); const raw = fs.readFileSync(localePath, "utf8"); locales.push({ name, dir: FRONTEND_LOCALES_DIR, format: "single-file", files: [ { namespace: "translation", path: localePath, }, ], data: JSON.parse(raw), }); } continue; } if (!entry.isDirectory()) continue; if (entry.name.startsWith(".")) continue; const localeDir = path.join(FRONTEND_LOCALES_DIR, entry.name); const namespaceEntries = fs .readdirSync(localeDir, { withFileTypes: true }) .filter( (item) => item.isFile() && item.name.endsWith(".json") && !item.name.endsWith(".bak") && !item.name.endsWith(".old"), ) .map((item) => ({ namespace: path.basename(item.name, ".json"), path: path.join(localeDir, item.name), })); namespaceEntries.sort((a, b) => a.path.localeCompare(b.path)); const data = {}; for (const file of namespaceEntries) { const raw = fs.readFileSync(file.path, "utf8"); try { data[file.namespace] = JSON.parse(raw); } catch (error) { console.warn(`Warning: failed to parse ${file.path}: ${error.message}`); data[file.namespace] = {}; } } locales.push({ name: entry.name, dir: localeDir, format: "multi-file", files: namespaceEntries, data, }); } locales.sort((a, b) => a.name.localeCompare(b.name)); return locales; } function loadBackendLocales() { if (!fs.existsSync(BACKEND_LOCALES_DIR)) { return []; } const entries = fs.readdirSync(BACKEND_LOCALES_DIR, { withFileTypes: true }); const locales = []; for (const entry of entries) { if (!entry.isFile()) continue; if (entry.name.endsWith(".bak") || entry.name.endsWith(".old")) { continue; } if (!/\.(ya?ml)$/i.test(entry.name)) continue; const localePath = path.join(BACKEND_LOCALES_DIR, entry.name); const name = entry.name.replace(/\.(ya?ml)$/i, ""); const raw = fs.readFileSync(localePath, "utf8"); let data = {}; try { const parsed = yaml.load(raw); data = isPlainObject(parsed) ? parsed : {}; } catch (error) { console.warn(`Warning: failed to parse ${localePath}: ${error.message}`); data = {}; } locales.push({ name, dir: BACKEND_LOCALES_DIR, format: "yaml-file", files: [ { namespace: "translation", path: localePath, }, ], data, }); } locales.sort((a, b) => a.name.localeCompare(b.name)); return locales; } function ensureBackup(localePath) { const backupPath = `${localePath}.bak`; if (fs.existsSync(backupPath)) { try { fs.rmSync(backupPath); } catch (error) { throw new Error( `Failed to recycle existing backup for ${path.basename( localePath, )}: ${error.message}`, ); } } fs.copyFileSync(localePath, backupPath); return backupPath; } function backupIfNeeded(filePath, backups, options) { if (!options.backup) return; if (!fs.existsSync(filePath)) return; if (backups.has(filePath)) return; const backupPath = ensureBackup(filePath); backups.set(filePath, backupPath); return backupPath; } function cleanupBackups(backups) { for (const backupPath of backups.values()) { try { if (fs.existsSync(backupPath)) { fs.rmSync(backupPath); } } catch (error) { console.warn( `Warning: failed to remove backup ${path.basename( backupPath, )}: ${error.message}`, ); } } backups.clear(); } function toModuleIdentifier(namespace, seen) { const RESERVED = new Set([ "default", "function", "var", "let", "const", "import", "export", "class", "enum", ]); const base = namespace.replace(/[^a-zA-Z0-9_$]/g, "_").replace(/^[^a-zA-Z_$]+/, "") || "ns"; let candidate = base; let counter = 1; while (RESERVED.has(candidate) || seen.has(candidate)) { candidate = `${base}_${counter}`; counter += 1; } seen.add(candidate); return candidate; } function regenerateLocaleIndex(localeDir, namespaces) { const seen = new Set(); const imports = []; const mappings = []; for (const namespace of namespaces) { const filePath = path.join(localeDir, `${namespace}.json`); if (!fs.existsSync(filePath)) continue; const identifier = toModuleIdentifier(namespace, seen); imports.push(`import ${identifier} from "./${namespace}.json";`); mappings.push(` "${namespace}": ${identifier},`); } const content = `${imports.join("\n")} const resources = { ${mappings.join("\n")} }; export default resources; `; fs.writeFileSync(path.join(localeDir, "index.ts"), content, "utf8"); } function writeLocale(locale, data, options) { const backups = new Map(); let success = false; try { if (locale.format === "yaml-file") { const target = locale.files[0].path; backupIfNeeded(target, backups, options); const serialized = yaml.dump(data ?? {}, { lineWidth: -1, noRefs: true }); fs.writeFileSync(target, `${serialized.trimEnd()}\n`, "utf8"); success = true; return; } if (locale.format === "single-file") { const target = locale.files[0].path; backupIfNeeded(target, backups, options); const serialized = JSON.stringify(data, null, 2); fs.writeFileSync(target, `${serialized}\n`, "utf8"); success = true; return; } const entries = Object.entries(data); const orderedNamespaces = entries.map(([namespace]) => namespace); const existingFiles = new Map( locale.files.map((file) => [file.namespace, file.path]), ); const visited = new Set(); for (const [namespace, value] of entries) { const target = existingFiles.get(namespace) ?? path.join(locale.dir, `${namespace}.json`); backupIfNeeded(target, backups, options); const serialized = JSON.stringify(value ?? {}, null, 2); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, `${serialized}\n`, "utf8"); visited.add(namespace); } for (const [namespace, filePath] of existingFiles.entries()) { if (!visited.has(namespace) && fs.existsSync(filePath)) { backupIfNeeded(filePath, backups, options); fs.rmSync(filePath); } } regenerateLocaleIndex(locale.dir, orderedNamespaces); locale.files = orderedNamespaces.map((namespace) => ({ namespace, path: path.join(locale.dir, `${namespace}.json`), })); success = true; } finally { if (success) { cleanupBackups(backups); } } } function processLocale( locale, baselineData, baselineEntries, usage, sourceFiles, missingFromSource, options, groupName, baselineName, ) { const data = JSON.parse(JSON.stringify(locale.data)); const flattened = flattenLocale(data); const expectedTotal = baselineEntries.size; const { missing, extra } = diffLocaleKeys(baselineEntries, flattened); const unused = []; for (const key of flattened.keys()) { if (!isKeyUsed(key, usage, sourceFiles)) { unused.push(key); } } const sourceMissing = locale.name === baselineName ? missingFromSource.filter((key) => !flattened.has(key)) : []; if ( unused.length === 0 && missing.length === 0 && extra.length === 0 && sourceMissing.length === 0 && !options.align ) { console.log(`[${locale.name}] No issues detected 🎉`); } else { console.log(`[${locale.name}] Check results:`); console.log( ` unused: ${unused.length}, missing vs baseline: ${missing.length}, extra: ${extra.length}`, ); if (sourceMissing.length > 0) { console.log(` missing in source: ${sourceMissing.length}`); logPreviewEntries("missing-source", sourceMissing); } logPreviewEntries("unused", unused); logPreviewEntries("missing", missing); logPreviewEntries("extra", extra); } const removed = []; let aligned = false; if (options.apply) { let updated; if (options.align) { aligned = true; updated = alignToBaseline(baselineData, data, options); } else { updated = JSON.parse(JSON.stringify(data)); } for (const key of unused) { removeKey(updated, key); removed.push(key); } cleanupEmptyBranches(updated); writeLocale(locale, updated, options); locale.data = JSON.parse(JSON.stringify(updated)); console.log( `[${locale.name}] Locale resources updated (${removed.length} unused removed${ aligned ? ", structure aligned" : "" })`, ); } return { group: groupName, locale: locale.name, file: locale.format === "multi-file" ? locale.dir : locale.files[0].path, totalKeys: flattened.size, expectedKeys: expectedTotal, unusedKeys: unused, removed, missingKeys: missing, extraKeys: extra, missingSourceKeys: sourceMissing, aligned: aligned && options.apply, }; } function summarizeResults(results) { return results.reduce( (totals, result) => { totals.totalUnused += result.unusedKeys.length; totals.totalMissing += result.missingKeys.length; totals.totalExtra += result.extraKeys.length; totals.totalSourceMissing += result.missingSourceKeys.length; return totals; }, { totalUnused: 0, totalMissing: 0, totalExtra: 0, totalSourceMissing: 0, }, ); } function processLocaleGroup(group, options) { const sourceDirs = [ ...new Set([...group.sourceDirs, ...options.extraSources]), ]; console.log(`\n[${group.label}] Scanning source directories:`); for (const dir of sourceDirs) { console.log(` - ${dir}`); } const sourceFiles = collectSourceFiles(sourceDirs, { supportedExtensions: group.supportedExtensions, }); const locales = group.locales; if (locales.length === 0) { console.log(`[${group.label}] No locale files found.`); return null; } const baselineLocale = locales.find( (item) => item.name.toLowerCase() === options.baseline.toLowerCase(), ); if (!baselineLocale) { const available = locales.map((item) => item.name).join(", "); throw new Error( `[${group.label}] Baseline locale "${options.baseline}" not found. Available locales: ${available}`, ); } const baselineData = JSON.parse(JSON.stringify(baselineLocale.data)); const baselineEntries = flattenLocale(baselineData); const baselineNamespaces = new Set(Object.keys(baselineData)); const usage = collectUsedI18nKeys(sourceFiles, baselineNamespaces); const baselineKeys = new Set(baselineEntries.keys()); const missingFromSource = Array.from(usage.usedKeys).filter( (key) => !baselineKeys.has(key), ); missingFromSource.sort(); locales.sort((a, b) => { if (a.name === baselineLocale.name) return -1; if (b.name === baselineLocale.name) return 1; return a.name.localeCompare(b.name); }); console.log( `\n[${group.label}] Checking ${locales.length} locale files...\n`, ); resetUsageCaches(); const results = locales.map((locale) => processLocale( locale, baselineData, baselineEntries, usage, sourceFiles, missingFromSource, options, group.label, baselineLocale.name, ), ); const totals = summarizeResults(results); console.log(`\n[${group.label}] Summary:`); for (const result of results) { console.log( ` • ${result.locale}: unused=${result.unusedKeys.length}, missing=${result.missingKeys.length}, extra=${result.extraKeys.length}, missingSource=${result.missingSourceKeys.length}, total=${result.totalKeys}, expected=${result.expectedKeys}`, ); } console.log( `\n[${group.label}] Totals → unused: ${totals.totalUnused}, missing: ${totals.totalMissing}, extra: ${totals.totalExtra}, missingSource: ${totals.totalSourceMissing}`, ); return { group: group.label, baseline: baselineLocale.name, sourceDirs, totals, results, }; } 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 localeGroups = [ { label: "frontend", locales: loadFrontendLocales(), sourceDirs: DEFAULT_FRONTEND_SOURCE_DIRS, supportedExtensions: FRONTEND_EXTENSIONS, }, { label: "backend", locales: loadBackendLocales(), sourceDirs: DEFAULT_BACKEND_SOURCE_DIRS, supportedExtensions: BACKEND_EXTENSIONS, }, ].filter((group) => group.locales.length > 0); if (localeGroups.length === 0) { console.log("No locale files found."); return; } const groupReports = []; const allResults = []; for (const group of localeGroups) { const report = processLocaleGroup(group, options); if (!report) continue; groupReports.push(report); allResults.push(...report.results); } if (groupReports.length > 1) { const overallTotals = summarizeResults(allResults); console.log( `\nOverall totals → unused: ${overallTotals.totalUnused}, missing: ${overallTotals.totalMissing}, extra: ${overallTotals.totalExtra}, missingSource: ${overallTotals.totalSourceMissing}`, ); } if (allResults.length === 0) { return; } 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.align) { console.log( "Alignment was evaluated in dry-run mode; rerun with --apply to rewrite locale files.", ); } else { console.log( "Pass --align to normalize locale structure/order based on the baseline locale.", ); } } if (options.reportPath) { const payload = { generatedAt: new Date().toISOString(), options: { apply: options.apply, backup: options.backup, align: options.align, baseline: options.baseline, keepExtra: options.keepExtra, }, groups: groupReports.map((report) => ({ group: report.group, baseline: report.baseline, sourceDirs: report.sourceDirs, totals: report.totals, locales: report.results.map((result) => result.locale), })), results: allResults, }; 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); }