feat: add multilingual interface support

This commit is contained in:
2026-07-24 16:00:12 +01:00
parent d2567c59ce
commit 16383c2a3f
10 changed files with 788 additions and 84 deletions
+24
View File
@@ -0,0 +1,24 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
test("the Docker image includes every application script referenced by the page", () => {
const html = fs.readFileSync(path.join(root, "index.html"), "utf8");
const dockerfile = fs.readFileSync(path.join(root, "Dockerfile"), "utf8");
const applicationScripts = [...html.matchAll(/<script src="\.\/([^"]+\.js)"/g)]
.map((match) => match[1])
.filter((scriptPath) => !scriptPath.startsWith("vendor/"));
assert.ok(applicationScripts.length > 0);
applicationScripts.forEach((scriptPath) => {
assert.ok(fs.existsSync(path.join(root, scriptPath)), `${scriptPath} must exist`);
assert.match(
dockerfile,
new RegExp(`^COPY ${scriptPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\.\\/$`, "m"),
`${scriptPath} must be copied by the Dockerfile`,
);
});
});
+85
View File
@@ -0,0 +1,85 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
DEFAULT_LANGUAGE,
TRANSLATIONS,
createI18n,
detectLanguage,
normalizeLanguage,
} = require("../i18n.js");
function createStorage(initialValue) {
let value = initialValue;
return {
getItem() {
return value;
},
setItem(key, nextValue) {
value = nextValue;
},
removeItem() {
value = undefined;
},
};
}
test("normalizes exact and base language tags", () => {
assert.equal(normalizeLanguage("pt-PT"), "pt-PT");
assert.equal(normalizeLanguage("pt-BR"), "pt-PT");
assert.equal(normalizeLanguage("en-US"), "en");
assert.equal(normalizeLanguage("fr"), null);
});
test("prefers a stored language over browser preferences", () => {
const storage = createStorage("en");
assert.equal(detectLanguage({ storage, languages: ["pt-PT"] }), "en");
});
test("uses the first supported browser language and otherwise falls back", () => {
assert.equal(detectLanguage({ storage: createStorage(), languages: ["fr", "pt-BR"] }), "pt-PT");
assert.equal(detectLanguage({ storage: createStorage(), languages: ["es-MX"] }), "es");
assert.equal(detectLanguage({ storage: createStorage(), languages: ["fr"] }), DEFAULT_LANGUAGE);
});
test("persists language changes and interpolates translated values", () => {
const storage = createStorage();
const i18n = createI18n({ storage, languages: ["en"] });
assert.equal(i18n.setLanguage("pt"), "pt-PT");
assert.equal(storage.getItem(), "pt-PT");
assert.equal(
i18n.t("noParcelFound", { parcelNumber: "57" }),
"Não foi encontrada a parcela com o número “57”.",
);
});
test("browser-default mode removes the preference and follows the browser language", () => {
const storage = createStorage("en");
const i18n = createI18n({ storage, languages: ["pt-PT"] });
assert.equal(i18n.setLanguage(null), "pt-PT");
assert.equal(i18n.preference, null);
assert.equal(storage.getItem(), undefined);
});
test("uses English for missing translations and the key for unknown messages", () => {
const storage = createStorage("pt-PT");
const originalTranslation = TRANSLATIONS["pt-PT"].zoomIn;
delete TRANSLATIONS["pt-PT"].zoomIn;
try {
const i18n = createI18n({ storage, languages: [] });
assert.equal(i18n.t("zoomIn"), TRANSLATIONS.en.zoomIn);
assert.equal(i18n.t("notARealMessage"), "notARealMessage");
} finally {
TRANSLATIONS["pt-PT"].zoomIn = originalTranslation;
}
});
test("all shipped languages contain the default translation keys", () => {
const defaultKeys = Object.keys(TRANSLATIONS[DEFAULT_LANGUAGE]).sort();
Object.entries(TRANSLATIONS).forEach(([language, translations]) => {
assert.deepEqual(Object.keys(translations).sort(), defaultKeys, language);
});
});