background blurbackground mobile blur

1/1/1970

Jak wdrożyłem i18n dla 20 języków w 3 dni

Cześć! 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 gigantyczne przedsięwzięcie, które wymagało dotknięcia niemal każdego pliku w bazie kodu, ale udało mi się je zrealizować w zaledwie 3 dni.

Poniżej opowiem, jak tego dokonałem, podam konkretne liczby związane z tą zmianą i wyjaśnię, dlaczego po raz kolejny postanowiłem napisać własną bibliotekę tłumaczeń zamiast skorzystać ze standardu branżowego.

Dlaczego nie i18next?

Kiedy po raz pierwszy zacząłem rozważać dodanie tłumaczeń, wziąłem pod uwagę standard branżowy: i18next i react-i18next.

Zamiast tego zdecydowałem się zoptymalizować pod kątem łatwości utrzymania przez AI. i18next jest potężny, ale różnorodność jego API może powodować, że LLM-y halucynują albo piszą niespójny kod. Ograniczając bibliotekę do prostego t() i interpolate(), zapewniłem, że ponad 10 równoległych agentów może pisać w 100% bezpieczny typowo kod przy niemal zerowej interwencji człowieka.

Byłem też ostrożny, jeśli chodzi o uzależnienie się od dużego ekosystemu, który mógłby później wprowadzić niekompatybilne zmiany. Po bolesnych doświadczeniach z migracjami takimi jak React Router v5 i MUI v4 → v5 wiem, że szybkie łamanie wstecznej kompatybilności jest aż nazbyt powszechne w świecie JavaScriptu. Koszt dodania funkcji liczby mnogiej później jest niższy niż koszt ręcznej migracji 139 tys. linii kodu teraz.

Chciałem czegoś bardzo prostego, niezwykle lekkiego i dopasowanego dokładnie do potrzeb mojego zespołu.

Więc napisałem własne rozwiązanie.

Zbudowałem ograniczony podzbiór o wadze 3 KB, zaprojektowany specjalnie po to, by umożliwić wysokoprecyzyjny, autonomiczny refaktoring przez AI. Pozwoliło mi to działać jako jeden inżynier, realizując 3-tygodniową pracę 5-osobowego zespołu w zaledwie 3 dni.

Własna implementacja

Wymyśliłem minimalną bibliotekę i18n, która waży około 3 KB po gzip. Udostępnia dwie główne funkcje: getTranslation() dla kontekstów spoza Reacta oraz hook useTranslation() dla komponentów.

Zwracają one t() do prostego zastępowania ciągów znaków oraz interpolate(), gdy muszę wstrzyknąć komponenty Reacta do tłumaczonego ciągu (np. link albo ikonę). Obie funkcje obsługują podstawianie zmiennych, np. "Hello {{thing}}", {thing: 'World'}.

Klucze stosują notację „slash-dot” (slashe dla ścieżki pliku do pliku lokalizacyjnego, kropki dla zagnieżdżonych obiektów w pliku). Aby zapewnić unikalność, klucze tłumaczeń w pliku nie mogą zawierać ukośników.

Oto rdzeniowa funkcja 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;
}

Oraz hook Reacta:

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]);
}

Rdzeń całej biblioteki to tylko około 580 linii kodu. Obsługuje:

  • Leniwe ładowanie plików tłumaczeń, dzięki czemu nie wysyłamy do każdego użytkownika wszystkich 20 języków.
  • Dzielenie tłumaczeń na fragmenty według „przestrzeni nazw” (np. common, misc, games/{gameId}).
  • Locale „debug”, które pokazuje surowe klucze, dzięki czemu mogę zweryfikować, że wszystko jest poprawnie podpięte.

Żeby system był łatwy w utrzymaniu, dodałem też obszerną dokumentację w pliku shared/src/i18n/README.md, obejmującą wszystko od struktury plików po przykłady użycia zarówno po stronie klienta, jak i serwera. Ponieważ nie używam standardowej biblioteki, posiadanie tego materiału referencyjnego jest kluczowe dla wdrażania nowych członków zespołu (lub po prostu przypominania mojemu przyszłemu ja albo LLM-om, jak to działa).

