refactor(i18n): make mobile catalogs sparse-compatible

This commit is contained in:
Brennan Benson
2026-07-31 00:21:28 -07:00
parent 56177f3792
commit 6d643d742f
7 changed files with 43 additions and 353 deletions
+5 -9
View File
@@ -51,20 +51,16 @@ The gate scans the desktop renderer plus both mobile source roots. Mobile uses
empty.
Verify mobile catalog references, conditional keys, interpolation options, and
locale parity:
the translations present in each sparse locale:
```sh
pnpm run verify:mobile-localization-catalog
```
Refresh a mobile translation catalog after changing English copy:
```sh
pnpm run bootstrap:mobile:es-catalog
pnpm run bootstrap:mobile:ja-catalog
pnpm run bootstrap:mobile:ko-catalog
pnpm run bootstrap:mobile:zh-catalog
```
The checked-in mobile runtime JSON is a temporary bridge to the planned
canonical PO source and read-only compiler. Feature work updates English only;
missing target values intentionally use runtime English fallback. Whole-catalog
translation commands are not part of the maintained workflow.
Sync catalog keys after adding or removing `translate(...)` calls:
@@ -1,285 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import {
collectStringLeaves,
repairCatalog,
repairTranslatedValue,
setLeaf,
shouldPreserveEnglishValue
} from './locale-translation-policy.mjs'
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
const MOBILE_LOCALES_DIR = path.join('mobile', 'src', 'i18n', 'locales')
const DESKTOP_LOCALES_DIR = path.join('src', 'renderer', 'src', 'i18n', 'locales')
const LOCALE_CONFIG = {
zh: { targetLanguage: 'zh-CN', displayName: 'Simplified Chinese' },
ko: { targetLanguage: 'ko', displayName: 'Korean' },
ja: { targetLanguage: 'ja', displayName: 'Japanese' },
es: { targetLanguage: 'es', displayName: 'Spanish' }
}
const MOBILE_LOCALE_KEY_OVERRIDES = {
'm.I-waJyg': { es: 'Conectando…', ja: '接続中…', ko: '연결 중…', zh: '正在连接…' },
'm.zDZEMlw': { es: 'Conectando…', ja: '接続中…', ko: '연결 중…', zh: '正在连接…' },
'm.T3HZLEU': { es: 'Conectando…', ja: '接続中…', ko: '연결 중…', zh: '正在连接…' },
'm.X8_vuao': { es: 'Detener', ja: '停止', ko: '중지', zh: '停止' },
'm.Scz67W0': { es: 'Continuar', ja: '続ける', ko: '계속', zh: '继续' },
'm.qzEC9yM': { es: 'ascendente', ja: '昇順', ko: '오름차순', zh: '升序' },
'm.2IFEfeA': { es: 'descendente', ja: '降順', ko: '내림차순', zh: '降序' },
'm.EiCMRDA': {
es: 'Sin preparar',
ja: '未ステージ',
ko: '스테이징되지 않음',
zh: '未暂存'
},
'm.YcfLGog': { es: 'Preparado', ja: 'ステージ済み', ko: '스테이징됨', zh: '已暂存' },
'm.FohFTKc': {
es: 'Todos los repositorios',
ja: 'すべてのリポジトリ',
ko: '모든 리포지토리',
zh: '所有仓库'
},
'm.dBmZ43Q': {
es: 'Todos los repos',
ja: 'すべてのリポジトリ',
ko: '모든 저장소',
zh: '所有代码库'
},
'm.zrhQGaA': {
es: '{{value0}} repositorios',
ja: '{{value0}} リポジトリ',
ko: '저장소 {{value0}}개',
zh: '{{value0}} 个代码库'
},
'm.px-yCeQ': {
es: '{{value0}} comentario',
ja: 'コメント {{value0}} 件',
ko: '댓글 {{value0}}개',
zh: '{{value0}} 条评论'
},
'm.Uv1hPbA': {
es: '{{value0}} comentarios',
ja: 'コメント {{value0}} 件',
ko: '댓글 {{value0}}개',
zh: '{{value0}} 条评论'
},
'm.ngn4NjA': {
es: '{{value0}} commit por detrás (commit base:',
ja: '{{value0}} コミット遅れ (ベースコミット:',
ko: '{{value0}}개 커밋 뒤처짐 (기준 커밋:',
zh: '落后 {{value0}} 个提交 (基础提交:'
},
'm.93sCYQo': {
es: '{{value0}} commits por detrás (commit base:',
ja: '{{value0}} コミット遅れ (ベースコミット:',
ko: '{{value0}}개 커밋 뒤처짐 (기준 커밋:',
zh: '落后 {{value0}} 个提交 (基础提交:'
}
}
function protectPlaceholders(text) {
const tokens = []
const protectedText = text.replace(PLACEHOLDER_RE, (match) => {
const token = `__PH${tokens.length}__`
tokens.push(match)
return token
})
return { protectedText, tokens }
}
function restorePlaceholders(text, tokens) {
let result = text
for (let index = 0; index < tokens.length; index += 1) {
const patterns = [`__PH${index}__`, `__ PH ${index} __`, `__PH ${index}__`, `__ PH${index}__`]
for (const pattern of patterns) {
result = result.replaceAll(pattern, tokens[index])
}
}
return result
}
async function translateText(text, targetLanguage) {
const url = new URL('https://translate.googleapis.com/translate_a/single')
url.searchParams.set('client', 'gtx')
url.searchParams.set('sl', 'en')
url.searchParams.set('tl', targetLanguage)
url.searchParams.set('dt', 't')
url.searchParams.set('q', text)
let lastError
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Translation request failed with status ${response.status}`)
}
const payload = await response.json()
return payload[0].map((part) => part[0]).join('')
} catch (error) {
lastError = error
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)))
}
}
throw lastError
}
async function mapWithConcurrency(items, concurrency, mapper) {
const results = Array.from({ length: items.length })
let nextIndex = 0
async function worker() {
while (nextIndex < items.length) {
const currentIndex = nextIndex
nextIndex += 1
results[currentIndex] = await mapper(items[currentIndex], currentIndex)
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()))
return results
}
async function loadCache(cachePath) {
try {
const raw = JSON.parse(await fs.readFile(cachePath, 'utf8'))
return new Map(Object.entries(raw))
} catch {
return new Map()
}
}
async function saveCache(cachePath, cache) {
await fs.writeFile(cachePath, `${JSON.stringify(Object.fromEntries(cache.entries()), null, 2)}\n`)
}
function mostFrequent(values) {
const counts = new Map()
for (const value of values) {
counts.set(value, (counts.get(value) ?? 0) + 1)
}
return [...counts.entries()].sort((left, right) => right[1] - left[1])[0]?.[0]
}
export function shouldReuseDesktopTranslation(english, translation) {
return translation !== english || shouldPreserveEnglishValue(english)
}
export function repairMobileTranslatedValue(options) {
return (
MOBILE_LOCALE_KEY_OVERRIDES[options.key]?.[options.locale] ?? repairTranslatedValue(options)
)
}
async function seedFromDesktopCatalog(root, locale, cache) {
const enCatalog = JSON.parse(
await fs.readFile(path.join(root, DESKTOP_LOCALES_DIR, 'en.json'), 'utf8')
)
const localeCatalog = JSON.parse(
await fs.readFile(path.join(root, DESKTOP_LOCALES_DIR, `${locale}.json`), 'utf8')
)
const localeLeaves = new Map(
collectStringLeaves(localeCatalog).map((leaf) => [leaf.key, leaf.value])
)
const translationsByEnglish = new Map()
for (const leaf of collectStringLeaves(enCatalog)) {
const translation = localeLeaves.get(leaf.key)
if (!translation) {
continue
}
const values = translationsByEnglish.get(leaf.value) ?? []
values.push(translation)
translationsByEnglish.set(leaf.value, values)
}
for (const [english, values] of translationsByEnglish) {
const translation = mostFrequent(values)
if (translation && shouldReuseDesktopTranslation(english, translation) && !cache.has(english)) {
cache.set(english, translation)
}
}
}
function parseLocaleArg(argv) {
const localeFlagIndex = argv.indexOf('--locale')
if (localeFlagIndex !== -1 && argv[localeFlagIndex + 1]) {
return argv[localeFlagIndex + 1]
}
return argv[2]
}
export async function main(root = process.cwd(), locale = parseLocaleArg(process.argv)) {
const config = LOCALE_CONFIG[locale]
if (!config) {
console.error(
`Unsupported locale "${locale}". Supported: ${Object.keys(LOCALE_CONFIG).join(', ')}`
)
return 1
}
const enPath = path.join(root, MOBILE_LOCALES_DIR, 'en.json')
const localePath = path.join(root, MOBILE_LOCALES_DIR, `${locale}.json`)
const cachePath = path.join(root, MOBILE_LOCALES_DIR, `.${locale}-catalog-cache.json`)
const enCatalog = JSON.parse(await fs.readFile(enPath, 'utf8'))
const localeCatalog = structuredClone(enCatalog)
const leaves = collectStringLeaves(enCatalog)
const uniqueValues = [...new Set(leaves.map((leaf) => leaf.value))]
const cache = await loadCache(cachePath)
await seedFromDesktopCatalog(root, locale, cache)
const toTranslate = uniqueValues.filter(
(value) => !shouldPreserveEnglishValue(value) && !cache.has(value)
)
console.log(
`Translating ${toTranslate.length} mobile strings to ${config.displayName} (${cache.size} reused)...`
)
let completed = 0
await mapWithConcurrency(toTranslate, 3, async (value) => {
const { protectedText, tokens } = protectPlaceholders(value)
const translated = await translateText(protectedText, config.targetLanguage)
const restored = restorePlaceholders(translated, tokens)
cache.set(
value,
repairTranslatedValue({ key: '', enValue: value, localeValue: restored, locale })
)
completed += 1
if (completed % 25 === 0) {
console.log(` ${completed}/${toTranslate.length}`)
await saveCache(cachePath, cache)
}
await new Promise((resolve) => setTimeout(resolve, 150))
})
for (const value of uniqueValues) {
if (shouldPreserveEnglishValue(value) && !cache.has(value)) {
cache.set(value, value)
}
}
await saveCache(cachePath, cache)
for (const leaf of leaves) {
setLeaf(
localeCatalog,
leaf.key,
repairMobileTranslatedValue({
key: leaf.key,
enValue: leaf.value,
localeValue: cache.get(leaf.value) ?? leaf.value,
locale
})
)
}
repairCatalog(enCatalog, localeCatalog, locale)
await fs.writeFile(localePath, `${JSON.stringify(localeCatalog, null, 2)}\n`)
console.log(`Wrote ${localePath}`)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
@@ -1,41 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
repairMobileTranslatedValue,
shouldReuseDesktopTranslation
} from './bootstrap-mobile-locale-catalog.mjs'
describe('bootstrap-mobile-locale-catalog', () => {
it('does not seed untranslated desktop actions as translations', () => {
expect(shouldReuseDesktopTranslation('Stop', 'Stop')).toBe(false)
expect(shouldReuseDesktopTranslation('Connecting…', 'Connecting…')).toBe(false)
expect(shouldReuseDesktopTranslation('Codex', 'Codex')).toBe(true)
})
it('uses contextual overrides without translating the Continue agent name', () => {
expect(
repairMobileTranslatedValue({
key: 'm.Scz67W0',
enValue: 'Continue',
localeValue: 'Continue',
locale: 'es'
})
).toBe('Continuar')
expect(
repairMobileTranslatedValue({
key: 'm.dSfrwic',
enValue: 'Continue',
localeValue: 'Continuar',
locale: 'es'
})
).toBe('Continue')
expect(
repairMobileTranslatedValue({
key: 'm.EiCMRDA',
enValue: 'Unstaged',
localeValue: 'sin escena',
locale: 'es'
})
).toBe('Sin preparar')
})
})
@@ -205,27 +205,22 @@ function verifyCalls(calls, englishEntries) {
return issues
}
function verifyLocaleParity(englishEntries, locale, localeEntries) {
function verifyLocaleEntries(englishEntries, locale, localeEntries) {
const issues = []
const englishKeys = [...englishEntries.keys()].sort()
const localeKeys = [...localeEntries.keys()].sort()
for (const key of englishKeys) {
if (!localeEntries.has(key)) {
issues.push(`${locale}.json missing key: ${key}`)
for (const key of localeKeys) {
const englishValue = englishEntries.get(key)
if (englishValue === undefined) {
issues.push(`${locale}.json has extra key: ${key}`)
continue
}
const englishPlaceholders = collectPlaceholderNames(englishEntries.get(key))
const englishPlaceholders = collectPlaceholderNames(englishValue)
const localePlaceholders = collectPlaceholderNames(localeEntries.get(key))
if (!sameValues(englishPlaceholders, localePlaceholders)) {
issues.push(`${locale}.json placeholder mismatch: ${key}`)
}
}
for (const key of localeKeys) {
if (!englishEntries.has(key)) {
issues.push(`${locale}.json has extra key: ${key}`)
}
}
return issues
}
@@ -284,7 +279,7 @@ export async function main(root = process.cwd()) {
for (const locale of MOBILE_LOCALES.slice(1)) {
const localeEntries = catalogs.get(locale)
if (localeEntries) {
issues.push(...verifyLocaleParity(englishEntries, locale, localeEntries))
issues.push(...verifyLocaleEntries(englishEntries, locale, localeEntries))
}
}
@@ -80,7 +80,21 @@ describe('verify-mobile-localization-catalog', () => {
expect(report).toContain('placeholders [name]')
})
it('requires identical locale keys and placeholders', async () => {
it('allows missing translations in sparse locale catalogs', async () => {
const en = { m: { greeting: 'Hello {{name}}', farewell: 'Bye' } }
const root = makeProject({
sourceText:
"import { t } from '@/i18n/mobile-i18n'\nexport const label = t('m.greeting', { name: 'Orca' })\n",
catalogs: {
en,
es: { m: { greeting: 'Hola {{name}}' } }
}
})
await expect(verifyMobileCatalog(root)).resolves.toBe(0)
})
it('validates placeholders and extra keys in present translations', async () => {
const en = { m: { greeting: 'Hello {{name}}' } }
const root = makeProject({
sourceText:
+16
View File
@@ -0,0 +1,16 @@
# Mobile localization catalogs
`locales/en.json` and the target-locale JSON files are temporary runtime inputs
until the canonical mobile PO source and read-only compiler land. They are a
bridge, not a second translation source or an invitation to regenerate whole
catalogs.
Feature changes update English only. Target catalogs may be sparse; missing
entries intentionally fall back to current English at runtime. Localization
work may update individual target entries, but must not copy English across a
locale to simulate coverage.
`../../locales/*.json` remains the separate Expo/native metadata projection.
Native permission prompts render before JavaScript, so the future compiler must
produce both the i18next runtime bundle and the Expo/native resources from the
same canonical source.
-5
View File
@@ -62,11 +62,6 @@
"verify:mobile-localization-catalog": "node config/scripts/verify-mobile-localization-catalog.mjs",
"sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix",
"verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs",
"bootstrap:mobile-locale-catalog": "node config/scripts/bootstrap-mobile-locale-catalog.mjs",
"bootstrap:mobile:zh-catalog": "node config/scripts/bootstrap-mobile-locale-catalog.mjs --locale zh",
"bootstrap:mobile:ko-catalog": "node config/scripts/bootstrap-mobile-locale-catalog.mjs --locale ko",
"bootstrap:mobile:ja-catalog": "node config/scripts/bootstrap-mobile-locale-catalog.mjs --locale ja",
"bootstrap:mobile:es-catalog": "node config/scripts/bootstrap-mobile-locale-catalog.mjs --locale es",
"verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check && node config/scripts/audit-localization-coverage.mjs --check --source-root mobile/app --allowlist config/mobile-localization-coverage-allowlist.json && node config/scripts/audit-localization-coverage.mjs --check --source-root mobile/src --allowlist config/mobile-localization-coverage-allowlist.json",
"audit:localization": "node config/scripts/audit-localization-coverage.mjs",
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs",