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:
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,