feat(i18n): presentation extraction — t() call sites, data-i18n templates, locale selector

Route every application-owned presentation string through the i18n
core: t() call sites across the app/layers/ui/data/voice surfaces,
data-i18n* attributes in the src/ui/templates/*.html markup (applied at
boot by applyDocumentTranslations from src/standalone/application.js),
and the dock locale selector (runtime-rendered from the offered pair —
a single EN button while only English ships).

Rebased onto current main, where the extraction follows the module
splits: the shell wiring now lands in the owner modules
(panelChrome, visualSettings, locationNavigation, shareRestoration,
shellFeedback, applicationShell) and the voice status corpus in the
realtime* modules; scene status lives in director.js +
playbackAdapter.js.

Modules shared with the node runtime (loadingFeedback,
installationFeedback, regionalModel, keySetupCore, vessels ingestion)
stay environment-neutral English and localize at their browser render
sites (chip maps in shellFeedback/installations controls, the weather
table in cockpitBriefing, keySetupRequirementEnvVars compositions), so
the package-boundary graph stays clean; the browser boundary groups
now own the i18n modules they reach (scripts/package-boundaries.json).
This commit is contained in:
Meir Michanie committed 2026-10-01 12:41:20 +02:00
1 parent bde1e430ef
commit 284c4d4601
98 files changed
+3474 -1625

No files matched your search

+17 -2
View File
@@ -210,6 +210,7 @@ export default {
'cctv.summary.empty': 'No cameras available in catalog.',
'cctv.summary.city': '{city} CCTV',
'cctv.summary.hdg': 'HDG {value}°',
'cctv.summary.hdgEstimated': 'HDG {value}° (ESTIMATED)',
'cctv.summary.fov': 'FOV {value}°',
'cctv.summary.coverage': 'COVERAGE {value}km²',
'cctv.summary.overlap': 'OVERLAP {count} cams',
@@ -415,8 +416,21 @@ export default {
'firms.card.focusAria': 'Focus fire detection {title}, {details}',
// src/data/traffic.js feed presentation (manager meta loadingLabel values).
'traffic.loadingSyncing': 'syncing LIVE traffic flow',
'traffic.liveCoverage': 'LIVE · TomTom flow · {percent}% cov',
'traffic.loadingSyncing':
'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated',
'traffic.liveCoverage':
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov',
'traffic.liveCoverageUnmatched':
'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated',
'traffic.simulatedNoMatches':
'SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)',
'traffic.syncingRoads': 'Syncing flow · Roads: {source}',
'traffic.liveRoadsFlowHidden':
'LIVE · Roads: {source} · Roads without flow hidden',
'traffic.liveRoadsNoFlowInView':
'LIVE · Roads: {source} · No flow roads in view',
'traffic.roadsFlowLive': 'LIVE · Roads: {source} · Flow {percent}%',
'traffic.roadsFlowSimulated': 'SIMULATED · Roads: {source} · Flow {percent}%',
'traffic.simUnavailable': 'SIMULATED — traffic service unreachable',
'traffic.simKeyless': 'SIMULATED — add TomTom key for live',
@@ -470,6 +484,7 @@ export default {
'awareness.cohort.vessels': 'AIS vessels',
'awareness.cohort.installations': 'Mapped installations',
'awareness.coverage.viewport': 'CURRENT VIEWPORT ONLY',
'awareness.coverage.within': 'WITHIN {km} KM',
'awareness.hdg': 'HDG {value}°',
'awareness.brg': 'BRG {value}°',
'awareness.brgUnknown': 'BRG —',
+128
View File
@@ -0,0 +1,128 @@
// Markup coverage gate (phase 2): every data-i18n* attribute in the static
// application markup must name a key that resolves in EVERY shipped catalog
// via the real catalog builder — a renamed or dropped catalog key can no
// longer strand a static extraction silently behind the key-itself fallback.
// index.html is now assembled from src/ui/templates/*.html by
// build/application-html.js before Vite processes it, so the gate expands the
// same templates the build serves and scans that assembled markup. Regex
// parsing is deliberate: no DOM dependency in unit tests.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { getCatalog } from './index.js';
import { CATALOG_LOCALES, availableLocales } from './locale.js';
import { expandApplicationHtml } from '../../build/application-html.js';
const INDEX_HTML = new URL('../../index.html', import.meta.url);
const UI_JS = new URL('../../src/ui/applicationShell.js', import.meta.url);
// The four spellings applyDocumentTranslations() knows about. Anything else
// (a typo like data-i18n-lable) must fail here, not silently never apply.
const I18N_ATTRIBUTE_PATTERN = /data-i18n(?:-title|-aria-label|-placeholder)?="([^"]+)"/g;
const KNOWN_ATTRIBUTE_NAMES = new Set([
'data-i18n',
'data-i18n-title',
'data-i18n-aria-label',
'data-i18n-placeholder',
]);
const html = expandApplicationHtml(readFileSync(INDEX_HTML, 'utf8'));
const references = [...html.matchAll(I18N_ATTRIBUTE_PATTERN)].map((match) => match[1]);
test('index.html carries static i18n references', () => {
assert.ok(references.length > 0, 'no data-i18n* attributes found — extraction missing?');
});
test('index.html sources its body from the application templates', () => {
const raw = readFileSync(INDEX_HTML, 'utf8');
assert.ok(raw.includes('<!-- gev:template '),
'index.html must keep the gev:template markers the build expands');
assert.equal((raw.match(/data-i18n/g) || []).length, 0,
'data-i18n markup belongs in src/ui/templates/*.html, not the marker shell');
});
test('every data-i18n* attribute value resolves in every shipped catalog', () => {
const catalogs = Object.fromEntries(CATALOG_LOCALES.map((locale) => [locale, getCatalog(locale)]));
for (const locale of CATALOG_LOCALES) {
assert.ok(catalogs[locale], `catalog builder produced the ${locale} locale`);
}
const missing = references.filter((key) => (
CATALOG_LOCALES.some((locale) => !(key in catalogs[locale]))
));
assert.deepEqual(
[...new Set(missing)],
[],
'static markup references keys absent from a catalog (translation would fall back or render the key)',
);
});
test('no unknown data-i18n* attribute spelling sneaks past the application pass', () => {
const stray = [...html.matchAll(/(data-i18n[a-z-]*)="/g)]
.map((match) => match[1])
.filter((name) => !KNOWN_ATTRIBUTE_NAMES.has(name));
assert.deepEqual(
[...new Set(stray)],
[],
'applyDocumentTranslations() only applies the four known attributes; a fifth spelling is dead markup',
);
});
test('attribute values are well-formed catalog keys', () => {
for (const key of references) {
assert.match(key, /^(shell|cockpit|layers|setup)\.[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)*$/, `malformed key: ${key}`);
}
});
test('the dock language switch keeps a static group container with runtime-rendered buttons', () => {
const selector = html.match(/<div class="dock-locale-switch"[\s\S]*?<\/div>/);
assert.ok(selector, 'the locale switch group is missing from the control-panel tray');
assert.match(selector[0], /data-i18n-aria-label="shell\.locale\.groupAriaLabel"/);
// Buttons are no longer static: ui.js _initLocaleSelector() renders one per
// locale in the configured pair (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE).
assert.doesNotMatch(
selector[0],
/dock-locale-btn/,
'static selector buttons must move to the runtime renderer so the configured pair shapes the switch',
);
// Markup-only contract: no listener may attach from markup.
assert.doesNotMatch(selector[0], / onclick| onsubmit| javascript:/i);
});
test('every offered locale has a shell.locale.<code>.ariaLabel in every catalog; the replaced family stays deleted', () => {
// The runtime renderer reads shell.locale.<code>.ariaLabel for every locale
// availableLocales() can yield under any configuration — i.e. every shipped
// catalog locale — and it must exist in every catalog.
for (const catalogLocale of CATALOG_LOCALES) {
const catalog = getCatalog(catalogLocale);
for (const code of CATALOG_LOCALES) {
assert.ok(
catalog[`shell.locale.${code}.ariaLabel`],
`${catalogLocale} catalog is missing shell.locale.${code}.ariaLabel for the runtime selector`,
);
}
assert.equal(catalog['shell.locale.english.ariaLabel'], undefined,
'the replaced phase-2 english.ariaLabel key stays deleted');
assert.equal(catalog['shell.locale.spanish.ariaLabel'], undefined,
'the replaced phase-2 spanish.ariaLabel key stays deleted');
}
// Default configuration pins the pair the shipped build offers: en-only
// while English is the only shipped catalog (the selector renders a single
// EN button; each secondary-locale PR widens this pin).
assert.deepEqual([...availableLocales()], ['en']);
});
test('ui.js _initLocaleSelector renders the configured pair (runtime contract)', () => {
const source = readFileSync(UI_JS, 'utf8');
const start = source.indexOf('_initLocaleSelector() {');
assert.ok(start >= 0, '_initLocaleSelector method not found in src/ui.js');
const body = source.slice(start, source.indexOf('\n }\n', start));
assert.ok(body.length > 0, '_initLocaleSelector body not extracted');
assert.match(body, /availableLocales\(\)/, 'iterates the resolved pair, not a hardcoded EN|ES');
assert.match(body, /shell\.locale\.\$\{locale\}\.ariaLabel/, 'aria-labels come from the locale key family');
assert.match(body, /toUpperCase\(\)/, 'labels are uppercase locale codes');
assert.match(body, /aria-pressed/, 'pressed state is synced');
assert.match(body, /persistLocaleAndReload/, 'clicks persist and reload');
assert.match(body, /dock-locale-switch/, 'renders into the static group container');
});
+83
View File
@@ -0,0 +1,83 @@
// Repair-pass anchors (extraction correctness).
//
// en behavior must stay byte-identical: the values added for the director
// status corpus, the keySetup dev surface, and the map-source wiring are
// pinned here to the exact literals the code rendered before extraction.
// Cross-module anchors (keySetupCore registry) catch catalog/registry drift
// in either direction. The locale-scoped CCTV wrap rules and per-locale value
// pins live in their locale PRs (es first); this file pins the English
// contract plus the pixel-identical English layout.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { getCatalog } from './index.js';
import { KEY_SETUP_KEYS, keySetupRequirement } from '../keySetupCore.mjs';
const en = getCatalog('en');
test('director status corpus keeps the pre-extraction English literals byte-identical', () => {
const expected = {
'setup.scenes.status.captureCameraNotReady': 'Cannot capture shot: camera not ready',
'setup.scenes.status.shotTitleDefault': 'Shot {n}',
'setup.scenes.status.captured': 'Captured: {scene} / {shot}',
'setup.scenes.status.selectShotFirst': 'Select a shot first',
'setup.scenes.status.updated': 'Updated: {scene} / {shot}',
'setup.scenes.status.deleteShotConfirm': 'Delete shot "{shot}"?',
'setup.scenes.status.loaded': 'Loaded: {scene} / {shot}',
'setup.scenes.status.cameraUnavailable': 'Camera unavailable — exit cockpit first',
'setup.scenes.status.noShotsToRun': 'No shots to run',
'setup.scenes.status.runningShot': 'Running {index}/{total}: {scene} / {shot}',
'setup.scenes.status.runComplete': 'Scene run complete',
'setup.scenes.status.runError': 'Error: {message}',
'setup.scenes.status.contextExitFailed': 'Could not exit {mode} — scene layers may be refused',
};
for (const [key, value] of Object.entries(expected)) {
assert.equal(en[key], value, `${key} drifted from the removed English literal`);
}
});
test('setup.keySetup.unlocks.* en values equal the keySetupCore registry copy', () => {
for (const entry of KEY_SETUP_KEYS) {
const key = `setup.keySetup.unlocks.${entry.id}`;
assert.equal(en[key], entry.unlocks, `${key} drifted from KEY_SETUP_KEYS unlocks`);
}
assert.equal(en['setup.keySetup.requirement'], 'Needs {envVars} — add it in Provider Settings');
assert.equal(
keySetupRequirement('cesium-ion'),
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
'en requirement sentence must stay byte-identical (pinned by keySetupCore.test.mjs)',
);
});
test('map-source keys match the static markup pinned by mapStackChips.test.mjs', () => {
assert.equal(en['cockpit.presets.mapSourceLabel'], 'MAP SOURCE');
assert.equal(en['cockpit.presets.mapSourceChipsAriaLabel'], 'Map source');
});
test('the seven redundant layers.radio transport seeds stay deleted', () => {
const deleted = [
'layers.radio.pause',
'layers.radio.resume',
'layers.radio.playNearestAria',
'layers.radio.pauseSelectedAria',
'layers.radio.pauseNearestAria',
'layers.radio.resumeSelectedAria',
'layers.radio.resumeNearestAria',
];
for (const key of deleted) {
assert.equal(en[key], undefined, `${key} was removed by the repair pass`);
}
});
test('the English CCTV control rows stay unwrapped: no locale-scoped wrap rule ships ahead of its locale', () => {
// Locale wrap rules (html[lang='…'] .cctv-controls { flex-wrap: wrap }) are
// added one per locale PR, each with a measured-width justification — never
// speculatively. Until then the base rule must stay unwrapped so English
// pixels are unchanged, and no html[lang] override may exist.
const css = readFileSync(new URL('../../src/ui/styles/cctv.css', import.meta.url), 'utf8');
const baseStart = css.indexOf('.cctv-controls {');
const baseBody = css.slice(baseStart, css.indexOf('}', baseStart));
assert.ok(!baseBody.includes('flex-wrap'), 'base .cctv-controls must stay unwrapped (EN pixel-identical)');
assert.ok(!/html\[lang=/i.test(css), 'no locale-scoped CCTV rule may ship before its locale does');
});