background blurbackground mobile blur

1/1/1970

Как я внедрил i18n на 20 языков за 3 дня

Привет! Я только что закончил огромную задачу: перевёл Foony на 20 разных языков. Это был серьёзный проект, в котором пришлось трогать почти каждый файл в кодовой базе, но мне удалось уложиться всего в 3 дня.

Дальше я расскажу, как именно всё делал, приведу конкретные цифры и объясню, почему снова написал свою библиотеку для переводов вместо того, чтобы использовать индустриальный стандарт.

Почему не i18next?

Когда я только начал думать о переводах, в первую очередь посмотрел на индустриальный стандарт: i18next и react-i18next.

Но вместо этого я решил оптимизировать всё под поддерживаемость с помощью ИИ. i18next мощный, но из-за богатого API большие языковые модели иногда начинают галлюцинировать или писать несогласованный код. Ограничив библиотеку до простых t() и interpolate(), я добился того, что больше 10 параллельных агентов могли писать 100% типобезопасный код почти без участия человека.

Я ещё не особо горел идеей залезать в большую экосистему, которая потом может внезапно поломать обратную совместимость. После болезненных миграций вроде React Router v5 и MUI v4 → v5 я отлично знаю, как часто в мире JavaScript всё резко ломают. Цена того, чтобы потом отдельно добавить поддержку плюрализации, ниже, чем сейчас руками мигрировать 139k строк кода.

Мне хотелось чего-то предельно простого, очень легковесного и при этом идеально подходящего под потребности моей команды.

Так что я написал свою.

Я собрал ограниченный поднабор на 3 КБ, специально заточенный под точный, автономный рефакторинг с помощью ИИ. Это позволило мне одному сделать за 3 дня объём работы, который обычно растягивается на 3 недели у команды из пяти человек.

Своя реализация

Я придумал минимальную библиотеку i18n, которая весит около 3 КБ в gzipped. У неё всего две основные функции: 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();
  
  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 языков каждому пользователю;
  • делать code splitting переводов по «неймспейсам» (например, common, misc, games/{gameId});
  • использовать локаль «debug», которая показывает сырые ключи, чтобы я мог проверить, что всё правильно подключено.

Чтобы система оставалась простой в поддержке, я ещё написал подробную документацию в shared/src/i18n/README.md. Там расписано всё: от структуры файлов до примеров использования и на клиенте, и на сервере. Поскольку я не использую стандартную библиотеку, такой справочник критичен и для онбординга новых людей в команде, и просто чтобы напомнить будущему себе или LLM, как всё устроено.

Цифры

Чтобы было понятнее, какого масштаба получилось обновление, вот что изменилось в кодовой базе:

  • 20 поддерживаемых языков (плюс debug-локаль для разработки).
  • 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. Скрипт, который обновляет только недостающие переводы, оказался лучше, и, скорее всего, я и дальше буду делать так же. Мне бы хотелось отслеживать, какие строки нужно обновить или перевести, но при этом держать систему максимально простой. Может быть, потом я перенесу работу с переводами в базу данных или что-то вроде того.

Я также добавил локаль «debug», которая доступна только в режиме разработки. С ней можно увидеть все подставленные строки и проверить, что всё работает (и вообще это выглядит прикольно). Когда включена debug-локаль, t() возвращает ключ, обёрнутый в скобки:

if (targetLocale === 'debug') {
  return `⟦${key}⟧`;
}

Так что вместо «Welcome to Foony!» ты увидишь ⟦welcome⟧. Так очень легко заметить, где ещё не хватает перевода.

И напоследок другой агент реализовал роутинг вида /{locale}/**, чтобы такие пути, как /ja/games/chess, вели на страницу в нужной локали, в этом случае на японский.

Перевод блога

С UI-строками всё было понятно, но что делать с блог-постами? Поднимать и менеджить ещё пачку агентов только ради перевода всех записей блога совсем не хотелось.

Я решил это так: попросил агента написать скрипт (scripts/src/generateBlogTranslations.ts), который автоматизирует весь процесс.

Вот как это устроено:

  1. Скрипт сканирует директорию client/src/posts/en в поисках английских MDX-файлов.
  2. Проверяет, каких переводов не хватает в других папках локалей (например, posts/ja, posts/es).
  3. Если перевода нет, он читает английский текст и отправляет его в Gemini 3 Pro Preview со специальным промптом, чтобы перевести содержимое и при этом сохранить разметку Markdown.
  4. Сохраняет новый файл в нужное место.

На фронтенде я использую import.meta.glob, чтобы динамически импортировать все эти MDX-файлы. Компонент PostPage просто смотрит на текущую локаль пользователя и лениво подгружает нужный MDX. Если перевода нет (например, я ещё не запускал скрипт), страница аккуратно откатывается к английской версии.

Итоги

В какой-то момент я понял, что у меня уже полностью рабочий сайт, переведённый на все 20 локалей!

Эти 3 дня были довольно безумными, но в итоге получился полностью локализованный сайт, который для пользователей по всему миру выглядит (почти) как родной. Благодаря своей лёгкой кастомной библиотеке и агентам, которые взяли на себя скучный рефакторинг, мне удалось сделать то, что ещё год назад казалось бы нереальным: полное i18n за 3 дня для сложного сайта силами одного разработчика. Будущее программирования не в том, чтобы самому быстро писать код. Главное теперь в том, чтобы уметь управлять ИИ-агентами и иметь достаточно экспертизы в домене, чтобы проверять их результат.

8 Ball Pool online multiplayer billiards icon