Files
bruno dd9224e685
CI / lint (push) Successful in 1m57s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 4m20s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 11m51s
fix: page Configurations inutilisable en mode mobile BUG-071
2026-09-22 21:24:56 -04:00

203 lines
7.4 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-attr="a:k1;b:k2" → several attributes (";"-separated)
* 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;
}
// ── Reverse-lookup cache (French text → key) ────────────────────────────
let _reverseCache = null;
function _buildReverseCache() {
_reverseCache = {};
for (const [k, v] of Object.entries(_fallbackStrings)) {
if (k.startsWith('_')) continue;
// Prefer shorter keys when multiple keys map to the same French text
if (!_reverseCache[v] || k.length < _reverseCache[v].length) {
_reverseCache[v] = k;
}
}
}
/** Translate a French string to the current locale by reverse lookup.
* "Paramètres client sauvegardés" → fr: same, en: "Client settings saved".
* If the string is not found in the FR locale, returns it unchanged. */
export function tr(frenchText) {
if (!frenchText || _currentLocale === 'fr') return frenchText;
if (!_reverseCache) _buildReverseCache();
var key = _reverseCache[frenchText];
return key ? t(key) : frenchText;
}
/** 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.
* Priority: server profile > localStorage > navigator.language > 'fr'. */
export async function initI18n() {
// 1. Determine locale: server profile > localStorage > navigator.language > 'fr'
let locale = null;
// Try server-side language preference (survives clear site data)
try {
const resp = await fetch('/api/auth/me', { credentials: 'include' });
if (resp.ok) {
const user = await resp.json();
if (user.language === 'en' || user.language === 'fr') {
locale = user.language;
}
}
} catch (e) { /* not logged in or network error — fall through */ }
// Fall back to localStorage
if (!locale) {
try { locale = localStorage.getItem('obsigate-lang'); } catch (e) { /* */ }
}
// Fall back to navigator.language
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 {
// ``no-store`` bypasses the HTTP cache: locale files are not content-hashed
// and a stale copy would surface raw keys in the UI (missing translations).
const resp = await fetch('/static/locales/' + locale + '.json', { cache: 'no-store' });
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);
}
}
// ── Global exports for non-module scripts (legacy app.js) ──────────────────
window.t = t;
window.tr = tr;
window.getLocale = getLocale;
window.setLocale = setLocale;
window.initI18n = initI18n;
/** 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:ATTR:key[;ATTR:key…] → sets any attribute(s).
// Single-pair form (data-i18n-attr="title:key") is preserved; multiple
// pairs are separated with ";" (BUG-071: the config TOC toggle needs both
// title and aria-label translated).
document.querySelectorAll('[data-i18n-attr]').forEach(function (el) {
const raw = el.getAttribute('data-i18n-attr');
raw.split(';').forEach(function (pair) {
const colon = pair.indexOf(':');
if (colon === -1) return;
const attr = pair.substring(0, colon).trim();
const key = pair.substring(colon + 1).trim();
if (!attr || !key) return;
el.setAttribute(attr, t(key));
});
});
}