background blurbackground mobile blur

1/1/1970

Jak jsem během 3 dnů přidal i18n pro 20 jazyků

Nazdar! Zrovna jsem dokončil obří úkol, ve kterém jsem přeložil Foony do 20 různých jazyků. Byla to pěkná nálož, která znamenala sáhnout skoro na každý soubor v kódové základně, ale všechno jsem zvládl během pouhých 3 dnů.

Níž popíšu, jak jsem to udělal, přidám konkrétní čísla a vysvětlím, proč jsem se znovu rozhodl napsat si vlastní překladovou knihovnu místo toho, abych použil průmyslový standard.

Proč ne i18next?

Když jsem se poprvé díval na přidání překladů, zvažoval jsem průmyslový standard: i18next a react-i18next.

Místo toho jsem se rozhodl optimalizovat na udržovatelnost pomocí AI. i18next je silný nástroj, ale jeho různorodé API může způsobit, že LLM modely začnou halucinovat nebo psát nekonzistentní kód. Tím, že jsem knihovnu omezil na jednoduché t() a interpolate(), jsem zajistil, že 10+ paralelních agentů dokáže psát 100% type-safe kód skoro bez lidského zásahu.

Taky jsem byl opatrný ohledně naskakování do velkého ekosystému, který může později zavádět breaking changes. Po bolestivých migracích jako React Router v5 a MUI v4 → v5 vím, že rychlé lámání zpětné kompatibility je ve světě JavaScriptu až moc běžné. Cena za to, že pluralizaci doplním později, je menší než cena ruční migrace 139k řádků kódu teď hned.

Chtěl jsem něco naprosto jednoduchého, super lehkého a přesně šitého na míru potřebám našeho týmu.

Tak jsem si napsal vlastní řešení.

Postavil jsem omezený subset knihovny o velikosti 3 KB, navržený speciálně tak, aby podporoval přesné a autonomní AI refaktoringy. Díky tomu jsem se mohl chovat jako jeden inženýr, který zvládne tří týdenní práci pětičlenného týmu za 3 dny.

Vlastní implementace

Vymyslel jsem minimální i18n knihovnu, která má zhruba 3 KB gzipped. Exponuje dvě hlavní funkce: getTranslation() pro ne-React kontexty a hook useTranslation() pro komponenty.

Ty vracejí t() pro jednoduchou náhradu stringů a interpolate() pro případy, kdy potřebuju do překladového řetězce vložit React komponenty (třeba odkaz nebo ikonu). Obě funkce podporují náhradu proměnných, např. "Hello {{thing}}", {thing: 'World'}.

Klíče používají „slash-dot“ zápis (lomítka pro cestu k lokalizačnímu souboru, tečky pro vnořené objekty v souboru). Aby byly klíče jedinečné, překladové klíče v jednom souboru nesmí obsahovat lomítka.

Tady je jádro funkce 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;
}

A React hook:

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

Jádro celé knihovny má jen asi 580 řádků kódu. Řeší:

  • lazy-loading překladových souborů, takže neposíláme všech 20 jazyků každému uživateli
  • code splitting překladů podle „namespace“ (např. common, misc, games/{gameId})
  • „debug“ locale, které ukazuje syrové klíče, abych mohl ověřit, že je všechno správně zapojené

Aby systém zůstal snadno udržovatelný, přidal jsem i podrobnou dokumentaci do shared/src/i18n/README.md, která pokrývá všechno od struktury souborů až po příklady použití na klientu i serveru. Protože nepoužívám standardní knihovnu, je taková reference zásadní pro onboardování nových lidí v týmu (nebo pro připomenutí budoucímu mně a LLMům, jak to funguje).

V číslech

Aby bylo vidět, jak velká tahle změna byla, tady je přehled změn v kódu:

  • 20 podporovaných jazyků (plus debug locale pro vývoj).
  • 360 vytvořených locale souborů.
  • 139 031 řádků překladového kódu.
  • 3 938 volání t() přidaných do klienta.
  • 728 upravených zdrojových souborů.
  • 18 anglických zdrojových souborů, které slouží jako „single source of truth“ (16 her + common + misc).

Orchestrace pomocí agentů

Ruční práce by trvala měsíce a byla by to nekonečná, otupující rutina. Místo toho jsem zapojil víc než tucet agentů v Cursoru, kteří odmakali většinu těžké práce.

Začal jsem tím, že jsem kódovou základnu rozsekal na „sekce“ podle složek. Každá hra na Foony měla vlastní složku a vlastní překladový namespace. Díky tomu je počáteční velikost bundle malá, protože načítáš jen překlady pro hru, kterou zrovna hraješ.

