commit d45c117af6ce5cc347a162ec06e43ba71f1d736a
parent 0f2ab3431cb55f35d37d77ee5ba84e1cdabffa57
Author: Florian Dold <dold@taler.net>
Date: Fri, 4 Sep 2026 14:16:07 +0200
taler-util: compose catalogues with locale fallback
Merge library and application catalogues with deterministic precedence
and exact combined completeness counts. Resolve regional and script
locales per message through progressively broader language tags.
Diffstat:
2 files changed, 382 insertions(+), 20 deletions(-)
diff --git a/packages/taler-util/src/i18n.test.ts b/packages/taler-util/src/i18n.test.ts
@@ -16,7 +16,13 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { i18n, setupI18n } from "./i18n.js";
+import {
+ composeTranslationCatalogues,
+ i18n,
+ languageFallbackChain,
+ setupI18n,
+ type TranslationCatalogue,
+} from "./i18n.js";
test("translations fall back to source strings before setup", () => {
assert.equal(i18n.str`Loading`, "Loading");
@@ -61,6 +67,116 @@ test("fixed translations override catalogues and understand regional locales", (
);
});
+test("locale tags produce a progressive normalized fallback chain", () => {
+ assert.deepEqual(languageFallbackChain("zh_Hant_TW"), [
+ "zh",
+ "zh-hant",
+ "zh-hant-tw",
+ ]);
+});
+
+test("regional catalogues inherit missing messages from parent locales", () => {
+ setupI18n("de_CH", {
+ de: {
+ domain: "messages",
+ locale_data: {
+ messages: {
+ "": { lang: "de" },
+ Greeting: ["Guten Tag"],
+ Farewell: ["Auf Wiedersehen"],
+ },
+ },
+ },
+ "de-CH": {
+ domain: "messages",
+ locale_data: {
+ messages: {
+ "": { lang: "de-CH" },
+ Greeting: ["Grüezi"],
+ Farewell: [""],
+ },
+ },
+ },
+ });
+ assert.equal(i18n.str`Greeting`, "Grüezi");
+ assert.equal(i18n.str`Farewell`, "Auf Wiedersehen");
+});
+
+test("fixed translations use the nearest locale in the fallback chain", () => {
+ setupI18n("zh_Hant_TW", {});
+ assert.equal(
+ i18n.fixedOnly({ en: "Trust", zh: "信托", "zh-Hant": "信託" }),
+ "信託",
+ );
+});
+
+function catalogue(
+ language: string,
+ messages: Record<string, unknown>,
+ untranslated: string[] = [],
+ optional: string[] = [],
+): TranslationCatalogue {
+ const owned = new Set([
+ ...Object.keys(messages),
+ ...untranslated,
+ ...optional,
+ ]);
+ const total = [...owned].filter((key) => !optional.includes(key)).length;
+ const translated = [...owned].filter(
+ (key) => !optional.includes(key) && !untranslated.includes(key),
+ ).length;
+ return {
+ domain: "messages",
+ lang: language,
+ completeness: total ? Math.floor((translated * 100) / total) : 0,
+ locale_data: {
+ messages: { "": { lang: language }, ...messages },
+ },
+ _pogen: { total, translated, untranslated, optional },
+ };
+}
+
+test("catalogue composition deduplicates keys and gives the application priority", () => {
+ const shared = {
+ de: catalogue(
+ "de",
+ {
+ Shared: ["Aus der Bibliothek"],
+ Dependency: ["Abhängigkeit"],
+ Optional: [""],
+ },
+ [],
+ ["Optional"],
+ ),
+ };
+ const application = {
+ de: catalogue("de", { Shared: ["Aus der Anwendung"], Own: [""] }, ["Own"]),
+ };
+ const composed = composeTranslationCatalogues(shared, application);
+ assert.deepEqual(composed.de.locale_data?.messages?.Shared, [
+ "Aus der Anwendung",
+ ]);
+ assert.deepEqual(composed.de.locale_data?.messages?.Dependency, [
+ "Abhängigkeit",
+ ]);
+ assert.deepEqual(composed.de._pogen, {
+ total: 3,
+ translated: 2,
+ untranslated: ["Own"],
+ optional: ["Optional"],
+ });
+ assert.equal(composed.de.completeness, 66);
+});
+
+test("an untranslated application entry blocks a dependency translation", () => {
+ const composed = composeTranslationCatalogues(
+ { de: catalogue("de", { Shared: ["Bibliothek"] }) },
+ { de: catalogue("de", { Shared: [""] }, ["Shared"]) },
+ );
+ assert.deepEqual(composed.de.locale_data?.messages?.Shared, [""]);
+ assert.equal(composed.de.completeness, 0);
+});
+
test("fixed translations use gettext for languages without fixed text", () => {
setupI18n("fr_CA", frenchCatalog);
assert.equal(
diff --git a/packages/taler-util/src/i18n.ts b/packages/taler-util/src/i18n.ts
@@ -8,25 +8,270 @@ export let jed: any = new jedLib.Jed({});
let activeLanguage = "en";
+export interface PogenCatalogueMetadata {
+ total: number;
+ translated: number;
+ untranslated: string[];
+ optional: string[];
+}
+
+export interface TranslationCatalogue {
+ domain?: string;
+ lang?: string;
+ completeness?: number;
+ plural_forms?: string;
+ locale_data?: {
+ messages?: Record<string, unknown>;
+ };
+ _pogen?: PogenCatalogueMetadata;
+}
+
+export type TranslationCatalogues = Record<string, TranslationCatalogue>;
+
+function normalizeLanguageTag(language: string): string {
+ return language.trim().replaceAll("_", "-").toLowerCase();
+}
+
+/** Return locale candidates from the broadest to the most specific. */
+export function languageFallbackChain(language: string): string[] {
+ const normalized = normalizeLanguageTag(language);
+ if (!normalized) return [];
+ const parts = normalized.split("-").filter(Boolean);
+ return parts.map((_, index) => parts.slice(0, index + 1).join("-"));
+}
+
+function catalogueMessages(
+ catalogue: TranslationCatalogue | undefined,
+): Record<string, unknown> {
+ return catalogue?.locale_data?.messages ?? {};
+}
+
+function usableMessage(value: unknown): boolean {
+ if (!Array.isArray(value) || value.length === 0) return false;
+ if (value.length === 1) return typeof value[0] === "string" && !!value[0];
+ return value.slice(1).every((entry) => typeof entry === "string" && !!entry);
+}
+
+interface CatalogueState {
+ catalogue: TranslationCatalogue;
+ owned: Set<string>;
+ untranslated: Set<string>;
+ optional: Set<string>;
+}
+
+function catalogueState(catalogue: TranslationCatalogue): CatalogueState {
+ const messages = catalogueMessages(catalogue);
+ const optional = new Set(catalogue._pogen?.optional ?? []);
+ const untranslated = new Set(catalogue._pogen?.untranslated ?? []);
+ const owned = new Set([
+ ...Object.keys(messages).filter((key) => key !== ""),
+ ...untranslated,
+ ...optional,
+ ]);
+ if (!catalogue._pogen) {
+ for (const key of owned) {
+ if (!usableMessage(messages[key])) untranslated.add(key);
+ }
+ }
+ return { catalogue, owned, untranslated, optional };
+}
+
+function finishCatalogue(
+ language: string,
+ catalogue: TranslationCatalogue,
+ owned: Set<string>,
+ untranslated: Set<string>,
+ optional: Set<string>,
+): TranslationCatalogue {
+ const total = [...owned].filter((key) => !optional.has(key)).length;
+ const translated = [...owned].filter(
+ (key) => !optional.has(key) && !untranslated.has(key),
+ ).length;
+ const completeness =
+ language.split("-", 1)[0] === "en"
+ ? 100
+ : total === 0
+ ? 0
+ : Math.floor((translated * 100) / total);
+ return {
+ ...catalogue,
+ completeness,
+ _pogen: {
+ total,
+ translated,
+ untranslated: [...untranslated].sort(),
+ optional: [...optional].sort(),
+ },
+ };
+}
+
+/**
+ * Merge catalogue sources from lowest to highest priority.
+ *
+ * A higher-priority source owns even its untranslated entries. This lets an
+ * application deliberately override a dependency without inheriting wording
+ * from that dependency. Locale fallback uses different semantics below: an
+ * untranslated regional entry inherits its parent locale.
+ */
+function mergeCatalogueSources(
+ language: string,
+ lower: TranslationCatalogue | undefined,
+ higher: TranslationCatalogue,
+): TranslationCatalogue {
+ if (!lower) {
+ const state = catalogueState(higher);
+ return finishCatalogue(
+ language,
+ higher,
+ state.owned,
+ state.untranslated,
+ state.optional,
+ );
+ }
+ const low = catalogueState(lower);
+ const high = catalogueState(higher);
+ const messages = { ...catalogueMessages(lower) };
+ const untranslated = new Set(low.untranslated);
+ const optional = new Set(low.optional);
+ const owned = new Set(low.owned);
+ const highMessages = catalogueMessages(higher);
+ for (const key of high.owned) {
+ owned.add(key);
+ untranslated.delete(key);
+ optional.delete(key);
+ if (Object.hasOwn(highMessages, key)) messages[key] = highMessages[key];
+ else delete messages[key];
+ if (high.untranslated.has(key)) untranslated.add(key);
+ if (high.optional.has(key)) optional.add(key);
+ }
+ const highHeader = highMessages[""];
+ if (highHeader !== undefined) messages[""] = highHeader;
+ return finishCatalogue(
+ language,
+ {
+ ...lower,
+ ...higher,
+ locale_data: { messages },
+ },
+ owned,
+ untranslated,
+ optional,
+ );
+}
+
+function overlayLocaleFallback(
+ language: string,
+ parent: TranslationCatalogue | undefined,
+ regional: TranslationCatalogue,
+): TranslationCatalogue {
+ if (!parent) return mergeCatalogueSources(language, undefined, regional);
+ const low = catalogueState(parent);
+ const high = catalogueState(regional);
+ const messages = { ...catalogueMessages(parent) };
+ const untranslated = new Set(low.untranslated);
+ const optional = new Set(low.optional);
+ const owned = new Set(low.owned);
+ const highMessages = catalogueMessages(regional);
+ for (const key of high.owned) {
+ owned.add(key);
+ const hasUsableRegionalTranslation =
+ !high.untranslated.has(key) && usableMessage(highMessages[key]);
+ if (hasUsableRegionalTranslation) {
+ messages[key] = highMessages[key];
+ untranslated.delete(key);
+ optional.delete(key);
+ if (high.optional.has(key)) optional.add(key);
+ } else if (!low.owned.has(key)) {
+ delete messages[key];
+ untranslated.add(key);
+ if (high.optional.has(key)) optional.add(key);
+ }
+ }
+ const highHeader = highMessages[""];
+ if (highHeader !== undefined) messages[""] = highHeader;
+ return finishCatalogue(
+ language,
+ {
+ ...parent,
+ ...regional,
+ locale_data: { messages },
+ },
+ owned,
+ untranslated,
+ optional,
+ );
+}
+
+/** Compose dependency catalogues followed by the application catalogue. */
+export function composeTranslationCatalogues(
+ ...sources: TranslationCatalogues[]
+): TranslationCatalogues {
+ const byLanguage = new Map<string, TranslationCatalogue>();
+ const outputKeys = new Map<string, string>();
+ for (const source of sources) {
+ for (const [language, catalogue] of Object.entries(source)) {
+ const normalized = normalizeLanguageTag(language);
+ if (!normalized) continue;
+ outputKeys.set(normalized, language.replaceAll("_", "-"));
+ byLanguage.set(
+ normalized,
+ mergeCatalogueSources(
+ normalized,
+ byLanguage.get(normalized),
+ catalogue,
+ ),
+ );
+ }
+ }
+
+ const withFallback = new Map<string, TranslationCatalogue>();
+ for (const language of byLanguage.keys()) {
+ let catalogue: TranslationCatalogue | undefined;
+ for (const candidate of languageFallbackChain(language)) {
+ const next = byLanguage.get(candidate);
+ if (next) catalogue = overlayLocaleFallback(language, catalogue, next);
+ }
+ if (catalogue) withFallback.set(language, catalogue);
+ }
+
+ return Object.fromEntries(
+ [...withFallback].map(([language, catalogue]) => [
+ outputKeys.get(language) ?? language,
+ catalogue,
+ ]),
+ );
+}
+
+function resolveTranslationCatalogue(
+ language: string,
+ catalogues: TranslationCatalogues,
+): TranslationCatalogue | undefined {
+ const normalizedCatalogues = new Map(
+ Object.entries(catalogues).map(([key, catalogue]) => [
+ normalizeLanguageTag(key),
+ catalogue,
+ ]),
+ );
+ let result: TranslationCatalogue | undefined;
+ for (const candidate of languageFallbackChain(language)) {
+ const catalogue = normalizedCatalogues.get(candidate);
+ if (catalogue) result = overlayLocaleFallback(language, result, catalogue);
+ }
+ return result;
+}
+
/**
* Set up jed library for internationalization,
* based on browser language settings.
*/
-export function setupI18n(lang: string, strings: { [s: string]: any }): void {
- lang = lang.replaceAll("_", "-");
+export function setupI18n(
+ lang: string,
+ strings: Record<string, unknown>,
+): void {
+ lang = normalizeLanguageTag(lang);
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) ??
- {};
+ resolveTranslationCatalogue(lang, strings as TranslationCatalogues) ?? {};
jed = new jedLib.Jed(catalog);
}
@@ -75,13 +320,13 @@ function fixedTranslation(
): TranslatedString {
const normalizedTranslations = new Map(
Object.entries(translations).map(([language, translation]) => [
- language.replaceAll("_", "-").toLowerCase(),
+ normalizeLanguageTag(language),
translation,
]),
);
const source = translations.en;
- const normalizedLanguage = activeLanguage.toLowerCase();
- const baseLanguage = normalizedLanguage.split("-", 1)[0];
+ const fallbackChain = languageFallbackChain(activeLanguage);
+ const baseLanguage = fallbackChain[0] ?? "en";
// The source language is itself fixed and must never be overridden by an
// English catalogue entry.
@@ -89,9 +334,10 @@ function fixedTranslation(
return substituteSerializedValues(source, values);
}
- const prescribed =
- normalizedTranslations.get(normalizedLanguage) ??
- normalizedTranslations.get(baseLanguage);
+ const prescribed = fallbackChain
+ .toReversed()
+ .map((language) => normalizedTranslations.get(language))
+ .find((translation) => translation !== undefined);
if (prescribed !== undefined) {
return substituteSerializedValues(prescribed, values);
}