background blurbackground mobile blur

1/1/1970

Jak jsem za 3 dny implementoval i18n do 20 jazyků

Zdravím! Právě jsem dokončil obrovský úkol, při kterém jsem přeložil Foony do 20 různých jazyků. Byl to velký podnik, který se dotkl skoro každého souboru v codebase, ale podařilo se mi to zvládnout za pouhé 3 dny.

Níže rozeberu, jak jsem to udělal, konkrétní čísla za touto změnou a proč jsem se rozhodl (zase) napsat vlastní překladovou knihovnu místo použití průmyslového standardu.

Proč ne i18next?

Když jsem o přidávání překladů uvažoval poprvé, zvažoval jsem průmyslový standard: i18next a react-i18next.

Místo toho jsem se rozhodl optimalizovat udržovatelnost AI. i18next je mocný, ale rozmanitost jeho API může způsobit, že LLM halucinují nebo píší nekonzistentní kód. Tím, že jsem knihovnu omezil na jednoduché t() a interpolate(), jsem zajistil, že více než 10 paralelních agentů dokáže napsat 100% typově bezpečný kód téměř bez lidského zásahu.

Také jsem se obával vázání se na velký ekosystém, který by mohl později zavést breaking changes. Poté, co mě popálily bolestivé migrace jako React Router v5 a MUI v4 → v5, vím, že rychlé porušování zpětné kompatibility je v JavaScript světě až příliš běžné. Náklady na pozdější přidání pluralizace jsou nižší než náklady na ruční migraci 139 tisíc řádků kódu nyní.

Chtěl jsem něco mrtvolně jednoduchého, extrémně lehkého a přesně přizpůsobeného potřebám mého týmu.

Tak jsem si to napsal sám.

Postavil jsem 3 KB omezenou podmnožinu speciálně navrženou tak, aby umožnila vysoce přesné, autonomní AI refaktorování. To mi umožnilo jako jeden inženýr zvládnout práci 5člennému týmu za 3 týdny během pouhých 3 dnů.

Vlastní implementace

Vymyslel jsem minimální i18n knihovnu, která má kolem 3 KB gzipovaných. Vystavuje dvě hlavní funkce: getTranslation() pro non-React kontexty a useTranslation() hook pro komponenty.

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

Klíče dodržují notaci "lomítko-tečka" (lomítka pro cestu k souboru lokalizace, tečky pro vnořené objekty v souboru). Pro zajištění unikátnosti nemohou klíče v souboru obsahovat lomítka.

Tady je jádrová 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. Zvládá:

  • 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" lokalizaci, která zobrazuje surové klíče, abych mohl ověřit, že je vše správně zapojené.

Aby systém zůstal snadno udržovatelný, přidal jsem také rozsáhlou dokumentaci v shared/src/i18n/README.md, která pokrývá vše od struktury souborů po příklady použití pro klienta i server. Vzhledem k tomu, že nepoužívám standardní knihovnu, je tato reference klíčová pro zaškolení nových členů týmu (nebo prostě pro připomenutí mého budoucího já nebo LLM, jak to funguje).

V číslech

Abyste si udělali představu o rozsahu této aktualizace, tady je, 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 přidaných volání t() napříč klientem.
  • 728 upravených zdrojových souborů.
  • 18 anglických zdrojových souborů, které slouží jako zdroj pravdy (16 her + common + misc).

Orchestrace s agenty

Dělat to ručně by zabralo měsíce otupující, mechanické práce. Místo toho jsem orchestroval přes tucet Cursor agentů současně, aby udělali tu těžkou dřinu.

Začal jsem rozdělením codebase na "sekce" podle složek. Každá hra na Foony dostala vlastní složku a vlastní překladový namespace. Tím se udržuje malá počáteční velikost, protože se načítají jen překlady pro hru, kterou hrajete.

Spustil jsem několik Cursor agentů současně. Každému agentovi jsem přiřadil konkrétní sekci, jako "převeď hru Šachy na použití překladů", a ten procházel soubor po souboru, hledal řetězce viditelné uživateli a nahrazoval je t('games/chess/some.key').

