

1/1/1970
Hoe ik in 3 dagen i18n voor 20 talen heb geïmplementeerd
Hallo daar! Ik heb net een gigantische klus afgerond waarbij ik Foony naar 20 verschillende talen heb vertaald. Het was een enorme onderneming waarbij ik bijna elk bestand in de codebase moest aanpakken, maar het is me gelukt om alles in slechts 3 dagen voor elkaar te krijgen.
Hieronder leg ik uit hoe ik het heb gedaan, deel ik de specifieke cijfers achter de wijziging en vertel ik waarom ik (alweer) heb besloten mijn eigen vertaalbibliotheek te schrijven in plaats van de industriestandaard te gebruiken.
Waarom geen i18next?
Toen ik voor het eerst keek naar het toevoegen van vertalingen, overwoog ik de industriestandaard: i18next en react-i18next.
In plaats daarvan koos ik ervoor om te optimaliseren voor onderhoudbaarheid door AI. i18next is krachtig, maar de variatie in zijn API kan ervoor zorgen dat LLMs gaan hallucineren of inconsistente code schrijven. Door de bibliotheek te beperken tot een simpele t() en interpolate(), zorgde ik ervoor dat 10+ parallelle agents 100% type-safe code konden schrijven met vrijwel geen menselijke tussenkomst.
Ik was ook huiverig om me vast te leggen op een groot ecosysteem dat later breaking changes zou kunnen introduceren. Na pijnlijke migraties zoals React Router v5 en MUI v4 → v5 weet ik dat het snel breken van backwards-compatibility maar al te gebruikelijk is in JavaScript-land. De kosten van het later toevoegen van pluralisatie-features zijn lager dan de kosten van het nu handmatig migreren van 139k regels code.
Ik wilde iets doodsimpels, extreem lichtgewichts, perfect afgestemd op de behoeften van mijn team.
Dus heb ik het zelf geschreven.
Ik bouwde een ingeperkte subset van 3 KB die specifiek is ontworpen om autonome AI-refactoring met hoge nauwkeurigheid mogelijk te maken. Hierdoor kon ik als één enkele engineer in slechts 3 dagen het werk van 3 weken voor een team van 5 personen verzetten.
De eigen implementatie
Ik bedacht een minimale i18n-bibliotheek van ongeveer 3 KB gzipped. Die biedt twee hoofdfuncties: getTranslation() voor niet-React contexten en een useTranslation() hook voor componenten.
Deze geven t() terug voor simpele string-vervanging en interpolate() voor wanneer ik React-componenten in een vertaalstring moet injecteren (zoals een link of een icoon). Beide functies ondersteunen variabele-vervanging, bijvoorbeeld "Hallo {{thing}}", {thing: 'Wereld'}.
Sleutels volgen een "slash-punt"-notatie (slashes voor het bestandspad naar het lokalisatiebestand, punten voor geneste objecten in het bestand). Om uniciteit te garanderen, mogen vertaalsleutels in een bestand geen forward-slashes bevatten.
Hier is de kern-t() functie:
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;
}
En de 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]);
}
De kern van de hele bibliotheek is slechts ongeveer 580 regels code. Hij regelt:
- Lazy-loading van vertaalbestanden, zodat we niet alle 20 talen meesturen naar elke gebruiker.
- Code-splitsing van vertalingen op basis van "namespace" (bijvoorbeeld
common,misc,games/{gameId}). - Een "debug"-locale die de ruwe sleutels toont, zodat ik kan controleren of alles correct is aangesloten.
Om het systeem onderhoudbaar te houden heb ik ook uitgebreide documentatie toegevoegd in shared/src/i18n/README.md, met alles van bestandsstructuur tot gebruiksvoorbeelden voor zowel client als server. Aangezien ik geen standaardbibliotheek gebruik, is deze referentie cruciaal voor het inwerken van nieuwe teamleden (of gewoon om mijn toekomstige zelf of LLMs eraan te herinneren hoe het werkt).
In cijfers
Om je een gevoel te geven van de schaal van deze update, hier wat er in de codebase is veranderd:
- 20 ondersteunde talen (plus een debug-locale voor dev).
- 360 locale-bestanden aangemaakt.
- 139.031 regels vertaalcode.
- 3.938 aanroepen naar
t()toegevoegd in de client. - 728 gewijzigde bronbestanden.
- 18 Engelse bronbestanden die als bron van waarheid dienen (16 games + common + misc).
Orchestreren met agents
Dit handmatig doen zou maanden van geestdodend, mechanisch werk hebben gekost. In plaats daarvan heb ik meer dan een dozijn Cursor-agents tegelijkertijd ingezet voor het zware werk.
Ik begon met het opdelen van de codebase in "secties" op basis van mappen. Elke game op Foony kreeg zijn eigen map en zijn eigen vertaal-namespace. Hierdoor blijft de initiële laadgrootte klein, omdat je alleen de vertalingen laadt voor de game die je speelt.
Ik liet meerdere Cursor-agents tegelijkertijd draaien. Ik gaf elke agent een specifieke sectie, zoals "converteer de Chess-game om vertalingen te gebruiken", en die ging bestand voor bestand op zoek naar gebruikersgerichte strings om die te vervangen door t('games/chess/some.key').
De agent voegde die sleutel vervolgens toe aan het juiste Engelse locale-bestand met een JSDoc-commentaar dat het "wat" en "waar" van de string uitlegde. Deze context is belangrijk bij het genereren van vertalingen voor andere talen, omdat het de LLM helpt te begrijpen of "Save" "Save Game Configuration" of "Save Your Draw & Guess Drawing" betekent.
Kwaliteitscontrole
Ik heb alle gegenereerde code snel doorgelopen. De agents waren verrassend goed, maar maakten af en toe foutjes, zoals het plaatsen van de useTranslation-hook na een vroege return-statement.
Sterk getypeerde vertalingen hielpen enorm. Hierdoor was gegarandeerd dat alle vertalingen voor elke locale alle juiste sleutels bevatten (en geen verkeerde). Het zorgde er ook voor dat aanroepen naar t() en interpolate() echte vertaalstrings gebruikten die ook bestonden.
Het typesysteem extraheert alle mogelijke vertaalsleutels uit de Engelse bronbestanden:
/**
* 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
Dit geeft perfecte TypeScript-autocomplete, en elke typefout in een vertaalsleutel wordt al bij het compileren gevangen. De agents kunnen geen fouten maken zoals t('games/ches/name'), want TypeScript markeert dat onmiddellijk.
Lokalisatie
Toen de Engelse conversie klaar was, deelde ik de overige locale-taken op. Ik maakte elke agent verantwoordelijk voor het converteren van één Engels locale-bestand naar een opgegeven taal.
Ik gaf de agents bijvoorbeeld een prompt zoals deze:
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.
Ik overwoog Cursor een script te laten maken om elk van deze bestanden in een LLM te voeren en die de vertalingen te laten genereren, maar ik wilde een beetje besparen op LLM-kosten. Een script gebruiken om alleen ontbrekende vertalingen bij te werken was de betere aanpak, en die zal ik in de toekomst waarschijnlijk vaker gebruiken. Ik zou graag bijhouden welke strings bijgewerkt of vertaald moeten worden, maar wil het simpel houden. Misschien verplaats ik het vertaalwerk naar een database of zoiets.
Ik heb ook een "debug"-locale toegevoegd die alleen beschikbaar is in development. Hiermee kan ik alle vervangen strings bekijken om te controleren of alles werkt (en bovendien vind ik het gewoon cool). Wanneer je de debug-locale gebruikt, geeft t() de sleutel terug, omsloten door haakjes:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Dus in plaats van "Welkom bij Foony!" zie je ⟦welcome⟧, waardoor het makkelijk is om ontbrekende vertalingen te spotten.
Tot slot heeft een andere agent de routing voor /{locale}/** geïmplementeerd, zodat iets als /ja/games/chess naar de juiste taal routeert (in dit geval Japans).
De blog vertalen
Het vertalen van UI-strings was één ding, maar hoe zit het met de blogposts? Ik wilde niet nog meer agents opzetten en beheren om al mijn blogposts te vertalen.
Ik loste dit op door een agent een script te laten maken (scripts/src/generateBlogTranslations.ts) dat het hele proces automatiseert.
Zo werkt het:
- Het scant de map
client/src/posts/enop Engelse MDX-bestanden. - Het controleert op ontbrekende vertalingen in de andere locale-mappen (bijvoorbeeld
posts/ja,posts/es). - Als er een vertaling ontbreekt, leest het de Engelse content en voert die in Gemini 3 Pro Preview met een specifieke prompt om de inhoud te vertalen met behoud van de Markdown-opmaak.
- Het slaat het nieuwe bestand op de juiste locatie op.
Aan de frontend gebruik ik import.meta.glob om al deze MDX-bestanden dynamisch te importeren. Mijn PostPage-component controleert vervolgens simpelweg de huidige locale van de gebruiker en lazy-loadt het juiste MDX-bestand. Als er een vertaling ontbreekt (omdat ik het script nog niet heb uitgevoerd), valt het netjes terug op het Engels.
Dag 4: geautomatiseerde vertaalgeneratie
Ik wist dat de oorspronkelijke oplossing niet zou schalen. Dus nu ik i18n live had, was het tijd om het wat robuuster te maken met een database-gestuurde aanpak.
Kort gezegd: wanneer Engelse tekst of JSDoc-commentaar veranderde, moesten vertalingen opnieuw worden gegenereerd. Handmatig bijhouden wat bijgewerkt moest worden zou foutgevoelig zijn en zonde van de tijd van ontwikkelaars.
Dus bouwde ik de oplossing die ik oorspronkelijk had gepland: een PostgreSQL-gebaseerd vertaalgeneratiesysteem.
Het database-schema
Ik heb een translations-tabel toegevoegd aan onze PostgreSQL-database met de volgende structuur:
key: De vertaalsleutel in "slash-punt"-notatie (bijvoorbeeld"games/yacht/nested.name","config.timeLimit.label").en_value: De Engelse bronwaardetarget_locale: De doel-locale-code (bijvoorbeeld"es","fr","zh")target_value: De vertaalde waardecontext: Een JSONB-veld dat JSDoc bevat voor deze sleutel en alle voorouder-sleutelscreated_atenupdated_at: Tijdstempels voor tracking
De unieke index ligt op (key, target_locale, en_value, context). Dit is cruciaal: door context op te nemen in de unieke constraint kunnen we automatisch detecteren wanneer JSDoc-commentaar verandert en vertalingen opnieuw genereren. Oude vertalingen worden bewaard voor historische referentie.
Het generatiescript
Ik heb scripts/src/generateLocalizations.ts gemaakt, dat de hele vertaalworkflow automatiseert:
- Engelse sleutels extraheren: Gebruikt AST-parsing (ts-morph) om alle vertaalsleutels uit
shared/src/i18n/locales/en/**bestanden te halen, waarbij alleen default exports worden verwerkt - JSDoc-context extraheren: Parseert JSDoc-commentaar voor elke sleutel en alle voorouder-sleutels (parent objects) voor rijke context
- Database bevragen: Controleert bestaande vertalingen in PostgreSQL, gematcht op
key,target_locale,en_valueENcontext. Als een van deze verandert, wordt de vertaling opnieuw gegenereerd. - Ontbrekende/gewijzigde sleutels identificeren: Vindt sleutels die vertaling nodig hebben of waarvan de Engelse waarden/commentaren zijn veranderd
- Vertalingen batchen: Groepeert per locale en namespace-prefix voor efficiëntere LLM-aanroepen (en maakt vertalingen sneller). Als de batch te groot wordt, gaat de vertaalkwaliteit echter achteruit.
- Vertalingen genereren: Gebruikt GPT 5.1 met uitgebreide context (JSDoc, taal+regio, toon, woordenlijst, voorbeelden). Ik heb gelezen dat 5.1 beter is dan 5.2 voor schrijven (klinkt minder saai), maar ik heb dit niet bevestigd.
- QA-checks: Valideert behoud van placeholders, bijvoorbeeld
{{name}}, sleutel-integriteit, JSON-formaat - Opslaan in database: Bewaart vertalingen met volledige context (JSDoc + voorouder-JSDoc)
- Locale-bestanden genereren: Leest uit de database en schrijft correct geformatteerde TypeScript locale-bestanden met
RecursivePartial-types
Belangrijkste voordelen
Deze aanpak biedt ons verschillende DevEx-verbeteringen:
- Automatische regeneratie: Wanneer Engelse tekst OF JSDoc-commentaar verandert, worden vertalingen automatisch opnieuw gegenereerd. Dus als iemand zegt dat een vertaling slecht is, is het heel eenvoudig om vertalingen opnieuw te genereren door meer context als commentaar toe te voegen.
- Rijke context: JSDoc-commentaar levert vertaalcontext (bijvoorbeeld "Foutmelding getoond aan spelers, max 15 tekens"), wat de LLM helpt nauwkeurigere vertalingen te produceren
- Voorouder-context: JSDoc van parent objects biedt namespace-context (bijvoorbeeld "Achievement omdat je in een game zat waarin alle eieren zijn vernietigd"), wat een beetje meer duidelijkheid geeft
- Historische tracking: Oude vertalingen worden in de database bewaard. Ze nemen niet veel ruimte in beslag, dus ik zie geen reden om ze voor nu te verwijderen, en het is leuk om de geschiedenis te zien.
Technische details
De implementatie gebruikt verschillende technieken om betrouwbaarheid en efficiëntie te garanderen:
- AST-gebaseerde extractie om er zeker van te zijn dat ik de juiste commentaren krijg
- Parallelle verwerking met Semaphore voor gelijktijdige batch-vertaling
- Exponentiële backoff retry-logica voor API-fouten. LLM-aanroepen staan berucht om hun wisselvalligheid.
Het script kan worden uitgevoerd met npm run generate-localizations vanuit de map scripts. Het maakt verbinding met PostgreSQL en verwerkt bij uitvoering alle ontbrekende of gewijzigde vertalingen voor alle ondersteunde locales.
Conclusie
Op dit punt had ik een volledig functionerende site, vertaald naar alle 20 locales!
Dit waren krankzinnige 3 dagen, maar het resultaat is een volledig gelokaliseerde site die voor gebruikers over de hele wereld (grotendeels) native aanvoelt. Door een eigen, lichtgewichts bibliotheek te bouwen en AI-agents te gebruiken voor het saaie refactor-werk, slaagde ik erin wat een jaar geleden onmogelijk was: volledige i18n in 3 dagen voor een complexe website door 1 engineer. De toekomst van programmeren draait niet om snel code schrijven. Het draait om het orchestreren van AI-agents en het bezitten van diepgaande domeinkennis om hun output te kunnen verifiëren.