aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
blob: 56d35f76958ead3377a289b2a577622613eef1ec (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
/**
 * 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';

// No flag emoji. They were regional-indicator pairs, which Windows has never
// shipped a glyph for — Segoe UI Emoji renders the pair as its two letters, so
// the menu read "GB", "FR", "NL" down the left-hand side instead of a flag.
// The alternative was to vendor ten flag images; not worth it, because a flag
// is a country and not a language anyway (which of them would mean Português
// (Brasil)?). The name alone carries it: each is written in its own language,
// which is the self-identifying signal the flag was standing in for.
export const LOCALES = [
  { code: 'en', name: 'English' },
  { code: 'fr', name: 'Français' },
  { code: 'es', name: 'Español' },
  { code: 'pt-BR', name: 'Português (Brasil)' },
  { code: 'zh-CN', name: '简体中文' },
  { code: 'ja', name: '日本語' },
  { code: 'de', name: 'Deutsch' },
  { code: 'it', name: 'Italiano' },
  { code: 'nl', name: 'Nederlands' },
  { code: 'pl', name: 'Polski' },
];

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;
}