Improve localization catalog sync workflow (#5110)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-10 11:13:01 -07:00 committed by GitHub
parent e2e95f4100
commit 44d1e2760f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 270 additions and 7 deletions

View File

@ -46,6 +46,18 @@ Run the maintained coverage gate:
pnpm run verify:localization-coverage
```
Sync catalog keys after adding or removing `translate(...)` calls:
```sh
pnpm run sync:localization-catalog
```
The sync command adds missing `en.json` entries from each call's string fallback,
copies untranslated English placeholders into other locale catalogs to keep
parity, removes locale entries whose English key was deleted, and repairs
placeholder mismatches. Run the machine-translation bootstrap commands only when
refreshing real translations, not for ordinary UI copy changes.
The coverage gate compares current candidates against
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
new candidates fail the check and must be localized or added with a reviewed

View File

@ -9,6 +9,8 @@ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
const LOCALES_RELATIVE_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const SOURCE_RELATIVE_ROOTS = [path.join('src', 'renderer', 'src'), path.join('src', 'main')]
function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
@ -208,7 +210,61 @@ function flattenCatalogEntries(value, prefix = '', entries = new Map()) {
return entries
}
function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
function getCatalogEntry(catalog, key) {
return key.split('.').reduce((cursor, part) => cursor?.[part], catalog)
}
function setCatalogEntry(catalog, key, value) {
const parts = key.split('.')
let cursor = catalog
for (const part of parts.slice(0, -1)) {
if (typeof cursor[part] !== 'object' || cursor[part] === null || Array.isArray(cursor[part])) {
cursor[part] = {}
}
cursor = cursor[part]
}
cursor[parts.at(-1)] = value
}
function deleteCatalogEntry(catalog, key) {
const parts = key.split('.')
const stack = []
let cursor = catalog
for (const part of parts.slice(0, -1)) {
if (
typeof cursor?.[part] !== 'object' ||
cursor[part] === null ||
Array.isArray(cursor[part])
) {
return false
}
stack.push([cursor, part])
cursor = cursor[part]
}
const leafKey = parts.at(-1)
if (!Object.hasOwn(cursor, leafKey)) {
return false
}
delete cursor[leafKey]
for (let index = stack.length - 1; index >= 0; index -= 1) {
const [parent, part] = stack[index]
const child = parent[part]
if (
typeof child === 'object' &&
child !== null &&
!Array.isArray(child) &&
Object.keys(child).length === 0
) {
delete parent[part]
}
}
return true
}
function collectLocaleParityIssues(enCatalog, localeCatalog) {
const enEntries = flattenCatalogEntries(enCatalog)
const localeEntries = flattenCatalogEntries(localeCatalog)
const missingInLocale = [...enEntries.keys()].filter((key) => !localeEntries.has(key))
@ -226,6 +282,71 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
}
}
return { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches }
}
function repairLocaleParity(enCatalog, localeCatalog) {
const { enEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
let changed = 0
for (const key of missingInLocale) {
setCatalogEntry(localeCatalog, key, enEntries.get(key))
changed += 1
}
for (const key of extraInLocale) {
if (deleteCatalogEntry(localeCatalog, key)) {
changed += 1
}
}
for (const key of interpolationMismatches) {
setCatalogEntry(localeCatalog, key, enEntries.get(key))
changed += 1
}
return changed
}
function referencesMissingFallbacks(missing) {
return missing.filter((reference) => typeof reference.fallback !== 'string')
}
function collectMissingCatalogEntries(missing) {
const entries = new Map()
for (const reference of missing) {
if (typeof reference.fallback !== 'string') {
continue
}
if (!entries.has(reference.key)) {
entries.set(reference.key, reference.fallback)
}
}
return entries
}
function applyMissingEnglishEntries(catalog, missing) {
const entries = collectMissingCatalogEntries(missing)
let changed = 0
for (const [key, fallback] of entries) {
if (getCatalogEntry(catalog, key) !== undefined) {
continue
}
setCatalogEntry(catalog, key, fallback)
changed += 1
}
return changed
}
function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
const { localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
if (
missingInLocale.length > 0 ||
extraInLocale.length > 0 ||
@ -262,12 +383,18 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
return 0
}
export async function main(root = process.cwd()) {
const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
function parseArgs(argv) {
return {
fix: argv.includes('--fix')
}
}
export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) {
const localesDir = path.join(root, LOCALES_RELATIVE_DIR)
const catalogPath = path.join(localesDir, 'en.json')
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
const catalogKeys = new Set(flattenCatalogKeys(catalog))
const sourceRoots = [path.join(root, 'src', 'renderer', 'src'), path.join(root, 'src', 'main')]
let catalogKeys = new Set(flattenCatalogKeys(catalog))
const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot))
const references = []
for (const sourceRoot of sourceRoots) {
@ -281,9 +408,33 @@ export async function main(root = process.cwd()) {
const missing = references.filter((reference) => !catalogKeys.has(reference.key))
if (missing.length > 0) {
const missingFallbacks = referencesMissingFallbacks(missing)
if (options.fix && missingFallbacks.length === 0) {
const added = applyMissingEnglishEntries(catalog, missing)
await fs.writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8')
catalogKeys = new Set(flattenCatalogKeys(catalog))
console.log(`Added ${added} missing localization key(s) to en.json.`)
} else {
if (options.fix && missingFallbacks.length > 0) {
console.error('Some missing localization keys do not have string fallbacks to bootstrap.')
console.error('')
console.error(formatMissingReferences(missingFallbacks))
return 1
}
console.error('Localization keys are missing from src/renderer/src/i18n/locales/en.json.')
console.error('')
console.error(formatMissingReferences(missing))
console.error('')
console.error('Run `pnpm run sync:localization-catalog` to add keys with string fallbacks.')
return 1
}
}
const remainingMissing = references.filter((reference) => !catalogKeys.has(reference.key))
if (remainingMissing.length > 0) {
console.error('Localization keys are missing from src/renderer/src/i18n/locales/en.json.')
console.error('')
console.error(formatMissingReferences(missing))
console.error(formatMissingReferences(remainingMissing))
return 1
}
@ -311,8 +462,19 @@ export async function main(root = process.cwd()) {
const localeName = fileName.replace(/\.json$/, '')
const localeCatalogPath = path.join(localesDir, fileName)
const localeCatalog = JSON.parse(await fs.readFile(localeCatalogPath, 'utf8'))
if (options.fix) {
const repaired = repairLocaleParity(catalog, localeCatalog)
if (repaired > 0) {
await fs.writeFile(localeCatalogPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
console.log(`Repaired ${fileName} parity (${repaired} key update(s)).`)
}
}
const exitCode = verifyLocaleParity(catalog, localeName, localeCatalog)
if (exitCode !== 0) {
if (!options.fix) {
console.error('')
console.error('Run `pnpm run sync:localization-catalog` to repair locale parity.')
}
return exitCode
}
}

View File

@ -0,0 +1,82 @@
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
}
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf8'))
}
function makeProject({ sourceText, enCatalog = {}, esCatalog = {} }) {
const root = mkdtempSync(path.join(tmpdir(), 'orca-localization-catalog-'))
const rendererDir = path.join(root, 'src', 'renderer', 'src', 'components')
const mainDir = path.join(root, 'src', 'main')
const localesDir = path.join(root, 'src', 'renderer', 'src', 'i18n', 'locales')
mkdirSync(rendererDir, { recursive: true })
mkdirSync(mainDir, { recursive: true })
mkdirSync(localesDir, { recursive: true })
writeFileSync(path.join(rendererDir, 'Example.tsx'), sourceText, 'utf8')
writeFileSync(path.join(mainDir, 'empty.ts'), 'export {}\n', 'utf8')
writeJson(path.join(localesDir, 'en.json'), enCatalog)
writeJson(path.join(localesDir, 'es.json'), esCatalog)
return { root, localesDir }
}
describe('verify-localization-catalog', () => {
it('bootstraps missing catalog entries from string fallbacks', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n"
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(1)
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
})
it('repairs stale locale keys and interpolation mismatches', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
enCatalog: { auto: { example: { greeting: 'Hello {{name}}' } } },
esCatalog: {
auto: {
example: { greeting: 'Hola' },
stale: { removed: 'Viejo' }
}
}
})
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
})
it('does not invent values for keys without string fallbacks', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.noFallback')\n"
})
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({})
})
})

View File

@ -45,6 +45,7 @@
"verify:computer-native": "node config/scripts/verify-computer-native.mjs",
"verify:cli-bin": "node config/scripts/verify-cli-bin.mjs",
"verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs",
"sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix",
"bootstrap:locale-catalog": "node config/scripts/bootstrap-locale-catalog.mjs",
"bootstrap:zh-catalog": "node config/scripts/bootstrap-zh-catalog.mjs",
"bootstrap:ko-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale ko",

View File

@ -4948,7 +4948,13 @@
"2b6356e744": "Guardado",
"81057d5f71": "Ahorro...",
"da37d6f10e": "Copiar",
"3149964b66": "copiado"
"3149964b66": "copiado",
"56f9a4a1d0": "Using `orca.yaml`",
"623e0c9f31": "`orca.yaml` could not be parsed",
"5a67e4793d": "No `orca.yaml` detected",
"07ba35bc68": "Check the indentation under `scripts:`. Hook keys should use two spaces, and command lines should use four.",
"787ca433ef": "Define only the supported keys: `scripts`, `setup`, `archive`, and `issueCommand`.",
"ecc73d9125": "Compare your file against the working template below and copy that shape if needed."
},
"RepositoryIconPicker": {
"2b7d27b93c": "Utilice el color del repositorio {{value0}}",