Pouštěl jsem víc Cursor agentů najednou. Každému jsem přiřadil konkrétní sekci, třeba „převést šachovou hru na překlady“ a agent pak procházel soubor po souboru, hledal texty pro uživatele a nahrazoval je voláním t('games/chess/some.key').

Agent pak přidal daný klíč do odpovídajícího anglického locale souboru s JSDoc komentářem, který vysvětluje „co“ to je a „kde“ se to používá. Tenhle kontext je důležitý při generování překladů do dalších jazyků, protože pomáhá LLMu pochopit, jestli „Save“ znamená „Save Game Configuration“ nebo „Save Your Draw & Guess Drawing“.

Kontrola kvality

Všechen vygenerovaný kód jsem rychle prošel. Agenti byli překvapivě dobří, ale občas se spletli, třeba dali hook useTranslation až za předčasný return.

Silně typované překlady mi neuvěřitelně pomohly. Zajistily, že všechny překlady pro každou locale měly všechny správné klíče (a žádné navíc). Taky to zajistilo, že volání t() a interpolate() používají existující překladové řetězce.

Typový systém vytáhne všechny možné překladové klíče z anglických zdrojových souborů:

/**
 * 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 dává perfektní autocomplete v TypeScriptu a jakýkoli překlep v překladu klíče se chytne už při kompilaci. Agenti tak nemůžou udělat chybu typu t('games/ches/name'), protože ji TypeScript hned označí.

Lokalizace

Jakmile byla anglická konverze hotová, rozkouskoval jsem zbylou práci na locale souborech. Každý agent dostal na starost převést jeden anglický locale soubor do konkrétního jazyka.

Třeba jsem agentům zadal prompt jako:

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.

Zvažoval jsem, že nechám Cursor napsat skript, který by každý z těchto souborů poslal do LLM a nechal ho vygenerovat překlady, ale chtěl jsem trochu ušetřit na nákladech za LLM. Použít skript, který doplňuje jen chybějící překlady, byla lepší cesta a podobné řešení nejspíš použiju i příště. Rád bych sledoval, které řetězce potřebují aktualizaci / překlad, ale chci to nechat co nejjednodušší. Možná to jednou přesunu do databáze nebo něčeho podobného.

Přidal jsem taky „debug“ locale, které je dostupné jen ve vývoji. Díky němu vidím všechny nahrazené řetězce a můžu si ověřit, že všechno funguje (a navíc to vypadá cool). Když použiješ debug locale, t() vrátí klíč zabalený v závorkách:

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

Takže místo „Welcome to Foony!“ uvidíš ⟦welcome⟧, což ti usnadní hledání chybějících překladů.

Další agent nakonec implementoval routování /{locale}/**, takže třeba /ja/games/chess pošle uživatele na správný jazyk (v tomhle případě japonštinu).

Překlady blogu

Překlad UI textů je jedna věc, ale co blogové příspěvky? Nechtěl jsem spouštět a spravovat ještě víc agentů jen kvůli překladu blogu.

Vyřešil jsem to tak, že jsem nechal agenta vytvořit skript (scripts/src/generateBlogTranslations.ts), který celý proces zautomatizuje.

Funguje to takhle:

  1. Projede složku client/src/posts/en a najde anglické MDX soubory.
  2. Zkontroluje chybějící překlady v ostatních locale složkách (např. posts/ja, posts/es).
  3. Když překlad chybí, přečte anglický obsah a pošle ho do Gemini 3 Pro Preview se specifickým promptem, který přeloží obsah a zachová Markdown formátování.
  4. Uloží nový soubor na správné místo.

Na frontendu používám import.meta.glob pro dynamický import všech těchto MDX souborů. Můj PostPage komponent pak jen vezme aktuální locale uživatele a lazy-loadne správný MDX soubor. Pokud překlad chybí (třeba proto, že jsem ještě nespustil skript), hezky to spadne zpátky na angličtinu.

Den 4: Automatizované generování překladů

Bylo mi jasné, že původní řešení nebude moc škálovat. Jakmile jsem měl základní i18n venku, byl čas ho trochu zpevnit a přejít na databázově řízený přístup.

Stručně: když se změní anglický text nebo JSDoc komentáře, překlady se musí přegenerovat. Ruční sledování, co potřebuje aktualizaci, by bylo plné chyb a zbytečně by žralo čas vývojářů.

Takže jsem postavil řešení, které jsem měl v plánu už od začátku: systém generování překladů postavený nad PostgreSQL.

Databázové schéma

Do PostgreSQL databáze jsem přidal tabulku translations s touto strukturou:

  • key: překladový klíč ve „slash-dot“ zápisu (např. "games/yacht/nested.name", "config.timeLimit.label")
  • en_value: anglická zdrojová hodnota
  • target_locale: kód cílové locale (např. "es", "fr", "zh")
  • target_value: přeložená hodnota
  • context: JSONB pole obsahující JSDoc pro tento klíč i všechny nadřazené klíče
  • created_at a updated_at: timestampy pro sledování změn

Unikátní index je na (key, target_locale, en_value, context). To je zásadní: tím, že do unikátního klíče zahrnu i context, můžu automaticky poznat, kdy se změní JSDoc komentáře, a pak překlad přegenerovat. Staré překlady v databázi nechávám pro referenci.

Generovací skript

Vytvořil jsem scripts/src/generateLocalizations.ts, který celý překladový workflow automatizuje:

  1. Extrahuje anglické klíče: Pomocí AST parsingu (ts-morph) vytáhne všechny překladové klíče ze souborů shared/src/i18n/locales/en/** a zpracuje jen default exporty.
  2. Extrahuje JSDoc kontext: Pro každý klíč a všechny jeho nadřazené objekty přečte JSDoc komentáře a vytvoří tak bohatý kontext.
  3. Dotazuje databázi: Zkontroluje existující překlady v PostgreSQL podle key, target_locale, en_value a context - když se cokoli z toho změní, překlad se znovu vygeneruje.
  4. Identifikuje chybějící / změněné klíče: Najde klíče, které potřebují překlad, nebo u kterých se změnila anglická hodnota či komentáře.
  5. Dávkuje překlady: Seskupuje je podle locale a prefixu namespace pro efektivnější LLM volání (a rychlejší překlady). Když je dávka moc velká, kvalita překladu logicky klesá.
  6. Generuje překlady: Používá GPT 5.1 s bohatým kontextem (JSDoc, jazyk + region, tón, glosář, příklady). Četl jsem, že 5.1 píše líp než 5.2 (nezní tak mdle), ale nemám to ještě ověřené.
  7. QA kontroly: Ověřuje zachování placeholderů jako {{name}}, integritu klíčů a správný JSON formát.
  8. Ukládá do databáze: Uloží překlady i s plným kontextem (JSDoc + JSDoc předků).
  9. Generuje locale soubory: Čte data z databáze a zapisuje korektně typované TypeScript locale soubory s RecursivePartial typy.

Hlavní výhody

Tenhle přístup přináší spoustu zlepšení pro DevEx:

  • Automatická regenerace: Když se změní anglický text NEBO JSDoc komentáře, překlady se automaticky přegenerují. Takže když někdo řekne, že je překlad špatný, stačí doplnit lepší kontext do komentáře a pustit skript znovu.
  • Bohatý kontext: JSDoc komentáře dávají překladům souvislosti (např. „chybová hláška pro hráče, max 15 znaků“), což pomáhá LLMu trefit přesnější překlad.
  • Kontext předků: JSDoc na rodičovských objektech dává širší kontext namespace (např. „achievement za hru, kde jsou zničena všechna vejce“), takže je význam zase o něco jasnější.
  • Historické sledování: Staré překlady zůstávají v databázi. Moc místa nezabírají, takže zatím nevidím důvod je mazat a je docela zajímavé sledovat historii.

Technické detaily

Implementace používá několik technik pro spolehlivost a efektivitu:

  • extrakce přes AST, aby se vzaly opravdu ty správné komentáře
  • paralelní zpracování pomocí Semaphore pro dávkové překlady
  • retry logiku s exponenciálním backoffem při chybách API, protože LLM volání jsou pověstně nespolehlivá

Skript se spouští příkazem npm run generate-localizations ze složky scripts. Připojí se k PostgreSQL a při spuštění zpracuje všechny chybějící nebo změněné překlady pro všechny podporované locale.

Závěr

V tuhle chvíli jsem měl plně funkční web přeložený do všech 20 locale!

Byly to šílené 3 dny, ale výsledkem je plně lokalizovaný web, který působí (většinou) přirozeně pro hráče z celého světa. Díky vlastní, lehké knihovně a využití AI agentů na všechnu tu nudnou refaktoringovou práci se mi podařilo něco, co by ještě před rokem působilo nemožně: kompletní i18n za 3 dny pro komplexní web, a to v podstatě jedním inženýrem.

Budoucnost programování není o tom psát kód rychle. Je o orchestraci AI agentů a o hlubokém doménovém know-how, díky kterému dokážeš jejich výstup zkontrolovat a doladit.

8 Ball Pool online multiplayer billiards icon