Files
CherryHQ-cherry-studio/scripts/check-unused-i18n.ts
fullex ff6de394f6 refactor(shared): dissolve command/file/shortcuts/externalApp into types/ and utils/ by shape
Move the four ad-hoc top-level @shared dirs into the closed top-level set, routed by shape: pure logic + class blueprints to utils/, type/contract declarations to types/.

command -> utils/command + types/command; file -> utils/file + types/file; shortcuts -> utils/shortcut + types/shortcut; externalApp -> utils/externalApp + types/externalApp.

Replace the exported menuRegistry singleton with a pure resolveMenu over a frozen contribution set (Invariant 1.2: no exported instance); keep MenuRegistry as a per-process blueprint sharing the same resolve algorithm.

Rewrite all consumer imports per symbol origin and align shared-layer-architecture and renderer-architecture docs.
2026-06-19 18:52:21 -07:00

526 lines
17 KiB
TypeScript

import { Command } from 'commander'
import * as fs from 'fs'
import * as path from 'path'
import * as readline from 'readline/promises'
import { type CallExpression, Node, Project, type SourceFile } from 'ts-morph'
import { pathToFileURL } from 'url'
import { COMMAND_DEFINITIONS } from '../src/shared/utils/command/definitions'
import { sortedObjectByKeys } from './sort'
const ROOT_DIR = path.resolve(__dirname, '..')
const LOCALES_DIR = path.join(ROOT_DIR, 'src/renderer/i18n/locales')
const TRANSLATE_DIR = path.join(ROOT_DIR, 'src/renderer/i18n/translate')
const BASE_LOCALE = 'zh-cn'
const BASE_LOCALE_PATH = path.join(LOCALES_DIR, `${BASE_LOCALE}.json`)
const SCAN_DIRS = ['src/renderer', 'src/main', 'src/shared', 'packages'].map((dir) => path.join(ROOT_DIR, dir))
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx'])
const IGNORED_DIRS = new Set(['.git', '.turbo', 'dist', 'node_modules', 'out', 'release', '.vite'])
const KEY_PROPERTY_NAMES = new Set([
'descriptionKey',
'i18nKey',
'label',
'labelKey',
'messageKey',
'placeholderKey',
'titleKey',
'tooltipKey'
])
const COMMENT_T_CALL_RE = /\bt\s*\(\s*['"`]([^'"`]+)['"`]/g
const DOTTED_KEY_RE = /(?<![\w.-])([A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+)(?![\w.-])/g
const DERIVED_KEY_SUFFIXES = ['_one', '_other']
export type I18NValue = string | { [key: string]: I18NValue }
export type I18N = { [key: string]: I18NValue }
export interface UnusedI18nResult {
allKeys: string[]
usedKeys: string[]
unusedKeys: string[]
groupedUnusedKeys: Record<string, string[]>
}
interface CliOptions {
all?: boolean
clean?: boolean
groups?: string
json?: boolean
}
function isI18nObject(value: I18NValue): value is I18N {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function flattenI18nKeys(obj: I18N, prefix = ''): string[] {
const keys: string[] = []
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (isI18nObject(value)) {
keys.push(...flattenI18nKeys(value, fullKey))
} else {
keys.push(fullKey)
}
}
return keys
}
function readJsonFile(filePath: string): I18N {
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as I18N
}
function writeJsonFile(filePath: string, json: I18N): void {
fs.writeFileSync(filePath, `${JSON.stringify(sortedObjectByKeys(json), null, 2)}\n`, 'utf-8')
}
export function findSourceFiles(dir: string): string[] {
const files: string[] = []
if (!fs.existsSync(dir)) return files
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name)) continue
files.push(...findSourceFiles(path.join(dir, entry.name)))
continue
}
if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
files.push(path.join(dir, entry.name))
}
}
return files
}
function getStringValue(node: Node | undefined): string | null {
if (!node) return null
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) return node.getLiteralValue()
return null
}
function collectStringValues(node: Node | undefined): string[] {
if (!node) return []
const stringValue = getStringValue(node)
if (stringValue) return [stringValue]
if (Node.isConditionalExpression(node)) {
return [...collectStringValues(node.getWhenTrue()), ...collectStringValues(node.getWhenFalse())]
}
return []
}
function getJsxAttributeStringValue(node: Node): string | null {
if (Node.isStringLiteral(node)) return node.getLiteralValue()
if (Node.isJsxExpression(node)) return getStringValue(node.getExpression())
return null
}
function isTranslationCall(node: Node): node is CallExpression {
if (!Node.isCallExpression(node)) return false
const expression = node.getExpression()
if (Node.isIdentifier(expression)) return expression.getText() === 't'
if (Node.isPropertyAccessExpression(expression)) return expression.getName() === 't'
return false
}
function getPropertyName(node: Node): string | null {
if (Node.isIdentifier(node) || Node.isStringLiteral(node)) return node.getText().replace(/^['"]|['"]$/g, '')
return null
}
function shouldCollectKeyProperty(name: string): boolean {
return KEY_PROPERTY_NAMES.has(name) || /^[a-zA-Z].*Key$/.test(name)
}
function isKnownLocaleKey(value: string, localeKeys: Set<string>): boolean {
return value.includes('.') && localeKeys.has(value)
}
function addUsedKey(key: string, localeKeys: Set<string>, usedKeys: Set<string>): void {
if (localeKeys.has(key)) usedKeys.add(key)
for (const suffix of DERIVED_KEY_SUFFIXES) {
const derivedKey = `${key}${suffix}`
if (localeKeys.has(derivedKey)) usedKeys.add(derivedKey)
}
}
function addKnownStringValue(value: string | null, localeKeys: Set<string>, usedKeys: Set<string>): void {
if (!value) return
addUsedKey(value, localeKeys, usedKeys)
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function collectTemplateExpressionMatches(
node: Node,
localeKeys: Set<string>,
topLevelNamespaces: Set<string>,
usedKeys: Set<string>
): void {
if (!Node.isTemplateExpression(node)) return
const parts = [
node.getHead().getLiteralText(),
...node.getTemplateSpans().map((span) => span.getLiteral().getLiteralText())
]
const firstPart = parts[0]
const namespace = firstPart.split('.')[0]
if (!namespace || !topLevelNamespaces.has(namespace) || !firstPart.includes('.')) return
const pattern = new RegExp(`^${parts.map(escapeRegExp).join('.*')}$`)
for (const key of localeKeys) {
if (pattern.test(key)) usedKeys.add(key)
}
}
function collectCommentReferences(sourceFile: SourceFile, localeKeys: Set<string>, usedKeys: Set<string>): void {
const fullText = sourceFile.getFullText()
const commentRanges = [
...sourceFile.getLeadingCommentRanges(),
...sourceFile.getDescendants().flatMap((node) => node.getLeadingCommentRanges())
]
const seenPositions = new Set<number>()
for (const range of commentRanges) {
if (seenPositions.has(range.getPos())) continue
seenPositions.add(range.getPos())
const comment = fullText.slice(range.getPos(), range.getEnd())
let match: RegExpExecArray | null
COMMENT_T_CALL_RE.lastIndex = 0
while ((match = COMMENT_T_CALL_RE.exec(comment)) !== null) {
addUsedKey(match[1], localeKeys, usedKeys)
}
}
}
function collectExactSourceTextReferences(
sourceFile: SourceFile,
localeKeys: Set<string>,
usedKeys: Set<string>
): void {
const fullText = sourceFile.getFullText()
let match: RegExpExecArray | null
DOTTED_KEY_RE.lastIndex = 0
while ((match = DOTTED_KEY_RE.exec(fullText)) !== null) {
addUsedKey(match[1], localeKeys, usedKeys)
}
}
function collectShortcutReferences(localeKeys: Set<string>, usedKeys: Set<string>): void {
for (const definition of COMMAND_DEFINITIONS) {
for (const key of [definition.titleKey, definition.categoryKey]) {
if (localeKeys.has(key)) usedKeys.add(key)
}
}
}
function collectTranslationNamespaceAliases(
sourceFile: SourceFile,
topLevelNamespaces: Set<string>
): Map<string, string> {
const aliases = new Map<string, string>()
sourceFile.forEachDescendant((node) => {
if (!Node.isVariableDeclaration(node)) return
const initializer = node.getInitializer()
if (!Node.isPropertyAccessExpression(initializer) || initializer.getName() !== 'translation') return
const nameNode = node.getNameNode()
if (!Node.isObjectBindingPattern(nameNode)) return
for (const element of nameNode.getElements()) {
const propertyNameNode = element.getPropertyNameNode()
const namespace = propertyNameNode ? getPropertyName(propertyNameNode) : element.getName()
const localNameNode = element.getNameNode()
if (!namespace || !topLevelNamespaces.has(namespace) || !Node.isIdentifier(localNameNode)) continue
aliases.set(localNameNode.getText(), namespace)
}
})
return aliases
}
export function collectUsedI18nKeysFromSource(sourceFile: SourceFile, localeKeys: Set<string>): Set<string> {
const usedKeys = new Set<string>()
const topLevelNamespaces = new Set([...localeKeys].map((key) => key.split('.')[0]))
const isI18nLabelFile = sourceFile.getFilePath().endsWith(path.join('src/renderer/i18n/label.ts'))
const translationNamespaceAliases = collectTranslationNamespaceAliases(sourceFile, topLevelNamespaces)
collectCommentReferences(sourceFile, localeKeys, usedKeys)
collectExactSourceTextReferences(sourceFile, localeKeys, usedKeys)
sourceFile.forEachDescendant((node) => {
collectTemplateExpressionMatches(node, localeKeys, topLevelNamespaces, usedKeys)
if (isTranslationCall(node)) {
for (const key of collectStringValues(node.getArguments()[0])) {
addKnownStringValue(key, localeKeys, usedKeys)
}
return
}
if (Node.isJsxAttribute(node) && node.getNameNode().getText() === 'i18nKey') {
const initializer = node.getInitializer()
addKnownStringValue(initializer ? getJsxAttributeStringValue(initializer) : null, localeKeys, usedKeys)
return
}
if (Node.isPropertyAssignment(node)) {
const propertyName = getPropertyName(node.getNameNode())
const value = getStringValue(node.getInitializer())
if (propertyName && value && shouldCollectKeyProperty(propertyName) && isKnownLocaleKey(value, localeKeys)) {
addUsedKey(value, localeKeys, usedKeys)
return
}
if (isI18nLabelFile && value && isKnownLocaleKey(value, localeKeys)) {
addUsedKey(value, localeKeys, usedKeys)
return
}
}
if (Node.isPropertyAccessExpression(node) && Node.isIdentifier(node.getExpression())) {
const expressionName = node.getExpression().getText()
const namespace = translationNamespaceAliases.get(expressionName) ?? expressionName
if (!topLevelNamespaces.has(namespace)) return
const key = `${namespace}.${node.getName()}`
if (localeKeys.has(key)) usedKeys.add(key)
}
})
return usedKeys
}
export function collectUsedI18nKeys(sourceFiles: string[], localeKeys: Set<string>): Set<string> {
const project = new Project({
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true,
compilerOptions: { jsx: 2 }
})
const usedKeys = new Set<string>()
collectShortcutReferences(localeKeys, usedKeys)
for (const filePath of sourceFiles) {
try {
const sourceFile = project.addSourceFileAtPath(filePath)
for (const key of collectUsedI18nKeysFromSource(sourceFile, localeKeys)) {
usedKeys.add(key)
}
project.removeSourceFile(sourceFile)
} catch (error) {
console.error(`Error parsing ${path.relative(ROOT_DIR, filePath)}:`, error)
}
}
return usedKeys
}
function groupKeys(keys: string[]): Record<string, string[]> {
return keys.reduce<Record<string, string[]>>((groups, key) => {
const group = key.split('.')[0]
groups[group] ??= []
groups[group].push(key)
return groups
}, {})
}
export function createUnusedI18nResult(baseLocale: I18N, usedKeys: Iterable<string>): UnusedI18nResult {
const allKeys = flattenI18nKeys(baseLocale).sort()
const usedKeyList = [...usedKeys].filter((key) => allKeys.includes(key)).sort()
const usedKeySet = new Set(usedKeyList)
const unusedKeys = allKeys.filter((key) => !usedKeySet.has(key))
return {
allKeys,
usedKeys: usedKeyList,
unusedKeys,
groupedUnusedKeys: groupKeys(unusedKeys)
}
}
export function findUnusedI18nKeys(baseLocale: I18N, sourceFiles: string[]): UnusedI18nResult {
const allKeys = flattenI18nKeys(baseLocale).sort()
const localeKeys = new Set(allKeys)
return createUnusedI18nResult(baseLocale, collectUsedI18nKeys(sourceFiles, localeKeys))
}
function deleteNestedKey(obj: I18N, keyPath: string): void {
const parts = keyPath.split('.')
const stack: Array<{ object: I18N; key: string }> = []
let current = obj
for (const part of parts.slice(0, -1)) {
const next = current[part]
if (!isI18nObject(next)) return
stack.push({ object: current, key: part })
current = next
}
delete current[parts[parts.length - 1]]
for (let index = stack.length - 1; index >= 0; index--) {
const { object, key } = stack[index]
const value = object[key]
if (isI18nObject(value) && Object.keys(value).length === 0) {
delete object[key]
}
}
}
export function removeI18nKeys(locale: I18N, keys: string[]): I18N {
const next = structuredClone(locale)
for (const key of keys) {
deleteNestedKey(next, key)
}
return sortedObjectByKeys(next) as I18N
}
function findTranslationFiles(): string[] {
return [LOCALES_DIR, TRANSLATE_DIR].flatMap((dir) =>
fs.existsSync(dir)
? fs
.readdirSync(dir)
.filter((file) => file.endsWith('.json'))
.map((file) => path.join(dir, file))
: []
)
}
function parseGroups(groups: string | undefined): string[] {
return groups
? groups
.split(',')
.map((group) => group.trim())
.filter(Boolean)
: []
}
function formatGroupSummary(groupedUnusedKeys: Record<string, string[]>): string {
return Object.entries(groupedUnusedKeys)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, keys]) => {
const examples = keys.slice(0, 5).join(', ')
const suffix = keys.length > 5 ? ', ...' : ''
return `- ${group}: ${keys.length} (${examples}${suffix})`
})
.join('\n')
}
async function promptGroups(groupedUnusedKeys: Record<string, string[]>): Promise<string[]> {
const groups = Object.entries(groupedUnusedKeys).sort(([a], [b]) => a.localeCompare(b))
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
try {
console.log('\nSelect groups to clean:')
groups.forEach(([group, keys], index) => {
console.log(`${index + 1}. ${group} (${keys.length})`)
})
const answer = await rl.question('Enter group numbers/names separated by comma, or "all": ')
if (answer.trim().toLowerCase() === 'all') {
return groups.map(([group]) => group)
}
return answer
.split(',')
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const numericIndex = Number(item)
if (Number.isInteger(numericIndex) && numericIndex >= 1 && numericIndex <= groups.length) {
return groups[numericIndex - 1][0]
}
return item
})
} finally {
rl.close()
}
}
export function selectKeysByGroups(groupedUnusedKeys: Record<string, string[]>, groups: string[]): string[] {
const selectedGroups = new Set(groups)
return Object.entries(groupedUnusedKeys)
.filter(([group]) => selectedGroups.has(group))
.flatMap(([, keys]) => keys)
.sort()
}
function selectAllKeys(groupedUnusedKeys: Record<string, string[]>): string[] {
return Object.values(groupedUnusedKeys).flat().sort()
}
function cleanTranslationFiles(keys: string[]): void {
for (const filePath of findTranslationFiles()) {
const json = readJsonFile(filePath)
writeJsonFile(filePath, removeI18nKeys(json, keys))
console.log(`Cleaned ${keys.length} keys from ${path.relative(ROOT_DIR, filePath)}`)
}
}
export async function runCli(options: CliOptions): Promise<void> {
const baseLocale = readJsonFile(BASE_LOCALE_PATH)
const sourceFiles = SCAN_DIRS.flatMap(findSourceFiles)
const result = findUnusedI18nKeys(baseLocale, sourceFiles)
if (options.json) {
console.log(JSON.stringify(result, null, 2))
} else {
console.log(`Found ${result.unusedKeys.length} unused i18n keys out of ${result.allKeys.length} total keys.`)
if (result.unusedKeys.length > 0) {
console.log(formatGroupSummary(result.groupedUnusedKeys))
}
}
if (!options.clean || result.unusedKeys.length === 0) return
const groups = parseGroups(options.groups)
const selectedGroups = options.all
? Object.keys(result.groupedUnusedKeys).sort()
: groups.length > 0
? groups
: await promptGroups(result.groupedUnusedKeys)
const keysToRemove = options.all
? selectAllKeys(result.groupedUnusedKeys)
: selectKeysByGroups(result.groupedUnusedKeys, selectedGroups)
if (keysToRemove.length === 0) {
console.log('No matching unused i18n keys selected.')
return
}
cleanTranslationFiles(keysToRemove)
console.log(`Removed ${keysToRemove.length} unused i18n keys from ${selectedGroups.join(', ')}.`)
}
async function main() {
const program = new Command()
.description('Find unused i18n keys and optionally clean them by top-level namespace')
.option('--all', 'with --clean, remove all unused keys without prompting')
.option('--clean', 'remove selected unused keys from all translation files')
.option('--groups <groups>', 'comma-separated top-level namespaces to clean')
.option('--json', 'print machine-readable JSON')
program.parse(process.argv)
await runCli(program.opts<CliOptions>())
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
}