

1/1/1970
Hur jag implementerade i18n till 20 språk på 3 dagar
Tjena! Jag har precis avslutat ett gigantiskt projekt där jag översatte Foony till 20 olika språk. Det var ett enormt åtagande som krävde att jag rörde nästan varje fil i kodbasen, men jag lyckades få allt klart på bara 3 dagar.
Nedan går jag igenom hur jag gjorde det, de specifika siffrorna bakom förändringen, och varför jag bestämde mig för att skriva mitt eget översättningsbibliotek (återigen) istället för att använda branschstandarden.
Varför inte i18next?
När jag först funderade på att lägga till översättningar övervägde jag branschstandarden: i18next och react-i18next.
Istället bestämde jag mig för att optimera för underhållbarhet med AI. i18next är kraftfullt, men variationen i dess API kan få LLM:er att hallucinera eller skriva inkonsekvent kod. Genom att begränsa biblioteket till en enkel t() och interpolate() såg jag till att 10+ parallella agenter kunde skriva 100% typsäker kod med nästan ingen mänsklig inblandning.
Jag var också skeptisk till att binda mig till ett stort ekosystem som senare kan introducera brytande ändringar. Efter att ha bränt mig på smärtsamma migrationer som React Router v5 och MUI v4 → v5 vet jag att snabba brott mot bakåtkompatibilitet är alltför vanliga 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 nu.
Jag ville ha något extremt enkelt, väldigt lättviktigt och skräddarsytt för mitt teams behov.
Så jag skrev mitt eget.
Jag byggde en 3 KB stor begränsad delmängd som är specifikt designad för att möjliggöra autonom AI-refaktorering med hög precision. Det här gjorde att jag som ensam ingenjör kunde utföra ett 3-veckorsarbete för ett 5-personers team på bara 3 dagar.
Den egna implementationen
Jag tog fram ett minimalt i18n-bibliotek som ligger på cirka 3 KB gzippat. Det exponerar två huvudfunktioner: getTranslation() för icke-React-kontext och en useTranslation()-hook för komponenter.
Dessa returnerar t() för enkel sträng-ersättning och interpolate() för när jag behöver injicera React-komponenter i en översättningssträng (som en länk eller en ikon). Båda funktionerna stödjer variabelersättning, t.ex. "Hello {{thing}}", {thing: 'World'}.
Nycklar följer en "snedstreck-punkt"-notation (snedstreck för filsökvägen till lokaliseringsfilen, punkter för nästlade objekt i filen). För att garantera unika värden får översättningsnycklar i en fil inte innehålla snedstreck.
Här är kärnfunktionen 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;
}
Och 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]);
}
Kärnan av hela biblioteket är bara cirka 580 rader kod. Det hanterar:
- Lazy-loading av översättningsfiler så att vi inte skickar alla 20 språk till varje användare.
- Kodsplittring av översättningar via "namespace" (t.ex.
common,misc,games/{gameId}). - En "debug"-locale som visar de råa nycklarna så att jag kan verifiera att allt är korrekt kopplat.
För att se till att systemet förblir lätt att underhålla lade jag också till omfattande 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 avgörande för att introducera nya teammedlemmar (eller bara påminna mitt framtida jag eller LLM:er om hur det fungerar).
I siffror
För att ge dig en känsla av omfattningen av den här uppdateringen, här är vad som ändrades i kodbasen:
- 20 språk stöds (plus en debug-locale för utveckling).
- 360 locale-filer skapade.
- 139 031 rader översättningskod.
- 3 938 anrop till
t()tillagda i klienten. - 728 källfiler modifierade.
- 18 engelska källfiler som fungerar som källa till sanningen (16 spel + common + misc).
Orkestrering med agenter
Att göra det här manuellt skulle ha tagit månader av sövande, mekaniskt arbete. Istället orkestrerade jag ett dussin Cursor-agenter samtidigt för att göra grovjobbet.
Jag började med att bryta ner kodbasen i "sektioner" baserat på mappar. Varje spel på Foony fick sin egen mapp och sitt eget översättnings-namespace. Det här håller nere den initiala laddningsstorleken eftersom du bara laddar översättningarna för det spel du spelar.
Jag körde flera Cursor-agenter samtidigt. Jag tilldelade varje agent en specifik sektion, som "konvertera schackspelet till att använda översättningar", och den gick igenom fil för fil, hittade strängar som syns för användaren och ersatte dem med t('games/chess/some.key').
Agenten lade sedan till den nyckeln i lämplig engelsk locale-fil med en JSDoc-kommentar som förklarade "vad" och "var" för strängen. Den här kontexten är viktig när översättningar genereras för andra språk, eftersom den hjälper LLM:en att förstå om "Save" betyder "Save Game Configuration" eller "Save Your Draw & Guess Drawing".
Kvalitetskontroll
Jag granskade snabbt all kod som genererades. Agenterna var förvånansvärt bra, men de gjorde enstaka misstag, som att placera useTranslation-hooken efter en tidig return-sats.
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 fel). Det säkerställde också att anrop till t() och interpolate() använde verkliga översättningssträngar som existerade.
Typsystemet extraherar alla möjliga översättningsnycklar från de engelska källfilerna:
/**
* 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
Det här ger perfekt TypeScript-autocomplete, och varje stavfel i en översättningsnyckel fångas vid kompilering. Agenterna kan inte göra misstag som t('games/ches/name') eftersom TypeScript omedelbart flaggar det.
Lokalisering
När den engelska konverteringen var klar delade jag upp de återstående locale-uppgifterna. Jag gjorde varje agent ansvarig för att konvertera en enda engelsk locale-fil till ett angivet 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 övervägde att låta Cursor skapa ett skript som matar in var och en av dessa filer i en LLM och låter den generera saker, men jag ville spara lite på LLM-kostnaden. Att använda ett skript för att bara uppdatera saknade översättningar var det bättre tillvägagångssättet, och jag kommer förmodligen att använda en liknande lösning i framtiden. Jag skulle vilja spåra vilka strängar som behöver uppdateras / översättas, men vill hålla saker enkla. Jag kanske flyttar översättningsarbetet till en databas eller liknande.
Jag lade också till en "debug"-locale som bara är tillgänglig under utveckling. Det låter mig se alla ersatta strängar för att verifiera att saker fungerar (plus att jag tycker det är coolt). När du använder debug-localen returnerar t() nyckeln inkapslad i klamrar:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Så istället för att se "Welcome to Foony!" skulle du se ⟦welcome⟧, vilket gör det enkelt att upptäcka eventuella saknade översättningar.
Slutligen implementerade en annan agent /{locale}/**-routing så att saker som /ja/games/chess skulle dirigeras till rätt språk (i det här fallet japanska).
Att översätta bloggen
Att översätta UI-strängarna var en sak, men hur var det med blogginläggen? Jag ville inte starta upp och hantera ännu fler agenter för att översätta alla mina blogginlägg.
Jag löste det här genom att låta en agent skapa ett skript (scripts/src/generateBlogTranslations.ts) som automatiserar hela processen.
Så här fungerar det:
- Det skannar
client/src/posts/en-katalogen efter engelska MDX-filer. - Det kollar efter saknade översättningar i de andra locale-mapparna (t.ex.
posts/ja,posts/es). - Om en översättning saknas läser det in 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-formatering bevaras.
- Det sparar den nya filen på rätt plats.
På frontend använder jag import.meta.glob för att dynamiskt importera alla dessa MDX-filer. Min PostPage-komponent kollar sedan helt enkelt användarens nuvarande locale och lazy-laddar rätt MDX-fil. Om en översättning saknas (eftersom jag inte har kört skriptet än) faller den graciöst tillbaka till engelska.
Dag 4: Automatisk översättningsgenerering
Jag visste att den ursprungliga lösningen inte skulle skala. Så nu när jag hade fått ut i18n var det dags att göra det lite mer robust med ett databasdrivet tillvägagångssätt.
Kortfattat: när engelsk text eller JSDoc-kommentarer ändrades behövde översättningar genereras om. Manuell spårning av vad som behövde uppdateras hade varit felbenäget och slöseri med utvecklartid.
Så jag byggde lösningen som jag ursprungligen hade planerat: ett PostgreSQL-baserat översättningsgenereringssystem.
Databasschemat
Jag lade till en translations-tabell i vår PostgreSQL-databas med följande struktur:
key: Översättningsnyckeln i "snedstreck-punkt"-notation (t.ex."games/yacht/nested.name","config.timeLimit.label").en_value: Det engelska källvärdettarget_locale: Målspråkets kod (t.ex."es","fr","zh")target_value: Det översatta värdetcontext: Ett JSONB-fält som innehåller JSDoc för denna nyckel och alla föräldrarnycklarcreated_atochupdated_at: Tidsstämplar för spårning
Det unika indexet ligger på (key, target_locale, en_value, context). Det här är avgörande: genom att inkludera context i den unika begränsningen kan vi automatiskt upptäcka när JSDoc-kommentarer ändras och regenerera översättningar. Gamla översättningar behålls för historisk referens.
Genereringsskriptet
Jag skapade scripts/src/generateLocalizations.ts som automatiserar hela översättningsflödet:
- Extraherar engelska nycklar: Använder AST-parsning (ts-morph) för att extrahera alla översättningsnycklar från
shared/src/i18n/locales/en/**-filer, och bearbetar bara default exports - Extraherar JSDoc-kontext: Parsar JSDoc-kommentarer för varje nyckel och alla föräldrarnycklar (föräldraobjekt) för att tillhandahålla rik kontext
- Frågar databasen: Kontrollerar befintliga översättningar i PostgreSQL och matchar på
key,target_locale,en_valueOCHcontext. Om någon av dessa ändras genereras översättningen om. - Identifierar saknade/ändrade nycklar: Hittar nycklar som behöver översättning eller har ändrade engelska värden/kommentarer
- Batchar översättningar: Grupperar efter locale och namespace-prefix för effektivare LLM-anrop (gör också översättningar snabbare). Om batchen är för stor blir dock översättningskvaliteten sämre.
- Genererar översättningar: Använder GPT 5.1 med omfattande kontext (JSDoc, språk+region, ton, ordlista, exempel). Jag har läst att 5.1 är bättre än 5.2 för skrivande (låter inte tråkigt), men jag har inte bekräftat det.
- QA-kontroller: Validerar att placeholders bevaras, t.ex.
{{name}}, nyckelintegritet, JSON-format - Lagrar i databasen: Sparar översättningar med full kontext (JSDoc + förälder-JSDoc)
- Genererar locale-filer: Läser från databasen och skriver korrekt formaterade TypeScript locale-filer med
RecursivePartial-typer
Viktiga fördelar
Det här tillvägagångssättet ger oss flera DevEx-förbättringar:
- Automatisk regenerering: När engelsk text ELLER JSDoc-kommentarer ändras genereras översättningar automatiskt om. Så om någon säger att en översättning är dålig är det jättelätt att regenerera översättningar genom att ge mer kontext som en kommentar.
- Rik kontext: JSDoc-kommentarer ger översättningskontext (t.ex. "Felmeddelande som visas för spelare, max 15 tecken"), vilket hjälper LLM:en att producera mer korrekta översättningar
- Förälderkontext: Föräldraobjektets JSDoc ger namespace-kontext (t.ex. "Prestation för att vara i en match där alla ägg förstörs"), vilket ger lite mer tydlighet
- Historisk spårning: Gamla översättningar sparas i databasen. De tar inte mycket plats, så jag ser inte mycket anledning att radera dem just nu, och det är coolt att se historiken.
Tekniska detaljer
Implementationen använder flera tekniker för att säkerställa pålitlighet och effektivitet:
- AST-baserad extraktion för att se till att jag får rätt kommentarer
- Parallell bearbetning med Semaphore för samtidig batch-översättning
- Exponentiell backoff-omförsökslogik för API-fel. LLM-anrop är ökänt opålitliga.
Skriptet kan köras med npm run generate-localizations från scripts-katalogen. Det ansluter till PostgreSQL och bearbetar alla saknade eller ändrade översättningar för alla språk som stöds när det körs.
Slutsats
Vid denna punkt hade jag en fullt fungerande sajt översatt till alla 20 språk!
Det här var galna 3 dagar, men resultatet är en helt lokaliserad sajt som känns (mestadels) lokal för användare runt om i världen. Genom att bygga ett eget, lättviktigt bibliotek och utnyttja AI-agenter för det tråkiga refaktoreringsarbetet lyckades jag göra det som hade varit omöjligt för bara ett år sedan: full i18n på 3 dagar för en komplex webbplats av 1 ingenjör. Framtiden för programmering handlar inte om att skriva kod snabbt. Det handlar om att orkestrera AI-agenter och ha den djupa domänexpertis som krävs för att verifiera deras output.