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

+24 -18
View File
@@ -20,7 +20,7 @@ import {
* the chip and the dialog are removed outright.
*/
import { t, hasMessage } from './i18n/index.js';
import { t, msg, tn } from './i18n/index.js';
/**
* Chip label — pure, exported for tests. The exact English values are pinned
@@ -32,8 +32,12 @@ import { t, hasMessage } from './i18n/index.js';
export function keySetupChipLabel(status) {
const missing = Math.max(0, (status?.total || 0) - (status?.setCount || 0));
return missing > 0
? t('setup.keySetup.chipWaiting', { count: missing })
: t('setup.keySetup.chipReady');
? tn(
missing,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
)
: t('POWERED UP');
}
/**
@@ -186,10 +190,7 @@ function buildRow(documentRef, key) {
const unlocks = documentRef.createElement('p');
unlocks.className = 'key-setup-unlocks';
// The registry `unlocks` text localizes per registry id; an id missing from
// the catalog falls back to the server-provided English copy.
const unlocksKey = `setup.keySetup.unlocks.${key.id}`;
unlocks.textContent = hasMessage(unlocksKey) ? t(unlocksKey) : key.unlocks;
unlocks.textContent = t(key.unlocks);
row.append(head, unlocks);
if (!external) {
@@ -214,8 +215,10 @@ function buildRow(documentRef, key) {
remove.type = 'button';
remove.className = 'key-setup-remove';
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
remove.textContent = t('setup.keySetup.row.remove');
remove.title = t('setup.keySetup.row.removeTitle', { title: key.title });
remove.textContent = t('REMOVE');
remove.title = t("Remove {title} from this app's saved keys", {
title: key.title,
});
fields.append(remove);
}
row.append(fields);
@@ -380,8 +383,8 @@ export async function initKeySetup({
const storeLabel = () =>
status?.store === 'pinokio-environment'
? t('setup.keySetup.store.pinokio')
: t('setup.keySetup.store.env');
? t('your app configuration')
: t('your local .env');
// doneMessageKey names the whole saved/removed sentence; the storage
// destination is interpolated so the {store} phrase stays one spelling.
@@ -392,7 +395,7 @@ export async function initKeySetup({
)?.set;
busy = true;
applyButton?.setAttribute('aria-disabled', 'true');
say(t('setup.keySetup.status.saving'));
say(t('Saving…'));
try {
const response = await doFetch('/api/setup/keys', {
method: 'POST',
@@ -406,7 +409,7 @@ export async function initKeySetup({
// payload.error is a server-authored message (API value) — shown as-is.
say(
payload.error ||
t('setup.keySetup.status.saveFailed', { status: response.status }),
t('Save failed ({status}).', { status: response.status }),
);
return;
}
@@ -440,7 +443,7 @@ export async function initKeySetup({
say(t(doneMessageKey, { store: storeLabel() }));
} catch (error) {
say(
t('setup.keySetup.status.saveFailedDetail', {
t('Save failed: {detail}', {
detail: error?.message || error,
}),
);
@@ -460,10 +463,13 @@ export async function initKeySetup({
})),
);
if (!Object.keys(updates).length) {
say(t('setup.keySetup.status.pasteFirst'));
say(t('Paste at least one key first.'));
return;
}
await submitUpdates(updates, 'setup.keySetup.status.saved');
await submitUpdates(
updates,
msg('Saved to {store}. Restarting — this page reloads itself.'),
);
};
chip.addEventListener('click', openDialog);
@@ -579,11 +585,11 @@ export async function initKeySetup({
// a deliberate two-step the lure cannot pre-satisfy.
const ok =
typeof globalThis.confirm !== 'function' ||
globalThis.confirm(t('setup.keySetup.confirm.remove'));
globalThis.confirm(t('Remove this key from your saved configuration?'));
if (!ok) return;
void submitUpdates(
Object.fromEntries(envVars.map((name) => [name, null])),
'setup.keySetup.status.removed',
msg('Removed from {store}. Restarting — this page reloads itself.'),
);
});