refactor(i18n): key messages by English text; add Russian

Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.

- Simplify src/i18n: drop namespaces, the configurable locale pair
  (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
  comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
  and markup, and fails on missing, unused, placeholder-mismatched or
  wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
  community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
  LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
  doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
  merge resurrected in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 13:48:56 +04:00
1 parent dfbaeafbd8
commit 13efd411b6
112 files changed
+4419 -4856

No files matched your search

+13 -14
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { t, msg } from '../../i18n/index.js';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
@@ -120,10 +120,10 @@ export function createOverlays({ state: layerState, services, parts, source }) {
const details = selected
? [
siteName
? t('layers.missions.launchSiteValue', {
? t('LAUNCH SITE · {site}', {
site: shortMissionLabel(siteName, 20).toUpperCase(),
})
: t('layers.missions.launchSite'),
: t('LAUNCH SITE'),
]
: [];
return {
@@ -589,38 +589,37 @@ export function createOverlays({ state: layerState, services, parts, source }) {
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
const siteName = compactLaunchSiteName(launch.launchSite);
const siteCallout = siteName
? t('layers.missions.launchSiteValue', {
? t('LAUNCH SITE · {site}', {
site: shortMissionLabel(siteName, 20).toUpperCase(),
})
: t('layers.missions.launchSite');
: t('LAUNCH SITE');
let title = mission;
let detail = siteCallout;
if (mode === 'countdown') {
title = t('layers.missions.replay.countdown', {
title = t('T−{time} · {mission}', {
time: String(state.countdownSeconds).padStart(2, '0'),
mission,
});
detail = `${t('layers.missions.replay.standbyDetail')}\n${siteCallout}`;
detail = `${t('LAUNCH STANDBY')}\n${siteCallout}`;
} else if (mode === 'ascent') {
title =
state.elapsedSinceStart < 1
? t('layers.missions.replay.liftoff', { mission })
? t('LIFTOFF · {mission}', { mission })
: t(
launch.trajectory.length > 1
? 'layers.missions.replay.ascentReplay'
: 'layers.missions.replay.ascentEstimate',
? msg('ASCENT REPLAY · {mission}')
: msg('ASCENT ESTIMATE · {mission}'),
{ mission },
);
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'recovery') {
title = t('layers.missions.replay.recovery', { mission });
title = t('STAGE RE-ENTRY / RECOVERY · {mission}', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
} else if (mode === 'orbit') {
title = t('layers.missions.replay.orbit', { mission });
title = t('ORBIT REPLAY · {mission}', { mission });
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
}
if (layerState._replayPaused)
title = t('layers.missions.replay.pausedPrefix', { title });
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
const nextText = `${title}\n${detail}`;
if (nextText !== layerState._replayVehicleOverlayText) {
layerState._replayVehicleOverlayText = nextText;
+20 -23
View File
@@ -220,12 +220,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
]
.filter(Boolean)
.join(' · ');
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('layers.missions.rows.unspecified'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('layers.missions.rows.unavailable'))}</td></tr>`;
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
})
: [];
if (launch.payloads.length > 5) {
payloadRows.push(
`<tr><td colspan="3" class="mission-table-empty">${t('layers.missions.rows.additionalPayloads', { count: launch.payloads.length - 5 })}</td></tr>`,
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
);
}
layerState._missionPanel.querySelector(
@@ -233,7 +233,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
).innerHTML = missionTableRows(
payloadRows,
3,
t('layers.missions.rows.classified'),
t('PAYLOAD DATA UNAVAILABLE'),
);
const stageRows = launch.recoveryStages.map((stage) => {
const endpoint = stage.endpoint;
@@ -241,21 +241,21 @@ export function createPanel({ state: layerState, services, parts, source }) {
stage.destination ||
(endpoint?.accuracy === 'PAD / RTLS'
? launch.launchSite
: t('layers.missions.rows.unavailable'));
: t('UNAVAILABLE'));
const position = endpoint
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
: stage.downrangeKm > 0
? t('layers.missions.rows.kmDownrange', {
? t('{value} KM DOWNRANGE', {
value: stage.downrangeKm.toLocaleString(),
})
: t('layers.missions.rows.positionUnavailable');
: t('POSITION UNAVAILABLE');
const stageDetail = [
Number.isFinite(stage.flightNumber)
? t('layers.missions.rows.flightNumber', {
? t('FLIGHT {count}', {
count: stage.flightNumber,
})
: null,
stage.reused ? t('layers.missions.rows.reused') : null,
stage.reused ? t('REUSED') : null,
stage.recoveryType,
]
.filter(Boolean)
@@ -263,7 +263,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
});
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
missionTableRows(stageRows, 3, t('layers.missions.rows.noStageData'));
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
const stageSection = layerState._missionPanel.querySelector(
'[data-mission-stages-section]',
);
@@ -291,7 +291,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
'[data-mission-roster-count]',
);
if (count)
count.textContent = t('layers.missions.roster.count', {
count.textContent = t('{count} / 30D', {
count: layerState._launches.length,
});
if (!list) return;
@@ -301,7 +301,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._launches,
);
if (!entries.length) {
list.innerHTML = `<div class="space-mission-roster-empty">${t('layers.missions.roster.empty')}</div>`;
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
restoreMissionRosterFocus(
list,
focusSnapshot,
@@ -314,15 +314,12 @@ export function createPanel({ state: layerState, services, parts, source }) {
list.innerHTML = entries
.map(({ launch, index }) => {
const color = parts.model.missionMarkerColor(launch).toCssColorString();
const date =
launch.launchTime?.slice(0, 10) ||
t('layers.missions.roster.dateUnavailable');
const provider =
launch.provider || t('layers.missions.roster.unspecifiedOperator');
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
const label = parts.overlays
.shortMissionLabel(launch.name, 27)
.toUpperCase();
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('layers.missions.roster.selectAria', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
})
.join('');
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
@@ -379,7 +376,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
setMissionPanelField(
'[data-mission-distance]',
Number.isFinite(altitudeM)
? t('layers.missions.telemetry.km', {
? t('{value} KM', {
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
maximumFractionDigits: 0,
}),
@@ -392,7 +389,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
setMissionPanelField(
'[data-mission-speed]',
Number.isFinite(speedMps)
? t('layers.missions.telemetry.kmPerSecond', {
? t('{value} KM/S', {
value: (speedMps / 1000).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
@@ -473,16 +470,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._missionPanel.className = 'context-space-mission-detail';
layerState._missionPanel.setAttribute(
'aria-label',
t('layers.missions.panel.selectedHeader'),
t('SELECTED SPACE MISSION'),
);
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('layers.missions.panel.selectedHeader')}</span><button type="button" data-mission-close title="${t('layers.missions.panel.showAllTitle')}" aria-label="${t('layers.missions.panel.deselectAria')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('layers.missions.panel.mission')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('layers.missions.panel.status')}<b data-mission-status></b></span><span data-mission-field>${t('layers.missions.panel.launchSite')}<b data-mission-site></b></span><span data-mission-field>${t('layers.missions.panel.launchTime')}<b data-mission-time></b></span><span data-mission-field>${t('layers.missions.panel.orbit')}<b data-mission-orbit></b></span><span>${t('layers.missions.panel.ascentPath')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('layers.missions.panel.distance')}<b data-mission-distance></b></span><span data-mission-field>${t('layers.missions.panel.speed')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('layers.missions.panel.payload')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('layers.missions.panel.colName')}</th><th>${t('layers.missions.panel.colType')}</th><th>${t('layers.missions.panel.colDestination')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('layers.missions.panel.stagesSection')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('layers.missions.panel.colStage')}</th><th>${t('layers.missions.panel.colStatus')}</th><th>${t('layers.missions.panel.colFinalPosition')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('layers.missions.replay.speedLabel')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('layers.missions.replay.speedAria')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('layers.missions.panel.focus')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('layers.missions.replay.start')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('layers.missions.panel.prevTitle')}"><span aria-hidden="true">‹</span> ${t('layers.missions.panel.prev')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('layers.missions.panel.nextTitle')}">${t('layers.missions.panel.next')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('layers.missions.panel.showAll')}</button>`;
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
layerState._missionPanel
.querySelector('.mission-action-row')
.insertAdjacentHTML(
'beforeend',
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
<button type="button" data-mission-replay-toggle title="${t('layers.missions.replay.pause')}" aria-label="${t('layers.missions.replay.pause')}">Ⅱ</button>
<button type="button" class="cancel" data-mission-replay-cancel title="${t('layers.missions.replay.cancel')}" aria-label="${t('layers.missions.replay.cancel')}"><span aria-hidden="true">×</span></button>
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
</div>`,
);
host.appendChild(layerState._missionPanel);
+1 -1
View File
@@ -522,7 +522,7 @@ export function createPaths({ state: layerState, services, parts, source }) {
return {
lat: Cesium.Math.toDegrees(lat),
lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)),
accuracy: t('layers.missions.estDownrange'),
accuracy: t('EST. DOWNRANGE'),
};
}
+5 -5
View File
@@ -77,14 +77,14 @@ export function createPolicyHelpers({
: 0;
return {
orbit: orbitName
? `${orbitAllowed ? '' : t('layers.missions.rows.plannedPrefix')}${orbitName}`
? `${orbitAllowed ? '' : t('PLANNED · ')}${orbitName}`
: null,
ascent:
suppliedTrajectoryPoints > 1
? t('layers.missions.rows.suppliedTrajectory')
? t('SUPPLIED TRAJECTORY POINTS')
: replayAvailable
? t('layers.missions.rows.reconstructedEstimate')
: t('layers.missions.rows.unavailable'),
? t('RECONSTRUCTED ESTIMATE')
: t('UNAVAILABLE'),
replayAvailable: Boolean(replayAvailable),
};
}
@@ -198,7 +198,7 @@ export function createPolicyHelpers({
function formatMissionEventTime(launchTime) {
const date = new Date(launchTime);
if (!launchTime || !Number.isFinite(date.getTime()))
return t('layers.missions.rows.unavailable');
return t('UNAVAILABLE');
return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`;
}
+11 -13
View File
@@ -201,10 +201,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
if (speedControl) speedControl.hidden = !replayAvailable;
button.hidden = active || !replayAvailable;
button.disabled = !replayAvailable;
button.textContent = t('layers.missions.replay.start');
button.textContent = t('REPLAY ASCENT');
button.classList.remove('active');
button.setAttribute('aria-pressed', String(active));
button.title = t('layers.missions.replay.startTitle');
button.title = t('Replay the estimated ascent with a following camera');
if (transport) {
transport.hidden = !active;
transport.classList.toggle(
@@ -219,13 +219,11 @@ export function createReplay({ state: layerState, services, parts, source }) {
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
toggleButton.setAttribute(
'aria-label',
layerState._replayPaused
? t('layers.missions.replay.resume')
: t('layers.missions.replay.pause'),
layerState._replayPaused ? t('Resume replay') : t('Pause replay'),
);
toggleButton.title = layerState._replayPaused
? t('layers.missions.replay.resume')
: t('layers.missions.replay.pause');
? t('Resume replay')
: t('Pause replay');
}
}
}
@@ -236,19 +234,19 @@ export function createReplay({ state: layerState, services, parts, source }) {
);
if (!transport || !layerState._replayCameraLaunchId) return;
const phase = state.countdownActive
? t('layers.missions.replay.phaseCountdown', {
? t('T minus {seconds}', {
seconds: state.countdownSeconds,
})
: state.preCountdownActive
? t('layers.missions.replay.phasePreparing')
? t('Preparing launch site')
: state.elapsedSinceStart < 1
? t('layers.missions.replay.phaseLiftoff')
? t('Liftoff')
: state.ascending
? t('layers.missions.replay.phaseAscent')
: t('layers.missions.replay.phaseOrbit');
? t('Ascent replay')
: t('Orbit replay');
transport.setAttribute(
'aria-label',
`${phase}${layerState._replayPaused ? t('layers.missions.replay.pausedSuffix') : ''}`,
`${phase}${layerState._replayPaused ? t(', paused') : ''}`,
);
}