25 KiB
План: i18n в preview (новый UI)
Статус: готов к реализации (вопросы §5 решены). Соответствует беклог-задаче «добавить i18n в preview» (
docs/backlog.md). Реализация не начата.
1. Что получается
Новый UI (web/src/routes/** кроме v1, web/src/lib/components/** кроме
v1) переходит на существующий модуль lib/i18n: переключатель RU/EN в шапке
работает по-настоящему, все строки локализованы, ошибки уже локализуются.
Инфраструктура i18n уже есть и проверена (v1-ветка) — задача в том, чтобы
подключить к ней preview и закрыть пробелы словарей.
До начала локализации v1 изолируется: у v1 появляется полная замороженная
копия lib/i18n → lib/v1/i18n, у нового UI — своя независимая ветка
lib/i18n, которую дальше меняем без оглядки на v1 (Фаза 0). Переключённая
локаль (localStorage) остаётся общей для обеих версий — язык переживает переход
между страницами.
2. Текущее состояние (исследование)
Уже есть
lib/i18n/locale.svelte.ts— состояние локали (runes), персист в localStorage, обновлениеdocument.documentElement.lang.lib/i18n/t.ts— перевод по точечному ключу с фолбэком на базовую локаль и интерполяцией{var}.lib/i18n/dict.ts— типDict,BASE_LOCALE = "en"(база — английский, ru — перевод),LOCALES = ["ru", "en"].lib/i18n/ru.ts(~1351 строка) иen.ts(188 строк) — полные переводы инструментов v1 по id (title/description/params/options/results).lib/i18n/tool-strings.ts— хелперы для v1 (toolTitle/toolDescription/ paramLabel/optionLabel/searchDoc).lib/i18n/matching.ts— кросс-языковой поиск (ё→е, диакритика, скоринг).- Тесты:
i18n.test.ts,tools-i18n.test.ts,matching.test.ts,smoke-i18n.test.ts,search-coverage.test.ts. +layout.svelteуже вызываетinitLocale()на клиенте.
Пробелы в новом UI
LangToggle.svelte— заглушка с локальнымlet lang = $state("RU"), не связана сlocale.svelte.ts.- Новый UI почти целиком на хардкод-английском:
- ~150+ строк по ~30 компонентам и маршрутам (заголовки, кнопки, аria, плейсхолдеры, подписи секций);
- 122 инструмента с английскими
title/descriptionвlib/registry/*.ts; - ~60+ подписей опций select;
- ~30 заголовков layout-групп (
title: "Canvas","Background", …); - вердикты анализаторов — raw English строки (
"Yes — valid PNG signature."), при том что v1 возвращает ключи словаря ("verifyYes") и рендерит их черезtools[id].results; labelOf(id)вSchemaFields.svelteгенерирует подписи полей из field ID (keepAspect → "Keep Aspect");- категории в
catalog.ts(GROUP_LABELS) — English uppercase, хотя вdictуже есть ключиcategories.X.
- Из всех новых компонентов
t()вызывает толькоSchemaToolView.svelte, и только для ошибок (ToolError.key).
Совпадение регистров
- Новый registry и старый v1 registry используют одинаковые id инструментов
(resize-png, flip-png, …) — переводы title/description из v1
ru.tsслужат исходником для новой веткиlib/i18n/ru.ts(копирование данных при наполнении Фазы 2, не общий модуль). - Расхождение: param ids в ru.ts (
width/height/keepAspect) не равны field ids новоно schema (sizedimension,anchor). Нужны новые переводы полей. ru.tstools[id].results для анализаторов уже содержит ключи (verifyYes,grayscaleNo,orientationPortraitи т.д.).
Архитектурные ограничения
- Изоляция old↔new (
isolation/no-mixed-imports): сейчасlib/i18n/— shared. По решению задачи (Фаза 0) i18n форкается: v1 получает полную копиюlib/v1/i18n, аlib/i18nпереходит в ветку «new» (правило изоляции запретит старому коду импортить новый i18n). До форка в общем модуле два скрытых v1-импорта:dict.tsтянетCategoryIdиз../v1/categories,tool-strings.ts—$lib/v1/registry; после форка оба живут только в копии. Новому UI нужен параллельныйschema-tool-strings.tsповерх$lib/registry. - Tone detection в
SchemaTextResult.svelte:/^(yes|true)/iпо value — сломается при русских вердиктах («Да», «Нет»). tools-i18n.test.tsпокрывает только v1 TOOLS; нужен аналог для нового registry.known-issues.spec.ts:test.fixme("error message is localized, not a raw i18n key").
3. Как делаем
Принципы:
- Мелкие атомарные коммиты (правило AGENTS.md); самому не коммитить — коммитит разработчик после ревью.
- Изменения «снизу вверх»: сначала общий слой i18n, потом словари, потом UI.
- Каждый этап заканчивается зелёными
pnpm --dir web lint+pnpm --dir web check+ тесты. - Решения по §5 зафиксированы и учитываются с первых правок словарей.
4. Фазы
Фаза 0: Форк i18n для v1 + подготовка общего слоя
Выполняется строго до остальных фаз, без изменения поведения UI. Цель — два
независимых i18n-слоя: v1 получает замороженную копию, новый UI — свою ветку,
которую дальше меняем свободно. На выходе фазы — зелёные lint, check,
test, test:rules.
- 0.1 Форк: скопировать
web/src/lib/i18n/→web/src/lib/v1/i18n/(полный снимок:dict.ts,en.ts,ru.ts,t.ts,locale.svelte.ts,matching.ts,tool-strings.ts+ все 5 тестов). v1 работает дальше только с копией — поздние правки нового i18n её не затрагивают. - 0.2 Привести копию к v1-канону (относительные пути от нового места):
- копия
dict.ts: импортCategoryId../v1/categories→../categories(=lib/v1/categories); - копии тестов (
tools-i18n.test.ts,search-coverage.test.ts,smoke-i18n.test.ts):../v1/registry→../registry(=lib/v1/registry); - импорт
$lib/v1/registryв копииtool-strings.tsостаётся валидным; - перепроверить остальные относительные и
$lib-пути в копии.
- копия
- 0.3 Переключить импорты старого UI: все
$lib/i18n/*вroutes/v1/**иlib/v1/components/**(39 импортов в 23 файлах) →$lib/v1/i18n/*. - 0.4 Очистить новый
lib/i18n/от v1-специфики, иначе isolation поймает new→old:- удалить
tool-strings.ts(завязан на v1-типыParamDef/ToolEntry; для нового UI вместо негоschema-tool-strings.ts, см. 0.8); - удалить v1-центричные тесты
tools-i18n.test.ts,search-coverage.test.ts,smoke-i18n.test.ts(они импортируют../v1/registry; живут только в копии); - остаются:
dict.ts,en.ts,ru.ts,t.ts,locale.svelte.ts,matching.ts,i18n.test.ts,matching.test.ts.
- удалить
- 0.5 Обновить правило изоляции и конфиги:
isolation/no-mixed-imports.js: добавитьlib/i18n/**вDEFAULT_NEW(копия по-прежнему old черезlib/v1/**);eslint-plugins/__tests__/no-mixed-imports.test.ts: кейсы «old → i18n чисто» (строки 83–89) и «lib/i18n — shared» (строки 99–105) обновить под новую классификацию: old→новый i18n = нарушение; добавить фикстуру__fixtures__/src/lib/v1/i18n/t.tsдля кейса old-копия;eslint.config.js: вallowDefaultProjectдобавить фикстуруeslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts; поправить комментарий изоляции «Общее (core/, i18n/, theme)» → «Общее (core/, theme)»;eslint-plugins/README.md: убрать i18n из списка shared (§ Isolation).
- 0.6 Новый
lib/i18n/dict.ts:CategoryIdпереключить с../v1/categoriesна../categories(новый структурно-идентичный тип) — скрытых v1-зависимостей в новом i18n не остаётся. - 0.7 Проверка форка:
pnpm --dir web lint,check,test(v1-копия тестов подхватываетсяvitest include: src/**/*.test.ts),test:rules; смоук: v1-страницы и оба переключателя RU/EN работают как раньше, языки обеих версий согласованы. - 0.8 Добавить
lib/i18n/schema-tool-strings.tsповерх$lib/registry(новый типToolEntry):toolTitle(tool),toolDescription(tool)с фолбэком на registry English;fieldLabel(field, id)— перевод по ключу изfields-секции словаря (см. решение C), фолбэк наlabelOf(id);groupLabel(title)— перевод по ключу изgroups-секции словаря (см. решение A);verdictText(toolId, key)— рендер вердикта по ключу;verdictTone(key)—success|danger|infoпо имени ключа (*Yes/*No/*Portrait/*Landscape) — не по строке.
- 0.9 Расширить тип
Dict(только новая веткаlib/i18n):- секция
previewне заводится: оставшиеся после чистки mono-лейблов (см. решение B) строки нового UI живут в собственных категориях — расширяем существующие (catalog,header,dropZone,sourceCard,resultCard,paramsCard,textResult,ui) и заводим две маленькие новые:actions(Generate / Open image / Download result…) иtextSource(placeholder, Try sample…); - новая глобальная секция
fields: Record<string, string>— переводы подписей полей по ключу из схемы (решение C); - новая глобальная секция
groups: Record<string, string>— переводы заголовков групп полей (решение A).
- секция
Фаза 1: Словари — наполнение (только новая ветка lib/i18n, v1 не трогаем)
- 1.1 Заполнить
en.tsиru.tsсекциямиactions,textSource,fields,groupsи ключами недостающих строк в существующих секциях. - 1.2 Проинвентаризировать разницу между 122 инструментами нового registry и
ключами
ru.tools: для инструментов без перевода (нет в v1) добавить title/description в ru.ts новой ветки (по категориям — несколько коммитов). - 1.3 Добавить в
ru.tsключиfields/groupsдля используемых в схемах ключей полей и заголовков групп (по ходу проставления ключей в schema, Фаза 5). - 1.4 Добавить
tools[id].options[fieldId][value]для select-полей, чьи опции нужно локализовать. - 1.5 Тест полноты (задел на Фазу 6): для каждого ключа label в schema есть перевод в обоих словарях.
Фаза 2: LangToggle → реальный locale
- 2.1
LangToggle.svelte: убрать локальныйlang, подключитьgetLocale(),setLocale(),LOCALES. Модель — language switch изroutes/v1/+layout.svelte. - 2.2 Проверить e2e
navigation.spec.ts«language toggle marks the active button» — должен проходить без изменений.
Фаза 3: Routes + layout
- 3.1
+layout.svelte: breadcrumbs черезt(), динамическийlang. - 3.2
+page.svelte,list-tools/+page.svelte: footer-строка →t("header.footerNote"). - 3.3
tools/[id]/+page.svelte: «Tool not found», fallback title →t().
Фаза 4: Компоненты — основной блок (по одному коммиту на компонент)
Каждый коммит: удаление декоративных mono-лейблов (решение B) + замена
оставшихся строк на t/хелперы + lint/check. Список mono-лейблов, которые
удаляем: eyebrow'ы (PNG PROCESSING / SINGLE TOOL, KIT SHOWCASE,
EASY-PNG-TOOLS / CATALOG), статусы (LIVE PREVIEW, rendering ok), панельные
подписи (TOOL SETTINGS), дублирующие капшены (SOURCE / RESULT,
GENERATOR / RESULT). Оставляем и локализуем только информативные: категории
каталога, счётчики инструментов, подписи данных (FORMAT, parts, px, «ZIP
(PNG)»), alt-тексты, а также содержательные заголовки («Configure output»).
SchemaToolView.svelte: убрать eyebrow/LIVE PREVIEW/TOOL SETTINGS, перевести «Configure output» и «This tool has no schema yet.».SchemaSourceTile.svelte: убрать figcaptionSOURCE, перевести «choose an image»/«no source…» и alt.SchemaResultTile.svelte: убрать figcaptionRESULT, перевести «no result yet»/«parts» и alt.SchemaPreview.svelte: убрать панельныеSOURCE / RESULT/GENERATOR / RESULT, перевести meta-капшены (FORMATи т.п.).SchemaActions.svelte: Generating…, Generate, Open image, Download result.SchemaFields.svelte: Reset, «updates automatically»,labelOf(field.id)→fieldLabel(field).SchemaTextSource.svelte: placeholder, Try sample, Render text.SchemaTextResult.svelte: убрать лейблRESULTу вердикта, перевести Copy / Download .txt; tone detection →verdictTone().CatalogToolbar.svelte: фильтры →t("categories.all")+t("categories.X"), поисковый placeholder.CatalogHeader.svelte: убрать eyebrow, перевести heading/lead и счётчик «TOOLS AVAILABLE».CatalogGroup.svelte: перевести суффикс счётчика «TOOLS».Dropzone.svelte: текст зоны →t("dropZone.pickDefault").- Schema field controls (по одному или группой): Width/Height, From/To, Backing plate, Opacity, Font/Size/Color/Bold, Sans/Serif/Mono, Angle, «+ Add color», «Remove color».
TopNav.svelte,ThemeToggle.svelte,CodeBlock.svelte,StepCard.svelte,Footer.svelte.
Фаза 5: Вердикты + ключи в schema
- 5.1 Анализаторы в
registry/analyze.ts: возвращают ключи ("verifyYes"вместо raw English). - 5.2
SchemaPreview.svelte: рендер вердикта черезverdictText(tool.id, key). - 5.3 Layout groups в
registry/*.ts(решение A):title→ ключ (title: "groups.canvas"); вSchemaFields.svelte—groupLabel(title). ВDict— глобальная секцияgroups. - 5.4 Лейблы полей в
registry/*.ts(решение C): на поле в схеме проставляетсяlabel-ключ (field.number({ label: "fields.thickness", … })), вывод в UI —fieldLabel(field)=t(label). ВDict— глобальная секцияfields. - 5.5 Категории в
catalog.ts:GROUP_LABELS→t("categories.X")(ключи уже есть в dict).
Фаза 6: Тесты
- 6.1
new-tools-i18n.test.ts— полнота словарей для нового registry: title/description ru для всех инструментов, каждыйlabel-ключ схемы есть вfields, каждыйgroups-ключ есть вgroups, без лишних ключей. - 6.2 Фикс
known-issues.spec.tstest.fixme — ошибки локализуются. - 6.3 В новой ветке
lib/i18nпоявится собственныйsmoke-i18n.test.ts(аналог v1: переключение локали,html lang, контент без смеси языков) — v1-копия теста не трогается.
Фаза 7: Кросс-языковой поиск
Переключить поиск в новом каталоге с toLowerCase() на
scoreDoc()/normalizeForSearch() из matching.ts (входит в объём, см.
решение D). Последний шаг, аккуратный отдельный коммит.
Фаза 8: Линтер словарей (i18n key linter)
Замена ручной проверки ключей на автоматическую. Инфраструктура уже есть:
eslint-plugins/ умеет читать соседние файлы (isolation/no-mixed-imports) и
гоняться фикстурами (test:rules).
- 8.1 Новое правило
i18n/dict-consistencyвweb/eslint-plugins/. Применяется к файлам-словарям (конвенция: экспортируютDict, лежат вlib/i18n/); новые локали подхватываются автоматически — правка правила не нужна. - 8.2 Проверки — только
warn, никогдаerror(пропущенный перевод не валит сборку):- Паритет дерева ключей: каждый ключ (dot-path
a.b.c) присутствует во всех остальных локалях, не только против BASE. Нет —missing key "x.y" in en. - Пустые значения:
""и строка из одних пробелов —empty value "x.y" in en. - Паритет плейсхолдеров: у одинакового ключа набор
{name}одинаковый во всех локалях — поймать потерянную при переводе переменную.
- Паритет дерева ключей: каждый ключ (dot-path
- 8.3 Цель — zero-warn. Сейчас
en.tsнеполный (вtoolsтолько результаты-помощники, остальной английский — из реестра), поэтому после включения будет много warn'ов: доводимen.tsдо полнотыru.ts(секции +toolsполностью, source — registry-English, без дрейфа). До достижения цели допускается allowlist-механизм в ядре правила для осознанных отклонений. - 8.4 Тесты правила — фикстуры в
web/eslint-plugins/__fixtures__/, док вweb/eslint-plugins/README.md; включение вeslint.config.jsкак"warn".
Фаза 9: Множественные числа (plural-формы)
- 9.1 Тип значения словаря расширяем:
string | PluralForms, гдеPluralForms = { other: string } & Partial<{ one, few, many }>по CLDR-правилам локали (ru: обязательныone/few/many;other— всегда, как фолбэк). Новая функцияtPlural(path, n)вlib/i18n/: выбирает категорию черезIntl.PluralRules(locale).select(n)и подставляет её с интерполяцией{n}. - 9.2 Первые потребители: «часть/части/частей» (split-into-parts-png),
счётчики инструментов (
catalog.toolsCount), «N параметров», «N файлов». - 9.3 Паритет в линтере (Фаза 8): если ключ — plural-объект в одной локали,
он обязан быть plural-объектом во всех остальных (тип-паритет; string в одной
из локалей — warn). Набор форм между локалями не сравнивается дословно — он
проверяется по правилам конкретной локали (
otherобязателен; en: one/other, ru: one/few/many). - 9.4 Открытый вопрос на этапе реализации: неоднозначность «объект-секция»
(
Record<string, string>) vs plural-объект в дереве словаря. Варианты: отдельная секцияpluralsв словаре, конвенция имён ключей, вывод типа из контекста. Фаза начинается с проработки этого вопроса, не с кода.
5. Решения
- A. Layout groups: заголовки групп храним i18n-ключами прямо в схеме
(
title: "groups.canvas"), перевод — в глобальной секцииgroupsсловаря (Dict.groups: Record<string, string>). В UI —groupLabel(title)=t(title)с фолбэком на сырой ключ. - B. Mono-лейблы: декоративные лейблы без полезной информации удаляем (eyebrow'ы, статусы, панельные капшены, дублирующие подписи секций) — они занимают место. Оставляем только там, где реально нужно, и локализуем: счётчики инструментов, категории каталога, подписи данных (FORMAT, parts, px, «ZIP (PNG)»), alt-тексты, содержательные headings.
- C. Лейблы полей: в схеме задаём i18n-ключ
(
field.number({ label: "fields.thickness", … })), в UI выводится перевод —fieldLabel(field)=t(label). Ключи — глобальная секцияfieldsсловаря, фолбэк на текущийlabelOf(id)для несопоставленных. - D. Объём: переводим всё сразу — и каркас, и контент реестра, и вердикты, и кросс-языковой поиск (Фаза 7). Крупные изменения кода разбиваем на небольшие атомарные шаги/коммиты.
6. Проверки
pnpm --dir web lintpnpm --dir web exec svelte-check --tsconfig ./tsconfig.jsonpnpm --dir web testpnpm --dir web test:rules(если менялся eslint/isolation-конфиг)- Смоук: переключатель RU/EN на новом UI; страницы каталога/инструмента, ошибка,
вердикт — без смеси языков;
html langследует за локалью.