background blurbackground mobile blur

1/1/1970

Sådan implementerede jeg i18n til 20 sprog på 3 dage

Hejsa! Jeg har lige afsluttet en kæmpe opgave, hvor jeg oversatte Foony til 20 forskellige sprog. Det var et stort projekt, der involverede næsten hver eneste fil i kodebasen, men det lykkedes mig at få det hele i mål på bare 3 dage.

Nedenfor fortæller jeg, hvordan jeg gjorde det, de specifikke tal bag ændringen, og hvorfor jeg besluttede at lave mit eget oversættelsesbibliotek (endnu engang) i stedet for at bruge industristandarden.

Hvorfor ikke i18next?

Da jeg først begyndte at overveje at tilføje oversættelser, kiggede jeg på industristandarden: i18next og react-i18next.

I stedet besluttede jeg at optimere for vedligeholdelse via AI. i18next er kraftfuldt, men dets API-mangfoldighed kan få LLM'er til at hallucinere eller skrive inkonsistent kode. Ved at begrænse biblioteket til en simpel t() og interpolate() sikrede jeg, at 10+ parallelle agenter kunne skrive 100% type-sikker kode med næsten ingen menneskelig indgriben.

Jeg var også skeptisk over for at binde mig til et stort økosystem, der senere kunne introducere breaking changes. Efter at have brændt fingrene på smertefulde migrationer som React Router v5 og MUI v4 → v5, ved jeg, at hurtige brud på bagudkompatibilitet er alt for almindelige i JavaScript-land. Omkostningen ved at tilføje pluraliseringsfunktioner senere er lavere end omkostningen ved manuelt at migrere 139.000 linjer kode nu.

Jeg ville have noget skide simpelt, ekstremt letvægts og skræddersyet præcis til mit teams behov.

Så jeg skrev mit eget.

Jeg byggede et begrænset 3 KB-undersæt, designet specifikt til at muliggøre autonom AI-refactoring med høj nøjagtighed. Det gjorde det muligt for mig at fungere som én enkelt udvikler, der løste et 5-personers teams 3-ugers arbejdsbyrde på bare 3 dage.

Den tilpassede implementering

Jeg lavede et minimalt i18n-bibliotek på cirka 3 KB gzippet. Det eksponerer to hovedfunktioner: getTranslation() til kontekster uden React og en useTranslation()-hook til komponenter.

Disse returnerer t() til simpel strengerstatning og interpolate() til når jeg skal injicere React-komponenter ind i en oversættelsesstreng (som et link eller et ikon). Begge funktioner understøtter variabelerstatning, f.eks. "Hello {{thing}}", {thing: 'World'}.

Nøgler følger en "slash-dot"-notation (skråstreger til filstien til lokaliseringsfilen, punktummer til indlejrede objekter i filen). For at sikre unikhed må oversættelsesnøgler i en fil ikke indeholde skråstreger.

Her er kerne-t()-funktionen:

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

Og React-hooken:

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

Kernen i hele biblioteket er kun omkring 580 linjer kode. Den håndterer:

  • Lazy-loading af oversættelsesfiler, så vi ikke sender alle 20 sprog til hver bruger.
  • Code-splitting af oversættelser efter "namespace" (f.eks. common, misc, games/{gameId}).
  • En "debug"-locale, der viser de rå nøgler, så jeg kan verificere, at alt er sat korrekt op.

