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
+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