/** * MeshBay i18n — lightweight string localization. * * Catalogues live one per language in `locales/` and are fetched on demand, so * a visitor downloads the language they read plus English as a fallback, never * the other eight. * * Usage: * import { t, initLocale, setLocale, getLocale, LOCALES } from './i18n.js'; * await initLocale(); // once, before the first render * t('nav.logout') // "Logout" * t('status.files', { n: 42 }) // "42 files" * * A catalogue value is a string, or an object keyed by Intl.PluralRules * category when it is rendered with a count: * 'status.files': { one: '{n} file', other: '{n} files' } * Polish needs `one`/`few`/`many`/`other` where English needs two, which is why * the plural category is chosen here rather than baked into the string. */ const LANG_KEY = 'mb_lang'; const FALLBACK = 'en'; export const LOCALES = [ { code: 'en', name: 'English', flag: '\u{1F1EC}\u{1F1E7}' }, { code: 'fr', name: 'Français', flag: '\u{1F1EB}\u{1F1F7}' }, { code: 'es', name: 'Español', flag: '\u{1F1EA}\u{1F1F8}' }, { code: 'pt-BR', name: 'Português (Brasil)', flag: '\u{1F1E7}\u{1F1F7}' }, { code: 'zh-CN', name: '简体中文', flag: '\u{1F1E8}\u{1F1F3}' }, { code: 'ja', name: '日本語', flag: '\u{1F1EF}\u{1F1F5}' }, { code: 'de', name: 'Deutsch', flag: '\u{1F1E9}\u{1F1EA}' }, { code: 'it', name: 'Italiano', flag: '\u{1F1EE}\u{1F1F9}' }, { code: 'nl', name: 'Nederlands', flag: '\u{1F1F3}\u{1F1F1}' }, { code: 'pl', name: 'Polski', flag: '\u{1F1F5}\u{1F1F1}' }, ]; const _codes = LOCALES.map(l => l.code); const _strings = {}; const _plurals = {}; let _locale = FALLBACK; // ── Resolution ────────────────────────────────────────────────────────────── /** * Match a requested tag against the registry, region-tolerantly. * * `navigator.language` is `pt-BR` or `zh-CN` on the machines those catalogues * are for, and `fr-CA` or `de-AT` on machines the base catalogue serves fine. * Trimming to the base first — which this used to do — sent a Brazilian browser * looking for a `pt` catalogue that does not exist. */ function _match(tag) { if (!tag) return null; const want = String(tag).replace('_', '-'); const exact = _codes.find(c => c.toLowerCase() === want.toLowerCase()); if (exact) return exact; const base = want.split('-')[0].toLowerCase(); return _codes.find(c => c.split('-')[0].toLowerCase() === base) || null; } /** * Storage access throws outright in some privacy modes rather than returning * null. A language preference is not worth failing the boot over, so both * directions swallow it and the browser's own languages decide instead. */ function _stored(value) { try { if (value === undefined) return localStorage.getItem(LANG_KEY); localStorage.setItem(LANG_KEY, value); } catch { return null; } return value; } function _preferred() { const stored = _match(_stored()); if (stored) return stored; for (const tag of navigator.languages || [navigator.language]) { const hit = _match(tag); if (hit) return hit; } return FALLBACK; } // ── Loading ───────────────────────────────────────────────────────────────── async function _load(code) { if (_strings[code]) return true; try { const mod = await import(`./locales/${code}.js`); _strings[code] = mod.default; return true; } catch (err) { console.warn(`[MeshBay] locale ${code} failed to load:`, err); return false; } } /** * Load the active catalogue and the English fallback, then fix the locale. * Resolves even when a catalogue is missing — an untranslated interface beats * a blank page, so a failed load degrades to English rather than throwing. */ export async function initLocale() { const want = _preferred(); const loads = [_load(want)]; if (want !== FALLBACK) loads.push(_load(FALLBACK)); const [ok] = await Promise.all(loads); _locale = ok ? want : FALLBACK; if (!_strings[FALLBACK]) await _load(FALLBACK); document.documentElement.lang = _locale; return _locale; } // ── Lookup ────────────────────────────────────────────────────────────────── export function getLocale() { return _locale; } export function setLocale(code) { const hit = _match(code); if (!hit) return false; _stored(hit); return true; } export function addLocale(code, strings) { _strings[code] = strings; } function _pluralRules(locale) { if (!_plurals[locale]) _plurals[locale] = new Intl.PluralRules(locale); return _plurals[locale]; } /** A catalogue entry is a plain string, or plural categories to choose from. */ function _select(entry, locale, params) { if (typeof entry === 'string') return entry; if (!entry || typeof entry !== 'object') return null; const n = Number(params && params.n); const cat = _pluralRules(locale).select(Number.isFinite(n) ? n : 0); return entry[cat] ?? entry.other ?? null; } export function t(key, params) { let s = _select(_strings[_locale] && _strings[_locale][key], _locale, params); if (s == null) { s = _select(_strings[FALLBACK] && _strings[FALLBACK][key], FALLBACK, params); } if (s == null) return key; if (params) { for (const [k, v] of Object.entries(params)) { // split/join rather than replace: a value is often a filename, and // String.replace reads `$&` and friends in the replacement. s = s.split(`{${k}}`).join(v); } } return s; }