mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
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:
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user