Improve localization catalog sync workflow (#5110)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
e2e95f4100
commit
44d1e2760f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
Loading…
Reference in New Issue