

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/**, обробляючи лише експорти за замовчуванням - Витягує контекст JSDoc: парсить JSDoc-коментарі для кожного ключа та всіх предкових ключів (батьківських об'єктів), щоб надати багатий контекст
- Запитує базу даних: перевіряє наявні переклади в PostgreSQL, зіставляючи за
key,target_locale,en_valueТАcontext. Якщо щось із цього змінюється, переклад перегенеровується. - Визначає відсутні чи змінені ключі: знаходить ключі, що потребують перекладу, або ті, чиї англійські значення/коментарі змінилися
- Групує переклади: групує за локаллю та префіксом простору імен для ефективніших викликів LLM (а також пришвидшує переклади). Однак якщо пакет надто великий, якість перекладу погіршується.
- Генерує переклади: використовує GPT 5.1 з повним контекстом (JSDoc, мова з регіоном, тон, глосарій, приклади). Я читав, що 5.1 кращий за 5.2 для письма (звучить менш прісно), але не перевіряв.
- Перевірки якості: валідує збереження плейсхолдерів, наприклад
{{name}}, цілісність ключів, формат JSON - Зберігає в базі даних: зберігає переклади з повним контекстом (JSDoc плюс предковий JSDoc)
- Генерує файли локалей: читає з бази даних і записує належним чином відформатовані TypeScript-файли локалей з типами
RecursivePartial
Ключові переваги
Цей підхід дає нам кілька покращень DevEx:
- Автоматична регенерація: коли змінюється англійський текст АБО коментарі JSDoc, переклади перегенеровуються автоматично. Тож якщо хтось скаже, що переклад поганий, дуже легко перегенерувати переклади, надавши більше контексту в коментарі.
- Багатий контекст: коментарі JSDoc надають контекст перекладу (наприклад, "Повідомлення про помилку, що показується гравцям, максимум 15 символів"), допомагаючи LLM створювати точніші переклади
- Контекст предків: JSDoc батьківського об'єкта надає контекст простору імен (наприклад, "Досягнення за участь у грі, де всі яйця знищено"), додаючи трохи більше ясності
- Історичне відстеження: старі переклади зберігаються в базі даних. Вони не займають багато місця, тож наразі не бачу особливих причин їх видаляти, та й цікаво бачити історію.
Технічні деталі
Реалізація використовує кілька технік для забезпечення надійності й ефективності:
- AST-екстракція, щоб гарантувати отримання правильних коментарів
- Паралельна обробка з використанням Semaphore для одночасного пакетного перекладу
- Логіка повтору з експоненційним відкатом для збоїв API. Виклики LLM сумнозвісно нестабільні.
Скрипт можна запустити командою npm run generate-localizations з теки scripts. При запуску він підключається до PostgreSQL і обробляє всі відсутні чи змінені переклади для всіх підтримуваних локалей.
Висновок
На цьому етапі я мав повністю функціонуючий сайт, перекладений на всі 20 локалей!
Це були божевільні 3 дні, але результат це повністю локалізований сайт, який відчувається (здебільшого) рідним для користувачів по всьому світу. Створивши власну легку бібліотеку та використовуючи ШІ-агентів для нудної роботи з рефакторингом, я впорався з тим, що ще рік тому було б неможливим: повна i18n за 3 дні для складного вебсайту силами одного інженера. Майбутнє програмування не в тому, щоб писати код швидко. Воно в тому, щоб координувати ШІ-агентів і мати глибоку експертизу в предметній області, аби перевіряти їхні результати.