// Internationalisation. // // Two kinds of locale files are merged into one flat dictionary: // locales/.json core UI texts (shared by all themes) // themes//locales/.json theme texts, all under the "theme." root // Missing keys fall back to English, then to the key itself. // // Strings support {param} interpolation and {@some.key} to embed another entry // (e.g. "{@theme.res.material}"), so core texts can use theme vocabulary. export const FALLBACK_LOCALE = 'en'; const MAX_EMBED_DEPTH = 5; let dictionary = {}; let currentLocale = FALLBACK_LOCALE; export const getLocale = () => currentLocale; function flatten(object, prefix = '', out = {}) { for (const [key, value] of Object.entries(object ?? {})) { const path = prefix ? `${prefix}.${key}` : key; if (value && typeof value === 'object') flatten(value, path, out); else out[path] = String(value); } return out; } async function fetchJson(url) { try { const response = await fetch(url); return response.ok ? await response.json() : {}; } catch { return {}; } } export async function loadDictionary(locale, themeId) { const order = locale === FALLBACK_LOCALE ? [FALLBACK_LOCALE] : [FALLBACK_LOCALE, locale]; const files = order.flatMap((l) => [`locales/${l}.json`, `themes/${themeId}/locales/${l}.json`]); const parts = await Promise.all(files.map(fetchJson)); dictionary = Object.assign({}, ...parts.map((p) => flatten(p))); currentLocale = locale; document.documentElement.lang = locale; } export function has(key) { return key in dictionary; } export function t(key, params = {}, depth = 0) { const text = dictionary[key]; if (text === undefined) return key; return text.replace(/\{(@?)([\w.]+)\}/g, (match, embed, name) => { if (embed) return depth < MAX_EMBED_DEPTH ? t(name, params, depth + 1) : ''; return name in params ? String(params[name]) : match; }); } /** * Translate static markup: * data-i18n="key" -> textContent * data-i18n-attr="title:key;aria-label:key" -> attributes */ export function applyTranslations(root = document) { for (const el of root.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n); for (const el of root.querySelectorAll('[data-i18n-attr]')) { for (const pair of el.dataset.i18nAttr.split(';')) { const [attr, key] = pair.split(':').map((s) => s.trim()); if (attr && key) el.setAttribute(attr, t(key)); } } } /** Display name of a theme in `locale` (falls back to English, then the id). */ export async function fetchThemeName(themeId, locale) { for (const l of [locale, FALLBACK_LOCALE]) { const file = await fetchJson(`themes/${themeId}/locales/${l}.json`); if (file?.theme?.name) return file.theme.name; } return themeId; }