W liczbach

Aby dać Wam wyobrażenie o skali tej aktualizacji, oto co się zmieniło w bazie kodu:

  • 20 obsługiwanych języków (plus locale debug do dewelopmentu).
  • 360 utworzonych plików lokalizacyjnych.
  • 139 031 linii kodu tłumaczeń.
  • 3 938 wywołań t() dodanych w kliencie.
  • 728 zmodyfikowanych plików źródłowych.
  • 18 angielskich plików źródłowych, które stanowią źródło prawdy (16 gier + common + misc).

Orkiestracja agentami

Robienie tego ręcznie zajęłoby miesiące mózgomielącej, mechanicznej pracy. Zamiast tego orkiestrowałem jednocześnie kilkunastoma agentami Cursora, którzy wykonali ciężką robotę.

Zacząłem od podzielenia bazy kodu na „sekcje” na podstawie folderów. Każda gra na Foony dostała własny folder i własną przestrzeń nazw tłumaczeń. To zachowuje mały rozmiar początkowego ładowania, ponieważ ładujesz tylko tłumaczenia dla gry, w którą grasz.

Uruchomiłem wielu agentów Cursora jednocześnie. Każdemu agentowi przypisałem konkretną sekcję, np. „przekonwertuj grę Chess, aby używała tłumaczeń”, a on przechodził plik po pliku, znajdował ciągi widoczne dla użytkownika i zastępował je t('games/chess/some.key').

Następnie agent dodawał ten klucz do odpowiedniego angielskiego pliku locale wraz z komentarzem JSDoc wyjaśniającym „co” i „gdzie” danego ciągu. Ten kontekst jest ważny przy generowaniu tłumaczeń dla innych języków, ponieważ pomaga LLM-owi zrozumieć, czy „Save” oznacza „Zapisz konfigurację gry” czy „Zapisz swój rysunek z Draw & Guess”.

Kontrola jakości

Szybko przejrzałem cały wygenerowany kod. Agenci byli zaskakująco dobrzy, ale od czasu do czasu popełniali błędy, np. umieszczali hook useTranslation po wcześniejszej instrukcji return.

Silnie typowane tłumaczenia ogromnie pomogły. Zapewniły, że wszystkie tłumaczenia dla każdego locale miały wszystkie poprawne klucze (i żadnych niewłaściwych). Zagwarantowały też, że wywołania t() i interpolate() używają prawdziwych, 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:

