Files
ObsiGate/frontend/js/i18n.js
T
bruno f85482214e
CI / lint (push) Failing after 20s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Failing after 9s
feat: i18n system - French/English internationalization
Core: i18n.js module with t(), setLocale(), initI18n()
- Flat key→string JSON locale files (fr.json + en.json, 165 keys)
- data-i18n / data-i18n-placeholder / data-i18n-attr DOM attributes
- Live locale switch via setLocale() - no page reload
- Language detection: localStorage > navigator.language > 'fr'
- Fallback: missing key → French → raw key

Wired into:
- app.js: await initI18n() early in init()
- config.js: profile save → await setLocale(), toasts + confirm via t()
- auth.js: network error toast via t()
- index.html: data-i18n on login form, profile section, search placeholder
2026-06-18 13:31:11 -04:00

147 lines
5.2 KiB
JavaScript

/* ObsiGate — i18n (internationalisation).
*
* Architecture:
* Flat key→string JSON locale files in /static/locales/{fr,en,...}.json
* Static DOM: data-i18n="key" → textContent
* data-i18n-placeholder="key" → placeholder
* data-i18n-attr:title="key" → title attribute
* data-i18n-html="key" → innerHTML (use sparingly)
* Dynamic JS: import { t } from './i18n.js'; t('key', {param: 'val'})
* Live reload: setLocale('en') updates every data-i18n element instantly.
*
* Key naming: section.what — lowercase, dot-separated, descriptive.
* e.g. header.search_placeholder button.save error.network
*
* Usage:
* import { t, setLocale, getLocale, initI18n } from './i18n.js';
* await initI18n(); // early, before any rendering
* el.textContent = t('button.cancel'); // dynamic string
* setLocale('en'); // user switches language
*/
// ── State ──────────────────────────────────────────────────────────────────
let _currentLocale = 'fr';
let _strings = {}; // { key: translated_string }
let _fallbackStrings = {}; // FR fallback, loaded once
let _loaded = false;
// ── Public helpers ─────────────────────────────────────────────────────────
/** Return the currently active locale code ('fr', 'en', ...). */
export function getLocale() {
return _currentLocale;
}
/** Translate a key. If params provided, replace {name} placeholders.
* Falls back: key → French → key itself. Never throws. */
export function t(key, params) {
let s = _strings[key];
if (s === undefined) {
s = _fallbackStrings[key];
if (s === undefined) {
console.warn('obsigate i18n: missing key', key);
s = key; // last resort — show the key so the dev notices
}
}
if (params) {
for (const [k, v] of Object.entries(params)) {
s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), String(v));
}
}
return s;
}
/** Change locale and re-translate the entire DOM.
* Persists to localStorage. */
export async function setLocale(locale) {
locale = locale || 'fr';
if (!_loaded) {
// Too early — store intent and apply after init
try { localStorage.setItem('obsigate-lang', locale); } catch (e) { /* */ }
return;
}
await _loadLocaleStrings(locale);
_applyDOM();
document.documentElement.lang = locale;
try { localStorage.setItem('obsigate-lang', locale); } catch (e) { /* */ }
}
/** Initialise the i18n system. Call once, early, before any rendering.
* Reads saved preference from localStorage, falls back to navigator.language,
* then to 'fr'. */
export async function initI18n() {
// 1. Determine locale: saved > navigator.language > 'fr'
let locale = null;
try { locale = localStorage.getItem('obsigate-lang'); } catch (e) { /* */ }
if (!locale) {
const nav = (navigator.language || '').toLowerCase();
locale = nav.startsWith('fr') ? 'fr'
: nav.startsWith('en') ? 'en'
: 'fr';
}
// 2. Load fallback (FR) — always needed
_fallbackStrings = await _fetchLocale('fr');
// 3. Load selected locale
await _loadLocaleStrings(locale);
// 4. Apply to DOM
_applyDOM();
document.documentElement.lang = locale;
_loaded = true;
}
// ── Internal ───────────────────────────────────────────────────────────────
async function _fetchLocale(locale) {
try {
const resp = await fetch('/static/locales/' + locale + '.json');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return await resp.json();
} catch (err) {
console.warn('obsigate i18n: failed to load locale ' + locale, err);
return {};
}
}
async function _loadLocaleStrings(locale) {
_currentLocale = locale;
if (locale === 'fr') {
_strings = _fallbackStrings;
} else {
_strings = await _fetchLocale(locale);
}
}
/** Walk the DOM and update every element with a data-i18n* attribute. */
function _applyDOM() {
// textContent
document.querySelectorAll('[data-i18n]').forEach(function (el) {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
// placeholder
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
const key = el.getAttribute('data-i18n-placeholder');
el.setAttribute('placeholder', t(key));
});
// innerHTML (use sparingly — XSS risk if keys ever hold user content)
document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
const key = el.getAttribute('data-i18n-html');
el.innerHTML = t(key);
});
// data-i18n-attr:TITLE → sets any attribute
document.querySelectorAll('[data-i18n-attr]').forEach(function (el) {
const raw = el.getAttribute('data-i18n-attr');
const colon = raw.indexOf(':');
if (colon === -1) return;
const attr = raw.substring(0, colon);
const key = raw.substring(colon + 1);
el.setAttribute(attr, t(key));
});
}