563 lines
20 KiB
JavaScript
563 lines
20 KiB
JavaScript
/*
|
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
|
* Source-Available Code / Dual-Licensed.
|
|
*
|
|
* Frontend internationalization. Language files live in /lang at the repository
|
|
* root and are embedded into the Rust binary at build time.
|
|
*/
|
|
|
|
const STORAGE_KEY = "rustpad:language";
|
|
const GUEST_STORAGE_KEY = "rustpad:guest-language";
|
|
const AUTH_STATE_KEY = "rustpad:auth-state";
|
|
const FALLBACK_LANGUAGE = "en";
|
|
const TRANSLATABLE_ATTRIBUTES = ["placeholder", "title", "aria-label", "aria-description"];
|
|
const SKIP_SELECTOR = [
|
|
"script",
|
|
"style",
|
|
"textarea",
|
|
"#editor",
|
|
"#preview",
|
|
"#chat-messages",
|
|
".markdown-body",
|
|
".revision__snippet",
|
|
".revision__preview",
|
|
".chat-message",
|
|
".resource-title-line a",
|
|
".share-list-identity",
|
|
".note-card-title h3",
|
|
".note-table-link",
|
|
"#document-title",
|
|
"#workspace-title",
|
|
"#public-title",
|
|
"#room-users",
|
|
"[data-account-primary]",
|
|
"[data-account-secondary]",
|
|
"[contenteditable='true']",
|
|
"[data-i18n-ignore]",
|
|
].join(",");
|
|
|
|
const textState = new WeakMap();
|
|
const attributeState = new WeakMap();
|
|
const bundles = new Map();
|
|
let catalog = [];
|
|
let activeCode = FALLBACK_LANGUAGE;
|
|
let activeBundle = null;
|
|
let fallbackBundle = null;
|
|
let sourceIndex = new Map();
|
|
let sourcePatterns = [];
|
|
let observer = null;
|
|
let initialized = false;
|
|
let initialization = null;
|
|
|
|
function configVersion() {
|
|
return window.__RUSTPAD_CONFIG__?.assetVersion || "dev";
|
|
}
|
|
|
|
function normalizeSource(value) {
|
|
return String(value ?? "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function compileSourcePattern(source, key) {
|
|
const names = [];
|
|
let cursor = 0;
|
|
let expression = "^";
|
|
const placeholder = /\{([a-zA-Z0-9_]+)\}/g;
|
|
let match;
|
|
while ((match = placeholder.exec(source))) {
|
|
expression += escapeRegExp(source.slice(cursor, match.index));
|
|
expression += "(.+?)";
|
|
names.push(match[1]);
|
|
cursor = match.index + match[0].length;
|
|
}
|
|
expression += escapeRegExp(source.slice(cursor));
|
|
expression += "$";
|
|
try {
|
|
return { key, source, names, regex: new RegExp(expression, "u") };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function rebuildSourceIndex() {
|
|
sourceIndex = new Map();
|
|
sourcePatterns = [];
|
|
const indexedBundles = new Set();
|
|
const candidates = [fallbackBundle, ...bundles.values()].filter(Boolean);
|
|
for (const bundle of candidates) {
|
|
if (indexedBundles.has(bundle)) continue;
|
|
indexedBundles.add(bundle);
|
|
for (const [key, rawValue] of Object.entries(bundle.translations || {})) {
|
|
if (typeof rawValue !== "string" || !rawValue.trim()) continue;
|
|
const value = normalizeSource(rawValue);
|
|
if (!sourceIndex.has(value)) sourceIndex.set(value, key);
|
|
if (/\{[a-zA-Z0-9_]+\}/.test(value)) {
|
|
const pattern = compileSourcePattern(value, key);
|
|
if (pattern) sourcePatterns.push(pattern);
|
|
}
|
|
}
|
|
}
|
|
sourcePatterns.sort((left, right) => right.source.length - left.source.length);
|
|
}
|
|
|
|
function interpolate(value, params = {}) {
|
|
return String(value ?? "").replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) =>
|
|
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
|
|
);
|
|
}
|
|
|
|
export function t(key, params = {}, fallback = key) {
|
|
const active = activeBundle?.translations?.[key];
|
|
const base = fallbackBundle?.translations?.[key];
|
|
const value = typeof active === "string" ? active : typeof base === "string" ? base : fallback;
|
|
return interpolate(value, params);
|
|
}
|
|
|
|
export function tp(key, count, params = {}, fallback = "") {
|
|
const numericCount = Number(count);
|
|
let category = "other";
|
|
try { category = new Intl.PluralRules(getLocale()).select(numericCount); } catch { /* use other */ }
|
|
const candidates = [`${key}.${category}`, `${key}.other`, `${key}.many`, `${key}.few`, `${key}.one`];
|
|
for (const candidate of candidates) {
|
|
const active = activeBundle?.translations?.[candidate];
|
|
const base = fallbackBundle?.translations?.[candidate];
|
|
const value = typeof active === "string" ? active : typeof base === "string" ? base : null;
|
|
if (value != null) return interpolate(value, { ...params, count: numericCount });
|
|
}
|
|
return interpolate(fallback || String(numericCount), { ...params, count: numericCount });
|
|
}
|
|
|
|
function matchSource(value) {
|
|
const normalized = normalizeSource(value);
|
|
if (!normalized) return null;
|
|
const exactKey = sourceIndex.get(normalized);
|
|
if (exactKey) return { key: exactKey, params: {} };
|
|
for (const pattern of sourcePatterns) {
|
|
const match = normalized.match(pattern.regex);
|
|
if (!match) continue;
|
|
const params = {};
|
|
pattern.names.forEach((name, index) => { params[name] = match[index + 1]; });
|
|
return { key: pattern.key, params };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function translateSource(value) {
|
|
const raw = String(value ?? "");
|
|
if (!raw.trim()) return raw;
|
|
const leading = raw.match(/^\s*/u)?.[0] || "";
|
|
const trailing = raw.match(/\s*$/u)?.[0] || "";
|
|
const match = matchSource(raw);
|
|
if (!match) return raw;
|
|
return `${leading}${t(match.key, match.params, normalizeSource(raw))}${trailing}`;
|
|
}
|
|
|
|
export function translateApiMessage(message) {
|
|
return translateSource(message);
|
|
}
|
|
|
|
function isSkipped(element) {
|
|
return element instanceof Element && Boolean(element.closest(SKIP_SELECTOR));
|
|
}
|
|
|
|
function translateTextNode(node, force = false) {
|
|
const parent = node.parentElement;
|
|
if (!parent || isSkipped(parent)) return;
|
|
const current = node.data;
|
|
let state = textState.get(node);
|
|
if (!state || (!force && current !== state.rendered)) {
|
|
state = { original: current, rendered: current };
|
|
textState.set(node, state);
|
|
}
|
|
const rendered = translateSource(state.original);
|
|
state.rendered = rendered;
|
|
if (node.data !== rendered) node.data = rendered;
|
|
}
|
|
|
|
function attributeMap(element) {
|
|
let map = attributeState.get(element);
|
|
if (!map) {
|
|
map = new Map();
|
|
attributeState.set(element, map);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function translateAttribute(element, name, force = false) {
|
|
if (!element.hasAttribute(name) || isSkipped(element)) return;
|
|
const current = element.getAttribute(name) || "";
|
|
const map = attributeMap(element);
|
|
let state = map.get(name);
|
|
if (!state || (!force && current !== state.rendered)) {
|
|
state = { original: current, rendered: current };
|
|
map.set(name, state);
|
|
}
|
|
const rendered = translateSource(state.original);
|
|
state.rendered = rendered;
|
|
if (current !== rendered) element.setAttribute(name, rendered);
|
|
}
|
|
|
|
function translateExplicit(element) {
|
|
const key = element.dataset.i18n;
|
|
if (key) element.textContent = t(key);
|
|
for (const attribute of TRANSLATABLE_ATTRIBUTES) {
|
|
const attrKey = element.dataset[`i18n${attribute.replace(/(^|-)([a-z])/g, (_, __, char) => char.toUpperCase())}`];
|
|
if (attrKey) element.setAttribute(attribute, t(attrKey));
|
|
}
|
|
}
|
|
|
|
function translateElementAttributes(element, force = false) {
|
|
translateExplicit(element);
|
|
if (isSkipped(element)) return;
|
|
for (const name of TRANSLATABLE_ATTRIBUTES) translateAttribute(element, name, force);
|
|
}
|
|
|
|
function translateTree(root = document, force = false) {
|
|
if (root instanceof Element) translateElementAttributes(root, force);
|
|
const elementRoot = root instanceof Document ? root.documentElement : root;
|
|
if (!elementRoot) return;
|
|
|
|
const elementWalker = document.createTreeWalker(elementRoot, NodeFilter.SHOW_ELEMENT);
|
|
let element = elementWalker.currentNode;
|
|
while (element) {
|
|
if (element instanceof Element) translateElementAttributes(element, force);
|
|
element = elementWalker.nextNode();
|
|
}
|
|
|
|
const textWalker = document.createTreeWalker(elementRoot, NodeFilter.SHOW_TEXT);
|
|
let textNode = textWalker.nextNode();
|
|
while (textNode) {
|
|
translateTextNode(textNode, force);
|
|
textNode = textWalker.nextNode();
|
|
}
|
|
}
|
|
|
|
function startObserver() {
|
|
observer?.disconnect();
|
|
observer = new MutationObserver(mutations => {
|
|
for (const mutation of mutations) {
|
|
if (mutation.type === "characterData") {
|
|
translateTextNode(mutation.target);
|
|
continue;
|
|
}
|
|
if (mutation.type === "attributes") {
|
|
translateAttribute(mutation.target, mutation.attributeName);
|
|
continue;
|
|
}
|
|
for (const node of mutation.addedNodes) {
|
|
if (node.nodeType === Node.TEXT_NODE) translateTextNode(node);
|
|
else if (node instanceof Element) translateTree(node);
|
|
}
|
|
}
|
|
});
|
|
observer.observe(document.documentElement, {
|
|
subtree: true,
|
|
childList: true,
|
|
characterData: true,
|
|
attributes: true,
|
|
attributeFilter: TRANSLATABLE_ATTRIBUTES,
|
|
});
|
|
}
|
|
|
|
async function fetchJson(path) {
|
|
const separator = path.includes("?") ? "&" : "?";
|
|
const response = await fetch(`${path}${separator}v=${encodeURIComponent(configVersion())}`, {
|
|
credentials: "same-origin",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!response.ok) throw new Error(t("i18n.loadFailed", { status: response.status }, `Could not load language resource (${response.status})`));
|
|
return response.json();
|
|
}
|
|
|
|
async function loadCatalog() {
|
|
if (catalog.length) return catalog;
|
|
const result = await fetchJson("/lang");
|
|
catalog = Array.isArray(result) ? result.filter(item => item && item.code && item.locale) : [];
|
|
return catalog;
|
|
}
|
|
|
|
async function loadBundle(code) {
|
|
if (bundles.has(code)) return bundles.get(code);
|
|
const bundle = await fetchJson(`/lang/${encodeURIComponent(code)}.json`);
|
|
if (!bundle?.meta?.code || !bundle?.translations) throw new Error(`Invalid language bundle: ${code}`);
|
|
bundles.set(bundle.meta.code, bundle);
|
|
return bundle;
|
|
}
|
|
|
|
function hasAccountSession() {
|
|
try { return localStorage.getItem(AUTH_STATE_KEY) === "1"; } catch { return false; }
|
|
}
|
|
|
|
function preferredCode() {
|
|
let saved = "";
|
|
try {
|
|
saved = hasAccountSession()
|
|
? (localStorage.getItem(STORAGE_KEY) || "")
|
|
: (localStorage.getItem(GUEST_STORAGE_KEY) || localStorage.getItem(STORAGE_KEY) || "");
|
|
} catch { /* storage may be blocked */ }
|
|
const available = new Set(catalog.map(item => item.code));
|
|
return available.has(saved) ? saved : FALLBACK_LANGUAGE;
|
|
}
|
|
|
|
function persistLanguage(code, scope = "auto") {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, code);
|
|
const guestScope = scope === "guest" || (scope === "auto" && !hasAccountSession());
|
|
if (guestScope) localStorage.setItem(GUEST_STORAGE_KEY, code);
|
|
} catch { /* storage may be blocked */ }
|
|
}
|
|
|
|
export function rememberGuestLanguage(code = activeCode) {
|
|
try {
|
|
const available = new Set(catalog.map(item => item.code));
|
|
const saved = localStorage.getItem(GUEST_STORAGE_KEY);
|
|
if (available.has(saved)) return;
|
|
const value = available.has(code) ? code : FALLBACK_LANGUAGE;
|
|
localStorage.setItem(GUEST_STORAGE_KEY, value);
|
|
} catch { /* storage may be blocked */ }
|
|
}
|
|
|
|
export async function restoreGuestLanguage() {
|
|
let code = FALLBACK_LANGUAGE;
|
|
try { code = localStorage.getItem(GUEST_STORAGE_KEY) || FALLBACK_LANGUAGE; } catch { /* storage may be blocked */ }
|
|
const applied = await setLanguage(code, { persist: false });
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, applied);
|
|
localStorage.setItem(GUEST_STORAGE_KEY, applied);
|
|
} catch { /* storage may be blocked */ }
|
|
return applied;
|
|
}
|
|
|
|
function applyDocumentLanguage() {
|
|
const meta = activeBundle?.meta || fallbackBundle?.meta;
|
|
if (!meta) return;
|
|
document.documentElement.lang = meta.code;
|
|
document.documentElement.dataset.locale = meta.locale;
|
|
}
|
|
|
|
export async function setLanguage(code, { persist = true, scope = "auto" } = {}) {
|
|
await initI18n();
|
|
const available = catalog.find(item => item.code === code);
|
|
const target = available ? available.code : FALLBACK_LANGUAGE;
|
|
activeBundle = await loadBundle(target);
|
|
activeCode = activeBundle.meta.code;
|
|
rebuildSourceIndex();
|
|
if (persist) persistLanguage(activeCode, scope);
|
|
applyDocumentLanguage();
|
|
translateTree(document, true);
|
|
document.dispatchEvent(new CustomEvent("rustpad:languagechange", {
|
|
detail: { language: activeCode, locale: activeBundle.meta.locale },
|
|
}));
|
|
return activeCode;
|
|
}
|
|
|
|
export function getLanguage() {
|
|
return activeCode;
|
|
}
|
|
|
|
export function getLocale() {
|
|
return activeBundle?.meta?.locale || fallbackBundle?.meta?.locale || "en-US";
|
|
}
|
|
|
|
export function getAvailableLanguages() {
|
|
return catalog.map(item => ({ ...item }));
|
|
}
|
|
|
|
function languageLabel(language) {
|
|
const nativeName = language.native_name || language.name || language.code;
|
|
const englishName = language.name || nativeName;
|
|
return nativeName === englishName ? nativeName : `${nativeName} · ${englishName}`;
|
|
}
|
|
|
|
function closeLanguagePicker(picker, { focusTrigger = false } = {}) {
|
|
if (!(picker instanceof HTMLElement)) return;
|
|
const menu = picker.querySelector(".profile-language-menu");
|
|
const trigger = picker.querySelector(".profile-language-trigger");
|
|
if (menu instanceof HTMLElement) menu.hidden = true;
|
|
picker.removeAttribute("data-open");
|
|
trigger?.setAttribute("aria-expanded", "false");
|
|
if (focusTrigger) trigger?.focus();
|
|
}
|
|
|
|
function focusLanguageOption(menu, direction = 1) {
|
|
const options = [...menu.querySelectorAll(".profile-language-option")];
|
|
if (!options.length) return;
|
|
const current = document.activeElement;
|
|
const index = options.indexOf(current);
|
|
const next = index < 0
|
|
? (direction > 0 ? 0 : options.length - 1)
|
|
: (index + direction + options.length) % options.length;
|
|
options[next].focus();
|
|
}
|
|
|
|
function bindLanguagePicker(input, picker) {
|
|
if (picker.dataset.languagePickerBound === "true") return;
|
|
picker.dataset.languagePickerBound = "true";
|
|
const trigger = picker.querySelector(".profile-language-trigger");
|
|
const menu = picker.querySelector(".profile-language-menu");
|
|
if (!(trigger instanceof HTMLButtonElement) || !(menu instanceof HTMLElement)) return;
|
|
|
|
const open = (focusSelected = false) => {
|
|
menu.hidden = false;
|
|
picker.dataset.open = "true";
|
|
trigger.setAttribute("aria-expanded", "true");
|
|
if (focusSelected) {
|
|
const selected = menu.querySelector('[aria-selected="true"]') || menu.querySelector(".profile-language-option");
|
|
selected?.focus();
|
|
}
|
|
};
|
|
|
|
trigger.addEventListener("click", () => {
|
|
if (menu.hidden) open(false);
|
|
else closeLanguagePicker(picker);
|
|
});
|
|
trigger.addEventListener("keydown", event => {
|
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
open(true);
|
|
}
|
|
});
|
|
menu.addEventListener("click", event => {
|
|
const option = event.target.closest(".profile-language-option");
|
|
if (!(option instanceof HTMLElement)) return;
|
|
input.value = option.dataset.languageCode || FALLBACK_LANGUAGE;
|
|
populateLanguageSelect(input, input.value);
|
|
closeLanguagePicker(picker, { focusTrigger: true });
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
});
|
|
menu.addEventListener("keydown", event => {
|
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
focusLanguageOption(menu, event.key === "ArrowDown" ? 1 : -1);
|
|
} else if (event.key === "Home" || event.key === "End") {
|
|
event.preventDefault();
|
|
const options = [...menu.querySelectorAll(".profile-language-option")];
|
|
(event.key === "Home" ? options[0] : options.at(-1))?.focus();
|
|
} else if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeLanguagePicker(picker, { focusTrigger: true });
|
|
}
|
|
});
|
|
document.addEventListener("pointerdown", event => {
|
|
if (!picker.contains(event.target)) closeLanguagePicker(picker);
|
|
});
|
|
picker.closest("dialog")?.addEventListener("close", () => closeLanguagePicker(picker));
|
|
}
|
|
|
|
export function populateLanguageSelect(input, selectedCode = getLanguage()) {
|
|
const selected = catalog.find(item => item.code === selectedCode)
|
|
|| catalog.find(item => item.code === FALLBACK_LANGUAGE)
|
|
|| catalog[0];
|
|
if (!selected) return;
|
|
|
|
if (input instanceof HTMLSelectElement) {
|
|
input.replaceChildren(...catalog.map(language => {
|
|
const option = document.createElement("option");
|
|
option.value = language.code;
|
|
option.textContent = languageLabel(language);
|
|
option.title = `${language.native_name || language.name || language.code} (${language.locale})`;
|
|
option.lang = language.code;
|
|
return option;
|
|
}));
|
|
input.value = selected.code;
|
|
return;
|
|
}
|
|
|
|
if (!(input instanceof HTMLInputElement)) return;
|
|
const picker = input.closest("[data-language-picker]");
|
|
if (!(picker instanceof HTMLElement)) return;
|
|
bindLanguagePicker(input, picker);
|
|
|
|
const current = picker.querySelector(".profile-language-current");
|
|
const trigger = picker.querySelector(".profile-language-trigger");
|
|
const menu = picker.querySelector(".profile-language-menu");
|
|
input.value = selected.code;
|
|
if (current instanceof HTMLElement) {
|
|
current.textContent = languageLabel(selected);
|
|
current.lang = selected.code;
|
|
}
|
|
if (trigger instanceof HTMLElement) {
|
|
trigger.title = `${selected.native_name || selected.name || selected.code} (${selected.locale})`;
|
|
}
|
|
if (!(menu instanceof HTMLElement)) return;
|
|
|
|
menu.replaceChildren(...catalog.map(language => {
|
|
const option = document.createElement("button");
|
|
const nativeName = language.native_name || language.name || language.code;
|
|
const englishName = language.name || nativeName;
|
|
const isSelected = language.code === selected.code;
|
|
option.type = "button";
|
|
option.className = "profile-language-option";
|
|
option.dataset.languageCode = language.code;
|
|
option.setAttribute("role", "option");
|
|
option.setAttribute("aria-selected", isSelected ? "true" : "false");
|
|
option.lang = language.code;
|
|
option.innerHTML = `<span class="profile-language-option__check" aria-hidden="true">${isSelected ? "✓" : ""}</span><span class="profile-language-option__copy"><strong></strong><small></small></span>`;
|
|
option.querySelector("strong").textContent = nativeName;
|
|
option.querySelector("small").textContent = nativeName === englishName ? language.locale : `${englishName} · ${language.locale}`;
|
|
return option;
|
|
}));
|
|
}
|
|
|
|
window.addEventListener("storage", event => {
|
|
if (event.key !== STORAGE_KEY && event.key !== GUEST_STORAGE_KEY && event.key !== AUTH_STATE_KEY) return;
|
|
if (event.key === GUEST_STORAGE_KEY && hasAccountSession()) return;
|
|
const next = preferredCode();
|
|
if (next !== activeCode) void setLanguage(next, { persist: false });
|
|
});
|
|
|
|
export function formatDateTime(value, options) {
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
if (Number.isNaN(date.getTime())) return String(value ?? "");
|
|
return new Intl.DateTimeFormat(getLocale(), options).format(date);
|
|
}
|
|
|
|
export function formatTime(value, options = { hour: "2-digit", minute: "2-digit" }) {
|
|
return formatDateTime(value, options);
|
|
}
|
|
|
|
export function formatNumber(value, options) {
|
|
return new Intl.NumberFormat(getLocale(), options).format(value);
|
|
}
|
|
|
|
function revealDocumentAfterI18n() {
|
|
const root = document.documentElement;
|
|
root.removeAttribute("data-i18n-pending");
|
|
if (root.style.visibility === "hidden") root.style.removeProperty("visibility");
|
|
}
|
|
|
|
export async function initI18n() {
|
|
if (initialized) return;
|
|
if (initialization) return initialization;
|
|
initialization = (async () => {
|
|
try {
|
|
await loadCatalog();
|
|
const code = preferredCode();
|
|
const [fallback, preferred] = await Promise.all([
|
|
loadBundle(FALLBACK_LANGUAGE),
|
|
code === FALLBACK_LANGUAGE ? Promise.resolve(null) : loadBundle(code),
|
|
]);
|
|
fallbackBundle = fallback;
|
|
activeBundle = preferred || fallbackBundle;
|
|
activeCode = activeBundle.meta.code;
|
|
rebuildSourceIndex();
|
|
} catch (error) {
|
|
console.warn("RustPad i18n initialization failed; using source language.", error);
|
|
catalog = catalog.length ? catalog : [{ code: "en", name: "English", native_name: "English", locale: "en-US" }];
|
|
fallbackBundle = fallbackBundle || { meta: catalog[0], translations: {} };
|
|
activeBundle = fallbackBundle;
|
|
activeCode = FALLBACK_LANGUAGE;
|
|
rebuildSourceIndex();
|
|
}
|
|
applyDocumentLanguage();
|
|
translateTree(document);
|
|
startObserver();
|
|
revealDocumentAfterI18n();
|
|
initialized = true;
|
|
})();
|
|
await initialization;
|
|
}
|