background blurbackground mobile blur

1/1/1970

Як я додав i18n для 20 мов за 3 дні

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

Нижче розповім, як я це зробив, поділюся конкретними цифрами і поясню, чому знову вирішив написати власну бібліотеку перекладу замість стандартної з індустрії.

Чому не i18next?

Коли я вперше взявся додавати переклади, то спочатку подивився на стандартні варіанти: i18next та react-i18next.

Замість цього я вирішив оптимізувати все під підтримку за допомогою AI. i18next потужний, але велика кількість варіантів його API може змушувати LLM вигадувати речі або писати нестійкий код. Обмеживши бібліотеку до простих t() та interpolate(), я зробив так, що 10+ паралельних агентів могли писати на 100% тайпсейфний код майже без участі людини.

Я також не хотів прив'язуватися до великої екосистеми, яка з часом може влаштувати руйнівні оновлення. Після болючих міграцій на кшталт React Router v5 та MUI v4 → v5 я добре знаю, що швидке ламання зворотної сумісності в JavaScript-світі трапляється постійно. Додати підтримку множини пізніше коштуватиме дешевше, ніж зараз вручну мігрувати 139 тисяч рядків коду.

Мені хотілося чогось максимально простого, дуже легкого і точно під наші потреби.

Тож я написав свою бібліотеку.

Я зібрав обмежену мінібібліотеку на 3 КБ, спеціально спроєктовану для точного автономного рефакторингу за допомогою AI. Завдяки цьому я, як один інженер, зробив приблизно тритижневу роботу команди з п'яти людей всього за 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;
  
  // Перевіряємо, чи ключ містить '/', це означає неймспейс
  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 мов кожному користувачу.
  • Розбивати переклади по "неймспейсах" (наприклад 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 на i18n", і агент проходився по файлах один за одним, шукав усі користувацькі рядки і замінював їх на t('games/chess/some.key').

Потім агент додавав цей ключ до відповідного англійського файлу локалі з JSDoc-коментарем, який пояснює "що це" і "де використовується" цей рядок. Такий контекст дуже важливий для генерування перекладів іншими мовами, бо допомагає LLM зрозуміти, чи "Save" означає "Зберегти конфігурацію гри", чи "Зберегти свій малюнок у Draw & Guess".

Контроль якості

Я швидко переглянув весь згенерований код. Агенти спрацювали дивовижно добре, але інколи помилялися, наприклад ставили хук useTranslation після раннього return.

Жорстко типізовані переклади дуже допомогли. Це гарантує, що всі переклади для кожної локалі мають правильні ключі (і не мають зайвих). Так само це гарантує, що всі виклики t() і interpolate() використовують реальні ключі, які реально існують.

Система типів витягує всі можливі ключі перекладів з англійських початкових файлів:

/**
 * Витягує всі можливі шляхи з вкладеного типу об'єкта і створює ключі в крапковій нотації.
 * Приклад: {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 одразу це підсвічує.

Локалізація

Коли з англійською версією було покінчено, я розбив решту задач по локалях. Кожен агент відповідав за конвертацію одного англійського файлу локалі в задану мову.

Наприклад, я давав агентам такий промпт:

Будь ласка, переконайся, що ar/games/dinomight.ts містить усі переклади з en/games/dinomight.ts.
Використовуй `export const account: DinomightTranslations = {`.
Повторюй зміни доти, доки не зникнуть усі помилки типів для твого файлу перекладів (якщо бачиш помилки для інших файлів, ігноруй їх, інші агенти в цей час працюють над цими файлами паралельно).
Твої переклади мають бути відмінними і точними відповідно до jsdoc-контексту, який заданий в en.
Ти мусиш зробити це вручну, без "helper" скриптів і без будь-яких скорочень.

Я думав доручити Cursor створити скрипт, який би проганяв кожен з цих файлів через LLM і генерував переклади, але мені хотілося трохи зекономити на витратах на LLM. Скрипт, який оновлює тільки відсутні переклади, виявився кращим варіантом, і, скоріше за все, я використаю схожу схему ще раз. Я б хотів відслідковувати, які рядки потребують оновлення або перекладу, але водночас хочеться, щоб все залишалося простим. Можливо, колись винесу переклади в базу даних чи щось подібне.

Я також додав "debug" локаль, яка доступна тільки в режимі розробки. Вона дозволяє побачити всі замінені рядки і перевірити, що все працює (і це просто прикольно). Якщо ввімкнути debug-локаль, t() повертає ключ, загорнутий у дужки:

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

Тобто замість "Welcome to Foony!" ти побачиш ⟦welcome⟧, і так легко помітити будь-які відсутні переклади.

Наостанок ще один агент реалізував роутинг /{locale}/**, щоб, наприклад, /ja/games/chess вів на сторінку з правильною мовою (у цьому випадку японською).

Переклад блогу

Перекладати рядки інтерфейсу – це одне, а як бути з блог-постами? Я не хотів запускати й координувати ще більше агентів тільки заради перекладу всіх своїх постів.

Я розв'язав це так: попросив агента написати скрипт (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 дні були досить скаженими, зате результат – повністю локалізований сайт, який для більшості користувачів у світі відчувається майже рідним. Завдяки невеликій кастомній бібліотеці й AI-агентам, які взяли на себе рутинний рефакторинг, мені вдалося зробити те, що ще рік тому здавалося б нереальним: повний i18n за 3 дні для складного сайту силами одного інженера. Майбутнє програмування не про те, щоб швидко писати код, а про те, щоб уміти керувати AI-агентами і мати достатньо глибоке розуміння продукту, щоб впевнено перевіряти їхню роботу.

8 Ball Pool online multiplayer billiards icon