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:
Sameh Khamis committed 2026-11-14 17:51:03 -08:00
1 parent b7160492e0
commit c19d82d906
5 files changed
+209 -3

No files matched your search

+74 -2
View File
@@ -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;
}
+99
View File
@@ -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
View File
@@ -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