

1/1/1970
Sådan implementerede jeg i18n til 20 sprog på 3 dage
Hejsa! Jeg er lige blevet færdig med en kæmpe opgave, hvor jeg oversatte Foony til 20 forskellige sprog. Det var et stort projekt, der krævede ændringer i næsten alle filer i kodebasen, men jeg nåede alligevel det hele på bare 3 dage.
Herunder fortæller jeg, hvordan jeg gjorde, hvilke tal der gemmer sig bag ændringen, og hvorfor jeg endnu en gang valgte at bygge mit eget oversættelsesbibliotek i stedet for at bruge industristandarden.
Hvorfor ikke i18next?
Da jeg først kiggede på at tilføje oversættelser, kiggede jeg naturligt nok på industristandarden: i18next og react-i18next.
I stedet valgte jeg at optimere til vedligeholdelse af AI. i18next er stærkt, men den mangeartede API kan få LLM'er til at hallucinere eller skrive inkonsistent kode. Ved at begrænse biblioteket til et simpelt t() og interpolate(), kunne jeg sikre, at 10+ parallelle agenter skrev 100 % type-sikker kode med næsten ingen menneskelig indblanding.
Jeg var også lidt skeptisk over for at købe ind på et stort økosystem, der senere kunne introducere breaking changes. Efter at have brændt nallerne på smertefulde migrationer som React Router v5 og MUI v4 → v5, ved jeg, at hurtige brud med bagudkompatibilitet er alt for almindelige i JavaScript-verdenen. Prisen for at tilføje flertalsbøjning og andre features senere er lavere end prisen for manuelt at migrere 139k linjer kode nu.
Jeg ville have noget helt simpelt, ekstremt letvægts og skræddersyet præcis til mit teams behov.
Så jeg skrev mit eget.
Jeg byggede et begrænset subset på 3 KB, der specifikt er designet til at muliggøre nøjagtig, autonom AI-refaktorering. Det gjorde det muligt for mig som én enkelt udvikler at klare et 5-personers teams 3 ugers arbejde på bare 3 dage.
Den skræddersyede implementation
Jeg endte med et minimalt i18n-bibliotek på omkring 3 KB gzippet. Det eksponerer to hovedfunktioner: getTranslation() til ikke-React-kontekster og en useTranslation()-hook til komponenter.
De returnerer t() til simpel strengudskiftning og interpolate() til når jeg skal sætte React-komponenter ind i en oversættelsesstreng (som et link eller et ikon). Begge funktioner understøtter variabeludskiftning, fx "Hello {{thing}}", {thing: 'World'}.
Her er den centrale t()-funktion:
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-hook'en:
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]);
}
Kernen i hele biblioteket er kun omkring 580 linjer kode. Det håndterer:
- Lazy-loading af oversættelsesfiler, så vi ikke sender alle 20 sprog til hver eneste bruger.
- Code-splitting af oversættelser efter "namespace" (fx
common,misc,games/{gameId}). - Et "debug"-locale, der viser de rå nøgler, så jeg kan tjekke, at alting er koblet rigtigt sammen.
For at sikre, at systemet bliver ved med at være let at vedligeholde, lavede jeg også fyldig dokumentation i shared/src/i18n/README.md, der dækker alt fra mappestruktur til eksempler på brug både på klient og server. Fordi jeg ikke bruger et standardbibliotek, er den reference afgørende, når nye folk skal ombord (eller når jeg selv, eller LLM'er, skal mindes om, hvordan det hele virker).
Tallene bag
For at give en fornemmelse af, hvor stor opdateringen egentlig var, er her, hvad der ændrede sig i kodebasen:
- 20 understøttede sprog (plus et debug-locale til udvikling).
- 360 locale-filer oprettet.
- 139.031 linjer oversættelseskode.
- 3.938 kald til
t()tilføjet på klienten. - 728 kildefiler ændret.
- 18 engelske kildefiler, der fungerer som "source of truth" (16 spil + common + misc).
Orkestrering med agents
Hvis jeg skulle have gjort det her manuelt, havde det taget måneder med hjernedød, mekanisk arbejde. I stedet orkestrerede jeg over et dusin Cursor-agenter, der kørte samtidig og tog sig af det tunge løft.
Jeg startede med at dele kodebasen op i "sektioner" baseret på mapper. Hvert spil på Foony fik sin egen mappe og sit eget oversættelses-namespace. Det holder den første indlæsning lille, fordi du kun henter oversættelserne til det spil, du faktisk spiller.
Jeg kørte flere Cursor-agenter på én gang. Jeg gav hver agent en bestemt sektion, for eksempel "konvertér skakspillet til at bruge oversættelser", og så gik den fil for fil, fandt brugerrettede strenge og erstattede dem med t('games/chess/some.key').
Agenten tilføjede derefter nøglen til den rigtige engelske locale-fil med en JSDoc-kommentar, der forklarede "hvad" og "hvor" for strengen. Den kontekst er vigtig, når der skal laves oversættelser til andre sprog, fordi den hjælper LLM'en med at forstå, om "Save" betyder "Gem spilkonfiguration" eller "Gem din Draw & Guess-tegning".
Kvalitetskontrol
Jeg gennemgik hurtigt al den kode, der blev genereret. Agenterne var overraskende gode, men de lavede indimellem små fejl, som at placere useTranslation-hook'en efter et tidligt return-statement.
Stærkt typede oversættelser hjalp helt vildt. Det sikrede, at alle oversættelser for hvert locale havde alle de rigtige nøgler (og ingen forkerte). Det sikrede også, at kald til t() og interpolate() kun brugte faktiske oversættelsesstrenge, der rent faktisk fandtes.
Typesystemet 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
Det giver perfekt TypeScript-autocomplete, og enhver slåfejl i en oversættelsesnøgle bliver fanget ved compile-tid. Agenterne kan ikke lave fejl som t('games/ches/name'), fordi TypeScript straks markerer det.
Lokalisering
Da konverteringen til engelsk var færdig, delte jeg de resterende locale-opgaver op. 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 sendte hver af de her filer ind i en LLM og lod den generere det hele, men jeg ville gerne spare lidt på LLM-omkostningerne. At bruge et script, der kun opdaterede manglende oversættelser, viste sig at være den bedre løsning, og jeg bruger nok noget lignende fremover. Jeg vil gerne kunne holde styr på, hvilke strenge der skal opdateres eller oversættes, men jeg vil også gerne holde det enkelt. Måske flytter jeg selve oversættelsesarbejdet over i en database eller noget i den stil.
Jeg tilføjede også et "debug"-locale, som kun er tilgængeligt i development. Det lader mig se alle udskiftede strenge, så jeg kan tjekke, at det hele virker (og så synes jeg også, det ser fedt ud). Når du bruger debug-locale'et, returnerer t() nøglen omgivet af klammer:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Så i stedet for at se "Welcome to Foony!" ser du ⟦welcome⟧, hvilket gør det supernemt at opdage manglende oversættelser.
Til sidst implementerede en anden agent routing af typen /{locale}/**, så ting som /ja/games/chess bliver routet til det rigtige sprog (i det her tilfælde japansk).
Oversættelse af bloggen
At oversætte UI-strenge var én ting, men hvad med blogindlæggene? Jeg havde ikke lyst til at starte og styre endnu flere agenter bare for at oversætte alle mine blogindlæg.
Det løste jeg ved at lade en agent lave et script (scripts/src/generateBlogTranslations.ts), der automatiserer hele processen.
Sådan fungerer det:
- Det scanner mappen
client/src/posts/enfor engelske MDX-filer. - Det tjekker for manglende oversættelser i de andre locale-mapper (fx
posts/ja,posts/es). - Hvis der mangler en oversættelse, læser det det engelske indhold og sender det ind i Gemini 3 Pro Preview med en specifik prompt, der oversætter indholdet og samtidig bevarer Markdown-formatet.
- Det gemmer den nye fil det rigtige sted.
På frontend'en bruger jeg import.meta.glob til dynamisk at importere alle de her MDX-filer. Min PostPage-komponent tjekker så bare brugerens aktuelle locale og lazy-loader den rigtige MDX-fil. Hvis en oversættelse mangler (fordi jeg endnu ikke har kørt scriptet), falder den pænt tilbage til engelsk.
Konklusion
På det tidspunkt havde jeg et fuldt fungerende site oversat til alle 20 locales!
Det var tre ret vilde dage, men resultatet er et fuldt lokaliseret site, der føles (mest) naturligt for brugere over hele verden. Ved at bygge et skræddersyet, letvægtsbibliotek og bruge AI-agenter til det kedelige refaktoreringsarbejde lykkedes det mig at gøre noget, der ville have været umuligt for bare et år siden: fuld i18n på 3 dage for et komplekst website med 1 udvikler. Fremtiden for programmering handler ikke om at skrive kode hurtigt. Den handler om at orkestrere AI-agenter og have den dybe domæneforståelse, der skal til for at kunne godkende deres output.