From ac848e30c147aabc98b8695c4486f6350546b121 Mon Sep 17 00:00:00 2001
From: tchellow <2349362+tchellow@users.noreply.github.com>
Date: Fri, 23 Oct 2026 19:44:33 -0300
Subject: [PATCH 01/16] feat(i18n): runtime UI translation layer + pt-BR pilot
Adds a build-free translation layer and a Brazilian Portuguese
dictionary, piloted on the navigation chrome and the home page.
- public/i18n.js: tr()/trn() with {placeholders} and CLDR plurals,
English text as the key with English fallback, language from the
saved choice (meshcore-lang), else the browser, else English.
Translates static markup through data-i18n* attributes.
- index.html loads i18n.js in
so the dictionary fetch starts
early; app.js waits for I18n.ready (3 s cap) before the first route.
- Customizer > Display: language picker (reloads on change).
- Translated: top nav, bottom nav + More sheet, nav drawer, home page,
timeAgo().
- tests/unit/test-i18n.js: detection, tr/trn, loading/fallback, DOM
application, and dictionary rules (same placeholders, no markup).
Co-Authored-By: Claude Opus 5.5
---
.eslintrc.json | 3 +
public/app.js | 26 +++-
public/bottom-nav.js | 17 ++-
public/customize-v2.js | 30 ++++
public/home.js | 166 +++++++++++----------
public/i18n.js | 209 ++++++++++++++++++++++++++
public/index.html | 53 +++----
public/locales/pt-BR.json | 134 +++++++++++++++++
public/nav-drawer.js | 16 +-
test-all.sh | 1 +
tests/unit/test-i18n.js | 298 ++++++++++++++++++++++++++++++++++++++
11 files changed, 832 insertions(+), 121 deletions(-)
create mode 100644 public/i18n.js
create mode 100644 public/locales/pt-BR.json
create mode 100644 tests/unit/test-i18n.js
diff --git a/.eslintrc.json b/.eslintrc.json
index 3149c9bb2..77aa39592 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -29,6 +29,7 @@
"HashColor": "readonly",
"HopDisplay": "readonly",
"HopResolver": "readonly",
+ "I18n": "readonly",
"IATA_CITIES": "readonly",
"IATA_COORDS_GEO": "readonly",
"L": "readonly",
@@ -283,6 +284,8 @@
"syncBadgeColors": "readonly",
"timeAgo": "readonly",
"toggleFavorite": "readonly",
+ "tr": "readonly",
+ "trn": "readonly",
"transportBadge": "readonly",
"truncate": "readonly",
"viewGeneration": "readonly",
diff --git a/public/app.js b/public/app.js
index 6b177c896..f9dcdbd02 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,6 +1,16 @@
/* === CoreScope — app.js === */
'use strict';
+// --- UI translations ---
+// i18n.js loads before this file in the browser and defines tr()/trn().
+// Sandboxes that load app.js on its own get English with {placeholders} filled.
+if (typeof globalThis.tr !== 'function') {
+ globalThis.tr = function (s, p) {
+ return p ? s.replace(/\{(\w+)\}/g, (m, k) => (Object.prototype.hasOwnProperty.call(p, k) ? String(p[k]) : m)) : s;
+ };
+ globalThis.trn = function (n, one, other, p) { return globalThis.tr(n === 1 ? one : other, Object.assign({ count: n }, p)); };
+}
+
// --- Route/Payload name maps ---
const ROUTE_TYPES = { 0: 'TRANSPORT_FLOOD', 1: 'FLOOD', 2: 'DIRECT', 3: 'TRANSPORT_DIRECT' };
const PAYLOAD_TYPES = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 6: 'Group Data', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 10: 'Multipart', 11: 'Control', 15: 'Raw Custom' };
@@ -341,8 +351,8 @@ function timeAgo(iso) {
else if (abs < 3600) { value = Math.floor(abs / 60); suffix = 'm'; }
else if (abs < 86400) { value = Math.floor(abs / 3600); suffix = 'h'; }
else { value = Math.floor(abs / 86400); suffix = 'd'; }
- if (s < 0) return 'in ' + value + suffix;
- return value + suffix + ' ago';
+ if (s < 0) return tr('in {time}', { time: value + suffix });
+ return tr('{time} ago', { time: value + suffix });
}
function getHashParams() {
@@ -1971,9 +1981,15 @@ window.addEventListener('DOMContentLoaded', () => {
if (window._customizerV2) window._customizerV2.init(window.SITE_CONFIG);
});
- // Navigate immediately — don't gate data-fetching pages on cosmetic theme fetch
- if (!location.hash || location.hash === '#/') location.hash = '#/home';
- else navigate();
+ // Navigate immediately — don't gate data-fetching pages on cosmetic theme fetch.
+ // The UI language does gate the first render (i18n.js resolves at once for
+ // English and gives up after a few seconds), so pages render translated.
+ const startRouting = () => {
+ if (!location.hash || location.hash === '#/') location.hash = '#/home';
+ else navigate();
+ };
+ if (window.I18n && window.I18n.ready) window.I18n.ready.then(startRouting);
+ else startRouting();
});
/**
diff --git a/public/bottom-nav.js b/public/bottom-nav.js
index abcfb3b4f..aa5667f8a 100644
--- a/public/bottom-nav.js
+++ b/public/bottom-nav.js
@@ -114,7 +114,7 @@
var r = accountRoute();
a.setAttribute('href', escapeHtml(r.hash));
var lb = a.querySelector('.bottom-nav-sheet-label');
- if (lb) lb.textContent = r.label;
+ if (lb) lb.textContent = tr(r.label);
});
var SHEET_ID = 'bottomNavMoreSheet';
@@ -137,7 +137,7 @@
nav.className = 'bottom-nav';
nav.setAttribute('data-bottom-nav', '');
nav.setAttribute('role', 'navigation');
- nav.setAttribute('aria-label', 'Bottom navigation');
+ nav.setAttribute('aria-label', tr('Bottom navigation'));
TABS.forEach(function (t) {
var el;
@@ -155,7 +155,9 @@
el.className = 'bottom-nav-tab';
el.setAttribute('data-bottom-nav-tab', t.route);
el.setAttribute('data-route', t.route);
- el.setAttribute('aria-label', t.label);
+ // data-i18n-* re-applies the labels if the dictionary lands after build().
+ el.setAttribute('aria-label', tr(t.label));
+ el.setAttribute('data-i18n-aria-label', t.label);
var ic = document.createElement('span');
ic.className = 'bottom-nav-icon';
@@ -164,7 +166,8 @@
var lb = document.createElement('span');
lb.className = 'bottom-nav-label';
- lb.textContent = t.label;
+ lb.textContent = tr(t.label);
+ lb.setAttribute('data-i18n', t.label);
el.appendChild(ic);
el.appendChild(lb);
@@ -233,7 +236,7 @@
sheet.className = 'bottom-nav-sheet';
sheet.setAttribute('data-bottom-nav-sheet', '');
sheet.setAttribute('role', 'menu');
- sheet.setAttribute('aria-label', 'More navigation');
+ sheet.setAttribute('aria-label', tr('More navigation'));
sheet.hidden = true;
moreRoutes().forEach(function (r) {
@@ -251,7 +254,7 @@
var lb = document.createElement('span');
lb.className = 'bottom-nav-sheet-label';
- lb.textContent = r.label;
+ lb.textContent = tr(r.label);
a.appendChild(ic);
a.appendChild(lb);
@@ -308,7 +311,7 @@
if (!btn) return;
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
btn.querySelector('.bottom-nav-sheet-icon').innerHTML = phIconHTML(isDark ? 'moon' : 'sun');
- btn.querySelector('.bottom-nav-sheet-label').textContent = isDark ? 'Light mode' : 'Dark mode';
+ btn.querySelector('.bottom-nav-sheet-label').textContent = isDark ? tr('Light mode') : tr('Dark mode');
}
function isSheetOpen() {
diff --git a/public/customize-v2.js b/public/customize-v2.js
index 2a01888b6..7c290c825 100644
--- a/public/customize-v2.js
+++ b/public/customize-v2.js
@@ -1565,6 +1565,7 @@
return '
' +
'
Display Settings
' +
'
UI preferences that affect how data is shown across pages.
' +
+ _renderLanguageSelector() +
'
Timestamps
' +
'
' +
'
';
}
+ // ── UI language (i18n.js) ──
+ // A per-browser choice in localStorage["meshcore-lang"], not a server
+ // override: "auto" (browser language) or a code from I18n.LANGUAGES.
+ // Changing it reloads the page (I18n.setPreference). The label keeps the
+ // English word next to the translation so a visitor stuck in a language
+ // they cannot read still finds the picker.
+ function _renderLanguageSelector() {
+ var I18n = window.I18n;
+ if (!I18n || !I18n.LANGUAGES) return '';
+ var pref = I18n.getPreference();
+ var label = tr('Language');
+ if (label !== 'Language') label += ' (Language)';
+ var opts = '' +
+ I18n.LANGUAGES.map(function (l) {
+ return '';
+ }).join('');
+ return '
' + escAttr(label) + '
' +
+ '
' +
+ '' +
+ '
' + escAttr(tr('Changing the language reloads the page.')) + '
';
+ }
+
// ── #1633 Hide 1-byte path hops toggle ──
// Writes localStorage["meshcore-hide-1byte-hops"]. Default OFF: key is
// removed (not "false") so MC_getHide1ByteHops cleanly returns false.
@@ -2359,6 +2382,13 @@
});
});
+ // UI language picker — saves the choice and reloads (i18n.js).
+ container.querySelectorAll('[data-cv2-lang]').forEach(function (sel) {
+ sel.addEventListener('change', function () {
+ if (window.I18n) window.I18n.setPreference(sel.value);
+ });
+ });
+
// #1633 Hide-1-byte-path-hops checkbox — persists + fires
// mc-hide-1byte-hops-changed; consumers re-render or rely on the next
// navigation refresh (analytics tab, packets list).
diff --git a/public/home.js b/public/home.js
index 7de673e70..4fd73e21d 100644
--- a/public/home.js
+++ b/public/home.js
@@ -65,18 +65,18 @@
function showChooser(container) {
container.innerHTML = `
-
Welcome to ${escapeHtml(window.SITE_CONFIG?.branding?.siteName || 'CoreScope')}
-
How familiar are you with MeshCore?
+
${tr('Welcome to {site}', { site: escapeHtml(window.SITE_CONFIG?.branding?.siteName || 'CoreScope') })}
${hasNodes ? 'Your nodes at a glance. Add more by searching below.' : escapeHtml(homeCfg?.heroSubtitle || 'Find your nodes to start monitoring them.')}
${hasNodes ? tr('Your nodes at a glance. Add more by searching below.') : escapeHtml(homeCfg?.heroSubtitle || tr('Find your nodes to start monitoring them.'))}
-
+
- ${hasNodes ? '
Loading your nodes…
' : ''}
+ ${hasNodes ? '
' + tr('Loading your nodes…') + '
' : ''}
${!hasNodes ? `
-
Claim your first node
-
Search for your node above, or paste your public key. Once claimed, you'll see live status, signal quality, and who's hearing you.
+
${tr('Claim your first node')}
+
${tr('Search for your node above, or paste your public key. Once claimed, you\'ll see live status, signal quality, and who\'s hearing you.')}
` : ''}
@@ -133,22 +133,22 @@
${exp ? '' : `
-
Getting on the mesh${homeCfg?.steps ? '' : ' — SF Bay Area'}
+
${homeCfg?.steps ? tr('Getting on the mesh') : tr('Getting on the mesh — SF Bay Area')}
`;
} catch (err) {
const is404 = err && err.message && err.message.includes('404');
const statusIcon = is404 ? '' : '';
const statusMsg = is404
- ? 'Waiting for first advert — this node has been seen in channel messages but hasn\u2019t advertised yet'
- : 'Could not load data';
+ ? tr('Waiting for first advert — this node has been seen in channel messages but hasn\u2019t advertised yet')
+ : tr('Could not load data');
return `
`).join('');
}
- // Fallback: Bay Area defaults when no config at all
+ // Fallback: Bay Area defaults when no config at all.
+ // Markup stays in code and enters the sentences as {placeholders}, so
+ // translations are plain text.
if (!html) {
const items = [
- { q: ' First: Join the Bay Area MeshCore Discord',
- a: '
The community Discord is the best place to get help and find local mesh enthusiasts.
' + tr('Tap the signal icon → {flood} to broadcast your node to the mesh. Companions only advert when you trigger it manually.', { flood: 'Flood' }) + '