background blurbackground mobile blur

1/1/1970

Hur jag implementerade i18n för 20 språk på 3 dagar

Hej! Jag har precis blivit klar med ett enormt jobb där jag översatte Foony till 20 olika språk. Det var ett gigantiskt projekt som innebar att jag behövde röra vid nästan varje fil i hela kodbasen, men jag lyckades få allt klart på bara 3 dagar.

Här nedanför går jag igenom hur jag gjorde, de konkreta siffrorna bakom förändringen och varför jag valde att bygga mitt eget översättningsbibliotek (igen) i stället för att använda branschstandarden.

Varför inte i18next?

När jag först började titta på att lägga till översättningar funderade jag förstås på branschstandarden: i18next och react-i18next.

Jag valde i stället att optimera för underhållsbarhet med hjälp av AI. i18next är kraftfullt, men den stora variationen i API:et kan få LLM:er att hallucinera eller skriva inkonsekvent kod. Genom att begränsa biblioteket till ett enkelt t() och interpolate() kunde jag se till att över 10 parallella agenter skrev 100 % typesäker kod med nästan ingen mänsklig inblandning.

Jag var också lite rädd för att låsa in mig i ett stort ekosystem som kan komma med breaking changes längre fram. Efter att ha bränt mig på jobbiga migreringar som React Router v5 och MUI v4 → v5 vet jag att snabb och frekvent sönderslagning av bakåtkompatibilitet är alldeles för vanlig i JavaScript-världen. Kostnaden för att lägga till pluraliseringsfunktioner senare är lägre än kostnaden för att manuellt migrera 139k rader kod just nu.

Jag ville ha något superenkelt, extremt lättviktigt och helt anpassat efter mitt teams behov.

Så jag skrev mitt eget.

Jag byggde ett begränsat subset på 3 KB som var specifikt designat för att möjliggöra träffsäker, autonom AI-refaktorisering. Det lät mig jobba som en ensam utvecklare som gör ett fempersoners teams tre veckors arbete på bara 3 dagar.

Den egna implementationen

Jag tog fram ett minimalt i18n-bibliotek som landar på ungefär 3 KB gzippat. Det exponerar två huvudfunktioner: getTranslation() för sammanhang utanför React och en useTranslation()-hook för komponenter.

De här returnerar t() för enkel strängersättning och interpolate() när jag behöver stoppa in React-komponenter i en översättningssträng (som en länk eller en ikon). Båda funktionerna stödjer variabelersättning, till exempel "Hej {{thing}}", {thing: 'Världen'}.

Här är den centrala t()-funktionen:

export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
  let namespace: string = '';
  let translationKey: string = key;
  
  // Kontrollera om key innehåller '/' vilket betyder att det är ett 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;
}

Och React-hooken:

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

Kärnan i hela biblioteket är bara ungefär 580 rader kod. Det hanterar:

  • Lazy-loading av översättningsfiler så att vi inte skickar med alla 20 språk till varje användare.
  • Code-splitting av översättningar per "namespace" (till exempel common, misc, games/{gameId}).
  • En "debug"-locale som visar de råa nycklarna så att jag kan verifiera att allt är kopplat rätt.

För att se till att systemet fortsätter vara lätt att underhålla lade jag också till en rejäl dokumentation i shared/src/i18n/README.md som täcker allt från filstruktur till användningsexempel för både klient och server. Eftersom jag inte använder ett standardbibliotek är den här referensen superviktig när man ska onboarda nya teammedlemmar (eller bara påminna mitt framtida jag eller LLM:er om hur allt funkar).

Siffrorna bakom

För att ge en känsla för hur stor uppdateringen var kommer här vad som ändrades i kodbasen:

  • 20 språk stöds (plus en debug-locale för utveckling).
  • 360 locale-filer skapades.
  • 139 031 rader översättningskod.
  • 3 938 anrop till t() lades till i klienten.
  • 728 källfiler ändrades.
  • 18 engelska källfiler som fungerar som source of truth (16 spel + common + misc).

Orkestrering med agenter

Att göra det här manuellt hade tagit månader av sövande, mekaniskt arbete. I stället orkestrerade jag över ett dussin Cursor-agenter samtidigt som fick göra grovjobbet.

Jag började med att dela upp kodbasen i "sektioner" baserat på mappar. Varje spel på Foony fick sin egen mapp och sitt eget översättnings-namespace. Det håller den initiala laddningsstorleken nere, eftersom du bara laddar översättningarna för spelet du spelar.

Jag körde flera Cursor-agenter samtidigt. Jag gav varje agent en specifik sektion, till exempel "konvertera schackspelet till att använda översättningar", och den gick igenom fil för fil, letade upp användarsynliga strängar och ersatte dem med t('games/chess/some.key').

