Pages such as potidaea.asm put their background on <html> and leave <body> transparent; the editor's base stylesheet paints <body> white, so the imported page showed light text on white. The canvas body now stays transparent, which also matches the exported page. The gateway's public name route sends Access-Control-Allow-Origin so Studio on sirius.x can fetch a page's stylesheets and inline them instead of falling back to links; the route only serves public content. The language picker had no toolbar to attach to in Studio and floated over the blocks panel; pages can now mark a slot for it and Studio does.
357 lines
17 KiB
JavaScript
357 lines
17 KiB
JavaScript
// 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 = "20260920d"; // 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}" +
|
|
".lang-picker-inline{margin-left:0}" +
|
|
"@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 slot = document.querySelector("[data-lang-slot]");
|
|
var nav = document.querySelector(".topnav");
|
|
if (slot) { slot.replaceWith(picker); picker.classList.add("lang-picker-inline"); } else
|
|
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; } } },
|
|
};
|
|
})();
|