mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
feat(i18n): an application uses Core's translator and words Core's messages its own way
The ./i18n entry exports t, tn, msg, translateKnown and the number and date
formatters. setMessageOverrides({ en: {...}, de: {...} }) shows the
application's text for any catalog key: in the active locale, else its
English, else Core's translation.
This commit is contained in:
1 parent
b7160492e0
commit
c19d82d906
5 files changed
+209
-3
No files matched your search
@@ -0,0 +1,6 @@
|
||||
- Let an application that composes Core use its translator and word Core's
|
||||
messages its own way. The `./i18n` entry exports `t`, `tn`, `msg`,
|
||||
`translateKnown` and the number and date formatters, and
|
||||
`setMessageOverrides({ en: {…}, de: {…} })` shows the application's text for
|
||||
any catalog key, in the active locale or else in English (samehkhamis,
|
||||
#1741).
|
||||
@@ -83,6 +83,21 @@ menu.onChoose = (code) => switchLocale(code); // switched in place and stored (i
|
||||
|
||||
`switchLocale` does what a choice in the globe picker does, and returns `'unchanged'` for the active language. `onLocaleChange` tells the application's own menu to mark the new language.
|
||||
|
||||
The same entry exports the translator Core's text goes through (`t`, `tn`, `msg`, `translateKnown`, `formatNumber`, `formatDate`, `formatRelativeTime`), so the application's own text follows the same language. To show its own wording for one of Core's messages, it overrides it by the catalog key, in English and in any locale it words:
|
||||
|
||||
```js
|
||||
import { setMessageOverrides } from '<core package>/i18n';
|
||||
|
||||
setMessageOverrides({
|
||||
en: { 'Download scene JSON': 'Download scene file' },
|
||||
de: { 'Download scene JSON': 'Szenendatei herunterladen' },
|
||||
});
|
||||
```
|
||||
|
||||
Keys are the catalogs' (`context|text` for a context, the English plural for `tn`). A `t` entry is text; a `tn` entry is `{one, other, …}` forms, chosen by the plural rules of the locale they are worded in; an entry of the other kind is ignored. An overridden message shows the application's text in the active locale, else its English: an English-only override replaces Core's translations of that message too, so word every locale that should keep its own. Each call replaces the earlier overrides and `{}` removes them.
|
||||
|
||||
Call it before views render. `onLocaleChange` listeners word their text again at once, but a view already drawn keeps its text until it next renders. Import it from the `./i18n` entry only: a second copy of the module (a path alias into Core's sources) keeps its own overrides and language.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`.
|
||||
|
||||
+74
-2
@@ -177,7 +177,69 @@ function interpolate(pattern, values) {
|
||||
/** A catalog string with something to show; an empty one counts as missing. */
|
||||
const filled = (value) => typeof value === 'string' && value.trim() !== '';
|
||||
|
||||
// A host application's own text for messages Core shows (setMessageOverrides),
|
||||
// by locale: { en: { 'English key': 'Its text' }, de: { … } }.
|
||||
let overrides = Object.freeze({});
|
||||
|
||||
/**
|
||||
* Show a host application's own text for Core's messages. `messages` maps a
|
||||
* locale (`en` included) to entries keyed as the catalogs are: the English
|
||||
* text, `context|text` for a t() context, the English plural for tn() (whose
|
||||
* entry is a `{one, other, …}` object). An overridden message shows the host's
|
||||
* text in the active locale, else the host's English, else Core's. Each call
|
||||
* replaces the previous overrides; `{}` removes them. Call it before views
|
||||
* render: onLocaleChange listeners word their text again at once, but a view
|
||||
* already drawn keeps its text until it next renders. A t() entry is text, a
|
||||
* tn() entry `{one, other, …}` forms worded in its locale; any other entry is
|
||||
* ignored.
|
||||
* @param {Record<string, Record<string, string|Record<string, string>>>} messages
|
||||
*/
|
||||
export function setMessageOverrides(messages) {
|
||||
const next = {};
|
||||
for (const [locale, entries] of Object.entries(messages ?? {})) {
|
||||
const code = normalizeLocale(locale);
|
||||
if (!code || !entries || typeof entries !== 'object') continue;
|
||||
const own = Object.fromEntries(
|
||||
Object.entries(entries).map(([key, entry]) => [
|
||||
key,
|
||||
entry && typeof entry === 'object'
|
||||
? Object.freeze({ ...entry })
|
||||
: entry,
|
||||
]),
|
||||
);
|
||||
next[code] = Object.freeze({ ...next[code], ...own });
|
||||
}
|
||||
overrides = Object.freeze(next);
|
||||
notifyLocaleChange();
|
||||
}
|
||||
|
||||
/** A host's entry for t() is text; for tn() plural forms with an `other`. */
|
||||
const usable = (entry, plural) =>
|
||||
plural
|
||||
? Boolean(entry) && typeof entry === 'object' && filled(entry.other)
|
||||
: filled(entry);
|
||||
|
||||
/**
|
||||
* The host's entry for a message, in the active locale or else in English,
|
||||
* with the locale it is worded in (whose plural rules pick its form).
|
||||
*/
|
||||
function overridden(text, plural = false) {
|
||||
for (const locale of new Set([getLocale(), DEFAULT_LOCALE])) {
|
||||
const entries = overrides[locale];
|
||||
const entry =
|
||||
entries && Object.hasOwn(entries, text) ? entries[text] : undefined;
|
||||
if (usable(entry, plural)) return { entry, locale };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookup(text) {
|
||||
const host = overridden(text);
|
||||
return host ? host.entry : catalogEntry(text);
|
||||
}
|
||||
|
||||
/** Core's own catalog entry for a message in the active locale. */
|
||||
function catalogEntry(text) {
|
||||
const catalog = CATALOGS[getLocale()];
|
||||
if (!catalog) return undefined;
|
||||
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
||||
@@ -212,7 +274,14 @@ export function t(text, values) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function tn(count, one, other, values) {
|
||||
const entry = lookup(other);
|
||||
const host = overridden(other, true);
|
||||
if (host) {
|
||||
const forms = host.entry;
|
||||
const category = pluralRules(host.locale).select(count);
|
||||
const pattern = filled(forms[category]) ? forms[category] : forms.other;
|
||||
return interpolate(pattern, { count, ...values });
|
||||
}
|
||||
const entry = catalogEntry(other);
|
||||
const category = pluralRules(getLocale()).select(count);
|
||||
let pattern;
|
||||
const forms = entry && typeof entry === 'object' ? entry : null;
|
||||
@@ -230,8 +299,11 @@ export function tn(count, one, other, values) {
|
||||
* missing-translation warning.
|
||||
*/
|
||||
export function translateKnown(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const host = overridden(text);
|
||||
if (host) return host.entry;
|
||||
const catalog = CATALOGS[getLocale()];
|
||||
if (!catalog || typeof text !== 'string') return text;
|
||||
if (!catalog) return text;
|
||||
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
|
||||
return filled(entry) ? entry : text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
onLocaleChange,
|
||||
setLocale,
|
||||
setMessageOverrides,
|
||||
t,
|
||||
tn,
|
||||
translateKnown,
|
||||
} from './index.js';
|
||||
import * as page from './page.js';
|
||||
|
||||
afterEach(() => {
|
||||
setMessageOverrides({});
|
||||
setLocale('en');
|
||||
});
|
||||
|
||||
test('a host shows its own text for a Core message, in English and each locale', () => {
|
||||
setMessageOverrides({
|
||||
en: { 'Download scene JSON': 'Download scene file' },
|
||||
de: { 'Download scene JSON': 'Szenendatei herunterladen' },
|
||||
});
|
||||
assert.equal(t('Download scene JSON'), 'Download scene file');
|
||||
setLocale('de');
|
||||
assert.equal(t('Download scene JSON'), 'Szenendatei herunterladen');
|
||||
// A locale the host did not word shows the host's English, not Core's text.
|
||||
setLocale('fr');
|
||||
assert.equal(t('Download scene JSON'), 'Download scene file');
|
||||
// Messages it does not name stay Core's.
|
||||
assert.equal(t('Share selected scene'), 'Partager la scène sélectionnée');
|
||||
});
|
||||
|
||||
test('overrides interpolate, take contexts and plurals, and reach translateKnown', () => {
|
||||
setMessageOverrides({
|
||||
en: {
|
||||
'Imported {file}': 'Opened {file}',
|
||||
'action|CLEAR': 'WIPE',
|
||||
'{count} cameras': { one: '{count} feed', other: '{count} feeds' },
|
||||
},
|
||||
});
|
||||
assert.equal(t('Imported {file}', { file: 'a.zip' }), 'Opened a.zip');
|
||||
assert.equal(t('CLEAR', { context: 'action' }), 'WIPE');
|
||||
assert.equal(t('CLEAR'), 'CLEAR');
|
||||
assert.equal(tn(1, '{count} camera', '{count} cameras'), '1 feed');
|
||||
assert.equal(tn(3, '{count} camera', '{count} cameras'), '3 feeds');
|
||||
assert.equal(translateKnown('Imported {file}'), 'Opened {file}');
|
||||
// English forms follow English plural rules in any locale.
|
||||
for (const locale of ['ru', 'zh-CN', 'fr']) {
|
||||
setLocale(locale);
|
||||
assert.equal(tn(1, '{count} camera', '{count} cameras'), '1 feed');
|
||||
assert.equal(tn(21, '{count} camera', '{count} cameras'), '21 feeds');
|
||||
assert.equal(tn(0, '{count} camera', '{count} cameras'), '0 feeds');
|
||||
}
|
||||
});
|
||||
|
||||
test('an entry of the wrong kind is ignored, and Core translates', () => {
|
||||
setLocale('ru');
|
||||
const core = {
|
||||
t: t('Share selected scene'),
|
||||
tn: tn(3, '{count} camera', '{count} cameras'),
|
||||
};
|
||||
setMessageOverrides({
|
||||
en: { 'Share selected scene': {}, '{count} cameras': 'cams' },
|
||||
});
|
||||
assert.equal(t('Share selected scene'), core.t);
|
||||
assert.equal(tn(3, '{count} camera', '{count} cameras'), core.tn);
|
||||
});
|
||||
|
||||
test('each call replaces the overrides, words listeners again, and ignores junk', () => {
|
||||
let calls = 0;
|
||||
const stop = onLocaleChange(() => calls++);
|
||||
setMessageOverrides({
|
||||
en: { EXPORT: 'SAVE' },
|
||||
xx: { EXPORT: 'no' },
|
||||
de: null,
|
||||
});
|
||||
assert.equal(t('EXPORT'), 'SAVE');
|
||||
setMessageOverrides({ en: { IMPORT: 'OPEN' } });
|
||||
assert.equal(t('EXPORT'), 'EXPORT');
|
||||
assert.equal(t('IMPORT'), 'OPEN');
|
||||
// An empty entry counts as none.
|
||||
setMessageOverrides({ en: { IMPORT: '' } });
|
||||
assert.equal(t('IMPORT'), 'IMPORT');
|
||||
assert.equal(calls, 3);
|
||||
stop();
|
||||
});
|
||||
|
||||
test('the package entry exports the translator and the overrides', () => {
|
||||
for (const name of [
|
||||
't',
|
||||
'tn',
|
||||
'msg',
|
||||
'translateKnown',
|
||||
'setMessageOverrides',
|
||||
'formatNumber',
|
||||
])
|
||||
assert.equal(typeof page[name], 'function', name);
|
||||
});
|
||||
+15
-1
@@ -5,7 +5,9 @@
|
||||
* page) instead of the globe picker. It starts the language the standalone
|
||||
* application starts, keeps `<html lang>`/`<html dir>` and the page's
|
||||
* `data-i18n` markup in step, and switches in place, stored for the next
|
||||
* visit, exactly as the picker does. Guide: docs/I18N.md.
|
||||
* visit, exactly as the picker does. It also exports the translator itself,
|
||||
* and lets the application show its own text for Core's messages
|
||||
* (`setMessageOverrides`). Guide: docs/I18N.md.
|
||||
*/
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
@@ -33,6 +35,18 @@ export {
|
||||
isRtlLocale,
|
||||
onLocaleChange,
|
||||
};
|
||||
// The translator Core's own text goes through, for an application's text and
|
||||
// for showing its own wording of Core's messages (setMessageOverrides).
|
||||
export {
|
||||
formatDate,
|
||||
formatNumber,
|
||||
formatRelativeTime,
|
||||
msg,
|
||||
setMessageOverrides,
|
||||
t,
|
||||
tn,
|
||||
translateKnown,
|
||||
} from './index.js';
|
||||
|
||||
/**
|
||||
* Start the page in its language: `?lang=`, then the stored choice, then the
|
||||
|
||||
Reference in new issue
Block a user