background blurbackground mobile blur

1/1/1970

Jak jsem za 3 dny implementoval i18n pro 20 jazyků

Čau! Právě jsem dodělal masivní úkol, při kterém jsem přeložil Foony do 20 různých jazyků. Byla to obří akce, která se dotkla skoro každého souboru v codebase, ale zvládl jsem to celé za pouhé 3 dny.

Níž rozepíšu, jak jsem na to šel, konkrétní čísla kolem téhle změny a proč jsem se zase jednou rozhodl napsat si vlastní překladovou knihovnu místo toho, abych sáhl po standardním řešení.

Proč ne i18next?

Na začátku, když jsem řešil překlady, jsem samozřejmě koukal na standard, který používá skoro každý: i18next a react-i18next.

Rozhodl jsem se ale optimalizovat hlavně na to, aby se s tím dobře pracovalo AI. i18next je silný nástroj, ale jeho API má tolik variant, že LLM občas halucinují nebo píšou nekonzistentní kód. Tím, že jsem knihovnu omezil na jednoduché t() a interpolate(), jsem zajistil, že víc než 10 paralelních agentů může psát 100% type-safe kód skoro bez zásahu člověka.

Byl jsem taky opatrný s tím, abych nenaskočil do obřího ekosystému, který za chvíli přinese 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 v JavaScriptovém světě až moc běžná věc. Cena za to, že si někdy později dopíšu podporu množného čísla, je menší než cena za ruční migraci 139k řádků kódu právě teď.

Chtěl jsem něco úplně jednoduchého, extrémně lehkého a přesně šitého na potřeby našeho týmu.

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

Postavil jsem ořezaný balíček o velikosti asi 3 KB, navržený přímo tak, aby umožnil přesný, autonomní refaktoring pomocí AI. Díky tomu jsem jako jediný vývojář zvládl práci, která by běžně zabrala pětičlennému týmu tak tři týdny, za pouhé 3 dny.

Vlastní implementace

Vymyslel jsem minimalistickou i18n knihovnu, která má po gzipu asi 3 KB. Ven vystavuje dvě hlavní funkce: getTranslation() pro prostředí mimo React a hook useTranslation() pro komponenty.

Vrací t() na jednoduchou záměnu řetězců a interpolate() na případy, kdy potřebuju do překladu dostat React komponenty (třeba odkaz nebo ikonu). Obě funkce podporují nahrazování proměnných, např. "Hello {{thing}}", {thing: 'World'}.

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;
  
  // Zkontroluj, jestli key obsahuje '/', což znamená 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();
  
  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. Umí:

  • Lazy-load překladových souborů, takže neposíláme všech 20 jazyků každému uživateli.
  • Rozdělení překladu podle „namespace“ (např. common, misc, games/{gameId}).
  • „Debug“ locale, které ukazuje syrové klíče, takže si můžu ověřit, že je všechno správně zapojené.

Aby zůstal systém jednoduchý na údržbu, přidal jsem i podrobnou dokumentaci v shared/src/i18n/README.md, která pokrývá všechno od struktury souborů po ukázky použití pro client i server. Protože nepoužívám standardní knihovnu, je tenhle referenční přehled klíčový pro zaučení nových lidí v týmu (a taky pro moje budoucí já nebo LLM, aby si připomněli, jak to celé funguje).

V číslech

Aby bylo vidět, jak velká tahle změna byla, tady je přehled toho, co se v codebase změnilo:

  • 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() napříč clientem.
  • 728 změněných zdrojových souborů.
  • 18 anglických zdrojových souborů, které slouží jako jediný zdroj pravdy (16 her + common + misc).

Orchestrace s agenty

Ruční práce by na tohle zabrala měsíce tupé, mechanické dřiny. Místo toho jsem rozběhl víc než tucet Cursor agentů najednou a nechal je odtáhnout tu nejtěžší práci.

Začal jsem tím, že jsem codebase rozsekal na „sekce“ podle složek. Každá hra na Foony dostala vlastní složku a vlastní překladový namespace. Díky tomu zůstává úvodní bundle malý, protože se načítají jen překlady pro hru, kterou právě hraješ.

Spouštěl jsem víc Cursor agentů paralelně. Každému agentovi jsem přidělil konkrétní sekci, třeba „překlop hru Chess na překlady“, a on pak jel soubor po souboru, hledal texty viditelné pro uživatele a nahrazoval je voláním t('games/chess/some.key').

