Files
houseplan-card/docs/specs/062-i18n-registry.md
T
2026-08-28 02:44:26 +03:00

18 KiB
Raw Blame History

Issue #62 — единый реестр языков и масштабируемые проверки i18n

  • Issue: https://github.com/Matysh/houseplan-card/issues/62
  • Статус документа: актуализировано 2026-08-28, готово к ревью ТЗ
  • Приоритет: P3
  • Тип: tech-debt, полный трек: задача меняет class A i18n и затрагивает несколько поверхностей; критерий small «одна поверхность» не выполняется
  • Пользовательское изменение: нет для существующих English/Russian установок

1. Сценарий

Основной потребитель результата — contributor/maintainer House Plan. На этапе добавления будущего языка он добавляет словарь и одну запись в реестр, а сборка сама подключает язык к определению локали, visual editor и проверкам паритета.

Для всех трёх пользовательских персон из docs/SCOPE.md поверхность остаётся той же: карточка и visual editor продолжают синхронно работать на English или Russian согласно явной настройке либо языку профиля Home Assistant.

2. Что человек увидит до и после

Пользователь English/Russian не увидит изменений; будущий переводчик вместо нескольких несвязанных правок получает один описанный и автоматически проверяемый путь добавления языка.

3. Проблема

Список языков сейчас продублирован в нескольких местах:

  • src/i18n.ts вручную задаёт Lang, imports, DICTS и русский fallback;
  • src/editor.ts вручную перечисляет English/Russian в selector;
  • test/i18n.test.mjs вручную проверяет только пару en/ru;
  • frontend и backend translation directories никак не сверяются.

Из-за этого новый JSON может не попасть в runtime, selector или gate. Докстрока src/i18n.ts при этом ошибочно обещает добавление языка без правок TypeScript.

Словари уже заметны по размеру, но при двух текущих языках асинхронная загрузка создала бы новый loading/error lifecycle без пользовательской пользы. Поэтому lazy loading исключён из #62 и должен стать отдельной задачей одновременно с добавлением третьей локали или при подтверждённой проблеме размера bundle.

4. Цели и scope

  1. Ввести один frontend registry как источник кодов, словарей, native labels и стабильного порядка языков.
  2. Вывести из registry тип Lang, language resolution и options visual editor.
  3. Расширить i18n gates на все зарегистрированные frontend dictionaries и набор backend translations.
  4. Описать contribution flow и ограничения текущей подстановки placeholders.
  5. Сохранить синхронный первый render и существующее поведение English/Russian.
  6. Исправить устаревший комментарий CardConfig.language в src/types.ts.

5. Не входит в задачу

  • добавление немецкого или любого другого языка;
  • выбор следующей локали по географии пользователей;
  • Weblate, Crowdin или другой внешний сервис;
  • code splitting, динамический import и сетевой fetch словаря;
  • RTL и зеркалирование геометрического UI;
  • новая политика локализации пользовательской документации;
  • изменение видимых английских или русских формулировок;
  • plural rules/ICU MessageFormat;
  • связывание Python runtime с TypeScript registry.

Упоминание lazy dictionaries удаляется из заголовка issue: #62 не должно выглядеть как обещание асинхронной загрузки, которой в acceptance criteria нет.

6. Контракт поведения

6.1. Registry

В src/i18n/registry.ts вводится статически анализируемый реестр. Каждая запись задаёт:

interface LanguageEntry {
  code: string;
  nativeLabel: string;
  dictionary: Record<string, string>;
}

en — обязательная fallback-запись. Lang выводится из литеральных кодов registry, а не поддерживается отдельным union. Registry экспортирует:

  • упорядоченный readonly список записей;
  • lookup зарегистрированной записи по нормализованному коду;
  • тип Lang;
  • английскую fallback-запись.

При двух локалях используются обычные static JSON imports. Добавление локали требует её frontend JSON, backend JSON и одной логической записи registry (включая её static import в том же модуле); src/editor.ts, langOf() и список локалей в тестах больше не редактируются.

Существующие editor.lang_en и editor.lang_ru не используются selector после рефакторинга, но в рамках #62 не удаляются: задача не смешивает инфраструктуру с чисткой словарей.

