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/<lang>.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
This commit is contained in:
Linus Rath
2026-09-24 19:39:48 +02:00
parent af6290fd4c
commit 008bff29f8
30 changed files with 182 additions and 12 deletions
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+51
View File
@@ -0,0 +1,51 @@
// List the RN-only overlay keys (locales/rn/en.json) a language has not
// translated in locales/rn/<lang>.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`);
}
+2 -2
View File
@@ -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<string, unknown>,
);
+68
View File
@@ -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<string, unknown>, prefix = '', out = new Map<string, unknown>()): Map<string, unknown> {
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<string, unknown>, key, out);
else out.set(key, v);
}
return out;
}
function readOverlay(code: string): Map<string, unknown> {
return flatten(JSON.parse(readFileSync(join(RN_DIR, `${code}.json`), 'utf8')) as Record<string, unknown>);
}
// 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<string>();
for (const m of message.matchAll(/(?<!\b(?:zero|one|two|few|many|other)\s*|=\d+\s*)\{\s*(\w+)\s*[,}]/g)) {
names.add(m[1]);
}
return [...names].sort();
}
describe('RN overlay translations', () => {
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([]);
});
}
});
+35 -10
View File
@@ -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<Record<LocaleCode, Dictionary>> = {
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<LocaleCode, () => Dictionary> = {
'zh-TW': () => require('../../locales/zh-TW/common.json'),
};
// The RN-only overlays, loaded alongside their catalog.
const OVERLAY_LOADERS: Record<LocaleCode, () => 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<Record<LocaleCode, Dictionary>> = {};
/** 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;