feat(i18n): language registry + UI-string catalog module

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-29 18:45:33 -07:00
parent 696f8f0901
commit 00534e116c
2 changed files with 88 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
// UI/annotation localization — registry, control-string catalog, and pure
// lookup helpers. UMD so the browser gets a `HEFi18n` global and
// `node --test` can require() it. English is the source of truth & fallback.
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFi18n = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const LANGUAGES = [
{ code: "en", nativeName: "English" },
{ code: "es", nativeName: "Español" },
{ code: "fr", nativeName: "Français" },
{ code: "ja", nativeName: "日本語" },
];
// Visitor-facing control chrome. Keys match `data-i18n` attributes in index.html.
const UI_STRINGS = {
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre dExpérience Humaine — Aperçu daltération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de lunivers", ja: "宇宙を読み込み中" },
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
"altitude.hint": { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer daltitude — sans fin : après le plus profond, on revient au plus haut. Cliquez sur une étiquette pour y aller.", ja: "ダイヤルを回して(ドラッグまたはスクロール)高度を変えます — 無限ループ:最も深い先は最も高い所へ戻ります。ラベルをクリックでそこへ移動。" },
"knobs.legend": { en: "Experience knobs (04)", es: "Mandos de experiencia (04)", fr: "Boutons dexpérience (04)", ja: "体験つまみ(0〜4" },
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
"scale.sky": { en: "sky", es: "cielo", fr: "ciel", ja: "空" },
"scale.coast": { en: "coast", es: "costa", fr: "côte", ja: "海岸" },
"scale.reef": { en: "reef", es: "arrecife", fr: "récif", ja: "サンゴ礁" },
"scale.abyss": { en: "abyss", es: "abismo", fr: "abîme", ja: "深海" },
};
function pickUiString(key, lang) {
const v = UI_STRINGS[key];
if (!v) return key;
return (v[lang] != null ? v[lang] : v.en) || key;
}
function resolveStrings(clipStrings, lang) {
if (!clipStrings || !clipStrings.en) return (clipStrings && clipStrings[lang]) || {};
if (lang === "en" || !clipStrings[lang]) return clipStrings.en;
return Object.assign({}, clipStrings.en, clipStrings[lang]);
}
return { LANGUAGES, UI_STRINGS, pickUiString, resolveStrings };
});
+35
View File
@@ -0,0 +1,35 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const I = require("../static/i18n.js");
test("LANGUAGES lists the four codes in order, English first", () => {
assert.deepEqual(I.LANGUAGES.map((l) => l.code), ["en", "es", "fr", "ja"]);
assert.equal(I.LANGUAGES[0].nativeName, "English");
assert.equal(I.LANGUAGES.find((l) => l.code === "ja").nativeName, "日本語");
});
test("pickUiString returns the language value, falls back to en, then to key", () => {
assert.equal(I.pickUiString("output.legend", "es"), I.UI_STRINGS["output.legend"].es);
// a key present in en but (hypothetically) missing in fr falls back to en:
const saved = I.UI_STRINGS["output.legend"].fr;
delete I.UI_STRINGS["output.legend"].fr;
assert.equal(I.pickUiString("output.legend", "fr"), I.UI_STRINGS["output.legend"].en);
I.UI_STRINGS["output.legend"].fr = saved;
assert.equal(I.pickUiString("totally.unknown.key", "en"), "totally.unknown.key");
});
test("UI_STRINGS has every language for every key", () => {
for (const [key, vals] of Object.entries(I.UI_STRINGS)) {
for (const code of ["en", "es", "fr", "ja"]) {
assert.ok(typeof vals[code] === "string" && vals[code].length, `${key} missing ${code}`);
}
}
});
test("resolveStrings merges lang over en (per-key fallback)", () => {
const cs = { en: { a: "A", b: "B" }, es: { a: "Aes" } };
assert.deepEqual(I.resolveStrings(cs, "es"), { a: "Aes", b: "B" });
assert.deepEqual(I.resolveStrings(cs, "fr"), { a: "A", b: "B" }); // no fr -> all en
assert.deepEqual(I.resolveStrings({}, "es"), {});
});