Skip to content

Auxiliary tools internationalization

Beyond the Spring Boot backend and the React frontend, PerfShop integrates several auxiliary tools that each have their own translation dictionaries. These tools follow a common, deliberately simple and framework-free pattern: flat JSON files, a vanilla loader, a declarative HTML data-i18n attribute, and a global _t() function.

This page groups the reference for the five tools involved: chaos-admin, monitoring, scripts-ui, jmeter-ui, and welcome.

Overview

Tool Path Loader Key count (order of magnitude)
chaos-admin chaos-admin/public/i18n/{fr,en}.json chaos-admin/public/js/i18n.js ~400
monitoring monitoring/public/i18n/{fr,en}.json monitoring/public/js/i18n.js ~150
scripts-ui scripts-ui/public/i18n/{fr,en}.json scripts-ui/public/js/i18n.js ~200
jmeter-ui jmeter-ui/public/i18n/{fr,en}.json jmeter-ui/public/js/i18n.js ~180
welcome welcome/i18n/{fr,en}.json welcome/entrypoint.sh + welcome.js ~30

The first four tools use exactly the same pattern — an asynchronous vanilla JavaScript loader. The welcome page uses a variant: injection is done by an entrypoint.sh shell script that adds window.__I18N__ = {...} at the top of welcome.js.

The i18n.js vanilla loader

The chaos-admin/public/js/i18n.js file is the reference loader. It is duplicated (or adapted) for the other tools. Its code fits in about a hundred lines and has no external dependencies.

Global variables

var PERFSHOP_LANG = (window.__CONFIG__ && window.__CONFIG__.LANG) || 'fr';

var DATE_LOCALE = (function () {
  var map = {
    fr: 'fr-FR',
    en: 'en-GB',
    es: 'es-ES',
    pt: 'pt-BR',
    zh: 'zh-CN',
    hi: 'hi-IN',
    ar: 'ar-SA'
  };
  return map[PERFSHOP_LANG] || map.fr;
})();

var I18N = {};

The language is read from window.__CONFIG__.LANG, which is injected by the container via a dynamically served config.js script. If absent, the default is fr.

A mapping table exposes a consistent DATE_LOCALE for each supported language, including anticipated languages (Portuguese, Chinese, Hindi, Arabic) even if their dictionaries are not yet written.

The I18N dictionary is initially empty; it is populated asynchronously by loadI18n().

_t() function — translation with numbered placeholders

function _t(key) {
  var text = (key in I18N) ? I18N[key] : key;
  for (var i = 1; i < arguments.length; i++) {
    text = text.replace('{' + (i - 1) + '}', arguments[i]);
  }
  return text;
}

The fallback is the key itself — just like on the React side. Placeholders are numbered ({0}, {1}, {2}) and not named, to match the MessageFormat style of the Spring backend.

Typical usage:

showToast(_t('toast.licence.activated'));
badge.textContent = _t('badge.level.active', levelName);
status.textContent = _t('student.license.valid_until', expireDate);

applyI18n() function — declarative DOM application

function applyI18n() {
  document.querySelectorAll('[data-i18n]').forEach(function (el) {
    var key = el.getAttribute('data-i18n');
    if (!key) return;
    if (el.tagName === 'TITLE') {
      document.title = _t(key);
    } else {
      el.textContent = _t(key);
    }
  });
  document.querySelectorAll('[data-i18n-html]').forEach(function (el) {
    var key = el.getAttribute('data-i18n-html');
    if (key) el.innerHTML = _t(key);
  });
  document.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) {
    var key = el.getAttribute('data-i18n-placeholder');
    if (key) el.placeholder = _t(key);
  });
  document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
    var key = el.getAttribute('data-i18n-title');
    if (key) el.title = _t(key);
  });
  var html = document.documentElement;
  if (html) html.lang = PERFSHOP_LANG;
}

Four declarative attributes are supported:

Attribute Target Usage
data-i18n textContent (or document.title for <title>) Default case, safe (no HTML)
data-i18n-html innerHTML When the translation intentionally contains inline markup (<strong>, <code>, <br>)
data-i18n-placeholder placeholder For <input> and <textarea> fields
data-i18n-title title (HTML attribute) For native tooltips

data-i18n vs data-i18n-html

The first is the default, safe path. The second is reserved for strings that intentionally contain HTML markup (for example <strong>Chaos Scripting</strong> increases complexity...). In the second case, the dictionary is static on the server side and contains no user input — innerHTML is therefore safe in this context.

HTML usage examples

<title data-i18n="admin.page.title">PerfShop — Chaos Engineering</title>

<h1><span data-i18n="admin.title">Chaos Engineering</span></h1>

<button data-i18n="admin.btn.reload">Reload</button>

<input type="email" id="new-email"
       data-i18n-placeholder="gestion.create.email.ph"
       placeholder="instructor@example.com">

<div class="scripting-intro" data-i18n-html="admin.scripting.intro">
  🔐 <strong>Chaos Scripting</strong> increases complexity...
</div>

The text between the tags is the fallback value displayed before the i18n loader runs (in case of slow loading or a network error) — it is also the French reference value serving as inline documentation.

Asynchronous loading

function loadI18n() {
  var lang = PERFSHOP_LANG;
  return _fetchJson('/i18n/' + lang + '.json')
    .catch(function (err) {
      if (lang !== 'fr') {
        console.warn('[i18n] ' + lang + '.json not found, fallback → fr.json');
        return _fetchJson('/i18n/fr.json');
      }
      console.error('[i18n] fr.json not found:', err.message);
      return null;
    })
    .then(function (dict) {
      I18N = (dict != null && typeof dict === 'object') ? dict : {};
    })
    .then(function () {
      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', applyI18n);
      } else {
        applyI18n();
      }
    });
}

