РазработкаМультиязычность (i18n): исследование и план

Мультиязычность (i18n): исследование и план

Актуально на: 2026-08-12

  • Статус: волны 0 и 1 внедрены, волны 2–6 впереди
  • Дата: 2026-08-01, обновлено 2026-08-12
  • Аудитория: разработчики, планирование спринтов

Числа в этом документе получены измерениями, а не оценкой на глаз. Способ получения каждого — в разделе 14. Где остаётся допущение, оно помечено явно.

0. С чего продолжать

Раздел для того, кто возвращается к задаче. Всё остальное в документе — обоснования; здесь только состояние и следующий шаг.

Сделано

ВолнаСтатусКоммит
0 — развязка русских строк-идентификаторовd64e4550
1 — каркас (next-intl, cookie, User.locale, переключатель)e6f68236
Исправления по итогам ревью веткиd94e8f91

Ветка orbital-helix-1040d65e, в main не влито. Миграция prisma/manual-migrations/2026-08-12-user-locale.sql уже применена к боевой БД 2026-08-12 — повторно выполнять не нужно (ADD COLUMN IF NOT EXISTS, так что повтор безвреден).

Переведено 30 ключей: навигация, меню пользователя, общие подписи. Это около 0,5 % от ~6 300 переводимых строк — весь массив остаётся в волнах 2–6.

Следующий шаг: экстрактор строк

94 % работы механические, и узкое место — не написать t("key"), а найти и вырезать строку. Отсюда план: сначала скрипт-codemod, потом прогон по зонам.

Что должен делать экстрактор:

  • обходить TSX, находить строковые литералы и JSX-текст с кириллицей;
  • генерировать ключ из пути файла и секции (осмысленный, не text_412 — переводчику нужен контекст, см. 6.7);
  • заменять на t("…"), дописывать в messages/ru.json, добавлять хук в компонент;
  • не трогать шаблонные литералы с ${} и JSX со вставками (369 строк) — помечать их для ручной работы: склейка фраз ломается при переводе;
  • не трогать каталог данных (8 файлов, 1 599 строк) — он исключён из перевода.

Предусловие уже выполнено: волна 0 закрыта, от подписей ничего не зависит, поэтому массовая замена не сломает логику.

Порядок зон после экстрактора — по возрастанию риска: волна 2 (456 строк) → 3 (943) → 4 (511) → 5 (контракт API, отдельным PR) → 6 (4 290, самая большая).

Как проверить, что ничего не сломалось

pnpm i18n:check      # покрытие словарей; падает на ключах, которых нет в ru.json
npx tsc --noEmit     # типизация ключей ловит опечатки в t("…")
npx next build

Ручная проверка локали — через cookie, без логина:

curl -s -H "Cookie: NEXT_LOCALE=tk" http://localhost:3000/sign-in | grep -o '<html lang="[a-z]*"'

Что ещё не покрыто автотестами — волна T: тестов в проекте по-прежнему нет, проверки выше делались разовыми прогонами.

Что решено и пересматривать не нужно

  • префикс локали в URL — отложен (раздел 9), решение обратимо;
  • хранение языка — cookie как рантайм + User.locale как долговременное (5.2);
  • фолбэк tk → ru, а не на английский (6.6);
  • переключение языка в воркспейсе форсирует автосейв и отменяется при его сбое (5.1).

Открыто

  • Кто переводит tk.json — вопрос 6 в разделе 13. Пул исполнителей узкий; сейчас tk покрыт на 37 % и держится на русском фолбэке. Влияет на то, нужна ли интеграция с TMS;
  • Manrope объявлен в globals.css, но не подключён — интерфейс рендерится системным шрифтом. Отдельный баг, к i18n не привязан (вопрос 7);
  • клиентский бандл: NextIntlClientProvider отдаёт клиенту весь словарь. На 30 ключах это дешевле выборочной передачи, но перед волной 6 придётся резать по неймспейсам.

1. Рамки задачи

ВопросРешение
ЯзыкиРусский (текущий) → английский → туркменский. Порядок предварительный. Профиль языков — раздел 6
Что переводимТолько UI приложения. AI-ассистент, сгенерированный бизнес-план, правовые документы, письма, docs-site — вне рамок
URLЛокаль в URL не отражается. Язык хранится в cookie и профиле пользователя (5.2). Префикс пути (/en/…) рассмотрен, проверен спайком и отложен — обоснование в разделе 9
ВалютаНе входит. Расчёты остаются в рублях, меняется только язык интерфейса

Всё, что вне рамок, — в разделе 12.

2. Текущее состояние

2.1. Инфраструктуры i18n нет

  • Ни одной библиотеки локализации в package.json.
  • В next.config.ts нет настроек локалей.
  • Единственные упоминания «locale» в коде — вызовы toLocaleString.

2.2. Объём захардкоженного русского текста

Замер по app/, components/, lib/ (TS/TSX), комментарии исключены. Строки разделены по трудоёмкости извлечения:

  • механические — простой строковый литерал или JSX-текст без вставок, заменяется на t("key") почти без раздумий;
  • ручные — шаблонный литерал с ${} или JSX-текст с {}, требует ключа с плейсхолдером и проверки, что фраза не склеена из кусков (склейка ломается при переводе на язык с другим порядком слов).
ЗонаМеханическихРучныхВсегоФайлов
Воркспейс (редактор бизнес-плана)3 7352263 961102
Каталог данных — не переводим1 59361 5998
Маркетплейс и витрина8756894344
Прочее (общие компоненты, лейауты)3372536235
AI-ассистент (UI вокруг чата)32813296
Админка2771829526
Ревью планов2051121610
Авторизация и профиль8689412
API-роуты (тексты ошибок)77128924
Всего найдено7 5133757 888267
К переводу (без каталога данных)5 9203696 289259

Главный вывод для планирования: 94 % работы механическая. Задача не столько сложная, сколько объёмная, и хорошо параллелится между исполнителями по зонам.

Крупнейший единичный файл — app/workspace-client.tsx (20 403 строки кода): 1 225 переводимых строк, из них 1 149 механических.

