mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-07 15:12:18 +08:00
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.
526 lines
17 KiB
TypeScript
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
|
|
})
|
|
}
|