

1/1/1970
Wie ich i18n in 3 Tagen für 20 Sprachen umgesetzt habe
Hallo! Ich habe gerade eine riesige Aufgabe abgeschlossen, bei der ich Foony in 20 verschiedene Sprachen übersetzt habe. Es war ein gewaltiges Unterfangen, bei dem fast jede Datei in der Codebase angefasst werden musste, aber ich habe alles in nur 3 Tagen geschafft.
Im Folgenden erkläre ich, wie ich vorgegangen bin, welche konkreten Zahlen hinter der Änderung stehen und warum ich (mal wieder) eine eigene Übersetzungsbibliothek geschrieben habe, statt den Industriestandard zu verwenden.
Warum nicht i18next?
Als ich mir das Thema Übersetzungen erstmals angeschaut habe, kam mir der Industriestandard in den Sinn: i18next und react-i18next.
Stattdessen habe ich mich entschieden, auf Wartbarkeit durch KI zu optimieren. i18next ist mächtig, aber die Vielfalt seiner API kann dazu führen, dass LLMs halluzinieren oder inkonsistenten Code schreiben. Indem ich die Bibliothek auf ein simples t() und interpolate() beschränkt habe, konnte ich sicherstellen, dass mehr als 10 parallele Agenten zu 100 Prozent typsicheren Code mit nahezu null menschlichem Eingreifen schreiben konnten.
Außerdem war ich vorsichtig, mich auf ein großes Ökosystem einzulassen, das später möglicherweise Breaking Changes mit sich bringt. Nachdem ich von schmerzhaften Migrationen wie React Router v5 und MUI v4 zu v5 gebrandmarkt wurde, weiß ich, dass das schnelle Brechen der Abwärtskompatibilität in der JavaScript-Welt nur allzu üblich ist. Die Kosten, später Pluralisierungs-Features hinzuzufügen, sind geringer als die Kosten, jetzt 139.000 Codezeilen manuell zu migrieren.
Ich wollte etwas, das absolut simpel, extrem leichtgewichtig und exakt auf die Bedürfnisse meines Teams zugeschnitten ist.
Also habe ich es selbst geschrieben.
Ich habe ein 3 KB großes, eingeschränktes Subset gebaut, das speziell darauf ausgelegt ist, hochpräzises, autonomes KI-Refactoring zu ermöglichen. Damit konnte ich als einzelner Entwickler in nur 3 Tagen bewältigen, wofür ein 5-köpfiges Team 3 Wochen gebraucht hätte.
Die eigene Implementierung
Ich habe eine minimale i18n-Bibliothek entwickelt, die gzipped bei etwa 3 KB liegt. Sie stellt zwei Hauptfunktionen bereit: getTranslation() für Nicht-React-Kontexte und einen useTranslation()-Hook für Komponenten.
Diese liefern t() für einfaches Ersetzen von Strings und interpolate() für die Fälle, in denen ich React-Komponenten in einen Übersetzungsstring einfügen muss (etwa einen Link oder ein Icon). Beide Funktionen unterstützen Variablenersetzung, z. B. "Hallo {{thing}}", {thing: 'Welt'}.
Die Schlüssel folgen einer "Slash-Punkt"-Notation (Slashes für den Dateipfad zur Lokalisierungsdatei, Punkte für verschachtelte Objekte in der Datei). Um die Eindeutigkeit zu gewährleisten, dürfen Übersetzungsschlüssel innerhalb einer Datei keine Schrägstriche enthalten.
Hier ist die Kernfunktion 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;
}
Und der 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]);
}
Der Kern der gesamten Bibliothek umfasst nur etwa 580 Codezeilen. Sie kümmert sich um:
- Lazy-Loading der Übersetzungsdateien, damit nicht alle 20 Sprachen an jeden Nutzer ausgeliefert werden.
- Code-Splitting der Übersetzungen nach "Namespace" (z. B.
common,misc,games/{gameId}). - Eine "Debug"-Locale, die die rohen Schlüssel anzeigt, damit ich überprüfen kann, dass alles korrekt verkabelt ist.
Damit das System einfach wartbar bleibt, habe ich außerdem eine ausführliche Dokumentation in shared/src/i18n/README.md ergänzt, die alles von der Dateistruktur bis zu Anwendungsbeispielen für Client und Server abdeckt. Da ich keine Standardbibliothek verwende, ist diese Referenz entscheidend, um neue Teammitglieder einzuarbeiten (oder mein zukünftiges Ich oder LLMs daran zu erinnern, wie es funktioniert).
Die Zahlen
Um einen Eindruck vom Umfang dieses Updates zu vermitteln, hier eine Übersicht der Änderungen in der Codebase:
- 20 unterstützte Sprachen (plus eine Debug-Locale für die Entwicklung).
- 360 erstellte Locale-Dateien.
- 139.031 Zeilen Übersetzungscode.
- 3.938 Aufrufe von
t()im Client hinzugefügt. - 728 geänderte Quelldateien.
- 18 englische Quelldateien, die als Quelle der Wahrheit dienen (16 Spiele plus common und misc).
Orchestrierung mit Agenten
Das alles manuell zu machen, hätte Monate stupider, mechanischer Arbeit gekostet. Stattdessen habe ich über ein Dutzend Cursor-Agenten gleichzeitig orchestriert, um die Schwerstarbeit zu erledigen.
Zuerst habe ich die Codebase nach Ordnern in "Sektionen" aufgeteilt. Jedes Spiel auf Foony bekam seinen eigenen Ordner und seinen eigenen Übersetzungs-Namespace. Das hält die initiale Ladegröße klein, da nur die Übersetzungen für das gerade gespielte Spiel geladen werden.
Ich habe mehrere Cursor-Agenten gleichzeitig laufen lassen. Jedem Agenten habe ich eine bestimmte Sektion zugewiesen, etwa "Schach-Spiel auf Übersetzungen umstellen", und er hat sich Datei für Datei durchgearbeitet, nutzersichtbare Strings gefunden und sie durch t('games/chess/some.key') ersetzt.
Der Agent hat den Schlüssel dann in die passende englische Locale-Datei eingefügt, mit einem JSDoc-Kommentar, der das "Was" und "Wo" des Strings erklärt. Dieser Kontext ist beim Generieren der Übersetzungen für andere Sprachen wichtig, da er dem LLM hilft zu verstehen, ob "Save" für "Spielkonfiguration speichern" oder "Deine Draw-and-Guess-Zeichnung speichern" steht.
Qualitätskontrolle
Ich habe den gesamten generierten Code zügig durchgesehen. Die Agenten waren erstaunlich gut, machten aber gelegentlich Fehler, etwa den useTranslation-Hook nach einem frühen return-Statement zu platzieren.
Stark typisierte Übersetzungen halfen enorm. Sie stellten sicher, dass alle Übersetzungen für jede Locale die richtigen Schlüssel enthielten (und keine falschen). Außerdem stellten sie sicher, dass Aufrufe von t() und interpolate() echte, existierende Übersetzungs-Strings verwendeten.
Das Typsystem extrahiert alle möglichen Übersetzungsschlüssel aus den englischen Quelldateien:
/**
* 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
Das ergibt eine perfekte TypeScript-Autovervollständigung, und jeder Tippfehler in einem Übersetzungsschlüssel wird zur Compile-Zeit erkannt. Die Agenten können keine Fehler wie t('games/ches/name') machen, da TypeScript das sofort markiert.
Lokalisierung
Sobald die englische Umstellung abgeschlossen war, habe ich die übrigen Locale-Aufgaben aufgeteilt. Jeder Agent war für die Umwandlung einer einzelnen englischen Locale-Datei in eine bestimmte Sprache zuständig.
Ich habe den Agenten zum Beispiel einen Prompt wie diesen gegeben:
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.
Ich habe überlegt, Cursor ein Skript erstellen zu lassen, das jede dieser Dateien an ein LLM weiterreicht und damit die Übersetzungen generiert, wollte aber etwas LLM-Kosten sparen. Ein Skript zu nutzen, um nur fehlende Übersetzungen zu aktualisieren, war der bessere Ansatz, und ich werde künftig wahrscheinlich eine ähnliche Lösung verwenden. Ich würde gerne nachverfolgen, welche Strings aktualisiert oder übersetzt werden müssen, will die Sache aber einfach halten. Vielleicht verlagere ich die Übersetzungsarbeit in eine Datenbank oder Ähnliches.
Außerdem habe ich eine "Debug"-Locale ergänzt, die nur in der Entwicklung verfügbar ist. So kann ich alle ersetzten Strings sehen, um zu prüfen, ob alles funktioniert (und ich finde es einfach cool). Wenn die Debug-Locale aktiv ist, gibt t() den Schlüssel in Klammern zurück:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
Statt "Welcome to Foony!" siehst du also ⟦welcome⟧, wodurch sich fehlende Übersetzungen leicht aufspüren lassen.
Schließlich hat ein weiterer Agent das /{locale}/**-Routing implementiert, sodass etwa /ja/games/chess zur richtigen Sprache führt (in diesem Fall Japanisch).
Den Blog übersetzen
Die UI-Strings zu übersetzen war eine Sache, aber was ist mit den Blogbeiträgen? Ich wollte nicht noch mehr Agenten hochfahren und verwalten, um all meine Blogposts zu übersetzen.
Ich habe das Problem gelöst, indem ein Agent ein Skript (scripts/src/generateBlogTranslations.ts) erstellt hat, das den gesamten Prozess automatisiert.
So funktioniert es:
- Es scannt das Verzeichnis
client/src/posts/ennach englischen MDX-Dateien. - Es prüft auf fehlende Übersetzungen in den anderen Locale-Ordnern (z. B.
posts/ja,posts/es). - Falls eine Übersetzung fehlt, liest es den englischen Inhalt und übergibt ihn an Gemini 3 Pro Preview mit einem speziellen Prompt, um den Inhalt unter Beibehaltung der Markdown-Formatierung zu übersetzen.
- Es speichert die neue Datei am richtigen Ort.
Im Frontend nutze ich import.meta.glob, um all diese MDX-Dateien dynamisch zu importieren. Meine PostPage-Komponente prüft dann einfach die aktuelle Locale des Nutzers und lädt die passende MDX-Datei per Lazy Loading. Falls eine Übersetzung fehlt (weil ich das Skript noch nicht ausgeführt habe), greift sie elegant auf Englisch zurück.
Tag 4: Automatisierte Übersetzungs-Generierung
Mir war klar, dass die ursprüngliche Lösung nicht skalieren würde. Nun, wo i18n stand, war es Zeit, das Ganze mit einem datenbankgestützten Ansatz robuster zu machen.
Kurz gesagt: Wenn sich englische Texte oder JSDoc-Kommentare änderten, mussten die Übersetzungen neu generiert werden. Manuell zu verfolgen, was aktualisiert werden muss, wäre fehleranfällig und Zeitverschwendung gewesen.
Also habe ich die Lösung gebaut, die ich ursprünglich geplant hatte: ein PostgreSQL-gestütztes Übersetzungs-Generierungssystem.
Das Datenbankschema
Ich habe eine translations-Tabelle in unserer PostgreSQL-Datenbank mit folgender Struktur ergänzt:
key: Der Übersetzungsschlüssel in "Slash-Punkt"-Notation (z. B."games/yacht/nested.name","config.timeLimit.label").en_value: Der englische Quellwerttarget_locale: Der Code der Ziel-Locale (z. B."es","fr","zh")target_value: Der übersetzte Wertcontext: Ein JSONB-Feld mit JSDoc für diesen Schlüssel und alle übergeordneten Schlüsselcreated_atundupdated_at: Zeitstempel zum Tracking
Der eindeutige Index liegt auf (key, target_locale, en_value, context). Das ist entscheidend: Indem context Teil des Unique Constraint ist, können wir automatisch erkennen, wann sich JSDoc-Kommentare ändern, und die Übersetzungen neu generieren. Alte Übersetzungen bleiben als historische Referenz erhalten.
Das Generierungs-Skript
Ich habe scripts/src/generateLocalizations.ts erstellt, das den gesamten Übersetzungs-Workflow automatisiert:
- Englische Schlüssel extrahieren: Nutzt AST-Parsing (ts-morph), um alle Übersetzungsschlüssel aus
shared/src/i18n/locales/en/**zu extrahieren, wobei nur Default-Exports verarbeitet werden - JSDoc-Kontext extrahieren: Parst JSDoc-Kommentare für jeden Schlüssel und alle übergeordneten Schlüssel (Parent-Objekte), um reichhaltigen Kontext bereitzustellen
- Datenbank abfragen: Prüft bestehende Übersetzungen in PostgreSQL anhand von
key,target_locale,en_valueUNDcontext. Wenn sich eines davon ändert, wird die Übersetzung neu generiert. - Fehlende oder geänderte Schlüssel identifizieren: Findet Schlüssel, die übersetzt werden müssen oder bei denen sich englische Werte oder Kommentare geändert haben
- Übersetzungen bündeln: Gruppiert nach Locale und Namespace-Präfix für effizientere LLM-Aufrufe (auch beschleunigt das die Übersetzung). Ist der Batch allerdings zu groß, leidet die Übersetzungsqualität.
- Übersetzungen generieren: Nutzt GPT 5.1 mit umfassendem Kontext (JSDoc, Sprache und Region, Tonalität, Glossar, Beispiele). Ich habe gelesen, dass 5.1 fürs Schreiben besser ist als 5.2 (klingt weniger fad), konnte das aber nicht bestätigen.
- QA-Checks: Validiert die Erhaltung von Platzhaltern, z. B.
{{name}}, Schlüsselintegrität, JSON-Format - In der Datenbank speichern: Speichert Übersetzungen mit vollem Kontext (JSDoc plus übergeordnete JSDoc)
- Locale-Dateien generieren: Liest aus der Datenbank und schreibt korrekt formatierte TypeScript-Locale-Dateien mit
RecursivePartial-Typen
Wichtige Vorteile
Dieser Ansatz bringt uns mehrere DevEx-Verbesserungen:
- Automatische Neuerstellung: Wenn sich englischer Text ODER JSDoc-Kommentare ändern, werden die Übersetzungen automatisch neu generiert. Falls also jemand sagt, eine Übersetzung sei schlecht, ist es sehr einfach, sie neu zu erzeugen, indem man mehr Kontext als Kommentar bereitstellt.
- Reichhaltiger Kontext: JSDoc-Kommentare liefern Übersetzungskontext (z. B. "Fehlermeldung für Spieler, max. 15 Zeichen") und helfen dem LLM, präzisere Übersetzungen zu erzeugen
- Übergeordneter Kontext: JSDoc von Parent-Objekten liefert Namespace-Kontext (z. B. "Erfolg dafür, in einem Spiel zu sein, in dem alle Eier zerstört werden") und sorgt für etwas mehr Klarheit
- Historische Verfolgung: Alte Übersetzungen werden in der Datenbank gespeichert. Sie nehmen kaum Platz weg, daher sehe ich derzeit keinen Grund, sie zu löschen, und es ist cool, die Historie zu sehen.
Technische Details
Die Implementierung nutzt mehrere Techniken, um Zuverlässigkeit und Effizienz sicherzustellen:
- AST-basierte Extraktion, damit ich die korrekten Kommentare bekomme
- Parallele Verarbeitung über Semaphore für gleichzeitige Batch-Übersetzungen
- Retry-Logik mit exponentiellem Backoff bei API-Fehlern. LLM-Aufrufe sind notorisch unzuverlässig.
Das Skript kann mit npm run generate-localizations aus dem scripts-Verzeichnis ausgeführt werden. Bei Ausführung verbindet es sich mit PostgreSQL und verarbeitet alle fehlenden oder geänderten Übersetzungen für sämtliche unterstützten Locales.
Fazit
An diesem Punkt hatte ich eine voll funktionsfähige Seite, die in alle 20 Locales übersetzt war!
Es waren verrückte 3 Tage, aber das Ergebnis ist eine vollständig lokalisierte Seite, die sich für Nutzer rund um den Globus (überwiegend) muttersprachlich anfühlt. Indem ich eine eigene, leichtgewichtige Bibliothek gebaut und KI-Agenten für die mühselige Refactoring-Arbeit eingesetzt habe, habe ich geschafft, was vor einem Jahr noch unmöglich gewesen wäre: vollständiges i18n in 3 Tagen für eine komplexe Website durch einen einzigen Entwickler. Die Zukunft des Programmierens besteht nicht darin, Code schnell zu schreiben. Sie besteht darin, KI-Agenten zu orchestrieren und über die tiefe Fachexpertise zu verfügen, ihre Ergebnisse zu überprüfen.