Agent pak přidal tento klíč do příslušného anglického locale souboru s JSDoc komentářem vysvětlujícím "co" a "kde" daného řetězce. Tento kontext je důležitý při generování překladů pro ostatní jazyky, protože pomáhá LLM pochopit, zda "Save" znamená "Uložit konfiguraci hry" nebo "Uložit svou kresbu Draw & Guess".

Kontrola kvality

Rychle jsem zkontroloval všechen vygenerovaný kód. Agenti byli překvapivě dobří, ale občas dělali chyby, jako třeba umístění useTranslation hooku za předčasný return příkaz.

Silně typované překlady ohromně pomohly. Tím se zajistilo, že všechny překlady pro každou lokalizaci měly všechny správné klíče (a žádné špatné). Také se tím zajistilo, že volání t() a interpolate() používala skutečné překladové řetězce, které existují.

Typový systém extrahuje 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

Tohle dává perfektní TypeScript autocomplete a jakýkoli překlep v překladovém klíči je zachycen v době kompilace. Agenti nemohou udělat chyby jako t('games/ches/name'), protože TypeScript to okamžitě označí.

Lokalizace

Jakmile byla anglická konverze hotová, rozdělil jsem zbývající úkoly lokalizace. Každého agenta jsem udělal zodpovědným za převedení jednoho anglického locale souboru do určeného jazyka.

Například jsem agentům dal prompt jako tento:

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.

Uvažoval jsem, že nechám Cursor vytvořit skript, který by každý z těchto souborů poslal do LLM a nechal ho vygenerovat věci, ale chtěl jsem trochu ušetřit na nákladech LLM. Použití skriptu pouze k aktualizaci chybějících překladů byl lepší přístup a podobné řešení pravděpodobně použiji i v budoucnu. Rád bych sledoval, které řetězce potřebují aktualizovat / přeložit, ale chci to udržovat jednoduché. Možná překladovou práci přesunu do databáze nebo něco takového.

Také jsem přidal "debug" lokalizaci, která je dostupná pouze ve vývoji. Umožňuje mi zobrazit všechny nahrazené řetězce a ověřit, že věci fungují (a navíc si myslím, že je to cool). Když použijete debug lokalizaci, t() vrací klíč obalený v závorkách:

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

Takže místo "Welcome to Foony!" uvidíte ⟦welcome⟧, takže snadno zachytíte chybějící překlady.