Agent pak tenhle klíč přidal do odpovídajícího anglického locale souboru spolu s JSDoc komentářem, který vysvětluje „co“ a „kde“ ten text je. Tenhle kontext je hodně důležitý při generování překladů do dalších jazyků, protože pomáhá LLM pochopit, jestli „Save“ znamená „Uložit nastavení hry“, nebo „Uložit tvoje Draw & Guess kreslení“.

Kontrola kvality

Všechen vygenerovaný kód jsem rychle prošel. Agenti byli překvapivě šikovní, ale sem tam udělali chybu, třeba že dali hook useTranslation až za raný return v komponentě.

Obrovsky pomohly silně typované překlady. Díky nim má každý jazyk všechny správné klíče (a žádné navíc). Taky to zaručuje, že volání t() a interpolate() používají jen skutečné překladové řetězce, které opravdu existují.

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

/**
 * Vytáhne všechny možné cesty z vnořeného objektového typu a vytvoří klíče v dot notaci.
 * Příklad: {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>}`
  // ... a tak dál pro všechny hry

Díky tomu máš perfektní autocomplete v TypeScriptu a jakýkoli překlep v překladovém klíči se chytí už při kompilaci. Agenti tak nemůžou dělat chyby jako t('games/ches/name'), protože je TypeScript hned nahlásí.

Lokalizace

Jakmile byla angličtina hotová, rozkouskoval jsem zbytek práce na locale souborech. Každý agent dostal na starost převod jednoho anglického locale souboru do konkrétního jazyka.

Třeba jsem agentům zadal prompt zhruba v tomhle stylu:

Ujisti se, že ar/games/dinomight.ts obsahuje všechny překlady z en/games/dinomight.ts.
Použij `export const account: DinomightTranslations = {`.
Opakuj úpravy, dokud pro tvůj překladový soubor nezmizí všechny typové chyby (když uvidíš chyby v jiných souborech, ignoruj je, běžíš paralelně s dalšími agenty, kteří mají na starost ty ostatní soubory).
Tvoje překlady musí být skvělé a správné vzhledem k jsdoc kontextu v en verzi.
Musíš to udělat ručně, bez psaní „helper“ skriptů a bez jakýchkoli zkratek.

Zvažoval jsem, že nechám Cursor napsat skript, který by tyhle soubory posílal do LLM a nechal ho překlady generovat, ale chtěl jsem trochu ušetřit na nákladech za LLM. Skript, který jen doplňuje chybějící překlady, mi přišel jako lepší cesta a nejspíš něco podobného použiju i příště. Rád bych měl přehled o tom, které řetězce čekají na aktualizaci nebo překlad, ale zároveň to chci udržet jednoduché. Možná časem přesunu překlady do databáze nebo něčeho podobného.

Navíc jsem přidal speciální „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 prostě vypadá cool). Když použiješ debug locale, t() vrací klíč zabalený do hranatých závorek:

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

Místo textu „Welcome to Foony!“ tak uvidíš ⟦welcome⟧, takže hned poznáš, jestli někde něco nechybí.

Nakonec ještě jiný agent zavedl routování ve tvaru /{locale}/**, takže adresy jako /ja/games/chess vedou na správný jazyk (v tomhle případě japonštinu).

Překlad blogu

Překlad UI textů je jedna věc, ale co blogové články? Nechtělo se mi spouštět a hlídat ještě víc agentů jen kvůli překladu všech příspěvků na blogu.

Vyřešil jsem to tak, že jeden agent napsal skript (scripts/src/generateBlogTranslations.ts), který celý proces automatizuje.

Funguje to takhle:

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

Na frontendu používám import.meta.glob k dynamickému importu všech těchto MDX souborů. Komponenta PostPage pak jen zkontroluje aktuální locale uživatele a lazy-loadne správný MDX soubor. Když překlad chybí (protože jsem třeba ještě nespustil skript), stránka v klidu spadne zpátky na angličtinu.

Závěr

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

Byly to dost divoké 3 dny, ale výsledkem je plně lokalizovaný web, který působí (většinou) jako domácí prostředí pro uživatele z celého světa. Díky vlastní lehké knihovně a využití AI agentů na nudnou refaktorovací dřinu se mi podařilo něco, co by ještě před rokem působilo skoro nemožně: kompletní i18n za 3 dny pro složitý web, a to v podání jednoho jediného vývojáře. Budoucnost programování není o tom psát kód co nejrychleji. Je to o tom umět dirigovat AI agenty a mít dost hlubokou znalost domény, abys dokázal jejich výstup zkontrolovat.

8 Ball Pool online multiplayer billiards icon