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

' + '
' + '' + opts + '' + + '
' + 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') })}

+

${tr('How familiar are you with MeshCore?')}

`; @@ -106,21 +106,21 @@ CORESCOPEMESH ANALYZER -

${hasNodes ? 'My Mesh' : escapeHtml(homeCfg?.heroTitle || siteName)}

-

${hasNodes ? 'Your nodes at a glance. Add more by searching below.' : escapeHtml(homeCfg?.heroSubtitle || 'Find your nodes to start monitoring them.')}

+

${hasNodes ? tr('My Mesh') : escapeHtml(homeCfg?.heroTitle || siteName)}

+

${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')}

${checklist(homeCfg)}
`} `; @@ -191,19 +191,19 @@ const data = await api('/nodes/search?q=' + encodeURIComponent(q), { ttl: CLIENT_TTL.nodeSearch }); const nodes = data.nodes || []; if (!nodes.length) { - suggest.innerHTML = '
No nodes found
'; + suggest.innerHTML = '
' + tr('No nodes found') + '
'; } else { suggest.innerHTML = nodes.slice(0, 10).map((n, idx) => { const claimed = isMyNode(n.public_key); return `
- ${escapeHtml(n.name || 'Unknown')} + ${escapeHtml(n.name || tr('Unknown'))} ${truncate(n.public_key, 16)}
${n.role || '?'} -
`; @@ -222,11 +222,11 @@ if (isMyNode(pk)) { removeMyNode(pk); btn.classList.remove('claimed'); - btn.innerHTML = '+ Claim'; + btn.innerHTML = claimBtnHtml(); } else { addMyNode(pk, nm); btn.classList.add('claimed'); - btn.innerHTML = ' Mine'; + btn.innerHTML = claimedBtnHtml(); } loadMyNodes(); }); @@ -250,6 +250,9 @@ document.addEventListener('click', handleOutsideClick, { signal: searchAbort.signal }); } + function claimBtnHtml() { return '+ ' + tr('Claim'); } + function claimedBtnHtml() { return ' ' + tr('Mine'); } + function handleOutsideClick(e) { const suggest = document.getElementById('homeSuggest'); const input = document.getElementById('homeSearch'); @@ -276,8 +279,8 @@ const h1 = document.querySelector('.home-hero h1'); const heroP = document.querySelector('.home-hero p'); if (myNodes.length) { - if (h1) h1.textContent = 'My Mesh'; - if (heroP) heroP.textContent = 'Your nodes at a glance. Add more by searching below.'; + if (h1) h1.textContent = tr('My Mesh'); + if (heroP) heroP.textContent = tr('Your nodes at a glance. Add more by searching below.'); // Hide onboarding prompt const onboard = document.querySelector('.onboarding-prompt'); if (onboard) onboard.style.display = 'none'; @@ -300,12 +303,12 @@ const status = getHealthStatus(node.role, heard) || 'silent'; const statusCls = status === 'healthy' ? 'status-ok' : status === 'degraded' ? 'status-warn' : 'status-err'; const statusDot = ''; - const statusText = status === 'healthy' ? 'Active' : status === 'degraded' ? 'Degraded' : 'Silent'; + const statusText = status === 'healthy' ? tr('Active') : status === 'degraded' ? tr('Degraded') : tr('Silent'); const name = node.name || mn.name || truncate(mn.pubkey, 12); // SNR quality label const snrVal = stats.avgSnr; - const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : null; + const snrLabel = snrVal != null ? snrQualityLabel(snrVal) : null; const snrColor = snrVal != null ? (snrVal > 10 ? 'var(--status-green)' : snrVal > 0 ? 'var(--accent)' : snrVal > -5 ? 'var(--status-yellow)' : 'var(--status-red)') : '#6b7280'; // Build sparkline from recent packets (packet timestamps → hourly buckets) @@ -316,17 +319,17 @@
${statusDot}
${escapeHtml(name)}
${node.role || '?'}
- +
${statusText}${heard ? ' · ' + timeAgo(heard) : ''}
${stats.packetsToday ?? 0}
-
Packets today
+
${tr('Packets today')}
${obs.length}
-
Observers
+
${tr('Observers')}
${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}
@@ -334,32 +337,32 @@
${stats.avgHops != null ? stats.avgHops.toFixed(1) : '—'}
-
Avg hops
+
${tr('Avg hops')}
- ${obs.length ? `
Heard by: ${obs.map(o => escapeHtml(o.observer_name || o.observer_id)).join(', ')}
` : ''} + ${obs.length ? `
${tr('Heard by:')} ${obs.map(o => escapeHtml(o.observer_name || o.observer_id)).join(', ')}
` : ''} ${sparkHtml ? `
${sparkHtml}
` : ''}
- - - + + +
`; } 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 `
${statusIcon}
${escapeHtml(mn.name || truncate(mn.pubkey, 12))}
- +
${statusMsg}
- +
`; } @@ -418,7 +421,7 @@ const opacity = v > 0 ? 0.4 + (v / max) * 0.6 : 0.1; return `
`; }).join(''); - return `
24h activity
${bars}
`; + return `
${tr('24h activity')}
${bars}
`; } // ==================== STATS ==================== @@ -428,10 +431,10 @@ const el = document.getElementById('homeStats'); if (!el) return; el.innerHTML = ` -
${s.totalTransmissions ?? s.totalPackets ?? '—'}
Transmissions
-
${s.totalNodes ?? '—'}
Nodes
-
${s.totalObservers ?? '—'}
Observers
-
${s.packetsLast24h ?? '—'}
Last 24h
+
${s.totalTransmissions ?? s.totalPackets ?? '—'}
${tr('Transmissions')}
+
${s.totalNodes ?? '—'}
${tr('Nodes')}
+
${s.totalObservers ?? '—'}
${tr('Observers')}
+
${s.packetsLast24h ?? '—'}
${tr('Last 24h')}
`; } catch {} } @@ -441,7 +444,7 @@ const card = document.getElementById('homeHealth'); const journey = document.getElementById('homeJourney'); if (!card) return; - card.innerHTML = '

Loading…

'; + card.innerHTML = '

' + tr('Loading…') + '

'; card.classList.add('visible'); if (journey) journey.classList.remove('visible'); @@ -458,29 +461,29 @@ const heard = stats.lastHeard || node.last_seen; const status = getHealthStatus(node.role, heard) || 'silent'; const color = status === 'healthy' ? 'green' : status === 'degraded' ? 'yellow' : 'red'; - const statusMsg = heard ? `Last heard ${timeAgo(heard)}` : 'No packets in the current window'; + const statusMsg = heard ? tr('Last heard {time}', { time: timeAgo(heard) }) : tr('No packets in the current window'); const snrVal = stats.avgSnr; - const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : ''; + const snrLabel = snrVal != null ? snrQualityLabel(snrVal) : ''; card.innerHTML = `
${status === 'healthy' ? '' : status === 'degraded' ? '' : ''} ${escapeHtml(node.name || truncate(pubkey, 16))} — ${statusMsg} - ${!claimed ? `` : ''} + ${!claimed ? `` : ''}
-
${stats.packetsToday ?? '—'}
Packets Today
-
${observers.length}
Observers
-
${heard ? timeAgo(heard) : '—'}
Last seen
-
${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}
Avg SNR${snrLabel ? ' · ' + snrLabel : ''}
-
${stats.avgHops != null ? stats.avgHops.toFixed(1) : '—'}
Avg Hops
+
${stats.packetsToday ?? '—'}
${tr('Packets Today')}
+
${observers.length}
${tr('Observers')}
+
${heard ? timeAgo(heard) : '—'}
${tr('Last seen')}
+
${snrVal != null ? Number(snrVal).toFixed(1) + ' dB' : '—'}
${tr('Avg SNR')}${snrLabel ? ' · ' + snrLabel : ''}
+
${stats.avgHops != null ? stats.avgHops.toFixed(1) : '—'}
${tr('Avg Hops')}
- ${observers.length ? `
Heard by: ${observers.map(o => escapeHtml(o.observer_name || o.observer_id)).join(', ')}
` : ''} + ${observers.length ? `
${tr('Heard by:')} ${observers.map(o => escapeHtml(o.observer_name || o.observer_id)).join(', ')}
` : ''} ${hasLocation ? '
' : ''}
-

Recent Activity

+

${tr('Recent Activity')}

${packets.length ? packets.slice(0, 10).map(p => { const decoded = p.decoded_json ? JSON.parse(p.decoded_json) : {}; const obsId = p.observer_name || p.observer_id || '?'; @@ -491,11 +494,11 @@ time: p.timestamp || p.created_at }).replace(/'/g, ''')}'> ${escapeHtml(payloadTypeName(p.payload_type))} - via ${escapeHtml(obsId)} + ${tr('via {observer}', { observer: escapeHtml(obsId) })} ${timeAgo(p.timestamp || p.created_at)} ${p.snr != null ? Number(p.snr).toFixed(1) + ' dB' : ''}
`; - }).join('') : '

No recent packets found for this node.

'} + }).join('') : '

' + tr('No recent packets found for this node.') + '

'}
`; @@ -507,7 +510,7 @@ e.target.remove(); loadMyNodes(); const h1 = document.querySelector('.home-hero h1'); - if (h1) h1.textContent = 'My Mesh'; + if (h1) h1.textContent = tr('My Mesh'); }); // Mini map @@ -534,7 +537,7 @@ }); }); } catch (e) { - card.innerHTML = '