Всего файлов TS/TSX — 321, кириллица встречается в 267 (83 %). Ещё ~9 300 слов кириллицы в комментариях — их не трогаем.

2.3. Форматирование чисел и дат прибито к ru-RU

~85 вызовов toLocaleString("ru-RU") / toLocaleDateString("ru-RU") с явной локалью-строкой плюс 58 вхождений в разметке. Даже при рублёвых расчётах формат числа и даты обязан следовать локали интерфейса.

2.4. <html lang> не соответствует контенту

app/layout.tsx:33<html lang="en"> при полностью русском контенте. Это баг уже сегодня.

2.5. Соотношение серверных и клиентских компонентов

148 .tsx в app/ + components/, из них 63 с "use client"; 31 page.tsx, 84 API-роута. Переводы нужны и в RSC, и на клиенте — это отсекает решения «только React-контекст».

2.6. Тестовой инфраструктуры нет

Ни playwright, ни jest, ни vitest; в package.json нет скрипта test. См. волну T.

2.7. Аутентификация: что уже есть

  • auth.ts:125session: { strategy: "jwt" };
  • коллбэк jwt (auth.ts:127) уже делает prisma.user.findUnique({ select: { role: true } }) — готовая точка, куда бесплатно добавляется locale;
  • UserUiSettings.settings — JSON с оформлением воркспейса (themeId, bgColor, paddingLeftPct, workspaceViewOrder), читается только на странице воркспейса и в настройках. Для локали это не подходит — см. 5.2.

3. Ключевые препятствия

П1. Русские строки в роли идентификаторов

Текст, который нельзя просто перевести, потому что от него зависит логика:

  • lib/workspace/navigation.ts:28NAVIGATION_MAP с русскими ключами: "План сбыта" → ops-sales. AI-ассистент возвращает русское название раздела, код резолвит его в ID;
  • lib/ai/validate-action.ts:133 — валидация сравнивает категорию с "материал" / "комплектующее";
  • app/workspace-client.tsx:4823, components/workspace/sections/mik-form-modal.tsx:92 — те же значения пишутся в модель;
  • сравнения в workspace-client.tsx: item === "Пустой раздел", cell === "проверить", originalText === "пусто".

Уточнение по схеме БД. В ранней редакции документа сюда же были записаны regimeName @default("ОСНО") и groupName @default("Производство"). Проверка показала, что это не идентификаторы: рядом с regimeName лежит regime TaxRegimeKind @default(osno) — настоящий enum, а regimeName при нём отображаемая подпись; groupName у сотрудника пользователь вводит сам (в проде 7 разных значений). Это пользовательские данные, переводу не подлежат и в развязке не нуждаются.

Что из этого лежит в проде — в разделе 8.

П2. app/workspace-client.tsx — 20 403 строки

