Files
MarekWoandClaude Opus 5 2ec2f461e2 feat(i18n): add UI translation infrastructure (stage 0)
Groundwork for translating the interface. No strings are extracted yet — this
stage only adds the mechanism, so the UI is byte-identical in English.

Translations are one flat JSON file per language. An admin can add a language by
dropping <lang>.json into $MC_CONFIG_DIR/translations/ and picking it in
Settings > Appearance — no rebuild, no restart, no compile step. A drop-in file
overrides a built-in one of the same name. Built-ins: en (source) and pl.

How it works:
- app/i18n.py loads and merges catalogs over en.json, so per-key English
  fallback is baked in server-side and the JS runtime needs no fallback logic.
  Catalogs are fingerprinted with stat() per render, so a dropped-in file is
  live on the next refresh rather than the next restart — which matters,
  because restarting drops the device connection for up to 60s.
- Template text renders server-side via t()/t_html()/tn() from inject_globals(),
  so the first paint is already correct. That context processor covers every
  render_template() in the app, including the six standalone iframe pages, so
  routes/views.py needed no changes.
- JS text comes from /i18n/<lang>.<hash8>.js, a blocking immutable script shared
  by all 8 entry points. The hash is in the path, not a query string, so
  intermediary caches and the service worker bust reliably.
- Language = per-browser mc_lang cookie over a server-wide DB default. The
  cookie is what makes iframes work: they are same-origin, so they send it
  automatically and the existing modal-open reload wiring needs no changes.

t() deliberately does not escape — Jinja autoescape handles that, and escaping
here would double-escape every French apostrophe. t_html()/tHtml() escape their
params but trust catalog markup.

Also fixed along the way:
- save_ui_settings() replaced the whole settings blob instead of merging, so a
  language-only POST would have wiped the toast settings. It worked before only
  because the one form always submitted every key.
- `const t = document.getElementById(...)` in populateUiSettingsForm shadowed
  the global translation helper; same for `var t` in six theme IIFEs.
  scripts/i18n_check.py now fails the build if that pattern comes back.
- Removed app/templates/contacts.html, dead since the contacts pages were split
  (no render_template reference anywhere), and two stale CSS comments.

Verified locally: catalog route headers/security, drop-in without restart,
malformed catalogs logged and skipped rather than offered in the picker,
cookie propagation into iframes, and 360px layout. Python and JS agree on
interpolation, escaping and Polish plural categories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:29:41 +02:00

114 lines
3.4 KiB
JavaScript

/**
* UI Translation Runtime
*
* Reads the catalog that /i18n/<lang>.<hash>.js put on window.MC_I18N. That catalog is
* already merged over English server-side, so there is no fallback chain here — a key
* that is missing is genuinely missing everywhere.
*
* Must load as a blocking script in <head>, before any script that calls t().
*
* Two functions, one rule:
* t() - textContent, .title, .placeholder, alert(), Notification body
* tHtml() - anywhere the result lands in innerHTML / insertAdjacentHTML
*
* tHtml() escapes the interpolated params, never the catalog value. Markup in the
* catalog is trusted (the admin installed the file); interpolated data is not.
*/
// Only {word} is a placeholder, so a literal "{" before punctuation survives untouched.
const I18N_PARAM_RE = /\{(\w+)\}/g;
const _pluralRules = {};
function _i18nCatalog() {
return window.MC_I18N || {};
}
/**
* Substitute {name} placeholders.
* An unknown placeholder is left verbatim so it shows up in the UI instead of vanishing.
*/
function _i18nInterpolate(text, params) {
if (!params || text.indexOf('{') === -1) return text;
return text.replace(I18N_PARAM_RE, (match, name) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
));
}
function _i18nEscape(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Resolve a key to its raw catalog string.
* A missing key returns the key itself — unmistakable in the UI, greppable in a screenshot.
*/
function _i18nResolve(key, count) {
const value = _i18nCatalog()[key];
if (value === undefined) {
if (window.MC_I18N_DEBUG) console.warn('Missing translation key:', key);
return key;
}
if (typeof value === 'string') return value;
const lang = window.MC_LANG || 'en';
if (!_pluralRules[lang]) {
try {
_pluralRules[lang] = new Intl.PluralRules(lang);
} catch (e) {
_pluralRules[lang] = new Intl.PluralRules('en');
}
}
const category = _pluralRules[lang].select(count === undefined ? 1 : count);
return value[category] || value.other || value.one || key;
}
/**
* Translate. Result is NOT HTML-escaped — use for text sinks only.
* @param {string} key
* @param {Object} [params] - values for {name} placeholders
* @returns {string}
*/
function t(key, params) {
return _i18nInterpolate(_i18nResolve(key), params);
}
/**
* Translate for an HTML sink. Catalog markup is preserved, params are escaped.
* @param {string} key
* @param {Object} [params]
* @returns {string}
*/
function tHtml(key, params) {
const text = _i18nResolve(key);
if (!params) return text;
const escaped = {};
for (const name of Object.keys(params)) {
escaped[name] = _i18nEscape(params[name]);
}
return _i18nInterpolate(text, escaped);
}
/**
* Translate with a plural form, using the catalog's {one, few, many, other} object.
* {count} is injected automatically.
* @param {string} key
* @param {number} count
* @param {Object} [params]
* @returns {string}
*/
function tn(key, count, params) {
return _i18nInterpolate(_i18nResolve(key, count), Object.assign({ count: count }, params || {}));
}
window.t = t;
window.tHtml = tHtml;
window.tn = tn;