Nakonec další agent implementoval /{locale}/** routování, takže věci jako /ja/games/chess směřují na správný jazyk (v tomto případě japonštinu).

Překlad blogu

Překlad UI řetězců byl jeden problém, ale co příspěvky na blogu? Nechtěl jsem rozjíždět a spravovat ještě další agenty pro překlad všech mých blogových příspěvků.

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

Tady je, jak to funguje:

  1. Prochází adresář client/src/posts/en pro anglické MDX soubory.
  2. Kontroluje chybějící překlady v ostatních locale složkách (např. posts/ja, posts/es).
  3. Pokud překlad chybí, načte anglický obsah a posílá ho do Gemini 3 Pro Preview se specifickým promptem, aby přeložil obsah a zachoval 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ů. Moje komponenta PostPage pak prostě zkontroluje aktuální lokalizaci uživatele a lazy-loaduje správný MDX soubor. Pokud překlad chybí (protože jsem ještě nespustil skript), elegantně se vrátí k angličtině.

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

Věděl jsem, že původní řešení nebude škálovat. Takže teď, když jsem měl i18n venku, byl čas to trochu vylepšit přístupem řízeným databází.

Stručně řečeno: když se změnil anglický text nebo JSDoc komentáře, překlady bylo třeba znovu vygenerovat. Manuální sledování toho, co potřebuje aktualizaci, by bylo náchylné k chybám a plýtvání časem vývojářů.

Tak jsem postavil řešení, které jsem původně plánoval: systém generování překladů poháněný PostgreSQL.

Schéma databáze

Přidal jsem tabulku translations do naší PostgreSQL databáze s následující strukturou:

  • key: Překladový klíč v notaci "lomítko-tečka" (např. "games/yacht/nested.name", "config.timeLimit.label").
  • en_value: Anglická zdrojová hodnota
  • target_locale: Kód cílové lokalizace (např. "es", "fr", "zh")
  • target_value: Přeložená hodnota
  • context: JSONB pole obsahující JSDoc pro tento klíč a všechny rodičovské klíče
  • created_at a updated_at: Časová razítka pro sledování

Unikátní index je na (key, target_locale, en_value, context). To je zásadní: zahrnutím context do unikátního omezení dokážeme automaticky detekovat, kdy se JSDoc komentáře změní, a regenerovat překlady. Staré překlady jsou zachovány pro historickou referenci.

Generovací skript

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

  1. Extrahuje anglické klíče: Používá AST parsování (ts-morph) k extrakci všech překladových klíčů ze souborů shared/src/i18n/locales/en/**, zpracovává pouze default exporty
  2. Extrahuje JSDoc kontext: Parsuje JSDoc komentáře pro každý klíč a všechny rodičovské klíče (rodičovské objekty) pro poskytnutí bohatého kontextu
  3. Dotazuje databázi: Kontroluje existující překlady v PostgreSQL, párování podle key, target_locale, en_value A context - pokud se kterákoli z nich změní, překlad je regenerován.
  4. Identifikuje chybějící/změněné klíče: Najde klíče, které potřebují překlad nebo mají změněné anglické hodnoty/komentáře
  5. Dávkuje překlady: Seskupuje podle lokalizace a prefixu namespace pro efektivnější LLM volání (také zrychluje překlady). Pokud je ale dávka příliš velká, kvalita překladu se zhorší.
  6. Generuje překlady: Používá GPT 5.1 s komplexním kontextem (JSDoc, jazyk+region, tón, glosář, příklady). Četl jsem, že 5.1 je lepší než 5.2 pro psaní (nezní fádně), ale neověřil jsem.
  7. QA kontroly: Validuje zachování placeholderů, např. {{name}}, integritu klíčů, JSON formát
  8. Ukládá do databáze: Ukládá překlady s plným kontextem (JSDoc + rodičovský JSDoc)
  9. Generuje locale soubory: Čte z databáze a zapisuje správně formátované TypeScript locale soubory s typy RecursivePartial

Klíčové výhody

Tento přístup nám dává několik DevEx vylepšení:

  • Automatická regenerace: Když se anglický text NEBO JSDoc komentáře změní, překlady jsou automaticky regenerovány. Takže pokud někdo řekne, že je překlad špatný, je opravdu snadné regenerovat překlady poskytnutím více kontextu jako komentáře.
  • Bohatý kontext: JSDoc komentáře poskytují kontext překladu (např. "Chybová zpráva zobrazená hráčům, max 15 znaků"), což pomáhá LLM produkovat přesnější překlady
  • Rodičovský kontext: JSDoc rodičovského objektu poskytuje kontext namespace (např. "Achievement za to, že jste ve hře, kde jsou zničena všechna vejce"), což dává trochu více jasnosti
  • Historické sledování: Staré překlady jsou uloženy v databázi. Nezabírají moc místa, takže nevidím moc důvodů je teď mazat, a je cool vidět historii.

Technické detaily

Implementace používá několik technik k zajištění spolehlivosti a efektivity:

  • AST-based extrakce, aby se zajistilo, že dostanu správné komentáře
  • Paralelní zpracování pomocí Semaforu pro souběžný dávkový překlad
  • Logika opakování s exponenciálním zpožděním pro selhání API. LLM volání jsou notoricky vrtkavá.

Skript lze spustit pomocí npm run generate-localizations z adresáře scripts. Připojuje se k PostgreSQL a při spuštění zpracovává všechny chybějící nebo změněné překlady pro všechny podporované lokalizace.

Závěr

V tomto bodě jsem měl plně funkční web přeložený do všech 20 lokalizací!

Byly to šílené 3 dny, ale výsledkem je plně lokalizovaný web, který působí (většinou) nativně pro uživatele po celém světě. Vybudováním vlastní, lehké knihovny a využitím AI agentů pro únavnou refaktorovací práci jsem zvládl něco, co by ještě před rokem bylo nemožné: plné i18n za 3 dny pro komplexní web jedním inženýrem. Budoucnost programování není o rychlém psaní kódu. Je o orchestraci AI agentů a o hluboké doménové expertíze potřebné k ověření jejich výstupu.

8 Ball Pool online multiplayer billiards icon