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:
@@ -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 d’Expérience Humaine — Aperçu d’altération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
|
||||
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de l’univers", 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 d’altitude — 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 (0–4)", es: "Mandos de experiencia (0–4)", fr: "Boutons d’expérience (0–4)", 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 };
|
||||
});
|
||||
@@ -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"), {});
|
||||
});
|
||||
Reference in New Issue
Block a user