Строки интерфейса
Весь текст лежит в
src/i18n/ru.tsиsrc/i18n/en.ts. В компонентах остались только вызовыt('ключ').
Как устроено
Русская локаль — эталонная: типы ключей выведены из неё. Поэтому пропущенный ключ в новом переводе будет ошибкой компиляции, а не пустотой на экране.
Подстановки пишутся как {имя}:
'home.addBlock': 'Добавить {minutes} минут: {title}',Что типы не ловят, стережёт src/i18n/i18n.test.ts: осиротевший ключ (структурная типизация пропускает лишнее), потерянную при переводе подстановку и правило выбора формы, указывающее за конец списка.
Множественное число
По-настоящему между языками отличается только оно. В русском три формы, в английском две, поэтому длину массива форм и правило выбора формы задаёт сама локаль, а не общий тип:
block: ['блок', 'блока', 'блоков'], // ru
block: ['block', 'blocks'], // enРусское правило: одиннадцать–четырнадцать всегда третья форма; кончается на 1 — первая; на 2–4 — вторая; иначе третья. Английское: единица — первая форма, всё остальное — вторая.
Есть отдельная таблица NOTE_UNITS (src/achievements/note.ts), которая привязывает числовые подстановки шаблонов достижений к словам: у всех семи «серий» шаблон один, а число приходит из реестра.
Даты и числа
Месяцы, дни недели, единицы ч/h, разделитель дробной части и порядок частей в короткой дате лежат в той же локали — ruFormats и enFormats, читает их src/domain/date.ts. Отсюда «21 августа» против «Aug 21» и «3 ч 30 м» против «3h 30m».
Таблицами, а не через Intl, и это не вкусовщина: вывод Intl ездит с версией ICU в браузере, а на эти подписи опираются коммиченные кадры документации, локаторы Playwright и точные ожидания в insights.test.ts. Обновился Chromium на сборочной машине — поехали снимки. Intl доверена ровно одна работа: разделитель разрядов, то есть пунктуация.
Как выбирается язык
Побеждает первый знакомый язык из четырёх источников; незнакомый код не роняет в русский, а пропускается:
| Источник | Зачем |
|---|---|
?lang= в адресе | Единственный рычаг снаружи: фрейм английского лендинга, съёмка, ручная проверка |
| Выбор в настройках | То, что человек нажал. Лежит в localStorage, ключ mypri:lang |
initDataUnsafe.user.language_code | Внутри мини-аппа язык клиента и есть язык человека |
navigator.language | Когда Telegram молчит — то есть в обычном браузере |
Если не совпало ничего — английский: приложение по умолчанию международное, на русский попадают те, кто на нём и читает.
?lang= стоит выше сохранённого выбора намеренно: иначе фрейм английского лендинга открывался бы по-русски у любого, кто раньше заходил в приложение. Чтобы параметр не отменял нажатую кнопку навсегда, chooseLanguage() вычищает его из адреса через history.replaceState.
Выбор лежит на устройстве, а не в настройках, которые едут на сервер, и это не экономия: настройки поднимаются из IndexedDB асинхронно, а язык нужен до первой отрисовки. Иначе приложение показывает один язык и через кадр другой. Следствие принято сознательно: телефон по-английски и компьютер по-русски — нормально, а «телефон переключился сам» — нет.
Как работает переключатель
Смена языка пересобирает дерево, а не перерисовывает его: boot.tsx держит <App key={locale}/> под подпиской useSyncExternalStore. Модульных вызовов t() в приложении нет, но два списка обёрнуты в memo и на смену языка не откликнулись бы — пропсы у них те же. Ключ снимает вопрос целиком.
Открытая вкладка переживает пересборку: её держит модульная переменная openTab в src/App.tsx, рядом с route и по той же причине. Без неё человек, нажавший «English» в настройках, оказывался бы на главной.
Как добавить ещё один язык
Ровно один файл xx.ts с теми же ключами, своими формами множественного числа, своим правилом выбора формы и своими таблицами дат — и одна строка в LOCALES. Переключатель в настройках соберётся сам: он строится из LANGUAGES.
Импортов в файле локали быть не должно. На этом держится tools/shots/labels.mjs: он читает ru.ts напрямую нодой, без сборщика, — стрипание типов включено, а разрешение импортов нет. Правило сторожит tools/deps.test.ts.
Что не переводится намеренно
- Подписи на обоях (
HIGH,MEDIUM,LOW,CHARGING) — часть оформления из референсов, а не текст интерфейса. - Названия устройств в выборе размера обоев.
- Названия языков в переключателе: тот, кто открыл приложение не на своём языке, должен узнать «English», а не «Английский».
- Сообщения
console.warn— они для разработчика. - Панель отладки целиком: она переносится в другой проект копированием и не имеет права импортировать словарь приложения.
Содержимое тоже переводится
Названия наборов, приоритетов, навыков, цветов и демо-профилей раньше лежали рядом со своими структурами и назывались содержимым, а не текстом интерфейса. Пока язык был один, разницу можно было провести; с двумя языками содержимое всё равно становится локальным.
Поэтому структура осталась на месте (идентификаторы, colorId, порядок, параметры генерации), а литералы названий заменились ключами: preset.*, word.*, color.*, demo.p.*. Разворачивает их titlesOf() в момент, когда они попадают на экран или в данные.
Смена языка не переименовывает уже созданные приоритеты и навыки. Они хранятся строкой, это данные человека, и трогать их приложение права не имеет.
Правило про ошибки
Ошибки разбора копии данных несут ключ строки, а не готовый текст. Сообщение видит пользователь, значит переводит его тот, кто рисует, а не слой хранения.
Строки — ещё и опора для тестов и скриншотов
Из ru.ts берут подписи:
- тип
AchievementId— он выводится из ключейach.*, поэтому опечатка в идентификаторе достижения не компилируется; - скриншот-харнесс — локаторы Playwright ищут элементы по тем же строкам. Переименовали строку — скрипт падает на отсутствующем ключе, а не снимает молча не тот экран. Язык там прибит параметром
?lang=ru; язык контекста и стаба Telegram — везение, а не договор; - справочник достижений в этой документации.
Из этого следует правило: переименование строки — это не косметика. После него надо прогнать npm test и npm run docs:shots.