

1/1/1970
Jak wdrożyłem i18n dla 20 języków w 3 dni
Hej! Właśnie skończyłem ogromne zadanie, w ramach którego przetłumaczyłem Foony na 20 różnych języków. To było spore przedsięwzięcie, które wymagało dotknięcia prawie każdego pliku w bazie kodu, ale udało mi się ogarnąć to wszystko w zaledwie 3 dni.
Niżej rozpisuję, jak to zrobiłem, jakie konkretnie liczby stoją za tą zmianą i dlaczego po raz kolejny postanowiłem napisać własną bibliotekę tłumaczeń zamiast użyć branżowego standardu.
Dlaczego nie i18next?
Kiedy pierwszy raz zabrałem się za dodawanie tłumaczeń, rozważałem branżowy standard: i18next i react-i18next.
Zamiast tego postanowiłem zoptymalizować wszystko pod kątem łatwej obsługi przez AI. i18next jest potężny, ale różnorodność jego API potrafi sprawić, że modele językowe halucynują albo piszą niespójny kod. Ograniczając bibliotekę do prostego t() i interpolate(), sprawiłem, że ponad 10 agentów działających równolegle mogło pisać w 100% typowany kod praktycznie bez ingerencji człowieka.
Miałem też opory przed wchodzeniem w duży ekosystem, który może w przyszłości wprowadzić łamiące zmiany. Po kilku bolesnych migracjach jak React Router v5 i MUI v4 → v5, wiem, że szybkie zrywanie kompatybilności wstecznej jest w świecie JavaScriptu aż za częste. Koszt dodania obsługi liczby mnogiej później jest mniejszy niż ręczna migracja 139k linii kodu teraz.
Chciałem czegoś bardzo prostego, ekstremalnie lekkiego i skrojonego dokładnie pod potrzeby mojego zespołu.
Więc napisałem własne rozwiązanie.
Zbudowałem ograniczony podzbiór o rozmiarze około 3 KB, zaprojektowany konkretnie po to, żeby umożliwić bardzo dokładne, autonomiczne refaktoryzacje przez AI. Dzięki temu mogłem jako pojedynczy inżynier zrealizować w 3 dni pracę, którą normalnie wykonywałby 5-osobowy zespół przez 3 tygodnie.
Własna implementacja
Wymyśliłem minimalną bibliotekę i18n, która po spakowaniu gz ma około 3 KB. Udostępnia dwie główne funkcje: getTranslation() do użycia poza Reactem i hook useTranslation() do komponentów.
Z nich dostajemy t() do prostego podstawiania tekstów oraz interpolate() na sytuacje, kiedy muszę wstrzyknąć komponenty Reacta w ciąg tłumaczenia (na przykład link albo ikonę). Obie funkcje wspierają podstawianie zmiennych, np. "Hello {{thing}}", {thing: 'World'}.
Oto główna funkcja t():
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Sprawdza, czy klucz zawiera '/', co oznacza przestrzeń nazw
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;
}
A to hook Reactowy:
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]);
}
Rdzeń całej biblioteki to tylko około 580 linii kodu. Obsługuje:
- Leniwe ładowanie plików z tłumaczeniami, żeby nie wysyłać wszystkich 20 języków do każdego użytkownika.
- Podział tłumaczeń na części według „namespace” (np.
common,misc,games/{gameId}). - Locale „debug”, które pokazuje surowe klucze, żebym mógł sprawdzić, czy wszystko jest poprawnie podpięte.
Żeby system nadal był łatwy w utrzymaniu, dodałem też porządną dokumentację w shared/src/i18n/README.md, która opisuje wszystko: od struktury plików po przykłady użycia po stronie klienta i serwera. Ponieważ nie korzystam ze standardowej biblioteki, taka referencja jest kluczowa przy wdrażaniu nowych osób do zespołu (albo po prostu dla mnie z przyszłości czy dla LLM-ów, żeby pamiętały, jak to działa).
Liczby
Żeby pokazać skalę tej aktualizacji, oto co zmieniło się w bazie kodu:
- Obsługiwane 20 języków (plus locale debugowe dla devów).
- Stworzone 360 plików locale.
- 139 031 linii kodu z tłumaczeniami.
- 3 938 wywołań
t()dodanych po stronie klienta. - Zmodyfikowane 728 plików źródłowych.
- 18 angielskich plików źródłowych, które są źródłem prawdy (16 gier + common + misc).
Orkiestracja z agentami
Ręczne zrobienie tego wszystkiego zajęłoby miesiące nudnej, mechanicznej pracy. Zamiast tego puściłem do boju ponad tuzin agentów w Cursorze, którzy zrobili większość ciężkiej roboty.
Na początek podzieliłem bazę kodu na „sekcje” według folderów. Każda gra na Foony dostała własny folder i własny namespace tłumaczeń. Dzięki temu początkowy rozmiar ładowania pozostaje mały, bo ładujesz tylko tłumaczenia dla gry, w którą aktualnie grasz.
Uruchomiłem kilka agentów w Cursorze równocześnie. Każdemu agentowi przypisałem konkretną sekcję, na przykład „przerób grę Chess tak, żeby korzystała z tłumaczeń”, i agent przechodził plik po pliku, wyszukując teksty widoczne dla użytkownika i zamieniając je na t('games/chess/some.key').
Agent dopisywał potem ten klucz do odpowiedniego angielskiego pliku locale, razem z komentarzem JSDoc wyjaśniającym „co” to za tekst i „gdzie” jest używany. Ten kontekst jest ważny przy generowaniu tłumaczeń na inne języki, bo pomaga modelowi zrozumieć, czy „Save” znaczy „Zapisz konfigurację gry”, czy „Zapisz swój rysunek w Draw & Guess”.
Kontrola jakości
Szybko przejrzałem cały wygenerowany kod. Agenci radzili sobie zaskakująco dobrze, ale popełniali pojedyncze błędy, na przykład umieszczali hook useTranslation po wczesnym return.
Bardzo pomogły mocno typowane tłumaczenia. Dzięki nim każde locale miało dokładnie te klucze, które powinno (i żadnych niepotrzebnych). Gwarantowało to też, że wywołania t() i interpolate() korzystają z faktycznie istniejących ciągów tłumaczeń.
System typów wyciąga wszystkie możliwe klucze tłumaczeń z angielskich plików źródłowych:
/**
* Wyciąga wszystkie możliwe ścieżki z zagnieżdżonego typu obiektu, tworząc klucze w notacji z kropkami.
* Przykład: {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
Dzięki temu podpowiadanie w TypeScripcie działa idealnie, a każda literówka w kluczu tłumaczenia jest wyłapywana na etapie kompilacji. Agenci nie są w stanie zrobić błędu w stylu t('games/ches/name'), bo TypeScript od razu to zgłosi.
Lokalizacja
Kiedy skończyłem konwersję angielskiej wersji, podzieliłem pozostałe zadania lokalizacyjne. Każdemu agentowi przydzieliłem jeden angielski plik locale do przerobienia na konkretny język.
Na przykład dawałem agentom taki prompt:
Upewnij się, że ar/games/dinomight.ts ma wszystkie tłumaczenia z en/games/dinomight.ts.
Użyj `export const account: DinomightTranslations = {`.
Powtarzaj poprawki, dopóki w twoim pliku z tłumaczeniem nie będzie już żadnych błędów typów (jeśli widzisz błędy w innych plikach, zignoruj je - działasz równolegle z innymi agentami odpowiedzialnymi za tamte pliki).
Twoje tłumaczenia muszą być świetne i poprawne względem kontekstu jsdoc podanego w en.
Musisz zrobić to ręcznie, bez pisania „helperów” i bez żadnych skrótów.
Rozważałem poproszenie Cursora, żeby stworzył skrypt, który podawałby każdy z tych plików do LLM i generował tłumaczenia, ale chciałem trochę przyoszczędzić na kosztach modeli. Lepiej było użyć skryptu tylko do uzupełniania brakujących tłumaczeń i pewnie w przyszłości znów pójdę w tym kierunku. Chciałbym śledzić, które teksty wymagają aktualizacji lub tłumaczenia, ale jednocześnie trzymać się prostego rozwiązania. Może przeniosę kiedyś pracę nad tłumaczeniami do bazy danych albo czegoś podobnego.
Dodałem też locale „debug”, dostępne tylko w trybie deweloperskim. Dzięki niemu mogę podejrzeć wszystkie podstawione klucze i sprawdzić, czy wszystko działa (plus po prostu wygląda to fajnie). Gdy korzystasz z locale debugowego, t() zwraca klucz owinięty w nawiasy:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Zamiast zobaczyć tekst „Witamy w Foony!”, zobaczysz ⟦welcome⟧, co bardzo ułatwia wypatrzenie brakujących tłumaczeń.
Na koniec inny agent zaimplementował routing /{locale}/**, dzięki czemu adresy typu /ja/games/chess prowadzą do wersji w odpowiednim języku (w tym przypadku japońskim).
Tłumaczenie bloga
Przetłumaczenie tekstów w interfejsie to jedno, ale co z wpisami na blogu? Nie chciałem uruchamiać i ogarniać jeszcze większej liczby agentów tylko po to, żeby tłumaczyć wszystkie posty.
Rozwiązałem to tak, że poprosiłem jednego agenta o stworzenie skryptu (scripts/src/generateBlogTranslations.ts), który automatyzuje cały proces.
Tak to działa:
- Przegląda katalog
client/src/posts/enw poszukiwaniu angielskich plików MDX. - Sprawdza brakujące tłumaczenia w pozostałych folderach locale (np.
posts/ja,posts/es). - Jeśli tłumaczenie jest brakujące, odczytuje angielską treść i wysyła ją do Gemini 3 Pro Preview z konkretnym promptem, który prosi o przetłumaczenie zawartości z zachowaniem formatowania Markdown.
- Zapisuje nowy plik we właściwym miejscu.
Po stronie frontendu używam import.meta.glob do dynamicznego importowania tych plików MDX. Mój komponent PostPage po prostu sprawdza bieżące locale użytkownika i leniwie ładuje odpowiedni plik MDX. Jeśli tłumaczenia brakuje (bo jeszcze nie odpaliłem skryptu), komponent po prostu wraca do wersji angielskiej.
Podsumowanie
W tym momencie miałem w pełni działającą stronę przetłumaczoną na wszystkie 20 locale!
To były szalone 3 dni, ale efekt to w pełni zlokalizowana strona, która dla użytkowników z całego świata wydaje się (w większości) natywna. Dzięki własnej, lekkiej bibliotece i wykorzystaniu agentów AI do żmudnej refaktoryzacji udało mi się osiągnąć coś, co jeszcze rok temu wydawałoby się nierealne: pełne i18n w 3 dni dla złożonej strony, zrobione przez jednego inżyniera. Przyszłość programowania nie polega na szybkim pisaniu kodu. Chodzi o orkiestrację agentów AI i posiadanie na tyle głębokiej wiedzy domenowej, żeby móc weryfikować ich wynik.