From 6d643d742fda5b336022b4a06ba6a8d80f9b7e81 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:21:28 -0700 Subject: [PATCH] refactor(i18n): make mobile catalogs sparse-compatible --- config/localization-audit.md | 14 +- .../bootstrap-mobile-locale-catalog.mjs | 285 ------------------ .../bootstrap-mobile-locale-catalog.test.mjs | 41 --- .../verify-mobile-localization-catalog.mjs | 19 +- ...erify-mobile-localization-catalog.test.mjs | 16 +- mobile/src/i18n/README.md | 16 + package.json | 5 - 7 files changed, 43 insertions(+), 353 deletions(-) delete mode 100644 config/scripts/bootstrap-mobile-locale-catalog.mjs delete mode 100644 config/scripts/bootstrap-mobile-locale-catalog.test.mjs create mode 100644 mobile/src/i18n/README.md diff --git a/config/localization-audit.md b/config/localization-audit.md index 99cfceb37b7..039e222b7a5 100644 --- a/config/localization-audit.md +++ b/config/localization-audit.md @@ -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: diff --git a/config/scripts/bootstrap-mobile-locale-catalog.mjs b/config/scripts/bootstrap-mobile-locale-catalog.mjs deleted file mode 100644 index 2a7b3efa6d9..00000000000 --- a/config/scripts/bootstrap-mobile-locale-catalog.mjs +++ /dev/null @@ -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()) -} diff --git a/config/scripts/bootstrap-mobile-locale-catalog.test.mjs b/config/scripts/bootstrap-mobile-locale-catalog.test.mjs deleted file mode 100644 index 33e3652f842..00000000000 --- a/config/scripts/bootstrap-mobile-locale-catalog.test.mjs +++ /dev/null @@ -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') - }) -}) diff --git a/config/scripts/verify-mobile-localization-catalog.mjs b/config/scripts/verify-mobile-localization-catalog.mjs index 79504d6dd8b..e423fd350c6 100644 --- a/config/scripts/verify-mobile-localization-catalog.mjs +++ b/config/scripts/verify-mobile-localization-catalog.mjs @@ -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)) } } diff --git a/config/scripts/verify-mobile-localization-catalog.test.mjs b/config/scripts/verify-mobile-localization-catalog.test.mjs index 56e7364dd97..0d5b3cd06c8 100644 --- a/config/scripts/verify-mobile-localization-catalog.test.mjs +++ b/config/scripts/verify-mobile-localization-catalog.test.mjs @@ -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: diff --git a/mobile/src/i18n/README.md b/mobile/src/i18n/README.md new file mode 100644 index 00000000000..dd50cfd1af3 --- /dev/null +++ b/mobile/src/i18n/README.md @@ -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. diff --git a/package.json b/package.json index 8e9dd6ac114..a76a9a8478a 100644 --- a/package.json +++ b/package.json @@ -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",