From 008bff29f8bf92f3e6f1071c1dc38bb85a3db634 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:39:48 +0200 Subject: [PATCH] feat: load app-only strings in every language, not just English The RN overlay (`locales/rn/en.json`, the strings the webmail catalog does not carry) only existed in English, so those 600 or so labels showed in English whatever language the app was set to. - load `locales/rn/.json` for every language next to its vendored catalog; an untranslated key still falls back to English - a test keeps each translation to English keys and the same ICU arguments - `node scripts/rn-missing-translations.mjs [lang]` lists what a language still lacks --- locales/rn/ar.json | 1 + locales/rn/ca.json | 1 + locales/rn/cs.json | 1 + locales/rn/da.json | 1 + locales/rn/de.json | 1 + locales/rn/es.json | 1 + locales/rn/fa.json | 1 + locales/rn/fr.json | 1 + locales/rn/he.json | 1 + locales/rn/hu.json | 1 + locales/rn/it.json | 1 + locales/rn/ja.json | 1 + locales/rn/ko.json | 1 + locales/rn/lv.json | 1 + locales/rn/mn.json | 1 + locales/rn/nb.json | 1 + locales/rn/nl.json | 1 + locales/rn/pl.json | 1 + locales/rn/pt.json | 1 + locales/rn/ro.json | 1 + locales/rn/ru.json | 1 + locales/rn/sk.json | 1 + locales/rn/tr.json | 1 + locales/rn/uk.json | 1 + locales/rn/zh-TW.json | 1 + locales/rn/zh.json | 1 + scripts/rn-missing-translations.mjs | 51 +++++++++++++++++++ src/i18n/__tests__/keys.test.ts | 4 +- src/i18n/__tests__/rn-overlays.test.ts | 68 ++++++++++++++++++++++++++ src/i18n/index.ts | 45 +++++++++++++---- 30 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 locales/rn/ar.json create mode 100644 locales/rn/ca.json create mode 100644 locales/rn/cs.json create mode 100644 locales/rn/da.json create mode 100644 locales/rn/de.json create mode 100644 locales/rn/es.json create mode 100644 locales/rn/fa.json create mode 100644 locales/rn/fr.json create mode 100644 locales/rn/he.json create mode 100644 locales/rn/hu.json create mode 100644 locales/rn/it.json create mode 100644 locales/rn/ja.json create mode 100644 locales/rn/ko.json create mode 100644 locales/rn/lv.json create mode 100644 locales/rn/mn.json create mode 100644 locales/rn/nb.json create mode 100644 locales/rn/nl.json create mode 100644 locales/rn/pl.json create mode 100644 locales/rn/pt.json create mode 100644 locales/rn/ro.json create mode 100644 locales/rn/ru.json create mode 100644 locales/rn/sk.json create mode 100644 locales/rn/tr.json create mode 100644 locales/rn/uk.json create mode 100644 locales/rn/zh-TW.json create mode 100644 locales/rn/zh.json create mode 100644 scripts/rn-missing-translations.mjs create mode 100644 src/i18n/__tests__/rn-overlays.test.ts diff --git a/locales/rn/ar.json b/locales/rn/ar.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/ar.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/ca.json b/locales/rn/ca.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/ca.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/cs.json b/locales/rn/cs.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/cs.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/da.json b/locales/rn/da.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/da.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/de.json b/locales/rn/de.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/de.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/es.json b/locales/rn/es.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/es.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/fa.json b/locales/rn/fa.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/fa.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/fr.json b/locales/rn/fr.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/fr.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/he.json b/locales/rn/he.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/he.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/hu.json b/locales/rn/hu.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/hu.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/it.json b/locales/rn/it.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/it.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/ja.json b/locales/rn/ja.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/ja.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/ko.json b/locales/rn/ko.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/ko.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/lv.json b/locales/rn/lv.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/lv.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/mn.json b/locales/rn/mn.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/mn.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/nb.json b/locales/rn/nb.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/nb.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/nl.json b/locales/rn/nl.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/nl.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/pl.json b/locales/rn/pl.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/pl.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/pt.json b/locales/rn/pt.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/pt.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/ro.json b/locales/rn/ro.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/ro.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/ru.json b/locales/rn/ru.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/ru.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/sk.json b/locales/rn/sk.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/sk.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/tr.json b/locales/rn/tr.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/tr.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/uk.json b/locales/rn/uk.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/uk.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/zh-TW.json @@ -0,0 +1 @@ +{} diff --git a/locales/rn/zh.json b/locales/rn/zh.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/rn/zh.json @@ -0,0 +1 @@ +{} diff --git a/scripts/rn-missing-translations.mjs b/scripts/rn-missing-translations.mjs new file mode 100644 index 00000000..d7e4442f --- /dev/null +++ b/scripts/rn-missing-translations.mjs @@ -0,0 +1,51 @@ +// List the RN-only overlay keys (locales/rn/en.json) a language has not +// translated in locales/rn/.json yet. Run from the RN repo root: +// +// node scripts/rn-missing-translations.mjs count per language +// node scripts/rn-missing-translations.mjs de the missing keys with their +// English text, as nested JSON +// +// Keys the vendored webmail catalog of that language already carries are not +// listed: they are translated there. +import { readFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const RN = join(ROOT, 'locales', 'rn'); + +function flatten(obj, prefix = '', out = new Map()) { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, key, out); + else out.set(key, v); + } + return out; +} + +function setDeep(obj, key, value) { + const parts = key.split('.'); + let cur = obj; + for (const part of parts.slice(0, -1)) cur = cur[part] ??= {}; + cur[parts.at(-1)] = value; +} + +const readJson = (path) => JSON.parse(readFileSync(path, 'utf8')); + +function missingFor(lang, en) { + const done = flatten(readJson(join(RN, `${lang}.json`))); + const webmail = flatten(readJson(join(ROOT, 'locales', lang, 'common.json'))); + return [...en].filter(([key]) => !done.has(key) && !webmail.has(key)); +} + +const en = flatten(readJson(join(RN, 'en.json'))); +const lang = process.argv[2]; + +if (lang) { + const out = {}; + for (const [key, value] of missingFor(lang, en)) setDeep(out, key, value); + console.log(JSON.stringify(out, null, 2)); +} else { + const langs = readdirSync(RN).filter((f) => f.endsWith('.json') && f !== 'en.json').map((f) => f.slice(0, -5)); + for (const l of langs.sort()) console.log(`${l.padEnd(6)} ${missingFor(l, en).length} of ${en.size} missing`); +} diff --git a/src/i18n/__tests__/keys.test.ts b/src/i18n/__tests__/keys.test.ts index 2970834d..d73c8992 100644 --- a/src/i18n/__tests__/keys.test.ts +++ b/src/i18n/__tests__/keys.test.ts @@ -74,8 +74,8 @@ describe('translation coverage', () => { }, SRC_WALK_TIMEOUT); it('every other locale is a subset of English', () => { - // The vendored webmail catalog, without the RN-only overlay (which only - // exists for English and is served to every locale through the fallback). + // The vendored webmail catalog, without the RN-only overlay (English is + // complete; the other overlays are partial and fall back to it). const baseEn = flatten( JSON.parse(readFileSync(join(SRC_ROOT, '..', 'locales', 'en', 'common.json'), 'utf8')) as Record, ); diff --git a/src/i18n/__tests__/rn-overlays.test.ts b/src/i18n/__tests__/rn-overlays.test.ts new file mode 100644 index 00000000..53b1596d --- /dev/null +++ b/src/i18n/__tests__/rn-overlays.test.ts @@ -0,0 +1,68 @@ +// The RN-only overlays of the other languages translate locales/rn/en.json. +// Every key they carry must exist in English, be a non-empty string and use +// the same ICU arguments, or t() would render a stale key or drop a value. +// A key a language has not translated yet is fine: it falls back to English. +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +vi.mock('expo-localization', () => ({ getLocales: () => [] })); + +import { SUPPORTED_LOCALES } from '../index'; + +const RN_DIR = join(__dirname, '..', '..', '..', 'locales', 'rn'); + +function flatten(obj: Record, prefix = '', out = new Map()): Map { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v as Record, key, out); + else out.set(key, v); + } + return out; +} + +function readOverlay(code: string): Map { + return flatten(JSON.parse(readFileSync(join(RN_DIR, `${code}.json`), 'utf8')) as Record); +} + +// Argument names a message uses: `{name}` and `{count, plural, …}`. The word +// in a plural branch (`one {item}`) is text, not an argument. +function argumentsOf(message: string): string[] { + const names = new Set(); + for (const m of message.matchAll(/(? { + const en = readOverlay('en'); + + it('finds the arguments of plain and plural messages', () => { + expect(argumentsOf('Hello {name}')).toEqual(['name']); + expect(argumentsOf('{count, plural, one {# item} other {# items}} in {folder}')).toEqual(['count', 'folder']); + expect(argumentsOf('{count, plural, =0 {none} one {{count} day} other {{count} days}}')).toEqual(['count']); + }); + + for (const { code } of SUPPORTED_LOCALES) { + if (code === 'en') continue; + it(`${code} only translates English keys, keeping their arguments`, () => { + const problems: string[] = []; + for (const [key, value] of readOverlay(code)) { + const english = en.get(key); + if (typeof english !== 'string') { + problems.push(`${key}: not in locales/rn/en.json`); + continue; + } + if (typeof value !== 'string' || !value.trim()) { + problems.push(`${key}: empty or not a string`); + continue; + } + const want = argumentsOf(english).join(', '); + const got = argumentsOf(value).join(', '); + if (want !== got) problems.push(`${key}: arguments {${got}} instead of {${want}}`); + } + expect(problems).toEqual([]); + }); + } +}); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 2b08dac4..0b889b3b 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -12,9 +12,9 @@ import { formatMessage, type MessageParams } from './format'; // catalogs are loaded on first use; see CATALOG_LOADERS below. import en from '../../locales/en/common.json'; -// Keys the native app needs that the webmail catalog does not carry. Only an -// English overlay exists today; other languages fall through to it via the -// en fallback below. +// Keys the native app needs that the webmail catalog does not carry. English +// is complete; a key another language has not translated yet falls through +// to it via the en fallback in translate(). import rnEn from '../../locales/rn/en.json'; export type { MessageParams } from './format'; @@ -71,10 +71,6 @@ function deepMerge(base: Dictionary, overlay: Dictionary): Dictionary { return out; } -const RN_OVERLAYS: Partial> = { - en: rnEn as Dictionary, -}; - // A catalog is a few hundred KB of JSON. Metro only evaluates a module when it // is first required, so keeping each require inside its loader means startup // builds English plus the active language instead of all 27, and switching @@ -110,15 +106,44 @@ const CATALOG_LOADERS: Record Dictionary> = { 'zh-TW': () => require('../../locales/zh-TW/common.json'), }; +// The RN-only overlays, loaded alongside their catalog. +const OVERLAY_LOADERS: Record Dictionary> = { + ar: () => require('../../locales/rn/ar.json'), + ca: () => require('../../locales/rn/ca.json'), + cs: () => require('../../locales/rn/cs.json'), + da: () => require('../../locales/rn/da.json'), + de: () => require('../../locales/rn/de.json'), + en: () => rnEn as Dictionary, + es: () => require('../../locales/rn/es.json'), + fa: () => require('../../locales/rn/fa.json'), + fr: () => require('../../locales/rn/fr.json'), + he: () => require('../../locales/rn/he.json'), + hu: () => require('../../locales/rn/hu.json'), + it: () => require('../../locales/rn/it.json'), + ja: () => require('../../locales/rn/ja.json'), + ko: () => require('../../locales/rn/ko.json'), + lv: () => require('../../locales/rn/lv.json'), + mn: () => require('../../locales/rn/mn.json'), + nb: () => require('../../locales/rn/nb.json'), + nl: () => require('../../locales/rn/nl.json'), + pl: () => require('../../locales/rn/pl.json'), + pt: () => require('../../locales/rn/pt.json'), + ro: () => require('../../locales/rn/ro.json'), + ru: () => require('../../locales/rn/ru.json'), + sk: () => require('../../locales/rn/sk.json'), + tr: () => require('../../locales/rn/tr.json'), + uk: () => require('../../locales/rn/uk.json'), + zh: () => require('../../locales/rn/zh.json'), + 'zh-TW': () => require('../../locales/rn/zh-TW.json'), +}; + const dictionaries: Partial> = {}; /** The merged catalog (vendored + RN overlay) for a locale, loaded on first use. */ export function getDictionary(locale: LocaleCode): Dictionary { let dict = dictionaries[locale]; if (!dict) { - const base = CATALOG_LOADERS[locale](); - const overlay = RN_OVERLAYS[locale]; - dict = overlay ? deepMerge(base, overlay) : base; + dict = deepMerge(CATALOG_LOADERS[locale](), OVERLAY_LOADERS[locale]()); dictionaries[locale] = dict; } return dict;