

1/1/1970
Как я внедрил i18n для 20 языков за 3 дня
Привет! Я только что закончил масштабную задачу: перевёл Foony на 20 разных языков. Это был огромный труд, потребовавший вмешательства почти в каждый файл кодовой базы, но мне удалось справиться всего за 3 дня.
Ниже я расскажу, как именно я это сделал, поделюсь конкретными цифрами и объясню, почему я (в очередной раз) решил написать собственную библиотеку перевода вместо того, чтобы взять индустриальный стандарт.
Почему не i18next?
Когда я впервые задумался о добавлении переводов, я рассматривал индустриальный стандарт: i18next и react-i18next.
Но в итоге решил оптимизировать под сопровождаемость со стороны ИИ. i18next мощный, но разнообразие его API может приводить к тому, что LLM начинает галлюцинировать или писать непоследовательный код. Ограничив библиотеку простыми t() и interpolate(), я обеспечил, чтобы более 10 параллельных агентов могли писать на 100% типобезопасный код практически без участия человека.
Я также с осторожностью относился к привязке к большой экосистеме, которая позже может ввести ломающие изменения. После болезненных миграций вроде React Router v5 и MUI v4 → v5 я знаю, что стремительное нарушение обратной совместимости в мире JavaScript встречается слишком часто. Стоимость добавления функций плюрализации позже ниже, чем стоимость ручной миграции 139 тысяч строк кода сейчас.
Мне хотелось чего-то предельно простого, очень лёгкого и точно подогнанного под нужды моей команды.
Поэтому я написал свою.
Я создал ограниченное подмножество размером в 3 КБ, специально спроектированное для высокоточного автономного рефакторинга силами ИИ. Это позволило мне в одиночку выполнить трёхнедельную работу команды из 5 человек всего за 3 дня.
Кастомная реализация
Я придумал минималистичную библиотеку i18n, которая весит около 3 КБ в gzip. Она предоставляет две основные функции: getTranslation() для не-React контекстов и хук useTranslation() для компонентов.
Они возвращают t() для простой подстановки строк и interpolate() на случай, когда нужно вставить React-компоненты в строку перевода (например, ссылку или иконку). Обе функции поддерживают подстановку переменных, например "Hello {{thing}}", {thing: 'World'}.
Ключи следуют нотации "слэш-точка" (слэши для пути к файлу локализации, точки для вложенных объектов внутри файла). Чтобы обеспечить уникальность, ключи переводов в файле не могут содержать прямых слэшей.
Вот основная функция t():
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Check if key contains '/' - this indicates a namespace
const slashIndex = key.indexOf('/');
if (slashIndex !== -1) {
const parts = key.split('/');
namespace = parts.slice(0, -1).join('/');
translationKey = parts[parts.length - 1];
}
const targetLocale = locale ?? currentLocale;
const text = getTranslationValue(targetLocale, namespace, translationKey);
if (values) {
return interpolateString(text, values);
}
return text;
}
И React-хук:
export function useTranslation() {
const [language] = useLanguage();
// Subscribe to locale loading events to trigger re-renders when translations are loaded
const version = useSyncExternalStore(
(callback) => LocaleQueryer.onLoad(callback),
() => LocaleQueryer.getVersion(),
() => LocaleQueryer.getVersion()
);
return useMemo(() => ({
t: (key: TranslationKeys, values?: Record<string, string | number>) =>
t(key, values, language),
interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) =>
interpolate(key, components, language),
}), [language, version]);
}
Ядро всей библиотеки занимает всего около 580 строк кода. Оно обеспечивает:
- Ленивую загрузку файлов перевода, чтобы не отправлять каждому пользователю все 20 языков.
- Разделение переводов по "пространствам имён" (например,
common,misc,games/{gameId}). - "Отладочную" локаль, которая показывает сырые ключи, чтобы я мог убедиться, что всё подключено правильно.
Чтобы система оставалась простой в сопровождении, я также добавил подробную документацию в shared/src/i18n/README.md, охватывающую всё: от структуры файлов до примеров использования на клиенте и сервере. Поскольку я не использую стандартную библиотеку, такой справочник критически важен для онбординга новых членов команды (или просто для напоминания будущему мне или LLM, как всё работает).
В цифрах
Чтобы передать масштаб этого обновления, вот что изменилось в кодовой базе:
- 20 поддерживаемых языков (плюс отладочная локаль для разработки).
- 360 созданных файлов локалей.
- 139 031 строка кода переводов.
- 3 938 вызовов
t(), добавленных по всему клиенту. - 728 изменённых исходных файлов.
- 18 английских исходных файлов, служащих источником истины (16 игр + common + misc).
Оркестрация агентами
Сделать это вручную заняло бы месяцы отупляющей механической работы. Вместо этого я одновременно оркестрировал больше десятка агентов Cursor, чтобы они выполнили основную работу.
Я начал с разбиения кодовой базы на "секции" по папкам. Каждая игра на Foony получила свою папку и своё пространство имён переводов. Это сохраняет небольшим начальный размер загрузки, поскольку загружаются только переводы той игры, в которую вы играете.
Я запускал несколько агентов Cursor одновременно. Каждому агенту я назначал конкретную секцию, например "переведи игру Chess на использование переводов", и он шёл файл за файлом, находя строки, видимые пользователю, и заменяя их на t('games/chess/some.key').
Затем агент добавлял этот ключ в соответствующий английский файл локали с JSDoc-комментарием, поясняющим "что" и "где" этой строки. Этот контекст важен при генерации переводов на другие языки, поскольку он помогает LLM понять, означает ли "Save" "Сохранить конфигурацию игры" или "Сохранить ваш рисунок Draw & Guess".
Контроль качества
Я быстро просмотрел весь сгенерированный код. Агенты были на удивление хороши, но иногда допускали ошибки, например, ставили хук useTranslation после раннего return.
Сильно типизированные переводы очень помогли. Это гарантировало, что все переводы для каждой локали имеют все правильные ключи (и ни одного лишнего). А ещё это гарантировало, что вызовы t() и interpolate() использовали реальные строки переводов.
Система типов извлекает все возможные ключи переводов из английских исходных файлов:
/**
* Extracts all possible paths from a nested object type, creating dot-notation keys.
* Example: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
*/
type ExtractPaths<T, Prefix extends string = ''> = T extends string
? Prefix extends '' ? never : Prefix
: T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends string
? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
: ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
: never
}[keyof T]
: never;
export type TranslationKeys =
| ExtractPaths<typeof import('./locales/en/index').default>
| `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
| `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
| `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
// ... and so on for all games
Это даёт идеальное автодополнение в TypeScript, а любая опечатка в ключе перевода ловится на этапе компиляции. Агенты не могут допустить ошибку вроде t('games/ches/name'), потому что TypeScript тут же её подсветит.
Локализация
Когда конвертация на английский была завершена, я разбил оставшиеся задачи по локалям. Каждый агент отвечал за конвертацию одного английского файла локали на конкретный язык.
Например, я давал агентам примерно такой промпт:
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
Я подумывал о том, чтобы Cursor создал скрипт, который скармливал бы каждый из этих файлов в LLM и генерировал результат, но хотел немного сэкономить на стоимости LLM. Использование скрипта только для обновления отсутствующих переводов оказалось лучшим подходом, и я, вероятно, воспользуюсь похожим решением в будущем. Хотелось бы отслеживать, какие строки нуждаются в обновлении или переводе, но при этом сохранить простоту. Возможно, я перенесу работу с переводами в базу данных или что-то подобное.
Я также добавил "отладочную" локаль, доступную только в разработке. Она позволяет мне видеть все заменённые строки, чтобы убедиться, что всё работает (плюс, по-моему, это просто круто). При использовании отладочной локали t() возвращает ключ, обёрнутый в скобки:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Так что вместо "Welcome to Foony!" вы увидите ⟦welcome⟧, что упрощает поиск отсутствующих переводов.
Наконец, ещё один агент реализовал маршрутизацию /{locale}/**, чтобы такие пути, как /ja/games/chess, вели на правильный язык (в данном случае на японский).
Перевод блога
Перевод строк интерфейса это одно дело, но что насчёт записей в блоге? Мне не хотелось запускать и контролировать ещё больше агентов для перевода всех моих постов.
Я решил это, попросив агента создать скрипт (scripts/src/generateBlogTranslations.ts), который автоматизирует весь процесс.
Вот как он работает:
- Он сканирует директорию
client/src/posts/enна наличие английских MDX-файлов. - Он проверяет отсутствующие переводы в других папках локалей (например,
posts/ja,posts/es). - Если перевод отсутствует, он читает английский контент и передаёт его в Gemini 3 Pro Preview с конкретным промптом, чтобы перевести содержимое, сохраняя Markdown-форматирование.
- Сохраняет новый файл в нужное место.
На фронтенде я использую import.meta.glob, чтобы динамически импортировать все эти MDX-файлы. Мой компонент PostPage затем просто проверяет текущую локаль пользователя и лениво загружает нужный MDX-файл. Если перевод отсутствует (потому что я ещё не запустил скрипт), он плавно откатывается на английский.
День 4: Автоматизированная генерация переводов
Я знал, что исходное решение не будет масштабироваться. Поэтому теперь, когда i18n был готов, пора было слегка усилить его подходом на базе базы данных.
Если коротко: когда менялся английский текст или JSDoc-комментарии, переводы нужно было перегенерировать. Ручное отслеживание того, что нужно обновить, было бы подвержено ошибкам и тратой времени разработчика.
Поэтому я построил решение, которое и планировал изначально: систему генерации переводов на основе PostgreSQL.
Схема базы данных
Я добавил таблицу translations в нашу базу PostgreSQL со следующей структурой:
key: ключ перевода в нотации "слэш-точка" (например,"games/yacht/nested.name","config.timeLimit.label").en_value: английское исходное значениеtarget_locale: код целевой локали (например,"es","fr","zh")target_value: переведённое значениеcontext: поле JSONB, содержащее JSDoc для этого ключа и всех родительских ключейcreated_atиupdated_at: временные метки для отслеживания
Уникальный индекс установлен на (key, target_locale, en_value, context). Это критически важно: включив context в уникальное ограничение, мы можем автоматически определять, когда меняются JSDoc-комментарии, и перегенерировать переводы. Старые переводы сохраняются для исторической справки.
Скрипт генерации
Я создал scripts/src/generateLocalizations.ts, который автоматизирует весь рабочий процесс перевода:
- Извлечение английских ключей: использует AST-парсинг (ts-morph) для извлечения всех ключей переводов из файлов
shared/src/i18n/locales/en/**, обрабатывая только default exports - Извлечение контекста JSDoc: парсит JSDoc-комментарии для каждого ключа и всех родительских ключей (родительских объектов), чтобы предоставить богатый контекст
- Запрос к базе: проверяет существующие переводы в PostgreSQL, сопоставляя по
key,target_locale,en_valueИcontext. Если что-то из этого меняется, перевод перегенерируется. - Определение отсутствующих/изменённых ключей: находит ключи, требующие перевода, или те, у которых изменилось английское значение/комментарии
- Пакетирование переводов: группирует по локали и префиксу пространства имён для более эффективных вызовов LLM (а ещё это ускоряет переводы). Однако если пакет слишком большой, качество перевода ухудшается.
- Генерация переводов: использует GPT 5.1 с обширным контекстом (JSDoc, язык+регион, тон, глоссарий, примеры). Я читал, что 5.1 пишет лучше, чем 5.2 (текст не звучит пресно), но сам не проверял.
- Проверки качества: валидирует сохранение плейсхолдеров, например
{{name}}, целостность ключей, формат JSON - Сохранение в базе: сохраняет переводы с полным контекстом (JSDoc + JSDoc предков)
- Генерация файлов локалей: читает из базы и пишет правильно отформатированные TypeScript-файлы локалей с типами
RecursivePartial
Ключевые преимущества
Этот подход даёт нам несколько улучшений в опыте разработки:
- Автоматическая перегенерация: когда меняется английский текст ИЛИ JSDoc-комментарии, переводы автоматически перегенерируются. Так что если кто-то говорит, что перевод плох, очень легко перегенерировать переводы, добавив больше контекста в комментарий.
- Богатый контекст: JSDoc-комментарии дают контекст перевода (например, "Сообщение об ошибке, показываемое игрокам, максимум 15 символов"), помогая LLM делать более точные переводы
- Контекст предков: JSDoc родительского объекта даёт контекст пространства имён (например, "Достижение за то, что ты в игре, где уничтожены все яйца"), добавляя ещё немного ясности
- Историческое отслеживание: старые переводы сохраняются в базе. Они занимают мало места, поэтому я пока не вижу особых причин их удалять, и круто видеть историю.
Технические детали
В реализации используется несколько техник для обеспечения надёжности и эффективности:
- AST-извлечение, чтобы получить правильные комментарии
- Параллельная обработка с использованием Semaphore для одновременного пакетного перевода
- Логика повторов с экспоненциальным backoff для сбоев API. Вызовы LLM, как известно, бывают ненадёжными.
Скрипт можно запустить командой npm run generate-localizations из директории scripts. При запуске он подключается к PostgreSQL и обрабатывает все отсутствующие или изменённые переводы для всех поддерживаемых локалей.
Заключение
На этом этапе у меня был полностью работающий сайт, переведённый на все 20 локалей!
Это были безумные 3 дня, но результат: полностью локализованный сайт, который ощущается (в основном) родным для пользователей по всему миру. Создав кастомную лёгкую библиотеку и задействовав ИИ-агентов для нудной рефакторинговой работы, я справился с тем, что ещё год назад было бы невозможно: полный i18n за 3 дня для сложного сайта силами одного инженера. Будущее программирования не в том, чтобы быстро писать код. Оно в том, чтобы оркестрировать ИИ-агентов и обладать глубокой доменной экспертизой для проверки их результата.