taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit ea0c06ae6ca2b1bfaf731a19c4a97a9991491465
parent 2bd48c4d3e6619f3c06468706bb4174d67dc78c1
Author: Florian Dold <dold@taler.net>
Date:   Mon, 31 Aug 2026 22:13:40 +0200

taler-util: add source-owned translations with catalog fallback

Diffstat:
Mpackages/taler-util/src/i18n.test.ts | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-util/src/i18n.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 191 insertions(+), 18 deletions(-)

diff --git a/packages/taler-util/src/i18n.test.ts b/packages/taler-util/src/i18n.test.ts @@ -16,9 +16,98 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { i18n } from "./i18n.js"; +import { i18n, setupI18n } from "./i18n.js"; test("translations fall back to source strings before setup", () => { assert.equal(i18n.str`Loading`, "Loading"); assert.deepEqual(i18n.Translate({ children: "Loading" }), ["Loading"]); }); + +const frenchCatalog = { + fr: { + domain: "messages", + locale_data: { + messages: { + "": { lang: "fr", plural_forms: "nplurals=2; plural=(n > 1);" }, + "Declaration for trusts (T)": ["Déclaration pour les trusts (T)"], + }, + }, + }, +}; + +test("fixed translations override catalogues and understand regional locales", () => { + setupI18n("de_CH", { + "de-CH": { + domain: "messages", + locale_data: { + messages: { + "": { lang: "de-CH" }, + "Declaration for trusts (T)": ["Falsche Übersetzung"], + }, + }, + }, + }); + assert.equal( + i18n.fixed({ + en: "Declaration for trusts (T)", + de: "Erklärung für Trusts (T)", + "de-CH": "Schweizer Erklärung für Trusts (T)", + }), + "Schweizer Erklärung für Trusts (T)", + ); + assert.equal( + i18n.fixed({ en: "Hello %1$s", de: "Hallo %1$s" }, "Ada"), + "Hallo Ada", + ); +}); + +test("fixed translations use gettext for languages without fixed text", () => { + setupI18n("fr_CA", frenchCatalog); + assert.equal( + i18n.fixed({ + en: "Declaration for trusts (T)", + de: "Erklärung für Trusts (T)", + }), + "Déclaration pour les trusts (T)", + ); +}); + +test("fixed translations fall back to English when gettext has no entry", () => { + setupI18n("es", {}); + assert.equal( + i18n.fixed({ + en: "Declaration for trusts (T)", + de: "Erklärung für Trusts (T)", + }), + "Declaration for trusts (T)", + ); +}); + +test("fixedOnly never consults gettext and English is always the source", () => { + setupI18n("fr", frenchCatalog); + assert.equal( + i18n.fixedOnly({ + en: "Declaration for trusts (T)", + de: "Erklärung für Trusts (T)", + }), + "Declaration for trusts (T)", + ); + setupI18n("en", { + en: { + domain: "messages", + locale_data: { + messages: { + "": { lang: "en" }, + "Declaration for trusts (T)": ["Wrong English override"], + }, + }, + }, + }); + assert.equal( + i18n.fixed({ + en: "Declaration for trusts (T)", + de: "Erklärung für Trusts (T)", + }), + "Declaration for trusts (T)", + ); +}); diff --git a/packages/taler-util/src/i18n.ts b/packages/taler-util/src/i18n.ts @@ -1,26 +1,33 @@ // @ts-ignore: no type decl for this library import * as jedLib from "jed"; -import { Logger } from "./logging.js"; - -const logger = new Logger("i18n/index.ts"); // Keep translations usable before an application installs its catalog. This // matters for shared components that can render while providers are still // waiting for configuration data. export let jed: any = new jedLib.Jed({}); +let activeLanguage = "en"; + /** * Set up jed library for internationalization, * based on browser language settings. */ export function setupI18n(lang: string, strings: { [s: string]: any }): void { - lang = lang.replace("_", "-"); - - if (!strings[lang]) { - strings[lang] = {}; - // logger.warn(`language ${lang} not found, defaulting to source strings`); - } - jed = new jedLib.Jed(strings[lang]); + lang = lang.replaceAll("_", "-"); + activeLanguage = lang; + const normalizedLanguage = lang.toLowerCase(); + const baseLanguage = normalizedLanguage.split("-", 1)[0]; + const normalizedCatalogs = new Map( + Object.entries(strings).map(([language, catalog]) => [ + language.replaceAll("_", "-").toLowerCase(), + catalog, + ]), + ); + const catalog = + normalizedCatalogs.get(normalizedLanguage) ?? + normalizedCatalogs.get(baseLanguage) ?? + {}; + jed = new jedLib.Jed(catalog); } /** @@ -48,6 +55,87 @@ function toI18nString(stringSeq: ReadonlyArray<string>): TranslatedString { return s as TranslatedString; } +export type FixedTranslations = Readonly< + { en: string } & Record<string, string> +>; + +function substituteSerializedValues( + translation: string, + values: readonly any[], +): TranslatedString { + return translation.replace(/%(\d+)\$s/g, (_match, index: string) => + String(values[Number.parseInt(index, 10) - 1]), + ) as TranslatedString; +} + +function fixedTranslation( + translations: FixedTranslations, + allowCatalogFallback: boolean, + values: readonly any[], +): TranslatedString { + const normalizedTranslations = new Map( + Object.entries(translations).map(([language, translation]) => [ + language.replaceAll("_", "-").toLowerCase(), + translation, + ]), + ); + const source = translations.en; + const normalizedLanguage = activeLanguage.toLowerCase(); + const baseLanguage = normalizedLanguage.split("-", 1)[0]; + + // The source language is itself fixed and must never be overridden by an + // English catalogue entry. + if (baseLanguage === "en") { + return substituteSerializedValues(source, values); + } + + const prescribed = + normalizedTranslations.get(normalizedLanguage) ?? + normalizedTranslations.get(baseLanguage); + if (prescribed !== undefined) { + return substituteSerializedValues(prescribed, values); + } + + if (allowCatalogFallback) { + return translateSerialized(source, values); + } + return substituteSerializedValues(source, values); +} + +/** + * Translate prescribed text whose authoritative translations live in source. + * Languages without a fixed translation continue through the gettext + * catalogue and ultimately fall back to the prescribed English source. + */ +export function fixed( + translations: FixedTranslations, + ...values: any[] +): TranslatedString { + return fixedTranslation(translations, true, values); +} + +/** + * Translate prescribed text using only source-controlled translations. + */ +export function fixedOnly( + translations: FixedTranslations, + ...values: any[] +): TranslatedString { + return fixedTranslation(translations, false, values); +} + +function translateSerialized( + source: string, + values: readonly any[], +): TranslatedString { + // jed throws an Error when the key is empty. + if (!source) return "" as TranslatedString; + return jed + .translate(source) + .ifPlural(1, source) + .fetch(...values); +} + /** * Internationalize a string template with arbitrary serialized values. */ @@ -56,13 +144,7 @@ export function singular( ...values: any[] ): TranslatedString { const s = toI18nString(stringSeq); - // jed throws a Error when key is empty - if (!s) return "" as TranslatedString; - const tr = jed - .translate(s) - .ifPlural(1, s) - .fetch(...values); - return tr; + return translateSerialized(s, values); } function withContext(ctx: string): typeof singular { @@ -162,6 +244,8 @@ export type Translator = (i18n: InternationalizationAPI) => TranslatedString; export const i18n = { str: singular, + fixed, + fixedOnly, ctx: withContext, singular, Translate,