/* * 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 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 preferredCode() { // English is always the default. A cached language is only used while an // authenticated profile is expected; the server session remains the source // of truth and will re-apply its saved preference after validation. let hasAccountSession = false; let saved = ""; try { hasAccountSession = localStorage.getItem("rustpad:auth-state") === "1"; if (hasAccountSession) saved = localStorage.getItem(STORAGE_KEY) || ""; } catch { /* storage may be blocked */ } if (!hasAccountSession) return FALLBACK_LANGUAGE; const available = new Set(catalog.map(item => item.code)); return available.has(saved) ? saved : FALLBACK_LANGUAGE; } 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 } = {}) { 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) { try { localStorage.setItem(STORAGE_KEY, activeCode); } catch { /* storage may be blocked */ } } 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 = ``; option.querySelector("strong").textContent = nativeName; option.querySelector("small").textContent = nativeName === englishName ? language.locale : `${englishName} · ${language.locale}`; return option; })); } 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; }