Failed to load node health.

'; + card.innerHTML = '

' + tr('Failed to load node health.') + '

'; } } @@ -542,15 +545,15 @@ const el = document.getElementById('homeJourney'); if (!el) return; const nodes = []; - nodes.push({ name: data.from, meta: 'Sender' }); + nodes.push({ name: data.from, meta: tr('Sender') }); if (data.observers && data.observers.length) { - data.observers.forEach(o => nodes.push({ name: o, meta: 'Observer' })); + data.observers.forEach(o => nodes.push({ name: o, meta: tr('Observer') })); } const flow = nodes.map((n, i) => { const nodeHtml = `
${escapeHtml(n.name)}
${n.meta}
`; return i < nodes.length - 1 ? nodeHtml + '
' : nodeHtml; }).join(''); - el.innerHTML = `
Packet Journey — ${escapeHtml(payloadTypeName(data.type))}
${flow}
`; + el.innerHTML = `
${tr('Packet Journey — {type}', { type: escapeHtml(payloadTypeName(data.type)) })}
${flow}
`; el.classList.add('visible'); } @@ -558,6 +561,9 @@ function escapeHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } function escapeAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); } function timeSinceMs(d) { return Date.now() - d.getTime(); } + function snrQualityLabel(snr) { + return snr > 10 ? tr('Excellent') : snr > 0 ? tr('Good') : snr > -5 ? tr('Marginal') : tr('Poor'); + } function checklist(homeCfg) { var html = ''; @@ -567,24 +573,26 @@ } // Render FAQ/checklist (additional Q&A) if (homeCfg?.checklist?.length) { - if (html) html += '

FAQ

'; + if (html) html += '

' + tr('FAQ') + '

'; html += homeCfg.checklist.map(i => `
${window.miniMarkdown ? miniMarkdown(i.answer) : escapeHtml(i.answer)}
`).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.

Join the Discord ↗

Start with #intro-to-meshcore — it has detailed setup instructions.

' }, - { q: ' Step 1: Connect via Bluetooth', - a: '

Flash BLE companion firmware from MeshCore Flasher.

' }, - { q: ' Step 2: Set the right frequency preset', - a: '

US Recommended:

910.525 MHz · BW 62.5 kHz · SF 7 · CR 5

Select "US Recommended" in the app or flasher.

' }, - { q: ' Step 3: Advertise yourself', - a: '

Tap the signal icon → Flood to broadcast your node to the mesh. Companions only advert when you trigger it manually.

' }, - { q: ' Step 4: Check "Heard N repeats"', - a: '' }, - { q: ' Repeaters near you?', - a: '

Check the network map to see active repeaters.

' } + { q: ' ' + tr('First: Join the Bay Area MeshCore Discord'), + a: '

' + tr('The community Discord is the best place to get help and find local mesh enthusiasts.') + '

' + tr('Join the Discord') + ' ↗

' + tr('Start with {channel} — it has detailed setup instructions.', { channel: '#intro-to-meshcore' }) + '

' }, + { q: ' ' + tr('Step 1: Connect via Bluetooth'), + a: '

' + tr('Flash {firmware} firmware from {flasher}.', { firmware: 'BLE companion', flasher: 'MeshCore Flasher' }) + '

' }, + { q: ' ' + tr('Step 2: Set the right frequency preset'), + a: '

' + tr('US Recommended:') + '

910.525 MHz · BW 62.5 kHz · SF 7 · CR 5

' + tr('Select {preset} in the app or flasher.', { preset: '"US Recommended"' }) + '

' }, + { q: ' ' + tr('Step 3: Advertise yourself'), + a: '

' + tr('Tap the signal icon → {flood} to broadcast your node to the mesh. Companions only advert when you trigger it manually.', { flood: 'Flood' }) + '

' }, + { q: ' ' + tr('Step 4: Check "Heard N repeats"'), + a: '' }, + { q: ' ' + tr('Repeaters near you?'), + a: '

' + tr('{map} to see active repeaters.', { map: '' + tr('Check the network map') + '' }) + '

' } ]; html = items.map(i => `
${i.a}
`).join(''); } diff --git a/public/i18n.js b/public/i18n.js new file mode 100644 index 000000000..424f51bba --- /dev/null +++ b/public/i18n.js @@ -0,0 +1,209 @@ +/* === CoreScope — i18n.js (UI translations) === + * + * Runtime-only translation layer: no build step, no library. + * + * tr('Packets') -> "Pacotes" + * tr('Last heard {time}', { time: t }) -> "Visto por último {time}" with {time} filled + * trn(n, '{count} packet', '{count} packets') + * + * The English text is the key. A string missing from the active dictionary + * falls back to English, so an untranslated or changed string shows up in + * English instead of breaking or showing a stale translation. + * + * Dictionaries live in public/locales/.json: a flat object of + * "English text": "translation". A plural entry is keyed by the English + * plural form and holds the CLDR categories of the target language: + * "{count} packets": { "one": "{count} pacote", "other": "{count} pacotes" } + * + * Placeholder values are inserted as given: escape node-controlled values + * (escapeHtml) before passing them when the result goes into innerHTML. + * + * Static markup is translated through attributes, applied once the + * dictionary is loaded (and by applyDom(root) for markup built later): + * data-i18n="Text" -> textContent + * data-i18n-title="Text" -> title + * data-i18n-aria-label="Text" -> aria-label + * data-i18n-placeholder="Text" -> placeholder + * + * The language is the saved choice (localStorage meshcore-lang), else the + * first browser language with a dictionary, else English. app.js waits for + * I18n.ready before the first route renders; changing the language reloads + * the page. + * + * To add a language: add public/locales/.json and one LANGUAGES entry. + */ +(function () { + 'use strict'; + + var STORAGE_KEY = 'meshcore-lang'; + var READY_TIMEOUT_MS = 3000; + // `name` is shown in the language picker, in the language itself. + var LANGUAGES = [ + { code: 'en', name: 'English' }, + { code: 'pt-BR', name: 'Português (Brasil)' } + ]; + var ATTRS = ['title', 'aria-label', 'placeholder']; + + function canonical(code) { + if (typeof code !== 'string') return null; + var lc = code.toLowerCase(); + for (var i = 0; i < LANGUAGES.length; i++) { + if (LANGUAGES[i].code.toLowerCase() === lc) return LANGUAGES[i].code; + } + return null; + } + + // detect resolves a saved preference ('auto' or a code) and the browser's + // language list to a supported code. A browser language matches exactly + // first, then by its primary subtag (pt-PT, pt -> pt-BR). + function detect(pref, browserLangs) { + if (pref && pref !== 'auto') { + var saved = canonical(pref); + if (saved) return saved; + } + var list = (browserLangs || []).filter(function (l) { return typeof l === 'string' && l; }); + for (var i = 0; i < list.length; i++) { + var exact = canonical(list[i]); + if (exact) return exact; + var primary = list[i].split('-')[0].toLowerCase(); + for (var j = 0; j < LANGUAGES.length; j++) { + if (LANGUAGES[j].code.split('-')[0].toLowerCase() === primary) return LANGUAGES[j].code; + } + } + return 'en'; + } + + function format(text, params) { + if (!params) return text; + return text.replace(/\{(\w+)\}/g, function (m, k) { + return Object.prototype.hasOwnProperty.call(params, k) ? String(params[k]) : m; + }); + } + + var dict = {}; + var pluralRules = null; + + function tr(text, params) { + var v = dict[text]; + return format(typeof v === 'string' && v ? v : text, params); + } + + function trn(count, singular, plural, params) { + var p = Object.assign({ count: count }, params); + var v = dict[plural]; + if (v && typeof v === 'object') { + var cat = pluralRules ? pluralRules.select(count) : (count === 1 ? 'one' : 'other'); + var s = v[cat] || v.other; + if (typeof s === 'string' && s) return format(s, p); + } + return format(count === 1 ? singular : plural, p); + } + + function applyDom(root) { + if (!root || typeof root.querySelectorAll !== 'function') return; + root.querySelectorAll('[data-i18n]').forEach(function (el) { + el.textContent = tr(el.getAttribute('data-i18n')); + }); + ATTRS.forEach(function (attr) { + root.querySelectorAll('[data-i18n-' + attr + ']').forEach(function (el) { + el.setAttribute(attr, tr(el.getAttribute('data-i18n-' + attr))); + }); + }); + } + + function readPref() { + try { return localStorage.getItem(STORAGE_KEY) || 'auto'; } catch (_) { return 'auto'; } + } + + // setPreference saves 'auto' or a language code and reloads, so every + // page and widget renders in the new language without per-module wiring. + function setPreference(code) { + var c = code === 'auto' ? 'auto' : canonical(code); + if (!c) return; + try { + if (c === 'auto') localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, c); + } catch (_) {} + location.reload(); + } + + var browserLangs = (typeof navigator !== 'undefined') + ? (navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language]) + : []; + var lang = detect(readPref(), browserLangs); + + var resolveReady; + var ready = new Promise(function (resolve) { resolveReady = resolve; }); + var settled = false; + + // settle installs the dictionary (or none: English) once, translates the + // static markup when the body exists, and releases app.js's first render. + function settle(code, data) { + if (settled) return; + settled = true; + if (data && typeof data === 'object') dict = data; + else code = 'en'; + lang = code; + I18n.lang = code; + try { pluralRules = new Intl.PluralRules(code); } catch (_) { pluralRules = null; } + document.documentElement.setAttribute('lang', code); + function finish() { + if (code !== 'en') { + applyDom(document); + // Translated nav labels change width: let the nav overflow logic re-measure. + window.dispatchEvent(new Event('resize')); + } + resolveReady(code); + window.dispatchEvent(new CustomEvent('i18n-ready', { detail: { lang: code } })); + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', finish, { once: true }); + else finish(); + } + + // The dictionary URL is resolved next to this script and carries its + // cache buster, so a deploy invalidates both together. + function dictUrl(code) { + var src = (document.currentScript && document.currentScript.src) || location.href; + var u = new URL('locales/' + code + '.json', src); + var v = new URL(src).searchParams.get('v'); + if (v) u.searchParams.set('v', v); + return u.toString(); + } + + var I18n = { + LANGUAGES: LANGUAGES, + STORAGE_KEY: STORAGE_KEY, + lang: lang, + ready: ready, + tr: tr, + trn: trn, + applyDom: applyDom, + detect: detect, + getPreference: function () { var p = readPref(); return p === 'auto' ? 'auto' : (canonical(p) || 'auto'); }, + setPreference: setPreference + }; + window.I18n = I18n; + window.tr = tr; + window.trn = trn; + + if (lang === 'en') { + settle('en', null); + } else { + var url = dictUrl(lang); + var timer = setTimeout(function () { + console.warn('[i18n] ' + lang + ' dictionary not loaded in time; using English'); + settle('en', null); + }, READY_TIMEOUT_MS); + fetch(url).then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + }).then(function (data) { + clearTimeout(timer); + settle(lang, data); + }).catch(function (e) { + clearTimeout(timer); + console.warn('[i18n] could not load ' + url + ': ' + e.message + '; using English'); + settle('en', null); + }); + } +})(); diff --git a/public/index.html b/public/index.html index 9aa3003d7..7ae927ef1 100644 --- a/public/index.html +++ b/public/index.html @@ -48,6 +48,9 @@ } })(); + + @@ -119,10 +122,10 @@ body[data-cb-sim="trit"] { filter: url(#cb-trit); } body[data-cb-sim="achromat"] { filter: url(#cb-achromat); } - -