6.2. Разрешение языка

langOf(hass, configLanguage) работает детерминированно:

  1. зарегистрированное явное configLanguage побеждает;
  2. для HA locale проверяется нормализованный exact tag;
  3. затем primary subtag до первого - или _;
  4. иначе возвращается en.

Сравнение регистронезависимое, _ нормализуется в -. Например, ru-RU и ru_RU разрешаются в ru, если отдельная региональная запись отсутствует. Неизвестное явное legacy-значение не падает: оно трактуется как Auto и проходит HA locale → English fallback.

t() и hasTranslation() сохраняют цепочку selected dictionary → en → key. subst() и plural semantics не меняются.

6.3. Visual editor

Selector строит options в стабильном порядке:

  1. локализованный Auto;
  2. все registry entries в порядке registry.

Подпись языка — compile-time native label (English, Русский), а не ключ editor.lang_<code> во всех словарях.

Существующие language: en|ru|'' читаются без миграции. Если в сохранённом config встретился неизвестный код, selector показывает отдельную временную option с этим кодом и не меняет config при редактировании другого поля. После явного выбора Auto/зарегистрированного языка временная option исчезает. Runtime до такого выбора применяет обычный HA locale → English fallback.

6.4. Parity gate

test/i18n.test.mjs получает данные из скомпилированного pure registry export, а не парсит TypeScript регулярным выражением. Для этого src/i18n.ts и src/i18n/registry.ts включаются в tsconfig.test.json.

Gate проверяет:

  • коды являются BCP 47 tags и уникальны после case-insensitive нормализации (_ считается эквивалентом -); canonical spelling registry сохраняется в selector и точных именах frontend/backend файлов (pt-BR, а не принудительно pt-br);
  • en существует и является fallback;
  • у каждой записи есть непустые nativeLabel и dictionary;
  • каждый src/i18n/<code>.json зарегистрирован и у каждой записи есть ровно один такой JSON;
  • key set каждой локали равен English;
  • значения непусты, placeholders совпадают с English;
  • текущий help body/aria contract выполняется для всех словарей;
  • набор <code>.json в custom_components/houseplan/translations/ совпадает с кодами registry, без незарегистрированных и отсутствующих файлов.

Тестовая fixture с третьей локалью не добавляется в production registry: вместо этого unit-тесты проверяют generic resolution на искусственном registry/helper, либо доказывают отсутствие ветвления по ru в resolver. Production file-set gate остаётся только на реально поставляемых локалях.

7. UX

Внешний вид selector для Auto/English/Russian и выбранные значения остаются прежними. Порядок остаётся Auto, English, Русский.

Единственный новый защитный UX — неизвестный сохранённый код виден как временная option вместо пустого/ложного выбора и не затирается несвязанным изменением.

Новых loading indicators, flashes, requests или error messages нет.

8. Модель данных и миграция

Schema и persisted config не меняются. Миграции нет.

  • language: en, language: ru, пустая строка и отсутствие поля сохраняют прежний смысл;
  • неизвестная строка сохраняется до явного изменения пользователем;
  • runtime безопасно использует Auto fallback;
  • CardConfig.language остаётся string, потому что обязан читать будущие и неизвестные сохранённые значения; комментарий больше не перечисляет только en|ru.

9. i18n и документация

Новых видимых строк нет. Native labels доверенные compile-time literals.

CONTRIBUTING.md получает раздел «Translations»:

  • frontend JSON + backend JSON + registry entry;
  • placeholders нельзя удалять, добавлять или переводить;
  • subst() не поддерживает plural rules, поэтому строка должна избегать грамматической формы, зависящей от {n};
  • добавление языка интерфейса само по себе не создаёт новый комплект пользовательской документации; текущие English/Russian документы продолжают сопровождаться по действующим правилам проекта;
  • RTL — отдельная продуктовая задача.

Существующая строка в Ground rules про «one JSON file + registering it in src/i18n.ts» не остаётся рядом вторым контрактом: она заменяется ссылкой на новый раздел и формулировкой про frontend/backend JSON и src/i18n/registry.ts.