/**
 * 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

To daje doskonałe autouzupełnianie TypeScripta, a każda literówka w kluczu tłumaczenia jest wychwytywana w czasie kompilacji. Agenci nie mogą popełnić błędu typu t('games/ches/name'), ponieważ TypeScript natychmiast go oznaczy.

Lokalizacja

Po zakończeniu konwersji na angielski rozdzieliłem pozostałe zadania związane z locale. Każdy agent był odpowiedzialny za konwersję pojedynczego angielskiego pliku locale na określony język.

Na przykład dawałem agentom prompt taki jak ten:

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.

Rozważałem, by Cursor stworzył skrypt podający każdy z tych plików do LLM-a i generujący tłumaczenia, ale chciałem trochę zaoszczędzić na kosztach LLM. Użycie skryptu tylko do aktualizacji brakujących tłumaczeń było lepszym podejściem i prawdopodobnie skorzystam z podobnego rozwiązania w przyszłości. Chciałbym śledzić, które ciągi wymagają aktualizacji lub tłumaczenia, ale chcę zachować prostotę. Być może przeniosę pracę nad tłumaczeniami do bazy danych albo coś w tym stylu.

Dodałem też locale „debug”, dostępne tylko w trybie deweloperskim. Pozwala mi ono podejrzeć wszystkie zastąpione ciągi, by zweryfikować, że wszystko działa (poza tym uważam, że to fajne). Gdy używasz locale debug, t() zwraca klucz opakowany w nawiasy:

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

Więc zamiast „Welcome to Foony!” zobaczysz ⟦welcome⟧, co ułatwia wyłapanie brakujących tłumaczeń.

Na koniec inny agent wdrożył routing /{locale}/**, dzięki czemu np. /ja/games/chess kieruje na właściwy język (w tym przypadku japoński).

Tłumaczenie bloga

Tłumaczenie ciągów UI to jedno, ale co z postami na blogu? Nie chciałem uruchamiać i zarządzać kolejnymi agentami, by tłumaczyć wszystkie moje wpisy.

Rozwiązałem to, każąc agentowi stworzyć skrypt (scripts/src/generateBlogTranslations.ts), który automatyzuje cały proces.

Oto jak to działa:

  1. Skanuje katalog client/src/posts/en w poszukiwaniu angielskich plików MDX.
  2. Sprawdza, czy w innych folderach locale (np. posts/ja, posts/es) brakuje tłumaczeń.
  3. Jeśli tłumaczenia brakuje, wczytuje angielską treść i podaje ją do Gemini 3 Pro Preview ze specyficznym promptem, który tłumaczy treść, zachowując formatowanie Markdown.
  4. Zapisuje nowy plik w odpowiednim miejscu.

Po stronie frontendu używam import.meta.glob, by dynamicznie importować wszystkie te pliki MDX. Mój komponent PostPage po prostu sprawdza aktualne locale użytkownika i leniwie ładuje właściwy plik MDX. Jeśli tłumaczenia brakuje (bo nie uruchomiłem jeszcze skryptu), elegancko cofa się do angielskiego.

Dzień 4: Automatyczne generowanie tłumaczeń

Wiedziałem, że pierwotne rozwiązanie nie będzie się skalować. Skoro więc miałem już wdrożone i18n, czas było je nieco wzmocnić podejściem opartym na bazie danych.

W skrócie: gdy zmieniał się angielski tekst lub komentarze JSDoc, tłumaczenia musiały zostać wygenerowane na nowo. Ręczne śledzenie tego, co wymaga aktualizacji, byłoby podatne na błędy i marnotrawiło czas dewelopera.

Zbudowałem więc rozwiązanie, które od początku planowałem: system generowania tłumaczeń oparty na PostgreSQL.

Schemat bazy danych

Dodałem do naszej bazy PostgreSQL tabelę translations o następującej strukturze:

  • key: Klucz tłumaczenia w notacji „slash-dot” (np. "games/yacht/nested.name", "config.timeLimit.label").
  • en_value: Angielska wartość źródłowa
  • target_locale: Docelowy kod locale (np. "es", "fr", "zh")
  • target_value: Przetłumaczona wartość
  • context: Pole JSONB zawierające JSDoc dla tego klucza i wszystkich kluczy nadrzędnych
  • created_at i updated_at: Znaczniki czasu do śledzenia

Indeks unikalny obejmuje (key, target_locale, en_value, context). To kluczowe: dzięki uwzględnieniu context w ograniczeniu unikalności możemy automatycznie wykryć zmianę komentarzy JSDoc i wygenerować tłumaczenia ponownie. Stare tłumaczenia są zachowywane jako referencja historyczna.

Skrypt generujący

Stworzyłem scripts/src/generateLocalizations.ts, który automatyzuje cały przepływ pracy nad tłumaczeniami:

  1. Wyciąga angielskie klucze: Wykorzystuje parsowanie AST (ts-morph), by wyciągnąć wszystkie klucze tłumaczeń z plików shared/src/i18n/locales/en/**, przetwarzając tylko domyślne eksporty
  2. Wyciąga kontekst JSDoc: Parsuje komentarze JSDoc dla każdego klucza i wszystkich kluczy nadrzędnych (obiektów rodziców), by zapewnić bogaty kontekst
  3. Odpytuje bazę danych: Sprawdza istniejące tłumaczenia w PostgreSQL, dopasowując po key, target_locale, en_value ORAZ context. Jeśli którakolwiek z tych wartości się zmieni, tłumaczenie jest generowane ponownie.
  4. Identyfikuje brakujące/zmienione klucze: Znajduje klucze, które wymagają tłumaczenia lub mają zmienione angielskie wartości/komentarze
  5. Grupuje tłumaczenia w batche: Grupuje według locale i prefiksu przestrzeni nazw, by efektywniej wywoływać LLM (a także by tłumaczenia były szybsze). Jeśli batch jest jednak zbyt duży, jakość tłumaczeń się pogarsza.
  6. Generuje tłumaczenia: Używa GPT 5.1 z obszernym kontekstem (JSDoc, język + region, ton, glosariusz, przykłady). Czytałem, że 5.1 jest lepszy od 5.2 do pisania (nie brzmi bezbarwnie), ale tego nie potwierdziłem.
  7. Sprawdzanie QA: Waliduje zachowanie placeholderów, np. {{name}}, integralność kluczy, format JSON
  8. Zapisuje w bazie danych: Zapisuje tłumaczenia z pełnym kontekstem (JSDoc + JSDoc kluczy nadrzędnych)
  9. Generuje pliki locale: Czyta z bazy i zapisuje poprawnie sformatowane pliki TypeScript locale z typami RecursivePartial

Kluczowe korzyści

To podejście daje nam kilka usprawnień DevEx:

  • Automatyczna regeneracja: Gdy zmienia się angielski tekst LUB komentarze JSDoc, tłumaczenia są automatycznie generowane ponownie. Więc jeśli ktoś powie, że tłumaczenie jest złe, naprawdę łatwo jest wygenerować tłumaczenia na nowo, podając więcej kontekstu jako komentarz.
  • Bogaty kontekst: Komentarze JSDoc dostarczają kontekstu tłumaczenia (np. „Komunikat błędu pokazywany graczom, maks. 15 znaków”), pomagając LLM-owi tworzyć dokładniejsze tłumaczenia
  • Kontekst kluczy nadrzędnych: JSDoc obiektu rodzica zapewnia kontekst przestrzeni nazw (np. „Osiągnięcie za bycie w grze, w której wszystkie jajka zostały zniszczone”), dając trochę więcej jasności
  • Śledzenie historyczne: Stare tłumaczenia są zapisywane w bazie danych. Nie zajmują dużo miejsca, więc nie widzę powodu, by je teraz usuwać, a fajnie jest zobaczyć historię.

Szczegóły techniczne

Implementacja wykorzystuje kilka technik, by zapewnić niezawodność i wydajność:

  • Ekstrakcję opartą na AST, by mieć pewność, że dostanę poprawne komentarze
  • Przetwarzanie równoległe z użyciem semafora dla równoczesnych tłumaczeń batchy
  • Logikę ponawiania prób z wykładniczym wycofywaniem dla błędów API. Wywołania LLM są notorycznie kapryśne.

Skrypt można uruchomić poleceniem npm run generate-localizations z katalogu scripts. Łączy się z PostgreSQL i przy uruchomieniu przetwarza wszystkie brakujące lub zmienione tłumaczenia dla wszystkich obsługiwanych locale.

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 efektem jest w pełni zlokalizowana strona, która wydaje się (w większości) natywna dla użytkowników na całym świecie. Budując własną, lekką bibliotekę i wykorzystując agentów AI do żmudnej pracy refaktoryzacyjnej, udało mi się dokonać czegoś, co jeszcze rok temu byłoby niemożliwe: pełnego i18n w 3 dni dla skomplikowanej strony przez 1 inżyniera. Przyszłość programowania nie polega na szybkim pisaniu kodu. Polega na orkiestrowaniu agentami AI i posiadaniu głębokiej wiedzy dziedzinowej, która pozwala zweryfikować ich wyniki.

8 Ball Pool online multiplayer billiards icon