sirius/js/i18n.js

355 lines
17 KiB
JavaScript
Raw Normal View History

// Sirius.X runtime translation.
//
// The pages are written in English. This script picks the reader's language
// (saved choice → ?lang= → browser languages → English), fetches
// i18n/<lang>.json — a flat map of English text → translation — and swaps
// every matching text node, placeholder, title and aria-label. A
// MutationObserver keeps script-built content (search rows, the register
// modal, portal rows, footer, profile menu) translated as it appears, so
// page scripts never need to know about languages.
//
// Rules of the road:
// • Keys are the exact English text of a node (whitespace collapsed).
// • <code>, <pre>, <kbd>, <textarea>, .mono and anything under
// [translate="no"] / [data-i18n-skip] is left alone — names, hashes,
// record keys and addresses must never change.
// • English is the source of truth: every node remembers its English
// text, so switching languages (or back to English) is lossless.
// • Adding a language = one JSON file in i18n/ + one line in LANGS.
//
// Exposed as window.siriusI18n = { lang, langs, t(text), setLang(code) }.
(function () {
var STORE = "sirius-lang";
var CACHE = "sirius-i18n:";
var VERSION = "20260920a"; // bump when dictionaries change
var LANGS = {
en: "English",
de: "Deutsch",
el: "Ελληνικά",
ru: "Русский",
es: "Español",
fr: "Français",
pt: "Português",
};
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, CODE: 1, PRE: 1, KBD: 1, TEXTAREA: 1, NOSCRIPT: 1, SVG: 1 };
var ATTRS = ["placeholder", "title", "aria-label"];
var base = (function () {
try { return new URL("../i18n/", document.currentScript.src).href; }
catch (e) { return "./i18n/"; }
})();
var dict = null; // English -> translation for the active language
var lang = "en";
var applying = false;
// ---------- language choice ----------
function normalise(code) {
if (!code) return null;
var primary = String(code).toLowerCase().split(/[-_]/)[0];
return LANGS[primary] ? primary : null;
}
function detect() {
var fromQuery = null;
try { fromQuery = normalise(new URLSearchParams(location.search).get("lang")); } catch (e) {}
if (fromQuery) { save(fromQuery); return fromQuery; }
var saved = null;
try { saved = normalise(localStorage.getItem(STORE)); } catch (e) {}
if (saved) return saved;
var list = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language];
for (var i = 0; i < list.length; i++) { var n = normalise(list[i]); if (n) return n; }
return "en";
}
function save(code) { try { localStorage.setItem(STORE, code); } catch (e) {} }
// ---------- dictionary loading (cache first, network second) ----------
function readCache(code) {
try {
var raw = localStorage.getItem(CACHE + code);
if (!raw) return null;
var obj = JSON.parse(raw);
return obj && obj.v === VERSION ? obj.d : null;
} catch (e) { return null; }
}
function writeCache(code, d) {
try { localStorage.setItem(CACHE + code, JSON.stringify({ v: VERSION, d: d })); } catch (e) {}
}
function load(code) {
if (code === "en") return Promise.resolve({});
var cached = readCache(code);
var net = fetch(base + code + ".json?v=" + VERSION, { cache: "no-cache" })
.then(function (r) { if (!r.ok) throw new Error("i18n " + r.status); return r.json(); })
.then(function (d) { writeCache(code, d); return d; });
if (cached) { net.then(function (d) { if (lang === code) { dict = d; applyAll(); } }).catch(function () {}); return Promise.resolve(cached); }
return net;
}
// ---------- translation ----------
function t(text) {
if (!dict) return text;
var key = String(text).replace(/\s+/g, " ").trim();
// An empty string is a valid translation (the word is dropped in that
// language) — only a missing key falls back to English.
var out = Object.prototype.hasOwnProperty.call(dict, key) ? dict[key] : undefined;
return typeof out === "string" ? out : text;
}
function skip(el) {
for (var e = el; e && e.nodeType === 1; e = e.parentNode) {
if (SKIP_TAGS[e.tagName]) return true;
if (e.getAttribute) {
if (e.getAttribute("translate") === "no" || e.hasAttribute("data-i18n-skip")) return true;
var cls = e.getAttribute("class") || "";
if (/(^|\s)mono(\s|$)/.test(cls)) return true;
}
}
return false;
}
function translateText(node) {
var cur = node.nodeValue;
if (!cur || !/\S/.test(cur)) return;
// A page script changed the text since we last touched it → new source.
if (node.__i18nOut !== cur) { node.__i18nSrc = cur; node.__i18nOut = null; }
var src = node.__i18nSrc != null ? node.__i18nSrc : cur;
var key = src.replace(/\s+/g, " ").trim();
var has = dict && key && Object.prototype.hasOwnProperty.call(dict, key) && typeof dict[key] === "string";
// Keep the node's leading/trailing whitespace (it separates inline
// siblings) but drop the source's internal line breaks — a multi-line
// paragraph in the HTML is one text node and must match its collapsed key.
var next = has
? src.match(/^\s*/)[0] + dict[key] + src.match(/\s*$/)[0]
: src;
if (next !== cur) node.nodeValue = next;
node.__i18nSrc = src;
node.__i18nOut = next;
}
function translateAttrs(el) {
for (var i = 0; i < ATTRS.length; i++) {
var a = ATTRS[i];
if (!el.hasAttribute(a)) continue;
var cur = el.getAttribute(a);
var orig = el.getAttribute("data-i18n-orig-" + a);
if (orig == null || el.getAttribute("data-i18n-out-" + a) !== cur) { orig = cur; el.setAttribute("data-i18n-orig-" + a, orig); }
var next = t(orig);
if (next !== cur) el.setAttribute(a, next);
el.setAttribute("data-i18n-out-" + a, next);
}
}
function walk(root) {
if (!root) return;
if (root.nodeType === 3) { if (!skip(root.parentNode)) translateText(root); return; }
if (root.nodeType !== 1 && root.nodeType !== 11) return;
if (root.nodeType === 1) {
if (skip(root)) return;
translateAttrs(root);
}
var tw = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, null);
var n;
while ((n = tw.nextNode())) {
if (n.nodeType === 1) { if (!skip(n)) translateAttrs(n); }
else if (!skip(n.parentNode)) translateText(n);
}
}
function applyAll() {
applying = true;
try {
walk(document.body);
if (document.title) {
if (!document.__i18nTitle) document.__i18nTitle = document.title;
document.title = t(document.__i18nTitle);
}
document.documentElement.setAttribute("lang", lang);
} finally { applying = false; }
}
// Keep script-built content translated. Mutations we cause ourselves are
// ignored (applying flag); everything else is walked as it lands.
var observer = null;
function observe() {
if (observer || !window.MutationObserver) return;
observer = new MutationObserver(function (records) {
if (applying || !dict) return;
applying = true;
try {
for (var i = 0; i < records.length; i++) {
var r = records[i];
if (r.type === "characterData") { if (!skip(r.target.parentNode)) translateText(r.target); }
else if (r.type === "attributes") { if (r.target.nodeType === 1 && !skip(r.target)) translateAttrs(r.target); }
else for (var j = 0; j < r.addedNodes.length; j++) walk(r.addedNodes[j]);
}
} finally { applying = false; }
});
observer.observe(document.documentElement, {
childList: true, subtree: true, characterData: true,
attributes: true, attributeFilter: ATTRS,
});
}
// ---------- switching ----------
function setLang(code) {
code = normalise(code) || "en";
lang = code;
save(code);
return load(code).then(function (d) {
if (lang !== code) return;
dict = code === "en" ? null : d;
applyAll();
if (!dict) { document.documentElement.setAttribute("lang", "en"); }
refreshPicker();
}).catch(function () {
// Dictionary unreachable → stay on English rather than half-translate.
dict = null; lang = "en"; applyAll(); refreshPicker();
});
}
// ---------- the dropdown ----------
// A custom menu rather than a <select>: a native select cannot show
// flags, and emoji flags render as bare letters on Windows. Flags are
// tiny inline SVGs (3:2, simplified) so no third-party image request
// ever leaves the visitor's browser. Sits on the right of the top nav,
// just before the wallet button.
var FLAGS = {
en: '<rect width="60" height="40" fill="#012169"/><path d="M0 0L60 40M60 0L0 40" stroke="#fff" stroke-width="8"/><path d="M0 0L60 40M60 0L0 40" stroke="#C8102E" stroke-width="3"/><path d="M30 0V40M0 20H60" stroke="#fff" stroke-width="10"/><path d="M30 0V40M0 20H60" stroke="#C8102E" stroke-width="6"/>',
de: '<rect width="60" height="40" fill="#000"/><rect y="13.33" width="60" height="13.34" fill="#D00"/><rect y="26.67" width="60" height="13.33" fill="#FFCE00"/>',
el: '<rect width="60" height="40" fill="#0D5EAF"/><rect y="4.44" width="60" height="4.45" fill="#fff"/><rect y="13.33" width="60" height="4.45" fill="#fff"/><rect y="22.22" width="60" height="4.45" fill="#fff"/><rect y="31.11" width="60" height="4.45" fill="#fff"/><rect width="22.22" height="22.22" fill="#0D5EAF"/><rect x="8.89" width="4.44" height="22.22" fill="#fff"/><rect y="8.89" width="22.22" height="4.44" fill="#fff"/>',
ru: '<rect width="60" height="40" fill="#fff"/><rect y="13.33" width="60" height="13.34" fill="#0039A6"/><rect y="26.67" width="60" height="13.33" fill="#D52B1E"/>',
es: '<rect width="60" height="40" fill="#AA151B"/><rect y="10" width="60" height="20" fill="#F1BF00"/>',
fr: '<rect width="20" height="40" fill="#0055A4"/><rect x="20" width="20" height="40" fill="#fff"/><rect x="40" width="20" height="40" fill="#EF4135"/>',
pt: '<rect width="60" height="40" fill="#F00"/><rect width="24" height="40" fill="#060"/><circle cx="24" cy="20" r="7" fill="#FFE000"/><circle cx="24" cy="20" r="3.5" fill="#F00"/>',
};
var GLOBE = '<svg class="lp-globe" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14.5 0 18M12 3c-3 3.5-3 14.5 0 18"/></svg>';
var CARET = '<svg class="lp-caret" viewBox="0 0 10 6" aria-hidden="true"><path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
function flagSvg(code) {
return '<svg class="lp-flag" viewBox="0 0 60 40" aria-hidden="true">' + (FLAGS[code] || "") + "</svg>";
}
var picker = null, pickerBtn = null, pickerMenu = null;
function closePicker() {
if (!picker) return;
picker.classList.remove("open");
pickerBtn.setAttribute("aria-expanded", "false");
}
function openPicker() {
if (!picker) return;
picker.classList.add("open");
pickerBtn.setAttribute("aria-expanded", "true");
var active = pickerMenu.querySelector('[aria-selected="true"]');
if (active) active.focus();
}
function paintPicker() {
if (picker) return;
var css = document.createElement("style");
css.textContent =
".lang-picker{position:relative;margin-left:auto;font-family:var(--sans,inherit)}" +
".topnav .lang-picker + .profile-wrap,.topnav .lang-picker + a.portal{margin-left:8px}" +
".lp-btn{display:inline-flex;align-items:center;gap:7px;padding:5px 10px 5px 9px;border-radius:999px;" +
"background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);color:var(--mut,#b8c2d4);" +
"font:inherit;font-size:12.5px;line-height:1;cursor:pointer;transition:border-color .12s,color .12s,background .12s}" +
".lp-btn:hover,.lang-picker.open .lp-btn{color:var(--ink,#f1f4fa);border-color:rgba(214,255,61,.35);background:rgba(214,255,61,.08)}" +
".lp-globe{width:15px;height:15px;opacity:.85;flex:none}" +
".lp-flag{width:20px;height:14px;border-radius:2px;box-shadow:0 0 0 1px rgba(255,255,255,.14);flex:none}" +
".lp-caret{width:9px;height:9px;opacity:.7;transition:transform .15s;flex:none}" +
".lang-picker.open .lp-caret{transform:rotate(180deg)}" +
".lp-menu{position:absolute;right:0;top:calc(100% + 8px);min-width:200px;padding:6px;border-radius:12px;" +
"background:var(--panel,#141a24);border:1px solid rgba(255,255,255,.1);box-shadow:0 12px 32px rgba(0,0,0,.45);z-index:200;display:none}" +
".lang-picker.open .lp-menu{display:block}" +
".lp-opt{display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;border:0;border-radius:8px;" +
"background:transparent;color:var(--mut,#b8c2d4);font:inherit;font-size:13.5px;text-align:left;cursor:pointer}" +
".lp-opt:hover,.lp-opt:focus-visible{background:rgba(255,255,255,.06);color:var(--ink,#f1f4fa);outline:none}" +
'.lp-opt[aria-selected="true"]{color:var(--acid,#d6ff3d);background:rgba(214,255,61,.08)}' +
".lp-opt .lp-code{margin-left:auto;font-family:var(--mono,monospace);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;opacity:.55}" +
".lang-picker-float{position:fixed;bottom:20px;right:20px;z-index:99}" +
"@media (max-width:560px){.lp-name{display:none}}";
document.head.appendChild(css);
picker = document.createElement("div");
picker.className = "lang-picker";
picker.setAttribute("translate", "no");
pickerBtn = document.createElement("button");
pickerBtn.type = "button";
pickerBtn.className = "lp-btn";
pickerBtn.setAttribute("aria-haspopup", "listbox");
pickerBtn.setAttribute("aria-expanded", "false");
pickerBtn.setAttribute("aria-label", "Language");
pickerBtn.title = "Language";
pickerMenu = document.createElement("div");
pickerMenu.className = "lp-menu";
pickerMenu.setAttribute("role", "listbox");
pickerMenu.setAttribute("aria-label", "Language");
var html = "";
for (var code in LANGS) {
html += '<button type="button" class="lp-opt" role="option" data-lang="' + code + '" aria-selected="false" tabindex="-1">' +
flagSvg(code) + "<span>" + LANGS[code] + '</span><span class="lp-code">' + code + "</span></button>";
}
pickerMenu.innerHTML = html;
picker.appendChild(pickerBtn);
picker.appendChild(pickerMenu);
pickerBtn.addEventListener("click", function (e) {
e.stopPropagation();
if (picker.classList.contains("open")) closePicker(); else openPicker();
});
pickerMenu.addEventListener("click", function (e) {
var opt = e.target.closest ? e.target.closest("[data-lang]") : null;
if (!opt) return;
e.stopPropagation();
closePicker();
pickerBtn.focus();
setLang(opt.getAttribute("data-lang"));
});
picker.addEventListener("keydown", function (e) {
var opts = pickerMenu.querySelectorAll("[data-lang]");
var i = Array.prototype.indexOf.call(opts, document.activeElement);
if (e.key === "Escape") { closePicker(); pickerBtn.focus(); }
else if (e.key === "ArrowDown") { e.preventDefault(); if (!picker.classList.contains("open")) openPicker(); else opts[Math.min(i + 1, opts.length - 1)].focus(); }
else if (e.key === "ArrowUp") { e.preventDefault(); if (i > 0) opts[i - 1].focus(); }
});
document.addEventListener("click", closePicker);
var nav = document.querySelector(".topnav");
if (nav) {
// Right side: directly before the wallet button (profile-wrap once
// profile-menu.js has wrapped the portal link, the bare link before
// that), after everything else.
// Order on the right: language · Dashboard (when signed in) · wallet.
var wallet = nav.querySelector(".dash-link") || nav.querySelector(".profile-wrap") || nav.querySelector("a.portal");
if (wallet) nav.insertBefore(picker, wallet); else nav.appendChild(picker);
} else {
picker.classList.add("lang-picker-float");
document.body.appendChild(picker);
}
refreshPicker();
}
function refreshPicker() {
if (!picker) return;
pickerBtn.innerHTML = GLOBE + flagSvg(lang) + '<span class="lp-name">' + (LANGS[lang] || lang) + "</span>" + CARET;
var opts = pickerMenu.querySelectorAll("[data-lang]");
for (var i = 0; i < opts.length; i++) {
opts[i].setAttribute("aria-selected", opts[i].getAttribute("data-lang") === lang ? "true" : "false");
}
}
// ---------- boot ----------
lang = detect();
var ready = lang === "en" ? Promise.resolve(null) : load(lang).then(function (d) { dict = d; return d; }).catch(function () { lang = "en"; return null; });
function boot() {
observe();
ready.then(function () { applyAll(); });
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
else boot();
// The picker is painted on `load`, after the deferred nav scripts
// (theme.js font pill, profile-menu.js wallet button) have laid out the
// top nav, so it lands in a stable spot on the right.
if (document.readyState === "complete") paintPicker();
else window.addEventListener("load", paintPicker);
window.siriusI18n = {
get lang() { return lang; },
langs: LANGS,
t: t,
setLang: setLang,
apply: function (root) { if (dict) { applying = true; try { walk(root || document.body); } finally { applying = false; } } },
};
})();