From 44d1e2760f1c0120f9bc3494ea9cbe330d3f4f09 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:13:01 -0700 Subject: [PATCH] Improve localization catalog sync workflow (#5110) Co-authored-by: Orca --- config/localization-audit.md | 12 ++ .../scripts/verify-localization-catalog.mjs | 174 +++++++++++++++++- .../verify-localization-catalog.test.mjs | 82 +++++++++ package.json | 1 + src/renderer/src/i18n/locales/es.json | 8 +- 5 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 config/scripts/verify-localization-catalog.test.mjs diff --git a/config/localization-audit.md b/config/localization-audit.md index 20c0a3ab6..a90475042 100644 --- a/config/localization-audit.md +++ b/config/localization-audit.md @@ -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 diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index aee2fdea5..e49c9413d 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -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 } } diff --git a/config/scripts/verify-localization-catalog.test.mjs b/config/scripts/verify-localization-catalog.test.mjs new file mode 100644 index 000000000..ce148c789 --- /dev/null +++ b/config/scripts/verify-localization-catalog.test.mjs @@ -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({}) + }) +}) diff --git a/package.json b/package.json index 0fe629e27..9fa35f806 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ad50da453..e3bf53d54 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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}}",