Agenten lade sedan till den nyckeln i rätt engelsk locale-fil med en JSDoc-kommentar som förklarade "vad" och "var" för strängen. Den kontexten är viktig när man genererar översättningar till andra språk, eftersom den hjälper LLM:en att förstå om "Save" betyder "Spara spelkonfiguration" eller "Spara din Draw & Guess-teckning".

Kvalitetskontroll

Jag gick snabbt igenom all kod som genererats. Agenterna var förvånansvärt bra, men de gjorde ibland misstag, som att lägga useTranslation-hooken efter ett tidigt return-statement.

Starkt typade översättningar hjälpte enormt. Det säkerställde att alla översättningar för varje locale hade alla rätt nycklar (och inga felaktiga). Det gjorde också att anrop till t() och interpolate() bara kunde använda översättningssträngar som faktiskt fanns.

Typesystemet plockar ut alla möjliga översättningsnycklar från de engelska källfilerna:

/**
 * Extraherar alla möjliga sökvägar från en nästlad objekttyp och skapar nycklar i dot-notation.
 * Exempel: {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

Det här ger perfekt TypeScript-autocomplete, och minsta stavfel i en översättningsnyckel fångas vid kompilering. Agenterna kan inte göra misstag som t('games/ches/name') eftersom TypeScript direkt markerar det.

Lokalisering

När konverteringen till engelska nycklar var klar delade jag upp de kvarvarande locale-uppgifterna. Jag gjorde varje agent ansvarig för att konvertera en enskild engelsk locale-fil till ett visst språk.

Till exempel gav jag agenterna en prompt som den här:

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.

Jag funderade på att låta Cursor skapa ett skript som matade in var och en av de här filerna i en LLM och lät den generera allt, men jag ville hålla nere LLM-kostnaden lite. Att använda ett skript som bara uppdaterar saknade översättningar var ett bättre upplägg, och jag kommer nog använda något liknande i framtiden. Jag skulle gärna vilja hålla koll på vilka strängar som behöver uppdateras eller översättas, men vill samtidigt hålla det enkelt. Kanske flyttar jag själva översättningsjobbet till en databas eller något.

Jag lade också till en "debug"-locale som bara finns i utvecklingsläge. Den låter mig se alla utbytta strängar för att verifiera att allt funkar (plus att jag tycker det ser coolt ut). När du använder debug-locale returnerar t() nyckeln inlindad i hakparenteser:

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

Så i stället för att se "Welcome to Foony!" ser du ⟦welcome⟧, vilket gör det lätt att upptäcka saknade översättningar.

Till sist implementerade en annan agent routingen /{locale}/** så att saker som /ja/games/chess skickas till rätt språk (i det här fallet japanska).

Att översätta bloggen

Att översätta alla UI-strängar var en sak, men hur skulle jag göra med blogginläggen? Jag ville inte starta upp och hålla reda på ännu fler agenter bara för att översätta alla mina blogginlägg.

Jag löste det genom att låta en agent skapa ett skript (scripts/src/generateBlogTranslations.ts) som automatiserar hela processen.

Så här funkar det:

  1. Den skannar katalogen client/src/posts/en efter engelska MDX-filer.
  2. Den letar efter saknade översättningar i de andra locale-mapparna (till exempel posts/ja, posts/es).
  3. Om en översättning saknas läser den det engelska innehållet och matar in det i Gemini 3 Pro Preview med en specifik prompt för att översätta innehållet samtidigt som Markdown-formatet behålls.
  4. Den sparar den nya filen på rätt plats.

På frontend-sidan använder jag import.meta.glob för att dynamiskt importera alla de här MDX-filerna. Min PostPage-komponent kollar sedan bara upp användarens aktuella locale och lazy-loadar rätt MDX-fil. Om en översättning saknas (för att jag inte kört skriptet än) faller den snyggt tillbaka till engelska.

Slutsats

Vid det här laget hade jag en fullt fungerande sajt översatt till alla 20 locales!

Det här var tre ganska galna dagar, men resultatet är en fullt lokaliserad sajt som känns (nästan) helt native för användare över hela världen. Genom att bygga ett eget, lättviktigt bibliotek och låta AI-agenter ta hand om det tråkiga refaktoreringsarbetet lyckades jag göra något som hade varit omöjligt för bara ett år sedan: full i18n på 3 dagar för en komplex webbplats av 1 utvecklare. Framtiden för programmering handlar inte om att skriva kod snabbt. Den handlar om att orkestrera AI-agenter och ha den djupa domänkunskap som krävs för att kunna verifiera deras resultat.

8 Ball Pool online multiplayer billiards icon