From ee721335746172e29ab94791773fd44203fad130 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 12 Oct 2026 18:41:34 +0400 Subject: [PATCH 01/19] feat(i18n): find UI text that is not marked for translation npm run i18n now also lists, and fails on, markup text and title / aria-label / placeholder without data-i18n*, and string literals that reach the page (textContent, innerHTML, setAttribute, toasts, label:/title: properties and similar) without t(), tn() or msg(). Opt-outs say why where the text lives: translate="no" and data-i18n-runtime in markup, // i18n-ignore in code. src/tools/ is skipped: its text is for AI clients. 534 places are reported today; npm test does not run this part yet. Co-Authored-By: Claude Opus 5.5 --- docs/I18N.md | 33 +++-- scripts/i18n.mjs | 293 ++++++++++++++++++++++++++++++++++++-- src/i18n/catalog.test.mjs | 51 +++++++ 3 files changed, 358 insertions(+), 19 deletions(-) diff --git a/docs/I18N.md b/docs/I18N.md index 21b2697b0..d59ef8bcf 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -49,7 +49,7 @@ label.textContent = t(FEED_STATE_LABELS[state]); ## After you add or change text ```sh -npm run i18n # shows what each language now lacks or no longer uses +npm run i18n # what each language lacks or no longer uses, and unmarked UI text npm run i18n -- --missing ru # the missing ru entries as JSON npm run i18n -- --prune # drop entries whose English no longer exists ``` @@ -58,12 +58,29 @@ Changing the English changes the key, so the old translation goes unused and the To see a language, open the app with `?lang=ru`, or use the language switch in the Visual Presets dock. +## Text the check reports as unmarked + +`npm run i18n` also lists UI text that is not marked at all, and fails while there is any: + +- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. +- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:` properties. + +Fix it by marking the text. When the text must stay as it is, say so where it lives: + +| Case | How | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML | +| Markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element | +| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: ` on the line or the line above | + +Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page. + ## Where things live -| File | What it holds | -| ------------------------------ | ----------------------------------------------------------------------------- | -| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language | -| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot | -| `src/i18n/locales/.json` | one catalog per language, keyed by English text | -| `scripts/i18n.mjs` | `npm run i18n`: extracts messages and checks the catalogs | -| `src/i18n/catalog.test.mjs` | runs that check under `npm test` | +| File | What it holds | +| ------------------------------ | ------------------------------------------------------------------------------ | +| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language | +| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot | +| `src/i18n/locales/.json` | one catalog per language, keyed by English text | +| `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text | +| `src/i18n/catalog.test.mjs` | runs that check under `npm test` | diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index b1c20bfef..773419c56 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -1,11 +1,12 @@ // Keeps the locale catalogs in step with the code. // -// npm run i18n check every catalog (also run by npm test) +// npm run i18n check every catalog and find unmarked UI text // npm run i18n -- --prune also drop entries the code no longer uses // npm run i18n -- --missing ru print what ru lacks, as JSON to translate // // Messages are the English literals passed to t(), tn() and msg() in src/, // plus the markup opted in with data-i18n* in src/ui/templates and index.html. +// It also lists UI text that is not marked at all (see findUnmarked). import { readFileSync, writeFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import path from 'node:path'; @@ -93,16 +94,7 @@ export function extractMessages() { messages.set(text, { plural: Boolean(plural), one: plural?.one, where }); }; - const sources = trackedFiles( - 'src/*.js', - 'src/*.mjs', - 'src/**/*.js', - 'src/**/*.mjs', - ).filter( - (file) => - !file.startsWith('src/i18n/') && - !/\.test\.mjs$|\/testSupport\//.test(file), - ); + const sources = sourceFiles(); for (const file of sources) { const source = readFileSync(path.join(ROOT, file), 'utf8'); for (const match of source.matchAll(CALL)) { @@ -140,6 +132,276 @@ export function extractMessages() { return messages; } +const hasWords = (text) => /[A-Za-z]{2,}/.test(text); +const lineAt = (source, index) => source.slice(0, index).split('\n').length; + +const VOID_TAGS = new Set( + 'area base br col embed hr img input link meta source track wbr'.split(' '), +); +const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']); + +/** + * User-visible markup text without data-i18n*. Skipped, with everything + * inside: translate="no" (text that stays as is, as in HTML), + * data-i18n-runtime (code sets this text through t()), and icons + * (aria-hidden, Material Symbols). + */ +export function unmarkedMarkup(file, html) { + const found = []; + const source = html.replace(/|]*>/g, (c) => + c.replace(/[^\n]/g, ' '), + ); + const TOKEN = + /<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g; + const stack = []; + const skipping = () => stack.at(-1)?.skip === true; + for (const match of source.matchAll(TOKEN)) { + const [, closing, rawTag, attrs = '', selfClosing, text] = match; + if (text !== undefined) { + const top = stack.at(-1); + const value = normalize(decodeHtml(text)); + if (!skipping() && hasWords(value) && !top?.marked) + found.push({ + where: `${file}:${lineAt(source, match.index)}`, + text: value, + }); + continue; + } + const tag = rawTag.toLowerCase(); + if (closing) { + const at = stack.map((entry) => entry.tag).lastIndexOf(tag); + if (at >= 0) stack.length = at; + continue; + } + const skip = + skipping() || + SKIP_TAGS.has(tag) || + /\stranslate="no"/.test(attrs) || + /\sdata-i18n-runtime\b/.test(attrs) || + /\saria-hidden="true"/.test(attrs) || + /class="[^"]*material-symbols/.test(attrs); + if (!skip) { + for (const [, name, value] of attrs.matchAll( + /\s(title|aria-label|placeholder)="([^"]*)"/g, + )) { + if ( + hasWords(value) && + !new RegExp(String.raw`\sdata-i18n-${name}\b`).test(attrs) + ) + found.push({ + where: `${file}:${lineAt(source, match.index)}`, + text: `${name}="${decodeHtml(value)}"`, + }); + } + } + if (!VOID_TAGS.has(tag) && !selfClosing) + stack.push({ tag, skip, marked: /\sdata-i18n(\s|=|$)/.test(attrs) }); + } + return found; +} + +// Where a string becomes UI text. Each pattern ends where the value starts. +const JS_SINKS = [ + /\.(?:textContent|innerText|innerHTML|outerHTML|title|placeholder|ariaLabel)\s*=(?!=)\s*/g, + /\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g, + /\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g, + /\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g, + /\b(?:showToast|toast|setHint|announce)\(\s*/g, + /(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText)\s*:\s*/gm, +]; +const MARKERS = /\b(?:t|tn|msg)\(/y; + +/** The expression starting at `start`, up to its end at bracket depth 0. */ +function expressionAt(source, start) { + let depth = 0; + let i = start; + for (; i < source.length; i++) { + const c = source[i]; + if (c === "'" || c === '"' || c === '`') { + i = skipString(source, i); + continue; + } + if ('([{'.includes(c)) depth++; + else if (')]}'.includes(c)) { + if (depth === 0) break; + depth--; + } else if (depth === 0 && (c === ';' || c === ',')) break; + else if (depth === 0 && c === '\n') { + const next = source.slice(i + 1).match(/^\s*(\S\S?)/)?.[1] ?? ''; + const before = source.slice(start, i).trimEnd().at(-1) ?? ''; + if (!/^(\?|:|\+|\.|&&|\|\|)/.test(next) && !/[?:+=&|(,]/.test(before)) + break; + } + } + return source.slice(start, i); +} + +function skipString(source, i) { + const quote = source[i]; + for (let j = i + 1; j < source.length; j++) { + if (source[j] === '\\') j++; + else if (quote === '`' && source[j] === '$' && source[j + 1] === '{') { + let depth = 1; + for (j += 2; j < source.length && depth; j++) { + if (source[j] === '{') depth++; + else if (source[j] === '}') depth--; + else if ('\'"`'.includes(source[j])) j = skipString(source, j); + } + j--; + } else if (source[j] === quote) return j; + else if (quote !== '`' && source[j] === '\n') return j; + } + return source.length; +} + +/** String literals that sit in value position: not inside any call or index, + * not compared, and not already passed to t/tn/msg. */ +function valueLiterals(expression) { + const literals = []; + let callDepth = 0; + const stack = []; + for (let i = 0; i < expression.length; i++) { + const c = expression[i]; + if (c === '(' || c === '[') { + const isCall = c === '[' || /[\w$\]\)]\s*$/.test(expression.slice(0, i)); + stack.push(isCall); + if (isCall) callDepth++; + continue; + } + if (c === ')' || c === ']') { + if (stack.pop()) callDepth--; + continue; + } + MARKERS.lastIndex = i; + if (MARKERS.test(expression)) { + stack.push(true); + callDepth++; + i = MARKERS.lastIndex - 1; + continue; + } + if (c !== "'" && c !== '"' && c !== '`') continue; + const end = skipString(expression, i); + const literal = expression.slice(i, end + 1); + const before = expression.slice(0, i).trimEnd(); + const after = expression.slice(end + 1).trimStart(); + const compared = + /(===|!==|==|!=|\bcase|\bin)$/.test(before) || + /^(===|!==|==|!=|in\b)/.test(after); + if (callDepth === 0 && !compared) literals.push(literal); + i = end; + } + return literals; +} + +/** The text a literal shows: static template parts, HTML tags removed. */ +function visibleText(literal, html) { + let body = literal.slice(1, -1).replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, ' '); + if (html) { + const attrs = [ + ...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g), + ].map((m) => m[1]); + body = [body.replace(/<[^>]*>/g, ' '), ...attrs].join(' '); + } + const text = normalize(body); + if (!hasWords(text)) return null; + if (/[{};]/.test(text) && /:\s*[^\s]/.test(text)) return null; // CSS + if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts + if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null; + return text; +} + +/** Blank out comments, keeping strings and line numbers intact. */ +function withoutComments(source) { + let out = ''; + for (let i = 0; i < source.length; i++) { + const c = source[i]; + if (c === "'" || c === '"' || c === '`') { + const end = skipString(source, i); + out += source.slice(i, end + 1); + i = end; + } else if (c === '/' && source[i + 1] === '/') { + const end = source.indexOf('\n', i); + const stop = end < 0 ? source.length : end; + out += ' '.repeat(stop - i); + i = stop - 1; + } else if (c === '/' && source[i + 1] === '*') { + const end = source.indexOf('*/', i + 2); + const stop = end < 0 ? source.length : end + 2; + out += source.slice(i, stop).replace(/[^\n]/g, ' '); + i = stop - 1; + } else out += c; + } + return out; +} + +export function unmarkedScript(file, original) { + const found = []; + const lines = original.split('\n'); + const source = withoutComments(original); + const ignored = (line) => + /i18n-ignore/.test(lines[line - 1] ?? '') || + /i18n-ignore/.test(lines[line - 2] ?? ''); + for (const sink of JS_SINKS) { + sink.lastIndex = 0; + for (const match of source.matchAll(sink)) { + const start = match.index + match[0].length; + const line = lineAt(source, start); + if (ignored(line)) continue; + const html = /HTML/.test(match[0]); + for (const literal of valueLiterals(expressionAt(source, start))) { + const text = visibleText(literal, html); + if (text) found.push({ where: `${file}:${line}`, text }); + } + } + } + return found; +} + +/** + * UI text that is not marked for translation: markup without data-i18n* and + * string literals that reach the page without t(), tn() or msg(). + * Opt out with translate="no" in markup or an `// i18n-ignore` comment on + * (or just above) the line in code. + * @returns {{where: string, text: string}[]} + */ +export function findUnmarked() { + const found = []; + for (const file of trackedFiles('index.html', 'src/ui/templates/*.html')) + found.push( + ...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')), + ); + for (const file of sourceFiles().filter( + (name) => !NOT_UI.some((pattern) => pattern.test(name)), + )) + found.push( + ...unmarkedScript(file, readFileSync(path.join(ROOT, file), 'utf8')), + ); + const seen = new Set(); + return found.filter(({ where, text }) => { + const key = `${where}\0${text}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +// Text that never reaches the page as UI: tool results and descriptions for +// AI clients (MCP and the voice model). +const NOT_UI = [/^src\/tools\//]; + +function sourceFiles() { + return trackedFiles( + 'src/*.js', + 'src/*.mjs', + 'src/**/*.js', + 'src/**/*.mjs', + ).filter( + (file) => + !file.startsWith('src/i18n/') && + !/\.test\.mjs$|\/testSupport\//.test(file), + ); +} + const placeholders = (text) => [...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)] .map((m) => m[1]) @@ -249,6 +511,15 @@ function main(args) { console.log(`${locale}: ${messages.size} messages, complete`); } } + const unmarked = findUnmarked(); + if (unmarked.length) { + failed = true; + console.log( + `${unmarked.length} UI strings not marked for translation (see docs/I18N.md):`, + ); + for (const { where, text } of unmarked) + console.log(` ${where} ${JSON.stringify(text)}`); + } return failed ? 1 : 0; } diff --git a/src/i18n/catalog.test.mjs b/src/i18n/catalog.test.mjs index 3d0272b24..4ac3bd0a1 100644 --- a/src/i18n/catalog.test.mjs +++ b/src/i18n/catalog.test.mjs @@ -6,6 +6,8 @@ import { extractMessages, readCatalog, shippedLocales, + unmarkedMarkup, + unmarkedScript, } from '../../scripts/i18n.mjs'; import { LOCALES } from './locale.js'; @@ -55,3 +57,52 @@ test('checkCatalog reports missing, stale, placeholder and plural problems', () assert.deepEqual(result.stale, ['Removed']); assert.equal(result.errors.length, 2); }); + +test('unmarkedMarkup finds text and attributes without data-i18n*', () => { + const texts = (html) => unmarkedMarkup('f.html', html).map((f) => f.text); + assert.deepEqual(texts('Close'), ['Close']); + assert.deepEqual(texts('Close'), []); + assert.deepEqual(texts(''), [ + 'title="Pin it"', + ]); + assert.deepEqual( + texts(''), + [], + ); + assert.deepEqual( + texts(''), + [], + ); + assert.deepEqual(texts('OFF'), []); + assert.deepEqual( + texts('public'), + [], + ); + assert.deepEqual(texts('— 42 —'), []); +}); + +test('unmarkedScript finds literals that reach the page without t/tn/msg', () => { + const texts = (js) => unmarkedScript('f.js', js).map((f) => f.text); + assert.deepEqual(texts("el.textContent = 'Saved';"), ['Saved']); + assert.deepEqual(texts("el.textContent = t('Saved');"), []); + assert.deepEqual(texts("el.textContent = on ? 'On air' : 'Off air';"), [ + 'On air', + 'Off air', + ]); + assert.deepEqual(texts("el.setAttribute('aria-label', `Open ${name}`);"), [ + 'Open', + ]); + assert.deepEqual( + texts("const row = { label: msg('Wind'), id: 'wind' };"), + [], + ); + assert.deepEqual(texts("const row = { label: 'Wind' };"), ['Wind']); + assert.deepEqual(texts("el.textContent = labelFor('flights');"), []); + assert.deepEqual(texts("el.className = 'is-active';"), []); + assert.deepEqual(texts("el.title = mode === 'Auto mode' ? a : b;"), []); + assert.deepEqual(texts("// el.textContent = 'Old copy';"), []); + assert.deepEqual( + texts("// i18n-ignore: product name\nel.textContent = 'OpenSky';"), + [], + ); +}); From 3129e531e681a0c63414b96d1b03fcdb71164ca6 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 12 Oct 2026 19:24:42 +0400 Subject: [PATCH 02/19] feat(i18n): translate the whole interface Mark every UI string the unmarked-text check reported (534) plus text it cannot see (helpers, joined labels, runtime-built panels), and translate the result to Spanish and Russian: 1,679 messages each. - Kept on purpose, with the reason where the text lives: provider and product names, place and camera names, codes and units, dB values, the HUD terminal readouts, demo shot titles that saved projects look up by title, and text for AI tools and the voice model. - Modules shared with the server or AI tools stay free of the i18n import (local msg); the wind reading and voice session stay English and are translated where the page shows them. - npm test now runs the unmarked-text check. - Fixes on the way: a duplicated radio privacy paragraph, the contact panel toggle saying Expand while open, the CSS filter mistaking & for CSS. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- docs/CURRENT-STATE.md | 7 +- docs/I18N.md | 4 +- docs/translations/ru.md | 7 +- scripts/i18n.mjs | 6 +- scripts/package-boundaries.json | 30 +- src/annotations/annotationEngine.js | 9 + src/annotations/drawTool.js | 17 +- src/annotations/screenAnnotationRenderer.js | 16 +- src/app/globePanelRuntime.js | 3 + src/cockpitMarkup.test.mjs | 830 +++++++++++++---- src/data/bhoteKoshiEmbeddedMedia.js | 81 +- src/data/bhoteKoshiEvent.js | 182 ++-- src/data/bhoteKoshiLocator.js | 61 +- src/data/layerState.js | 9 +- src/data/satelliteClass.js | 20 +- src/firstRunExperience.test.mjs | 627 ++++++++++--- src/hud.js | 10 +- src/i18n/catalog.test.mjs | 8 + src/i18n/locales/es.json | 824 ++++++++++++++++- src/i18n/locales/ru.json | 850 +++++++++++++++++- src/keySetup.js | 43 +- src/keySetupCore.mjs | 20 +- src/layers/alpr/index.js | 51 +- src/layers/alpr/presentation.js | 3 +- src/layers/alpr/source.js | 7 +- src/layers/cctv/policy.js | 36 +- src/layers/cyclones/index.js | 110 ++- src/layers/directions/index.js | 120 ++- src/layers/firms/queries.js | 8 +- src/layers/firms/selection.js | 7 +- src/layers/perimeters/index.js | 12 +- src/layers/recentImagery/index.js | 24 +- src/layers/recentImagery/model.js | 16 +- src/layers/satellites/labels.js | 2 +- src/layers/submarineCables/bundledSource.js | 2 +- src/layers/weather/index.js | 157 +++- src/layers/wind/fields.js | 6 +- src/layers/wind/index.js | 85 +- src/layers/wind/inspection.js | 49 +- src/layers/wind/presentation.js | 32 +- src/loadingFeedback.js | 4 + src/locationReadouts.test.mjs | 2 +- src/main.js | 5 +- src/mapStackChips.test.mjs | 2 +- src/maps/catalog.js | 10 +- src/overlays/worldOverlay.js | 7 +- .../worldOverlayAllocation.worker.mjs | 9 + src/scenes/dataPacks/presentation.js | 3 +- src/scenes/director.js | 76 +- src/scenes/interactions.js | 23 +- src/scenes/nepalEvidencePack.js | 12 + src/scenes/project.js | 4 + src/scenes/recipes.js | 43 +- src/scenes/sharing.js | 106 ++- src/sdr/controller.js | 61 +- src/sources/live/standalone.js | 3 + src/styles/surveillance.js | 2 +- src/ui/cameraOrientationControls.js | 9 +- src/ui/cctvCalibration.js | 22 +- src/ui/cockpitBriefing.js | 2 +- src/ui/cockpitContext.js | 20 +- src/ui/cockpitInstruments.js | 18 +- src/ui/cockpitLayout.js | 11 +- src/ui/cyberTheme.test.mjs | 2 +- src/ui/frameRateMonitor.js | 6 +- src/ui/layerPanel.js | 27 +- src/ui/layerPanel.test.mjs | 4 +- src/ui/localSdrPresentation.js | 54 +- src/ui/panelPositionControls.js | 4 +- src/ui/radioBindings.js | 7 +- src/ui/railCardBlocks.js | 5 +- src/ui/railTimeline.js | 19 +- src/ui/recentImagery.js | 254 ++++-- src/ui/scenePresentation.js | 42 +- src/ui/sceneSharing.js | 6 +- src/ui/shareRestoration.js | 6 +- src/ui/shellFeedback.js | 3 + src/ui/streetLevelControls.js | 2 +- src/ui/templates/cockpit.html | 44 +- src/ui/templates/command-dock.html | 10 +- src/ui/templates/context.html | 131 ++- src/ui/templates/display-controls.html | 30 +- src/ui/templates/hud-loading.html | 2 +- src/ui/templates/layer-panels.html | 18 +- src/ui/templates/scene-chrome.html | 8 +- src/ui/templates/welcome.html | 4 +- src/ui/visualPresets.js | 20 +- src/ui/visualSettings.js | 14 +- src/ui/weatherPanel.js | 51 +- src/voice/control.js | 25 +- src/voice/gevActions.js | 6 + src/voice/realtimeCost.js | 21 +- src/voice/realtimeInput.js | 1 + src/voice/sessionCommands.js | 30 +- src/voice/speech.js | 246 ++++- src/voice/voiceCard.test.mjs | 2 +- src/voice/voiceCardPresentation.js | 7 +- src/voice/voiceCost.js | 4 +- 99 files changed, 4670 insertions(+), 1294 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c03cc5c..cd22dcefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ switch in the Visual Presets dock, then the browser language; English stays the default and the fallback. `npm run i18n`, also run by `npm test`, fails on missing, unused or broken translations. Built on the i18n foundation by - @meirm (#601), with the Russian catalog started from #312. + @meirm (#601), with the Russian catalog started from #312. It also fails on UI text that is + not marked at all, so the whole interface, including surfaces added after + the original extraction, is translated (1,679 messages). - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0eadf2ac5..26e39a5dd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3030,8 +3030,11 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives - **Not translated by design:** layer ids, share-link and hash parameters, API fields, voice tool schemas, provider names, feed data (callsigns, vessel and station names) and attribution. -- **Gaps.** Strings added after #601's extraction (for example the Street Level - panel) are still raw English; there is no lint for unmarked UI text yet. +- **Unmarked text.** The same check lists UI text that is not marked at all + (markup without `data-i18n*`, literals reaching the page without `t`/`tn`/`msg`) + and fails on it. Text kept on purpose says why: `translate="no"`, + `data-i18n-runtime`, or `// i18n-ignore: `. `src/tools/` is skipped: + it writes for AI clients, not the page. ## Canonical Docs Order diff --git a/docs/I18N.md b/docs/I18N.md index d59ef8bcf..ac099f0c6 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -60,10 +60,10 @@ To see a language, open the app with `?lang=ru`, or use the language switch in t ## Text the check reports as unmarked -`npm run i18n` also lists UI text that is not marked at all, and fails while there is any: +`npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check: - **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`. -- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:` properties. +- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `innerText`, `innerHTML`, `title`, `placeholder`; passed to `setAttribute('title' | 'aria-label' | 'aria-valuetext' | 'placeholder' | 'alt', …)`, `createTextNode`, `insertAdjacentHTML`, `showToast`, `toast`, `setHint`, `announce`; or kept in `label:`, `title:`, `tooltip:`, `placeholder:`, `hint:`, `ariaLabel:`, `caption:`, `subtitle:`, `kicker:`, `emptyText:`, `blurb:`, `countLabel:` properties. Fix it by marking the text. When the text must stay as it is, say so where it lives: diff --git a/docs/translations/ru.md b/docs/translations/ru.md index bb6f4bd95..8dda853ec 100644 --- a/docs/translations/ru.md +++ b/docs/translations/ru.md @@ -21,7 +21,7 @@ A `tn()` entry needs four forms. `Intl.PluralRules('ru')` picks: | `many` | 0, 5–20, 25–30, 111–114… | 5 камер | | `other` | fractions: 1.5, 2.25… | 1,5 камеры | -When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}». +When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}». The same trick keeps place names and tool names out of a preposition's case: «откуда: {origin}», «Активно: {name}». ## Glossary @@ -53,4 +53,9 @@ When a word next to a number cannot agree in every form, move the number after a | HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. | | PREV / NEXT | ПРЕД. / СЛЕД. | | Previous / Next (button text) | Назад / Далее | +| anchor / data pack / bundle (scene) | якорь / пакет данных / архив | +| basemap / box (Recent Imagery area) | подложка / область | +| camera move | перелёт | +| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВОСТИ / МЕСТН. | +| compass points (N, NNE, …) | С, ССВ, … | | L / R (relative bearing) | Л / П | diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index 773419c56..30e339ca8 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -207,7 +207,7 @@ const JS_SINKS = [ /\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g, /\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g, /\b(?:showToast|toast|setHint|announce)\(\s*/g, - /(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText)\s*:\s*/gm, + /(?:^|[{,\s])(?:label|title|tooltip|placeholder|hint|ariaLabel|caption|subtitle|kicker|emptyText|blurb|countLabel)\s*:\s*/gm, ]; const MARKERS = /\b(?:t|tn|msg)\(/y; @@ -300,11 +300,11 @@ function visibleText(literal, html) { const attrs = [ ...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g), ].map((m) => m[1]); - body = [body.replace(/<[^>]*>/g, ' '), ...attrs].join(' '); + body = decodeHtml([body.replace(/<[^>]*>/g, ' '), ...attrs].join(' ')); } const text = normalize(body); if (!hasWords(text)) return null; - if (/[{};]/.test(text) && /:\s*[^\s]/.test(text)) return null; // CSS + if (/\{[^{}]*:[^{}]*\}|^[\w-]+\s*:\s*[^;]+;/.test(text)) return null; // CSS if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null; return text; diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 12ac3e34c..2f8e042f0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1003,7 +1003,11 @@ "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/rendering.js", "src/layers/recentImagery/thumbnails.js", - "src/maps/imageryHost.js" + "src/maps/imageryHost.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -1342,7 +1346,11 @@ "src/sources/httpBody.js", "src/sources/vectorTiles.js", "src/sources/phaseTiming.js", - "src/layers/alpr/floors.js" + "src/layers/alpr/floors.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium", "@mapbox/vector-tile", "pbf"] }, @@ -3080,7 +3088,11 @@ "modules": [ "src/layers/directions/index.js", "src/layers/directions/source.js", - "src/data/routeSteps.js" + "src/data/routeSteps.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, @@ -3358,7 +3370,11 @@ "src/sources/overpassFeaturesRecords.js", "src/sources/overpassFeatures.js", "src/sources/featureSource.js", - "src/sources/capability.js" + "src/sources/capability.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -3440,7 +3456,11 @@ "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", - "src/layers/perimeters/inciweb.js" + "src/layers/perimeters/inciweb.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/es.json", + "src/i18n/locales/ru.json" ], "external": ["cesium"] }, diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 27b049bb8..a85a143f9 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1137,12 +1137,14 @@ export function createAnnotationEngine({ { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'Palace of Fine Arts', color: 'amber', }, { type: 'area', target: 'Presidio of San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'The Presidio (former Army base)', color: 'green', footprint: true, @@ -1150,6 +1152,7 @@ export function createAnnotationEngine({ { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'ILM / Lucasfilm', color: 'cyan', }, @@ -1157,6 +1160,7 @@ export function createAnnotationEngine({ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina District, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'next to the Marina', }, ], @@ -1186,6 +1190,7 @@ export function createAnnotationEngine({ { type: 'highlight', target: 'Palace of Fine Arts, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'Palace of Fine Arts', color: 'amber', }, @@ -1200,6 +1205,7 @@ export function createAnnotationEngine({ type: 'arrow', target: 'Palace of Fine Arts, San Francisco', toTarget: 'Marina Green, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'next to the Marina', color: 'cyan', }, @@ -1223,6 +1229,7 @@ export function createAnnotationEngine({ { type: 'area', target: 'Presidio of San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'The Presidio — a former Army base', color: 'green', footprint: true, @@ -1237,6 +1244,7 @@ export function createAnnotationEngine({ { type: 'pin', target: 'Letterman Digital Arts Center, San Francisco', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'ILM / Lucasfilm', color: 'red', }, @@ -1250,6 +1258,7 @@ export function createAnnotationEngine({ { type: 'route', color: 'amber', + // i18n-ignore: dev demo standing in for voice-agent labels, which are not translated label: 'Crissy Field shoreline', points: [ { target: 'Palace of Fine Arts, San Francisco' }, diff --git a/src/annotations/drawTool.js b/src/annotations/drawTool.js index 5ffe1e6df..66743c65b 100644 --- a/src/annotations/drawTool.js +++ b/src/annotations/drawTool.js @@ -198,7 +198,10 @@ export function initDrawTool({ viewer, annotations }) { // a limit rather than the harmless tail of a double-click. if (reason === 'full') setHint( - `That shape already has ${MAX_VERTICES} points — finish it or press Backspace.`, + t( + 'That shape already has {count} points — finish it or press Backspace.', + { count: MAX_VERTICES }, + ), ); else if (reason === 'invalid') setHint(t('That point is off the globe — click on the world.')); @@ -241,7 +244,11 @@ export function initDrawTool({ viewer, annotations }) { return result; } catch (error) { if (destroyed || attempt !== generation) return null; - setHint(`Could not place the shape: ${error?.message || error}`); + setHint( + t('Could not place the shape: {error}', { + error: error?.message || error, + }), + ); return null; } }; @@ -325,7 +332,11 @@ export function initDrawTool({ viewer, annotations }) { // Somebody else is using the pointer — possibly an older instance of // this same tool that has not finished tearing down. Say so instead of // half-starting. - setHint(`${pointerOwner()} is using the pointer — close it first.`); + setHint( + t('{owner} is using the pointer — close it first.', { + owner: pointerOwner(), + }), + ); return active; } } diff --git a/src/annotations/screenAnnotationRenderer.js b/src/annotations/screenAnnotationRenderer.js index a2447ecce..c6c214fff 100644 --- a/src/annotations/screenAnnotationRenderer.js +++ b/src/annotations/screenAnnotationRenderer.js @@ -4,6 +4,12 @@ import { getActiveTrackedReadoutId, TRACKED_OVERLAY_SOURCE_ID, } from '../data/trackedReadout.js'; +import { t } from '../i18n/index.js'; + +/** A label whose detailed outline could not be resolved. */ +function outlineUnavailableLabel(label) { + return t('{label} · Detailed outline unavailable', { label: label || '' }); +} /** * Screen-space annotation renderer (Direction B — the "whiteboard" aesthetic). @@ -163,7 +169,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.poly); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -181,7 +187,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.path); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -215,7 +221,7 @@ export function createScreenAnnotationRenderer( }); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -259,7 +265,7 @@ export function createScreenAnnotationRenderer( group.appendChild(parts.leader); parts.label = makeCallout( anno.outlineUnavailable - ? `${anno.label || ''} · Detailed outline unavailable` + ? outlineUnavailableLabel(anno.label) : anno.label, c, ); @@ -322,7 +328,7 @@ export function createScreenAnnotationRenderer( } rec.anno = anno; if (anno.outlineUnavailable && rec.parts.label) { - rec.parts.label.text.textContent = `${anno.label || ''} · Detailed outline unavailable`; + rec.parts.label.text.textContent = outlineUnavailableLabel(anno.label); sizeCallout(rec.parts.label); } diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 2b259d7b9..464bcbac3 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -541,6 +541,7 @@ export function panelRuntime(config) { const notice = document.createElement('div'); notice.id = 'status'; notice.textContent = + // i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale "This panel lost its 3D graphics, likely because other God's Eye " + "View panels in this conversation hold them. Use Open in God's " + 'Eye View above, or show it in a new conversation.'; @@ -616,6 +617,7 @@ export function panelRuntime(config) { if (context?.displayMode) displayMode = context.displayMode; if (context?.availableDisplayModes) expand.hidden = !context.availableDisplayModes.includes('fullscreen'); + // i18n-ignore: panel runtime runs as source text in the host page, with no i18n module or app locale expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand'; }; expand.addEventListener('click', () => { @@ -640,6 +642,7 @@ export function panelRuntime(config) { appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, appInfo: { name: 'gods-eye-view', + // i18n-ignore: product name sent to the MCP host title: "God's Eye View", version: '1.0.0', }, diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 744423c90..0b376d9fc 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,4 +1,7 @@ -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { + readShellSource, + shellMethod, +} from './testSupport/readShellSource.mjs'; import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { PanelLayoutController } from './ui/panelLayoutController.js'; @@ -7,10 +10,19 @@ import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { SceneControls } from './ui/sceneControls.js'; import { isRenderedOnScreen } from './ui/cockpitPresentation.js'; import { onKeyDown as cockpitKeyDown } from './ui/cockpitInput.js'; -import { enter as cockpitEnter, exit as cockpitExit, readAircraftInfo, _adoptTrackedEntity } from './ui/cockpitTrackingController.js'; +import { + enter as cockpitEnter, + exit as cockpitExit, + readAircraftInfo, + _adoptTrackedEntity, +} from './ui/cockpitTrackingController.js'; import { setVisionMode, cycleVisionMode } from './ui/cockpitInstruments.js'; import { updateContext as cockpitContext } from './ui/cockpitContext.js'; -import { syncSignalLayout, setContextCollapsed, setSignalCollapsed } from './ui/cockpitLayout.js'; +import { + syncSignalLayout, + setContextCollapsed, + setSignalCollapsed, +} from './ui/cockpitLayout.js'; import { setBriefAutoRotate } from './ui/cockpitBriefing.js'; import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js'; import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; @@ -18,8 +30,14 @@ import { clearSelectedLayers } from './ui/contextActions.js'; import { _selectContextMode } from './ui/contextTransactions.js'; import { _restoreContextSession } from './ui/contextSession.js'; import { readFileSync as readRadioSource } from 'node:fs'; -const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); -const radioPresentation = readRadioSource(new URL('./ui/radioPresentation.js', import.meta.url), 'utf8'); +const radioBindings = readRadioSource( + new URL('./ui/radioBindings.js', import.meta.url), + 'utf8', +); +const radioPresentation = readRadioSource( + new URL('./ui/radioPresentation.js', import.meta.url), + 'utf8', +); import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -28,13 +46,27 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8')); +const html = expandApplicationHtml( + fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'), +); const ui = readShellSource(); const css = readStylesheet(path.join(ROOT, 'style.css')); -const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); -const manager = fs.readFileSync(path.join(ROOT, 'src', 'data', 'lifecycle.js'), 'utf8'); -const contextLayer = readLayerSource(path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), 'utf8'); -const voiceActions = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); +const sceneDirector = fs.readFileSync( + path.join(ROOT, 'src', 'scenes', 'director.js'), + 'utf8', +); +const manager = fs.readFileSync( + path.join(ROOT, 'src', 'data', 'lifecycle.js'), + 'utf8', +); +const contextLayer = readLayerSource( + path.join(ROOT, 'src', 'data', 'militaryAwareness.js'), + 'utf8', +); +const voiceActions = fs.readFileSync( + path.join(ROOT, 'src', 'voice', 'gevActions.js'), + 'utf8', +); test('Cockpit has one reset action beside its bottom exit path', () => { assert.doesNotMatch(html, /id="cockpit-quick-entry"/); @@ -43,24 +75,48 @@ test('Cockpit has one reset action beside its bottom exit path', () => { html, /id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/, ); - const topCenterActions = html.match(/