For at sikre at systemet forbliver let at vedligeholde, tilføjede jeg også omfattende dokumentation i shared/src/i18n/README.md, der dækker alt fra filstruktur til brugseksempler for både klient og server. Da jeg ikke bruger et standardbibliotek, er denne reference kritisk for at onboarde nye teammedlemmer (eller bare at minde mit fremtidige jeg eller LLM'er om, hvordan det fungerer).

I tal

For at give dig en fornemmelse af omfanget af denne opdatering, er her hvad der ændrede sig i kodebasen:

  • 20 sprog understøttet (plus en debug-locale til udvikling).
  • 360 locale-filer oprettet.
  • 139.031 linjer oversættelseskode.
  • 3.938 kald til t() tilføjet på tværs af klienten.
  • 728 kildefiler ændret.
  • 18 engelske kildefiler, der fungerer som "source of truth" (16 spil + common + misc).

Orkestrering med agenter

At gøre dette manuelt ville have taget måneder af åndssvagt, mekanisk arbejde. I stedet orkestrerede jeg over et dusin Cursor-agenter samtidigt til at tage det tunge løft.

Jeg startede med at opdele kodebasen i "sektioner" baseret på mapper. Hvert spil på Foony fik sin egen mappe og sit eget oversættelses-namespace. Det holder den indledende load-størrelse lille, da du kun loader oversættelserne for det spil, du spiller.

Jeg kørte flere Cursor-agenter samtidigt. Jeg gav hver agent en specifik sektion, som "konverter Skak-spillet til at bruge oversættelser", og den gik fil for fil igennem, fandt brugervendte strenge og erstattede dem med t('games/chess/some.key').

Agenten ville derefter tilføje den nøgle til den passende engelske locale-fil med en JSDoc-kommentar, der forklarede "hvad" og "hvor" for strengen. Denne kontekst er vigtig, når man genererer oversættelser til andre sprog, da den hjælper LLM'en med at forstå, om "Save" betyder "Gem spilkonfiguration" eller "Gem din Tegn & Gæt-tegning".

Kvalitetskontrol

Jeg gennemgik hurtigt al den kode, der blev genereret. Agenterne var overraskende dygtige, men de begik lejlighedsvise fejl, som at placere useTranslation-hooken efter en tidlig return-sætning.

Stærkt typede oversættelser hjalp enormt. Det sikrede, at alle oversættelser for hver locale havde alle de korrekte nøgler (og ingen af de forkerte). Det sikrede også, at kald til t() og interpolate() brugte rigtige oversættelsesstrenge, der eksisterede.

Type-systemet udtrækker alle mulige oversættelsesnøgler fra de engelske kildefiler:

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

Dette giver perfekt TypeScript-autocomplete, og enhver tastefejl i en oversættelsesnøgle fanges på kompileringstidspunktet. Agenterne kan ikke begå fejl som t('games/ches/name'), fordi TypeScript straks markerer det.

Lokalisering

Da den engelske konvertering var færdig, opdelte jeg de resterende locale-opgaver. Jeg gjorde hver agent ansvarlig for at konvertere én engelsk locale-fil til et bestemt sprog.

For eksempel gav jeg agenterne en prompt som denne:

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.

Jeg overvejede at lade Cursor lave et script, der kunne fodre hver af disse filer ind i en LLM og få den til at generere tingene, men jeg ville gerne spare lidt på LLM-omkostningerne. At bruge et script til kun at opdatere manglende oversættelser var den bedre tilgang, og jeg vil sandsynligvis bruge en lignende løsning i fremtiden. Jeg vil gerne tracke, hvilke strenge der skal opdateres / oversættes, men vil holde tingene simple. Jeg flytter måske oversættelsesarbejdet til en database eller noget lignende.

Jeg tilføjede også en "debug"-locale, som kun er tilgængelig i udvikling. Det lader mig se alle erstattede strenge for at verificere, at tingene virker (plus jeg synes, det er fedt). Når du bruger debug-localen, returnerer t() nøglen pakket ind i parenteser:

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

Så i stedet for at se "Velkommen til Foony!" ville du se ⟦welcome⟧, hvilket gør det nemt at spotte eventuelle manglende oversættelser.

Endelig implementerede en anden agent /{locale}/**-routing, så ting som /ja/games/chess ville route til det korrekte sprog (i dette tilfælde japansk).

Oversættelse af bloggen

At oversætte UI-strengene var én ting, men hvad med blogindlæggene? Jeg ville ikke spinne endnu flere agenter op og administrere dem til at oversætte alle mine blogindlæg.

Jeg løste dette ved at få en agent til at lave et script (scripts/src/generateBlogTranslations.ts), der automatiserer hele processen.

Sådan fungerer det:

  1. Det scanner mappen client/src/posts/en for engelske MDX-filer.
  2. Det tjekker for manglende oversættelser i de andre locale-mapper (f.eks. posts/ja, posts/es).
  3. Hvis en oversættelse mangler, læser det det engelske indhold og fodrer det ind i Gemini 3 Pro Preview med en specifik prompt om at oversætte indholdet, mens Markdown-formatering bevares.
  4. Det gemmer den nye fil på den korrekte placering.

På frontend bruger jeg import.meta.glob til dynamisk at importere alle disse MDX-filer. Min PostPage-komponent tjekker så blot brugerens nuværende locale og lazy-loader den korrekte MDX-fil. Hvis en oversættelse mangler (fordi jeg ikke har kørt scriptet endnu), falder den elegant tilbage til engelsk.

Dag 4: Automatiseret oversættelsesgenerering

Jeg vidste, at den oprindelige løsning ikke ville skalere. Så nu hvor jeg havde i18n ude, var det tid til at gøre det lidt mere robust med en database-drevet tilgang.

Kort fortalt: når engelsk tekst eller JSDoc-kommentarer ændrede sig, skulle oversættelser regenereres. Manuel tracking af, hvad der skulle opdateres, ville have været fejlbehæftet og spild af udviklertid.

Så jeg byggede den løsning, jeg oprindeligt havde planlagt: et PostgreSQL-baseret oversættelsesgenereringssystem.

Database-skemaet

Jeg tilføjede en translations-tabel til vores PostgreSQL-database med følgende struktur:

  • key: Oversættelsesnøglen i "slash-dot"-notation (f.eks. "games/yacht/nested.name", "config.timeLimit.label").
  • en_value: Den engelske kildeværdi
  • target_locale: Mål-locale-koden (f.eks. "es", "fr", "zh")
  • target_value: Den oversatte værdi
  • context: Et JSONB-felt indeholdende JSDoc for denne nøgle og alle ancestor-nøgler
  • created_at og updated_at: Tidsstempler til tracking

Det unikke indeks er på (key, target_locale, en_value, context). Dette er afgørende: ved at inkludere context i unique constraint kan vi automatisk opdage, når JSDoc-kommentarer ændres, og regenerere oversættelser. Gamle oversættelser bevares til historisk reference.

Genereringsscriptet

Jeg lavede scripts/src/generateLocalizations.ts, der automatiserer hele oversættelsesworkflowet:

  1. Udtrækker engelske nøgler: Bruger AST-parsing (ts-morph) til at udtrække alle oversættelsesnøgler fra shared/src/i18n/locales/en/**-filer, og behandler kun default exports
  2. Udtrækker JSDoc-kontekst: Parser JSDoc-kommentarer for hver nøgle og alle ancestor-nøgler (forældreobjekter) for at give rig kontekst
  3. Forespørger database: Tjekker eksisterende oversættelser i PostgreSQL og matcher på key, target_locale, en_value OG context. Hvis nogen af disse ændres, regenereres oversættelsen.
  4. Identificerer manglende/ændrede nøgler: Finder nøgler, der har brug for oversættelse eller har ændrede engelske værdier/kommentarer
  5. Batcher oversættelser: Grupperer efter locale og namespace-præfiks for mere effektive LLM-kald (gør også oversættelser hurtigere). Hvis batchen er for stor, vil oversættelseskvaliteten dog blive dårligere.
  6. Genererer oversættelser: Bruger GPT 5.1 med omfattende kontekst (JSDoc, sprog+region, tone, ordliste, eksempler). Jeg har læst, at 5.1 er bedre end 5.2 til at skrive (lyder ikke kedeligt), men jeg har ikke bekræftet det.
  7. QA-tjek: Validerer bevarelse af placeholders, f.eks. {{name}}, nøgleintegritet, JSON-format
  8. Gemmer i database: Gemmer oversættelser med fuld kontekst (JSDoc + ancestor JSDoc)
  9. Genererer locale-filer: Læser fra databasen og skriver korrekt formaterede TypeScript-locale-filer med RecursivePartial-typer

Vigtige fordele

Denne tilgang giver os flere DevEx-forbedringer:

  • Automatisk regenerering: Når engelsk tekst ELLER JSDoc-kommentarer ændres, regenereres oversættelser automatisk. Så hvis nogen siger, at en oversættelse er dårlig, er det virkelig nemt at regenerere oversættelser ved at give mere kontekst som en kommentar.
  • Rig kontekst: JSDoc-kommentarer giver oversættelseskontekst (f.eks. "Fejlmeddelelse vist til spillere, maks. 15 tegn"), hvilket hjælper LLM'en med at producere mere nøjagtige oversættelser
  • Ancestor-kontekst: Forældreobjekters JSDoc giver namespace-kontekst (f.eks. "Achievement for at være i et spil, hvor alle æg destrueres"), hvilket giver lidt mere klarhed
  • Historisk tracking: Gamle oversættelser gemmes i databasen. De fylder ikke meget, så jeg ser ikke megen grund til at slette dem indtil videre, og det er fedt at se historikken.

Tekniske detaljer

Implementeringen bruger flere teknikker for at sikre pålidelighed og effektivitet:

  • AST-baseret udtrækning for at sikre, at jeg får de rigtige kommentarer
  • Parallel behandling med Semaphore til samtidig batch-oversættelse
  • Eksponentiel backoff retry-logik for API-fejl. LLM-kald er notorisk ustabile.

Scriptet kan køres med npm run generate-localizations fra scripts-mappen. Det forbinder til PostgreSQL og behandler alle manglende eller ændrede oversættelser for alle understøttede locales, når det køres.

Konklusion

På dette tidspunkt havde jeg et fuldt fungerende site oversat til alle 20 locales!

Det var 3 vanvittige dage, men resultatet er et fuldt lokaliseret site, der føles (mestendels) native for brugere over hele verden. Ved at bygge et tilpasset, letvægts-bibliotek og udnytte AI-agenter til det kedelige refactoring-arbejde lykkedes det mig at opnå, hvad der ville have været umuligt for bare et år siden: fuld i18n på 3 dage for et komplekst website af 1 udvikler. Fremtiden for programmering handler ikke om at skrive kode hurtigt. Det handler om at orkestrere AI-agenter og besidde den dybe domæneekspertise til at verificere deres output.

8 Ball Pool online multiplayer billiards icon