Докстрока src/i18n.ts приводится в соответствие реальному flow.

10. Acceptance criteria и доказательства

  • AC1. Lang, langOf() и options visual editor получают языки из одного registry. Доказательство: typecheck + unit на registry/options.
  • AC2. Exact tag, primary subtag, _/case normalization, explicit config, invalid explicit config и English fallback покрыты матрицей unit-тестов. Доказательство: unit-тест pure resolver.
  • AC3. Frontend locale file set, backend locale file set и registry взаимно однозначны. Доказательство: i18n file-set unit test.
  • AC4. Каждый зарегистрированный словарь автоматически проходит key, non-empty, placeholder и help-key parity. Доказательство: registry-driven test/i18n.test.mjs.
  • AC5. Selector имеет порядок Auto/English/Русский и строит registered options без ручного списка en/ru. Доказательство: unit на pure options helper и source diff src/editor.ts без ручного списка локалей.
  • AC6. Неизвестный сохранённый язык не ломает card/editor, использует Auto fallback и не затирается изменением другого editor field. Доказательство: unit для resolver/options/form normalization.
  • AC7. English/Russian dictionary values и видимый selector не меняются. Доказательство: git diff словарей + существующие unit/build gates.
  • AC8. В production bundle нет locale fetch, dynamic import, Promise-based translation path или нового первого пустого кадра. Доказательство: static registry source diff + успешный production build.
  • AC9. CONTRIBUTING и комментарии описывают фактический contribution flow и plural limitation. Доказательство: documentation diff.

11. План автотестов

Unit

  • registry uniqueness после нормализации, BCP 47 code format, English presence/fallback;
  • exact/primary/fallback locale matrix, case and _ normalization;
  • invalid explicit config;
  • registry-driven dictionary/file/backend parity;
  • placeholders и help keys для всех entries;
  • selector order/native labels/unknown temporary option;
  • сохранение неизвестного значения при несвязанном form change.

Обязательные локальные gates

npm run typecheck
npm test
npm run build
npm run inventory

Browser smoke, golden, backend HA harness и performance capture не требуются: пиксели, backend runtime, bundle topology и пользовательские сценарии не меняются. Production build обязателен для подтверждения static bundling.

12. Риски

Риск Мера
Locale зарегистрирован не во всех местах взаимная file-set проверка
Resolver меняет старый fallback полная матрица language resolution
Visual editor стирает неизвестное значение временная option + unit round-trip
Рефакторинг добавляет async flash static imports и запрет async path в AC8
Backend/frontend расходятся CI parity по обоим каталогам
Типы registry превращаются в общий string compile-time Lang из as const codes

13. Откат

Откат возвращает ручные imports/DICTS/selector options. Config schema, словари и сохранённые значения не меняются, поэтому data rollback не нужен.

14. Release-артефакты

  • Changelog: не нужен (User-Visible: no), поскольку English/Russian UX не меняется; защитное отображение неизвестного кода — compatibility guard.
  • User guide: не меняется.
  • Developer docs: CONTRIBUTING.md, комментарий src/i18n.ts и комментарий CardConfig.language обязательны.
  • Generated bundle: пересобрать и синхронизировать штатно перед code review.
  • Golden/screenshots: не требуются.

15. План реализации

  1. Выделить registry и pure helpers resolution/options.
  2. Перевести src/i18n.ts и src/editor.ts на registry.
  3. Добавить compatibility handling неизвестного editor value.
  4. Перевести i18n tests на compiled registry и включить locale file-set checks.
  5. Обновить CONTRIBUTING/comments.
  6. Выполнить обязательные gates, собрать и синхронизировать bundle.

16. Технические предположения — можно менять на ревью

  • src/i18n/registry.ts содержит static imports и readonly entries; отдельный generated manifest не нужен при двух языках.
  • Pure helper для selector может жить рядом с registry, чтобы тесты не зависели от Lit/ha-form.
  • Backend runtime не импортирует и не генерирует registry; связь обеспечивается только test gate.
  • Code splitting заводится отдельным issue не раньше третьей локали или измеримого bundle-size bottleneck.