Files
CherryHQ-cherry-studio/scripts/check-i18n.ts
fullex 70909eddc5 chore(i18n): fail loudly on non-literal main t() and add coverage tests
Follow-ups from reviewing the i18n split:

- check-i18n: the main key-coverage guard silently skipped any t() call whose
  key was not a string literal, so a future t(someVar) in main would ship
  unverified against the small main catalog. It now reports non-literal t()
  calls as a hard failure, matching the intent that main use literal keys.
- main i18n test: cover the getAppLanguage() branch where the system locale is
  absent from the catalog and resolution falls back to the default language.
- renderer i18n test: capture the language to restore inside beforeAll — at
  module-collection time lazy init has not run yet, so i18n.language was
  undefined and afterAll restored to undefined.
2026-07-03 08:13:04 -07:00

232 lines
7.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fs from 'fs'
import * as path from 'path'
import { sortedObjectByKeys } from './sort'
const baseLocale = process.env.BASE_LOCALE ?? 'zh-cn'
const baseFileName = `${baseLocale}.json`
const rendererLocalesDir = path.join(__dirname, '../src/renderer/i18n/locales')
const mainI18nDir = path.join(__dirname, '../src/main/i18n')
const mainSrcDir = path.join(__dirname, '../src/main')
type I18NValue = string | { [key: string]: I18NValue }
type I18N = { [key: string]: I18NValue }
/**
* 递归检查目标对象与模板对象的键值结构是否完全一致(缺键、多键、嵌套结构不符都会抛错)。
* 用于确保所有翻译文件与基准模板(中文翻译文件)保持相同的键值结构。
*/
function checkRecursively(target: I18N, template: I18N): void {
for (const key in template) {
if (!(key in target)) {
throw new Error(`缺少属性 ${key}`)
}
if (key.includes('.')) {
throw new Error(`应该使用严格嵌套结构 ${key}`)
}
if (typeof template[key] === 'object' && template[key] !== null) {
if (typeof target[key] !== 'object' || target[key] === null) {
throw new Error(`属性 ${key} 不是对象`)
}
checkRecursively(target[key] as I18N, template[key] as I18N)
}
}
for (const targetKey in target) {
if (!(targetKey in template)) {
throw new Error(`多余属性 ${targetKey}`)
}
}
}
function isSortedI18N(obj: I18N): boolean {
return JSON.stringify(obj) === JSON.stringify(sortedObjectByKeys(obj))
}
/**
* 检查 JSON 对象中是否存在重复键,并收集所有重复键(若无重复则返回空数组)。
*/
function checkDuplicateKeys(obj: I18N): string[] {
const keys = new Set<string>()
const duplicateKeys: string[] = []
const checkObject = (obj: I18N, path: string = '') => {
for (const key in obj) {
const fullPath = path ? `${path}.${key}` : key
if (keys.has(fullPath)) {
if (!duplicateKeys.includes(fullPath)) {
duplicateKeys.push(fullPath)
}
} else {
keys.add(fullPath)
}
if (typeof obj[key] === 'object' && obj[key] !== null) {
checkObject(obj[key] as I18N, fullPath)
}
}
}
checkObject(obj)
return duplicateKeys
}
function readI18N(filePath: string): I18N {
if (!fs.existsSync(filePath)) {
throw new Error(`文件 ${filePath} 不存在,请检查路径或文件名`)
}
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
} catch (error) {
throw new Error(`解析 ${filePath} 出错。${error}`)
}
}
/**
* 校验一组翻译文件:基准模板无重复键且有序,其余文件有序且与基准结构完全一致。
*
* @param label 分组名(用于报错信息,如 renderer / main
* @param baseFilePath 基准模板文件(通常是中文翻译)
* @param files 该组需要校验的全部翻译文件(含基准模板本身)
*/
function checkCatalog(label: string, baseFilePath: string, files: string[]): I18N {
const baseJson = readI18N(baseFilePath)
const duplicateKeys = checkDuplicateKeys(baseJson)
if (duplicateKeys.length > 0) {
throw new Error(`[${label}] 主模板 ${path.basename(baseFilePath)} 存在以下重复键:\n${duplicateKeys.join('\n')}`)
}
if (!isSortedI18N(baseJson)) {
throw new Error(`[${label}] 主模板 ${path.basename(baseFilePath)} 的键值未按字典序排序。`)
}
for (const filePath of files) {
if (path.resolve(filePath) === path.resolve(baseFilePath)) continue
const targetJson = readI18N(filePath)
if (!isSortedI18N(targetJson)) {
throw new Error(`[${label}] 翻译文件 ${path.basename(filePath)} 的键值未按字典序排序。`)
}
try {
checkRecursively(targetJson, baseJson)
} catch (e) {
console.error(e)
throw new Error(`[${label}] 在检查 ${filePath} 时出错`)
}
}
return baseJson
}
function listJsonFiles(dir: string): string[] {
return fs
.readdirSync(dir)
.filter((file) => file.endsWith('.json'))
.map((file) => path.join(dir, file))
}
function keyExists(base: I18N, key: string): boolean {
let current: I18NValue | undefined = base
for (const segment of key.split('.')) {
if (current == null || typeof current !== 'object' || !(segment in current)) {
return false
}
current = current[segment]
}
return typeof current === 'string'
}
function collectSourceFiles(dir: string, acc: string[] = []): string[] {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
// Skip test folders and the i18n module itself (the catalog is the source of truth there).
if (entry.name === '__tests__' || entry.name === 'i18n') continue
collectSourceFiles(path.join(dir, entry.name), acc)
} else if (/\.tsx?$/.test(entry.name)) {
acc.push(path.join(dir, entry.name))
}
}
return acc
}
/**
* Verify that every `t('some.key')` call in files that import `t` from `@main/i18n`
* resolves to a string in the main catalog. This catches the common drift where main code
* starts using a key the small main catalog does not carry.
*
* A non-literal key — `t(someVar)`, a template string, a ternary — cannot be checked
* against the catalog statically, so it is reported as a loud failure rather than skipped
* silently: main code must use literal keys so this guard can cover them.
*
* Keys accessed through `getI18n()` subtree destructuring (app menu / tray / dialog
* namespaces) are guaranteed structurally by the catalog check above and are not
* statically re-verified here.
*/
function checkMainKeyCoverage(mainBaseJson: I18N): void {
const importsMainT = /import\s*(?:type\s*)?\{[^}]*\bt\b[^}]*\}\s*from\s*['"]@main\/i18n['"]/
const anyTCall = /(?<![\w.])t\(/g
const literalTCall = /^t\(\s*(['"])([\w.]+)\1/
const missing = new Set<string>()
const dynamic = new Set<string>()
for (const file of collectSourceFiles(mainSrcDir)) {
const content = fs.readFileSync(file, 'utf-8')
if (!importsMainT.test(content)) continue
const rel = path.relative(mainSrcDir, file)
for (const call of content.matchAll(anyTCall)) {
if (call.index === undefined) continue
const literal = literalTCall.exec(content.slice(call.index))
if (!literal) {
const snippet = content
.slice(call.index, call.index + 40)
.split('\n')[0]
.trim()
dynamic.add(`${snippet}… (${rel})`)
continue
}
const key = literal[2]
if (!keyExists(mainBaseJson, key)) {
missing.add(`${key} (${rel})`)
}
}
}
const errors: string[] = []
if (dynamic.size > 0) {
errors.push(`main 源码存在无法静态校验的非字面量 t() 调用(请改用字面量 key\n${[...dynamic].join('\n')}`)
}
if (missing.size > 0) {
errors.push(`main 源码使用了 main catalogsrc/main/i18n中不存在的 i18n key\n${[...missing].join('\n')}`)
}
if (errors.length > 0) {
throw new Error(errors.join('\n\n'))
}
}
function checkTranslations(): void {
// Renderer catalog: only the human-authored locales/ files are structure-checked, matching
// historical behavior (the machine-translated translate/ files are validated by the sync job).
checkCatalog('renderer', path.join(rendererLocalesDir, baseFileName), listJsonFiles(rendererLocalesDir))
// Main catalog: all 12 files (locales/ + translate/) must be aligned and sorted.
const mainBaseFilePath = path.join(mainI18nDir, 'locales', baseFileName)
const mainFiles = [
...listJsonFiles(path.join(mainI18nDir, 'locales')),
...listJsonFiles(path.join(mainI18nDir, 'translate'))
]
const mainBaseJson = checkCatalog('main', mainBaseFilePath, mainFiles)
checkMainKeyCoverage(mainBaseJson)
}
export function main() {
try {
checkTranslations()
console.log('i18n 检查已通过')
} catch (e) {
console.error(e)
throw new Error(`检查未通过。尝试运行 pnpm i18n:sync 以解决问题。`)
}
}
main()