window._i18nReady = loadI18n();

The fallback chain is:

  1. fetch /i18n/<lang>.json → success → I18N = dict
  2. If failure and lang !== 'fr'fetch /i18n/fr.json → success → I18N = dict FR
  3. If everything fails → I18N = {}_t() returns the raw key

At the end of loading, applyI18n() is called — either immediately if the DOM is already ready, or after the DOMContentLoaded event.

The window._i18nReady promise is exposed globally. Business scripts that call _t() at startup (before DOMContentLoaded) must wait for this promise:

window._i18nReady.then(function () {
  // _t() is now available
  _buildClassicLabels();
  Tabs.init();
  loadStatus();
});

That's exactly what student-main.js does in the student chaos page — see Student chaos page.

Dictionary format

Each <lang>.json file is a flat JSON object identical in structure to frontend/src/i18n/fr.json:

{
  "admin.page.title": "PerfShop — Chaos Engineering",
  "admin.title": "Chaos Engineering",
  "admin.subtitle": "perfshop — backend & frontend anomaly injection",
  "admin.btn.reload": "Reload",
  "admin.btn.reset": "Reset all",
  "admin.btn.logout": "Log out",
  "admin.student.label": "Student page",
  "admin.student.active": "Active",
  "admin.student.blocked": "Blocked"
}

Keys follow the dotted hierarchical convention, in English. The prefix designates the section or module of the panel (admin.*, gestion.*, login.*, lic.*, etc.).

Loading in script order

The loading order is critical. In each HTML page of the auxiliary tools, the sequence is:

<!-- 1. Public config injected by the container -->
<script src="/config.js"></script>

<!-- 2. i18n loader — exposes window._i18nReady -->
<script src="/js/i18n.js"></script>

<!-- 3. Business scripts — can call _t() after _i18nReady -->
<script src="/admin/chaos-sections.js"></script>
<script src="/admin/js/gestion.js"></script>

Each business script must either wait on window._i18nReady.then(...), or only consume _t() inside event handlers (clicks, polls) that are always triggered after initial loading.

Special case: the welcome page

The welcome page (see Welcome page) uses a variant of the pattern, dictated by the fact that its JavaScript runs in an Nginx context without dynamic config.

On container startup, entrypoint.sh reads the /usr/share/nginx/html/i18n/<lang>.json file, adds the internal key __lang__ to it to reflect the active language, and injects the content at the beginning of welcome.js as a global declaration:

I18N_JSON=$(cat "${I18N_FILE}")
I18N_JSON=$(printf '%s' "${I18N_JSON}" | sed 's/^{/{  "__lang__": "'"${LANG}"'",/')

TMPFILE=$(mktemp)
printf '// i18n — injected by entrypoint.sh\nwindow.__I18N__ = %s;\n\n' "${I18N_JSON}" > "${TMPFILE}"
cat "${WEBROOT}/welcome.js" >> "${TMPFILE}"
mv "${TMPFILE}" "${WEBROOT}/welcome.js"
chmod 644 "${WEBROOT}/welcome.js"

The final chmod 644 is critical: mktemp creates the file in 600 root-only, which would prevent the Nginx worker from reading it and would cause a 403.

In the browser, welcome.js finds window.__I18N__ already available before any execution and can access it directly without fetch. The translation function is simpler because it has no async loading to handle:

function t(key) {
  return (window.__I18N__ && window.__I18N__[key]) || key;
}

If the requested i18n file does not exist, entrypoint.sh falls back to fr.json with a warning in the container logs.

Volumetry by tool

Tool Approximate keys Main role
chaos-admin ~400 Full instructor panel + student page (7 tabs, sliders, modals, toasts, license)
scripts-ui ~200 Web editor for Robot Framework / pytest scripts — tree, menus, messages
jmeter-ui ~180 JMeter test launcher — configuration, execution, results
monitoring ~150 Real-time HTML dashboard — labels, tooltips, state messages
welcome ~30 Minimalist landing page — title, tagline, table headers, service descriptions

FR/EN symmetry

Like the backend and frontend dictionaries, the rule is absolute: every key in FR must exist in EN and vice versa. A typical shell script:

for tool in chaos-admin/public monitoring/public scripts-ui/public jmeter-ui/public welcome; do
  echo "=== $tool ==="
  jq 'keys | sort' "$tool/i18n/fr.json" > /tmp/fr.keys
  jq 'keys | sort' "$tool/i18n/en.json" > /tmp/en.keys
  diff /tmp/fr.keys /tmp/en.keys && echo "  ✓ symmetric"
done

At the time of writing, all five tools are in perfect FR/EN symmetry.

Adding a new language to a tool

The process is trivial and requires no code modification:

  1. Copy public/i18n/fr.json to public/i18n/<lang>.json
  2. Translate the values (leave the keys as-is)
  3. Redeploy with PERFSHOP_LANG=<lang>

The i18n.js loader will automatically detect the new file via the /i18n/${lang}.json pattern and use it. No registry entry, no import to add.

For the welcome page, the principle is identical — create welcome/i18n/<lang>.json, redeploy with PERFSHOP_LANG=<lang>. The entrypoint.sh script will detect the file at startup and inject it.

Adding a new key

  1. Add the key in FR in public/i18n/fr.json (or welcome/i18n/fr.json)
  2. Add the same key in EN in the corresponding file
  3. Use the key in HTML via data-i18n="my.key" or in JS via _t('my.key')
  4. Redeploy (no build needed — JSON files are served statically)

See also