1 225 переводимых строк, 94 % механические. Файл не разбиваем; режем работу по секциям, которые уже вынесены в components/workspace/sections/*, с русским фолбэком для непереведённого.

П3. Тексты ошибок в API

89 строк в 24 роутах. Роуты не знают локаль запроса. Решение — коды ошибок вместо текста, см. волну 5.

П4. Локале-зависимые страницы теряют статический рендер

Прямое следствие отказа от префикса в URL. Чтение cookie в серверном компоненте переводит маршрут в динамический рендер. Сейчас sign-in, sign-up, forgot-password и legal/* собираются статически — при cookie-локали они станут динамическими.

При текущем трафике это не имеет значения. Смягчение, если понадобится: оставить публичные и правовые страницы на русском (без языковой вариативности) — тогда они сохраняют статику, а переключается только приложение за логином.

Что перестало быть препятствием

Композиция proxy.ts с withAuth и неделимый переезд дерева роутов были главными рисками, пока рассматривался префикс в URL. Оба уходят вместе с ним. Разбор сохранён в разделе 7 как готовое решение на случай, когда префикс понадобится.

4. Выбор слоя переводов

Вариант A — next-intl

Проверено: next-intl@4.13.4 встаёт на Next 16.1.3 + React 19.2 + next-auth 4.24.13 без конфликтов peer-зависимостей.

Используем в режиме без i18n-роутинга: локаль берётся из cookie в i18n/request.ts, сегмент [locale] и middleware не нужны.

Что остаётся полезного даже без роутинга:

  • работа и в RSC (getTranslations), и на клиенте (useTranslations) — закрывает 2.5;
  • ICU MessageFormat — плюрализация на трёх языках с разным числом форм, форматы чисел и дат по локали;
  • типизация ключей — опечатка ловится компилятором, а не в рантайме;
  • фолбэк на дефолтную локаль из коробки.

Отпадают createMiddleware и createNavigation — они нужны только для префиксного роутинга.

Вариант B — собственный слой

JSON-словари + свой хелпер и контекст. Ноль зависимостей, полный контроль.

Минусы под нашу задачу: ICU-плюрализацию на три языка писать самим; разводить RSC и клиент вручную; без типизации ключей опечатка вылезает в рантайме. Суммарно 300–400 строк инфраструктуры ради подмножества next-intl — меньше, чем было бы с роутингом, но всё ещё неоправданно.

Рекомендация

next-intl 4.13+ без i18n-роутинга. Даже в урезанном виде он закрывает три вещи, которые в своём слое дают отдельные классы багов: RSC/клиент, плюрализацию и типизацию ключей.

5. Целевая архитектура

i18n/
  request.ts        getRequestConfig — читает cookie NEXT_LOCALE, грузит словарь
  locales.ts        список локалей, дефолт, определение по Accept-Language
messages/
  ru.json           источник истины, всегда полный
  en.json / tk.json могут быть неполными → фолбэк на ru
next.config.ts      обёрнут в createNextIntlPlugin("./i18n/request.ts")
app/                структура НЕ меняется — ни [locale], ни переезда групп
proxy.ts            не трогаем — остаётся как есть

Ключевые решения:

  1. Дерево роутов не меняется. Ни один URL не сдвигается, proxy.ts с withAuth остаётся нетронутым, разошедшиеся наружу ссылки на листинги не ломаются.
  2. Структура ключей по зонам: common.*, nav.*, workspace.* (вложенно по секциям), marketplace.*, admin.*, auth.*, reviews.*, errors.*.
  3. Фолбэк на русский. Отсутствующий ключ отдаёт русский текст, а не undefined и не сам ключ. Для туркменского фолбэк именно на русский содержательно верен (6.6).
  4. <html lang> вычисляется из активной локали — заодно чинится 2.4.

5.1. Переключатель языка

  • Место: в шапке рядом с меню пользователя; на страницах (auth) — отдельно, потому что там шапки нет, а выбрать язык до входа нужно; в подвале левой панели воркспейса.
  • Механика: выставить cookie NEXT_LOCALE, обновить профиль (если пользователь залогинен), обновить текущий маршрут (router.refresh()), чтобы серверные компоненты перерисовались на новом языке.
  • Краевой случай решён: в воркспейсе refresh() перемонтирует дерево, и несохранённое состояние пропало бы. Переключатель принимает beforeSwitch — там это saveWorkspaceStateNow. Язык меняется только после успешного сохранения; при сбое переключение отменяется, и пользователь остаётся на странице со своими правками.

Две разные роли, которые важно не смешивать.

Cookie NEXT_LOCALE — рантайм-механизм. Из него читается локаль при каждом рендере: в i18n/request.ts, без обращения к БД и без запроса сессии. Работает одинаково для гостя и залогиненного.

Колонка User.locale — долговременное хранилище. Читается один раз, при входе, и синхронизируется в cookie. Даёт то, ради чего колонка и заводится: предпочтение переживает очистку кук, инкогнито и переход на новое устройство.

При такой схеме определение языка не стоит ни одного запроса к БД, а профиль делает ровно то, что должен, — помнит выбор между сессиями.

Место для колонки — модель User, рядом с role. Не UserUiSettings: там оформление воркспейса, и читается оно только на двух типах страниц (2.7). Язык интерфейса — атрибут аккаунта, а не рабочей области. В auth.ts:127 уже есть findUnique с select: { role: true }locale добавляется туда бесплатно.

model User {
  // ...
  role   GlobalRole @default(USER)
  locale String?    // null = пользователь не выбирал → определяем по браузеру
}

Колонка обязательно nullable, без @default("ru"). Иначе навсегда теряется различие между «выбрал русский» и «не выбирал», и при будущей смене языка по умолчанию все окажутся жёстко прибиты к русскому.

Правила приоритета

СитуацияЧто побеждает
Пользователь нажал переключательПишем и в cookie, и в БД (если залогинен)
Рендер любой страницыcookie → Accept-Languageru
Вход в аккаунт, в БД язык заданЗначение из БД, cookie перезаписываем
Вход в аккаунт, в БД пусто, в cookie есть явный выборПринимаем выбор из cookie и записываем в БД

Деталь, которая делает правила непротиворечивыми: cookie пишем только при явном выборе пользователя, никогда — по результату определения из Accept-Language. Тогда наличие cookie само по себе означает «человек выбрал», и не приходится гадать, откуда взялось значение.

Осознанный компромисс: смена языка на устройстве A не долетит до устройства B, пока B заново не войдёт. Альтернатива — дёргать сессию при каждом рендере; обмен выгодный.

5.3. Как увидеть, что ещё не переведено

Русский фолбэк (5, п. 3) держит интерфейс рабочим, но у него есть обратная сторона: на глаз непереведённая строка неотличима от переведённой — и там, и там осмысленный текст.

Поэтому есть режим, в котором строки, взятые из русского фолбэка, приходят обрамлёнными: ⟦Язык интерфейса⟧. Включается тумблером «Показать непереведённое» рядом с переключателем языка.

Обрамление, а не подсветка цветом, — потому что перевод попадает не только в текст JSX, но и в aria-label, title, placeholder, где никакой разметки быть не может. Символы ⟦⟧ выбраны за то, что в контенте не встречаются и ищутся через Ctrl+F.

Кому виден тумблер: вне продакшена — всем (рабочий инструмент разработки), в продакшене — только администраторам, иначе маркеры ⟦⟧ читались бы как поломка интерфейса. Стенд может открыть режим всем через NEXT_PUBLIC_I18N_DEBUG=1.

Саму маркировку включает персональная cookie PF_I18N_DEBUG, поэтому роль на сервере не проверяется: запрос к сессии на каждый рендер стоил бы дороже всего определения локали, а увидеть маркеры можно только у себя.

Второй инструмент, для планирования, а не для глаз, — pnpm i18n:check: печатает процент покрытия по каждой локали и список непереведённых ключей. Падает он только на ключах, которых нет в ru.json, — такой перевод молча не применится.

6. Профиль языков: английский и туркменский

6.1. Главное: RTL не нужен

Современный туркменский — латиница, слева направо. Возврат к латинице начался в 1993, финальная редакция алфавита — 1999 (30 букв). Персо-арабское письмо используется туркменами Ирана и не касается пользователей из Туркменистана.

Следствие: работа с dir, логическими CSS-свойствами и зеркалированием не требуется. Если когда-нибудь появится RTL-язык, это отдельная крупная задача, потому что весь воркспейс написан в физических CSS-свойствах.

6.2. Диакритика и шрифты

Алфавит: A B Ç D E Ä F G H I J Ž K L M N Ň O Ö P R S Ş T U Ü W Y Ý Z. Восемь букв с диакритикой; три — за пределами Latin-1:

БуквыБлокПодмножество Google Fonts
ä ç ö ü ýLatin-1 Supplementlatin
ň ž şLatin Extended-Alatin-ext

ň и ş частотны, не экзотика.

Что в проекте сейчас:

  • app/layout.tsx:9-17 грузит Geist / Geist_Mono с subsets: ["latin"] — без кириллицы, хотя приложение русское;
  • app/globals.css:57 задаёт font-family: "Manrope", "Segoe UI", ..., но Manrope нигде не подключён. Интерфейс рендерится системным шрифтом;
  • Geist реально используется в одном месте (globals.css:1870).

Текущий риск для туркменского низкий — системные Segoe UI и Arial покрывают Latin Extended-A. Но когда шрифтовой стек починят, подмножества обязаны включать latin-ext и cyrillic. У Geist на Google Fonts оба есть — проверено:

const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin", "latin-ext", "cyrillic"] });

next/font/google самохостит шрифты на сборке — рантайм-запросов к fonts.googleapis.com из браузера нет. Для аудитории в Туркменистане с ограниченным доступом к внешним сервисам это плюс; доступность остальных внешних скриптов (аналитика OpenPanel) стоит проверить отдельно.

6.3. Плюрализация: туркменский проще русского

ЛокальКатегории CLDR
ruone, few, many, other
enone, other
tkone, other

Русский — худший случай. Структура сообщений, спроектированная под него, вмещает остальные два без изменений формата. Обратное неверно — поэтому ru.json и есть источник истины.

6.4. Форматирование чисел и дат

ICU поддерживает tk во всех трёх Intl-API.

ЛокальЧислоДата (dateStyle: "long")
ru1 234 567,891 августа 2026 г.
en1,234,567.89August 1, 2026
tk1 234 567,891 awgust 2026

Туркменский формат чисел совпадает с русским. Вызовы toLocaleString("ru-RU") при переходе на туркменский визуально ничего не сломают — чинить их надо ради английского.

6.5. Расширение текста

Туркменский агглютинативный: словоформы длиннее русских. Английский обычно на 10–20 % короче. Английская локаль не проверяет вёрстку на переполнение, туркменская проверяет. Узкие места: подписи кнопок, вкладки разделов, заголовки колонок финансовых таблиц, бейджи статусов. Приём — псевдолокаль с удлинением строк на +30 % до появления настоящих переводов.

6.6. Каскад фолбэков

Непереведённый ключ в tk.json падает на русский, не на английский: русский в Туркменистане распространён как второй язык, английский — существенно меньше.

Архитектурно это бесплатно при дефолтной локали ru. Зафиксировать стоит явно: если дефолтной когда-нибудь сделают английскую, фолбэк для туркменского надо будет задать отдельно.

Сопоставление Accept-Language резолвит tk-TMtk — проверено, см. 7.4.

6.7. Источник переводов — операционный риск

Туркменский слабо поддержан индустрией локализации: мало переводчиков, машинный перевод заметно слабее. На архитектуру не влияет, на планирование влияет: en.json можно закрыть машинным переводом с ревью, tk.json — почти наверняка нет. Закладывайте отставание туркменской локали на несколько волн; фолбэк держит приложение рабочим.

Отсюда же довод за осмысленные ключи и глоссарий: workspace.sections.opsSales.title, а не workspace.text_412. Переводчику без контекста и без словаря финансовых терминов («кэш-фло», «эффективность инвестиций», «ставка дисконтирования») качественный перевод не сделать. Опора — ../domain/glossary.md.

6.8. Сводка

АспектВлияние на план
RTLНе нужен
latin-ext в шрифтахМелкая правка в волне 1; попутно чинится кириллица
ПлюрализацияИзменений нет, русский покрывает худший случай
Формат чисел/датИзменений нет, tk совпадает с ru; чинить ради en
ВёрсткаПсевдолокаль-тест добавить в приёмку волн 2–4
ФолбэкЗафиксировать tk → ru явно
ПереводыПланировать отставание tk от en

Выбор туркменского не меняет архитектуру и не увеличивает оценку.

7. Спайк: что проверено на живом коде

Каркас собран в отдельном git-worktree на текущем HEAD, рабочее дерево не затронуто, worktree удалён.

Спайк проверял вариант с префиксом в URL — тот, который по итогам отложен (раздел 9). Его результаты сохраняют ценность в двух отношениях: часть подтверждает совместимость next-intl с нашим стеком независимо от режима, часть — готовый рецепт на случай, когда префикс понадобится.

7.1. Совместимость и типы — применимо к любому режиму

  • pnpm add next-intl4.13.4, конфликтов peer-зависимостей с Next 16.1.3 / React 19.2 / next-auth 4.24.13 нет;
  • next build проходит успешно;
  • плагин в конфиге обязателен, иначе страницы падают с Couldn't find next-intl config file:
// next.config.ts
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
export default withNextIntl(nextConfig);

7.2. Композиция proxy.ts — рецепт на будущее

Актуально только при возврате к префиксу в URL. В текущем плане proxy.ts не трогаем.

next-intl требует широкий matcher. Наивная композиция «широкий matcher + withAuth на всё» ломает вход в приложение: /sign-in сам попадает под проверку авторизации, уходит в редирект на себя, и это вырождается в 404 — в логе при этом ничего похожего на ошибку авторизации.

Лечится явным списком защищённых путей:

import createMiddleware from "next-intl/middleware";
import { withAuth } from "next-auth/middleware";
import { NextResponse, type NextRequest } from "next/server";
import { routing } from "./i18n/routing";
 
// Путь без префикса локали — вся авторизационная логика работает только с ним.
function stripLocale(pathname: string): string {
  const m = pathname.match(/^\/([a-z]{2})(?=\/|$)/);
  if (m && (routing.locales as readonly string[]).includes(m[1])) {
    return pathname.slice(m[1].length + 1) || "/";
  }
  return pathname;
}
 
const intlMiddleware = createMiddleware(routing);
 
const authMiddleware = withAuth((req) => intlMiddleware(req), {
  pages: { signIn: "/sign-in" },
  callbacks: {
    authorized({ token, req }) {
      const pathname = stripLocale(req.nextUrl.pathname);   // ← ключевое
      if (isPublicPath(pathname, req.method)) return true;
      if (!token) return false;
      if (pathname.startsWith("/admin")) return token.role === "ADMIN";
      if (pathname.startsWith("/reviews")) {
        return token.role === "REVIEWER" || token.role === "ADMIN";
      }
      return true;
    },
  },
});
 
// Список должен оставаться явным: matcher широкий, и если пускать через
// withAuth всё подряд, страница /sign-in уходит в редирект сама на себя.
const PROTECTED = [
  /^\/projects(\/|$)/, /^\/marketplace(\/|$)/, /^\/reviews(\/|$)/,
  /^\/admin(\/|$)/, /^\/model-value-catalog(\/|$)/, /^\/my-listings(\/|$)/,
  /^\/my-purchases(\/|$)/, /^\/settings(\/|$)/, /^\/profile(\/|$)/,
  /^\/referrals(\/|$)/, /^\/api\//,
];
 
const runAuth = authMiddleware as unknown as (r: NextRequest) => Response;
 
export default function proxy(req: NextRequest) {
  const pathname = stripLocale(req.nextUrl.pathname);
 
  // API не участвует в локале-роутинге: только авторизация, без rewrite.
  if (pathname.startsWith("/api/")) {
    if (isPublicPath(pathname, req.method)) return NextResponse.next();
    return runAuth(req);
  }
 
  if (isPublicPath(pathname, req.method)) return intlMiddleware(req);
  if (!PROTECTED.some((re) => re.test(pathname))) return intlMiddleware(req);
  return runAuth(req);
}

Две ловушки, которые стоили бы дня отладки:

  • stripLocale обязателен внутри authorized — иначе pathname.startsWith("/admin") не сработает для /en/admin, и админка окажется открыта любому авторизованному пользователю на нерусских локалях;
  • runAuth требует приведения типа: сигнатура withAuth из next-auth v4 не совпадает с тем, как её вызывают из proxy.ts в Next 16. Приведение безопасно (проверено в рантайме), но это заплатка.

Переезд дерева под app/[locale]/ ломает ровно 16 импортов — все вида @/app/(app)/settings/actions (11 из них — именно этот модуль). После правки tsc --noEmit даёт 0 ошибок.

7.3. Матрица авторизации при префиксе

ПутьКодРезультат
/admin, /en/admin, /tk/admin307/sign-in?callbackUrl=…
/projects, /en/projects, /tk/projects307/sign-in?callbackUrl=…
/reviews, /en/reviews, /settings, /en/settings307/sign-in?callbackUrl=…
/marketplace/tags (служебная)307/sign-in ✅ остаётся закрытой
/marketplace/abc, /en/marketplace/abc✅ публичный доступ сохранён
/sign-in, /en/sign-in, /tk/sign-in200

<html lang> отдавался корректно: /en/…lang="en", /tk/…lang="tk", без префикса → lang="ru".

7.4. Определение локали

Accept-Language резолвится корректно — это применимо и к текущему плану, где определение по браузеру остаётся фолбэком при отсутствии cookie:

ЗаголовокРезультат
en-US,en;q=0.9en
tk-TM,tk;q=0.9tk (tk-TM резолвится в tk из коробки)
ru-RU,ru;q=0.9ru

7.5. Статическая генерация при префиксе

generateStaticParams + setRequestLocale давали статику на каждую локаль: в prerender-манифесте лежали /ru|en|tk/sign-in, /…/sign-up, /…/forgot-password, /en/legal/*. 31 маршрут под [locale] из 118.

Это ровно то, что теряется при cookie-локали (П4) — и главный технический аргумент за префикс, если трафик вырастет.

7.6. Что спайк НЕ проверял

  • Поведение с реальным авторизованным пользователем (проверялись только анонимные запросы);
  • ни одна строка UI не переведена — проверялся каркас, не перевод;
  • hreflang и локализованные OG;
  • нагрузка и размер бандла при трёх словарях.

8. Данные в проде: что придётся мигрировать

Проверено чтением боевой БД (только SELECT).

8.1. Масштаб

Проектов103
Сохранённых состояний воркспейса30
Пользователей14
Суммарный объём JSON-состояний131 kB (максимум в строке — 10 kB)

Ранняя стадия продукта. Любая миграция данных здесь — вопрос минут, а не риск.

8.2. Русские значения-идентификаторы в проде

Материалы хранятся не нормализованно, а внутри JSON ProjectWorkspaceState.state (модели Material в схеме нет):

{ "id": "demo-mik-cloud", "name": "Облачная инфраструктура (K8s + БД)",
  "unit": "пакет/мес", "category": "материал" }
ЗначениеВ скольких состояниях из 30
"материал"13
"комплектующее"12
"разово"1
"месяц", "квартал", "год", "услуга"0

Проверенные нормализованные колонки переводить не нужно — это пользовательские данные:

  • ProjectTaxSettings.regimeName: ОСНО (6), УСН доходы (1) — подпись при enum regime;
  • ProjectPersonnelEmployee.groupName: 7 значений, введённых пользователями.

8.3. Рекомендация по волне 0

Не делать разовую миграцию JSON, а добавить нормализатор на чтении, принимающий и старые русские значения, и новые идентификаторы:

const MIK_CATEGORY = { "материал": "material", "комплектующее": "component" } as const;

Дешевле, безопаснее, без окна простоя. Разовый проход по 30 состояниям можно сделать позже как уборку.

9. Отложено: префикс локали в URL

Вариант /en/marketplace/123 рассматривался как основной, проверен спайком и отложен. Обоснование — ниже, чтобы решение можно было пересмотреть осознанно, а не переоткрывать с нуля.

9.1. Что показали данные

Листингов в маркетплейсе72 (68 одобренных, 4 проданных)
Просмотров листингов за всё время12
Заказов26
sitemap.ts / robots.tsотсутствуют

Есть только alternates.canonical в метаданных двух публичных страниц. Поисковикам никто не сообщает, что эти 72 страницы существуют. SEO как направление не начато.

9.2. Почему главный аргумент здесь слабее, чем обычно

Префикс нужен ради индексируемой языковой версии страницы. Но индексируемый контент листинга — это пользовательский текст на русском: название, описание, теги пишут авторы бизнес-планов. Перевод интерфейсной обвязки не превращает страницу в англоязычную — поисковик увидит тот же русский текст.

То есть hreflang повесится на две страницы, отличающиеся шапкой и футером при идентичном теле. Это ровно та ситуация, ради которой префиксы обычно не заводят.

9.3. Постоянная стоимость, а не только разовая

Разовые ~3 дня на переезд 31 роута — не главное. Главное — два постоянных налога:

  • Дисциплина навигации. Каждый Link и router.push обязан идти через обёртку из i18n/navigation. Сегодня 27 файлов, дальше — каждый новый файл навсегда. Забыли один раз — пользователь молча выпадает на язык по умолчанию, и это не ловится ни типами, ни линтером.
  • Поверхность для дыр в авторизации. Ловушка со stripLocale из 7.2 — не единичный случай, а класс: каждое новое правило доступа обязано быть локале-осведомлённым, и тестовая матрица умножается на число языков.

9.4. Это не дверь в одну сторону

Решающий довод. Роутинг ортогонален переводу. Все 6 289 строк, обёртки useTranslations/getTranslations, структура словарей, плюрализация — при обоих вариантах одинаковы. Это 94 % работы.

Если через полгода префикс понадобится, доплата — те же ~3 дня за переезд роутов на уже переведённом коде. Ничего не пропадает. Русские URL при localePrefix: "as-needed" не меняются, ломать нечего.

9.5. Что должно случиться, чтобы вернуться к префиксу

  • появился реальный нерусскоязычный трафик на маркетплейс;
  • принято решение вкладываться в SEO — но тогда первым делом нужны sitemap.ts и robots.ts, а не префиксы;
  • листинги начали публиковать на нескольких языках, и отдельная языковая версия страницы стала осмысленной;
  • вырос трафик на публичные страницы настолько, что потеря статического рендера (П4) начала стоить денег.

10. План миграции

Волны идут в порядке «риск ↓, польза ↑». Каждая мержится отдельно.

Волна T — тестовая инфраструктура

Тестов в проекте нет (2.6). Без префикса объём здесь сильно меньше, чем предполагалось: проверять авторизацию по локалям больше не нужно, потому что пути не меняются.

Минимум: playwright, smoke-прогон ключевых страниц, проверка что переключение языка не ломает сессию и не теряет несохранённое состояние воркспейса.

Волна 0 — развязать русские строки-идентификаторы ✅ внедрена 2026-08-12

Структура бизнес-плана. peStructure был массивом строк, и из текста пункта вычислялось всё: навигация — поиском подписи в NAVIGATION_MAP, признак минимального БП — подстрокой [МИНИМУМ], отображаемая подпись — вырезанием [...] регуляркой. Теперь пункт — объект PlanStructureItem с полями label / section / isMinimum: подпись переводится, поведение задаётся полями. NAVIGATION_MAP удалена целиком.

Уточнение к П1: исследование считало, что карту резолва кормит AI-ассистент. Проверка показала обратное — navigate_to_section возвращает готовый ClassicSectionId (lib/ai/action-types.ts:8), а карта обслуживала только клики в структуре плана. Поэтому алиасы AI не понадобились.

Категории материалов. Новый модуль lib/workspace/mik-category.ts: идентификаторы material / component, normalizeMikCategory (принимает и старые русские значения, и новые), MIK_CATEGORY_LABELS для показа. Тип MikRow.category заменён во всех 9 местах, где он был объявлен строковым union. Разовой миграции JSON нет — нормализатор на чтении, как и планировалось в 8.3.

Валидация действия AI больше не сравнивает категорию с подписями: isKnownMikCategoryInput принимает и идентификатор, и русский алиас, а текст ошибки собирается из MIK_CATEGORY_INPUT_HINT.

Сравнения с русскими литералами в workspace-client.tsx:

БылоСтало
originalText === "пусто"константа EMPTY_BINDING_TEXT
item === "Пустой раздел" / "Пустой блок"массив объектов { id, label }, действие по id
label === "Объект" / "Ряд" / "От"значение лежит рядом с подписью в объекте
cell === "проверить"статус строки — поле status, подпись отдельно
missing[0] !== "Раздел закрыт предыдущим gate"удалено

Последнее оказалось мёртвым кодом: ветка выполняется только при !isOpen, а именно !isOpen и порождает эту заглушку в progress-gates.ts — условие не срабатывало никогда. Перевод строки сделал бы мёртвую ветку заметным багом.

Схему БД в части regimeName / groupName не трогали — там подписи, а не идентификаторы.

Исправлено по итогам ревью. Переименование было доведено не везде: пять мест рендерили категорию сырой (material вместо «материал»), а токен модели pf:mik.list уносил идентификатор в выгружаемые DOCX/PPTX/XLSX — то есть в русский бизнес-план, отдаваемый банку. Все места переведены на mikCategoryLabel(), который сам прогоняет значение через нормализатор: категория может прийти из ветки, которую read-time нормализатор не проходил — например из правки ревью, составленной по домиграционному снимку. Та же нормализация добавлена в extractSectionSlice, иначе старый снимок отличался бы от текущего состояния по каждой строке материалов и ревьюер видел бы правки, которых автор не делал.

Что стоит помнить об откате. Состояния теперь сохраняются с идентификаторами. Прежний код на такой записи распознал бы component как «материал» — обратной совместимости у отката нет, только у чтения вперёд.

Волна 1 — каркас ✅ внедрена 2026-08-12

Что появилось в коде:

ФайлРоль
i18n/locales.tsсписок локалей, дефолт, имя cookie, разбор Accept-Language с q-весами
i18n/get-locale.tsрезолв активной локали: cookie → Accept-Languageru
i18n/messages.tsзагрузка словаря с глубоким слиянием поверх ru.json — фолбэк
i18n/request.tsgetRequestConfig для next-intl, timeZone: "Europe/Moscow"
i18n/actions.tssetLocale (явный выбор) и syncLocaleWithProfile (сведение при входе)
messages/{ru,en,tk}.jsoncommon.*, nav.*, userMenu.*, language.*
types/next-intl.d.tsтипизация ключей по ru.json
components/language-switcher.tsxпереключатель, варианты menu и compact
components/locale-sync.tsxсведение профиля с cookie после входа
i18n/debug.ts + components/untranslated-toggle.tsxрежим «показать непереведённое» (5.3)
scripts/check-i18n-messages.mjspnpm i18n:check — полнота словарей
prisma/manual-migrations/2026-08-12-user-locale.sqlUser.locale TEXT

Изменённое: next.config.ts (плагин), app/layout.tsx (<html lang> из локали, провайдер, подмножества latin-ext + cyrillic), lib/require-db-user.ts и components/app-shell.tsx (проброс locale тем же запросом), components/ui-settings.tsx и components/user-menu.tsx (первые переведённые строки), app/(auth)/layout.tsx (переключатель до входа).

Дерево роутов и proxy.ts не тронуты, как и планировалось.

Проверено на живом сервере — резолв локали, все восемь случаев:

Вход<html lang>
Accept-Language: ru-RU,ru;q=0.9ru
en-US,en;q=0.9en
tk-TM,tk;q=0.9tk
de-DE,de;q=0.9 (неподдерживаемый)ru
заголовка нетru
en-US + cookie NEXT_LOCALE=tktk — cookie сильнее заголовка
ru-RU + cookie NEXT_LOCALE=zz (мусор)ru
en;q=0.3,tk;q=0.9tk — q-веса учитываются

Фолбэк проверен там же: при tk подпись переключателя приходит из ru.json, потому что в tk.json этого ключа нет.

Исправлено по итогам ревью

Многоагентное ревью первой редакции нашло два дефекта в самом каркасе, оба чинились в этой же ветке:

  • чтение cookie не работало. \s внутри шаблонной строки схлопывалось в букву «s», регулярка становилась (?:^|;s*)NEXT_LOCALE= и не находила cookie, если та не первая в document.cookie. Путь «cookie → профиль» из 5.2 при этом был мёртв: LocaleSync считал, что cookie нет, и не вызывал синхронизацию никогда. Заменено разбором через split;
  • защита от потери правок не срабатывала. saveWorkspaceStateNow не бросает исключение, а резолвится значением false — и на сбое записи, и на заблокированном автосейве. try/catch вокруг beforeSwitch не ловил ничего, язык переключался, router.refresh() перемонтировал дерево и терял несохранённое. Теперь beforeSwitch возвращает boolean, и явный false отменяет переключение.

Там же ужесточена запись профиля: setLocale гасит только «пользователь удалён» (Prisma P2025), а недоступную базу или невыполненную миграцию пробрасывает — иначе cookie и профиль молча расходились. Порядок изменён на «сначала профиль, потом cookie»: язык не переключается, если выбор негде сохранить.

Переключатель доступен в трёх местах: меню пользователя в общей шапке, страницы (auth) (там своей шапки нет, а выбрать язык до входа нужно) и подвал левой панели воркспейса. В воркспейсе он спрятан классом pf-panel-expanded-only — когда панель свёрнута в рейку, три кнопки туда не помещаются.

Миграция применена к боевой БД 2026-08-12: колонка text, nullable, без DEFAULT; у всех 18 пользователей NULL, то есть язык определяется по браузеру, пока его не выбрали явно.

Уточнения, которых не было в исследовании

  • next-intl встал версии 4.13.6 (в спайке была 4.13.4), next build проходит;
  • next/font разбирает subsets статически: вынести список в константу и раскрыть спредом нельзя — сборка падает с Unexpected spread. Только литерал в каждом вызове;
  • П4 подтверждён на практике: после подключения каркаса все маршруты в манифесте стали динамическими (ƒ), статических не осталось. Ожидаемая плата за cookie-локаль;
  • NextIntlClientProvider без пропсов отдаёт клиенту весь словарь. На 29 ключах это дешевле выборочной передачи, но перед волной 6 придётся резать по неймспейсам.

Волна 2 — авторизация, профиль, общие компоненты

456 строк (423 механических), 47 файлов. Мало текста, много просмотров.

Волна 3 — маркетплейс и витрина

943 строки (875 механических), 44 файла.

Волна 4 — админка и ревью планов

511 строк (482 механических), 36 файлов. Внутренний инструмент, низкий риск.

Волна 5 — тексты ошибок API

89 строк в 24 роутах → коды ошибок (errors.plan.notFound), клиент резолвит через errors.*. Меняется контракт API — отдельным PR, не смешивать с UI-волнами.

Волна 6 — воркспейс и чат

4 290 строк (4 063 механических), 108 файлов. Самая большая зона. Режем по секциям components/workspace/sections/*; workspace-client.tsx — по частям в порядке частоты использования разделов. Фолбэк на русский держит экраны рабочими всё это время.

Вне волн — каталог данных

1 599 строк в 8 файлах (dynamic-document-data-schema.ts, model-value-catalog, calculated-sql-functions-catalog) — описания таблиц и полей для внутреннего инструмента, не UI продукта. Предлагаю явно исключить и пометить в коде.

Оценка

Допущение, которое надо откалибровать по факту первой волны: механическая строка — ~80 в час с самопроверкой, ручная (с плейсхолдером) — ~20 в час.

ВолнаСтрок (мех. / ручн.)ОценкаЧем определяется
T — тестовая инфраструктура1 деньplaywright + smoke; матрица по локалям не нужна
0 — развязка идентификаторов1–2 днянормализатор + NAVIGATION_MAP; миграции данных нет
1 — каркас1,5 дняплагин, cookie, User.locale, переключатель; переезда роутов нет
2 — авторизация и общее423 / 331 день
3 — маркетплейс875 / 682 дня
4 — админка и ревью482 / 291 день
5 — ошибки API77 / 122 дняменяется контракт, нужен разбор на клиенте
6 — воркспейс и чат4 063 / 22712 дней8 дней по нормативу + 50 % на объём файла
~21 деньна одного разработчика

Читать так:

  • до волны 6 — ~9 дней, и после них английский интерфейс работает везде, кроме редактора бизнес-плана;
  • волна 6 — больше половины всей работы, но хорошо параллелится: 108 файлов, секции независимы, 95 % строк механические;
  • волны 2–4 и 6 можно раздать разным исполнителям — они не пересекаются по файлам.

Отказ от префикса убрал из плана ~4 дня разовой работы и оба постоянных налога из 9.3.

11. Риски и проверки

РискПроверкаСтатус
Перевод сломал логику (П1)Тест на резолв секций из AI-ответа и категории материаловЗакрыт по коду: от подписей больше ничего не зависит (волна 0). Нормализатор категорий проверен разовым прогоном на 16 входах, постоянных тестов нет — в волну T
Язык «перескакивает» при входе с разных устройствПрогон таблицы приоритетов из 5.2Реализовано в i18n/actions.ts, автотестами не покрыто — в волну T
Переключение языка теряет несохранённое состояние воркспейсаДождаться автосейва перед refresh()Закрыт: переключатель в воркспейсе форсирует saveWorkspaceStateNow и переключает язык только после успеха
Публичные страницы стали динамическими и просели по TTFBЗамер после волны 3; при необходимости оставить их на русскомНе измерено (П4)
Числа в английском UI в русском форматеФорматтер от локали вместо "ru-RU"Не сделано
Пропущенные ключи выглядят как поломкаФолбэк на ru.json + проверка полноты словарей в CI (предупреждение)Закрыт: слияние в i18n/messages.ts + pnpm i18n:check (падает только на ключах, которых нет в ru.json)
Раздувание клиентского бандлаОтдавать клиенту только нужные неймспейсы, не весь ru.jsonНе измерено
ň ž ş фолбэчным шрифтомПодмножество latin-ext; строка Ňž ş — täze meýilnamaРешение известно (6.2)
Длинные туркменские слова ломают вёрсткуПсевдолокаль +30 % в приёмке волн 2–4Не сделано

12. Что осталось за рамками

  • AI-ассистент. lib/ai/system-prompt.ts:1 начинается с «ВСЕ ТЕКСТЫ ТОЛЬКО НА РУССКОМ ЯЗЫКЕ». Многоязычный ассистент — промпт-на-локаль и отдельное тестирование качества генерации.
  • Сгенерированный бизнес-план и экспорт документов — продуктовое решение, не UI.
  • Правовые документы (content/legal/*) — требуют юридической валидации.
  • Валюта. В схеме есть projectCurrencyCode / currencyCode с дефолтом RUB (prisma/schema.prisma:751–753, 780–782), но захардкожен в 58 местах UI.
  • docs-site — отдельное приложение на Nextra 3 / Next 14.
  • Пользовательский контент (описания листингов, отзывы, названия групп персонала) — переводу не подлежит.
  • SEO-инфраструктура (sitemap.ts, robots.ts) — отсутствует; предпосылка для возврата к префиксу (9.5), но самостоятельная задача.

13. Открытые вопросы

  1. Какие языки и нужен ли RTL? Закрыт: ru → en → tk, все LTR.
  2. Работает ли композиция next-intl с withAuth? Закрыт: работает, рецепт в 7.2. В текущем плане не нужен.
  3. Нужна ли миграция прод-данных в волне 0? Закрыт: 30 состояний, 131 kB — нормализатор на чтении дешевле миграции.
  4. Нужен ли префикс локали в URL? Закрыт: отложен, раздел 9.
  5. Где хранить язык пользователя? Закрыт: cookie как рантайм + User.locale как долговременное хранилище, 5.2.
  6. Кто переводит tk.json? Для английского вариантов много, для туркменского пул исполнителей узкий. Выяснить до волны 1 — от этого зависит, нужна ли интеграция с TMS.
  7. Чинить ли шрифтовой стек отдельной задачей? Geist без кириллицы — закрыто в волне 1: подмножества latin, latin-ext, cyrillic. Остаётся отдельный баг: Manrope объявлен в globals.css, но нигде не подключён — интерфейс рендерится системным шрифтом.
  8. Нужен ли туркменский в маркетплейсе? Листинги пишут пользователи, они останутся русскоязычными. Если для tk переводить только воркспейс и общие экраны, волна 3 выпадает из туркменского объёма.
  9. Переключение языка при несохранённом состоянии воркспейса Закрыт: форсируем автосейв. Переключатель в воркспейсе вызывает saveWorkspaceStateNow({ trigger: "locale-switch" }) и, только дождавшись успеха, ставит cookie и делает refresh(). Если сохранение не удалось, язык не переключается — иначе refresh() перемонтировал бы дерево и правки пропали бы молча.

14. Как проверялось

Подсчёт строк — скрипт на регулярках по app/, components/, lib/: вырезаются комментарии, отдельно считаются строковые литералы, шаблонные литералы (с ${} и без) и JSX-текст (с {} и без). Точность на уровне ±5 % — для планирования достаточно, для биллинга нет.

Локали — прямые вызовы ICU:

node -e 'console.log(new Intl.PluralRules("tk").select(1),
                     new Intl.NumberFormat("tk").format(1234567.89),
                     Intl.DateTimeFormat.supportedLocalesOf(["tk"]))'

Проверялось на Node 22.22 (ICU 78). При смене рантайма результаты по tk стоит перепроверить — поддержка редких локалей зависит от версии ICU в сборке.

Подмножества шрифта — запрос к Google Fonts CSS API с десктопным User-Agent, разбор комментариев /* latin-ext */.

Спайк — отдельный git-worktree на текущем HEAD: pnpm add next-intl, переезд дерева, tsc --noEmit, next dev + матрица curl, next build, разбор .next/app-path-routes-manifest.json и .next/prerender-manifest.json. Worktree удалён, рабочее дерево не изменено.

Прод-данныеpsql только на чтение: агрегаты по Project, ProjectWorkspaceState, ProjectTaxSettings, ProjectPersonnelEmployee, PlanListing, ListingView, Order и поиск русских литералов в JSON-состояниях. Данные не изменялись.

См. также

Внешние источники: next-intl: App Router, next-intl v4 + Next.js 16, Turkmen alphabet — Wikipedia.