background blurbackground mobile blur

1/1/1970

Wie ich i18n in 20 Sprachen in nur 3 Tagen implementiert habe

Howdy! Ich habe gerade eine riesige Aufgabe abgeschlossen: Ich habe Foony in 20 verschiedene Sprachen übersetzt. Das war ein großes Unterfangen, bei dem ich fast jede Datei in der Codebase anfassen musste, aber ich habe es geschafft, alles in nur 3 Tagen durchzuziehen.

Weiter unten erzähle ich dir, wie ich das gemacht habe, welche Zahlen dahinterstecken und warum ich mich mal wieder entschieden habe, meine eigene Übersetzungsbibliothek zu bauen, statt den Branchenstandard zu verwenden.

Warum nicht i18next?

Als ich mir zum ersten Mal angeschaut habe, wie ich Übersetzungen einbauen könnte, habe ich natürlich zuerst an den Branchenstandard gedacht: i18next und react-i18next.

Stattdessen habe ich auf Wartbarkeit durch KI optimiert. i18next ist mächtig, aber die vielen verschiedenen APIs können dazu führen, dass LLMs halluzinieren oder uneinheitlichen Code schreiben. Indem ich die Bibliothek auf ein simples t() und interpolate() beschränkt habe, konnte ich sicherstellen, dass mehr als 10 Agents parallel 100 % typsicheren Code schreiben können, fast ganz ohne menschliches Eingreifen.

Ich hatte auch keine große Lust, mich an ein riesiges Ökosystem zu binden, das später vielleicht wieder Breaking Changes reinwirft. Nach schmerzhaften Migrationen wie React Router v5 und MUI v4 → v5 weiß ich, dass schnell kaputtgehende Rückwärtskompatibilität in JavaScript-Land leider ziemlich normal ist. Den Pluralisierungs-Kram später nachzurüsten ist günstiger, als jetzt 139k Zeilen Code von Hand zu migrieren.

Ich wollte etwas super Simples, extrem Leichtgewichtiges, das genau zu den Bedürfnissen meines Teams passt.

Also habe ich meine eigene Lösung gebaut.

Ich habe ein auf 3 KB begrenztes Subset gebaut, das speziell dafür ausgelegt ist, hochgenaues, autonomes KI-Refactoring zu ermöglichen. Dadurch konnte ich als einzelne Person ungefähr das 3-Wochen-Pensum eines 5-köpfigen Teams in nur 3 Tagen erledigen.

Die eigene Implementierung

Am Ende ist eine minimale i18n-Bibliothek herausgekommen, die gzipped bei etwa 3 KB liegt. Sie stellt zwei Hauptfunktionen zur Verfügung: getTranslation() für Nicht-React-Kontexte und einen useTranslation() Hook für Komponenten.

Darüber bekomme ich t() für einfache String-Ersetzungen und interpolate(), wenn ich React-Komponenten in einen Übersetzungsstring einsetzen muss (zum Beispiel einen Link oder ein Icon). Beide Funktionen unterstützen Variablenersetzung, z. B. "Hello {{thing}}", {thing: 'World'}.

Hier ist die zentrale t() Funktion:

export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
  let namespace: string = '';
  let translationKey: string = key;
  
  // Prüfe, ob der Key ein '/' enthält - das weist auf einen Namespace hin
  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();
  
  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 Zeilen Code. Sie übernimmt:

  • Lazy Loading der Übersetzungsdateien, damit wir nicht allen Nutzern alle 20 Sprachen ausliefern.
  • Code-Splitting der Übersetzungen nach „Namespace“ (z. B. common, misc, games/{gameId}).
  • Eine „Debug“-Locale, die die rohen Keys anzeigt, damit ich prüfen kann, ob alles richtig verdrahtet ist.

Damit das System langfristig leicht wartbar bleibt, habe ich außerdem eine ausführliche Doku in shared/src/i18n/README.md geschrieben, die alles abdeckt: von der Dateistruktur bis zu Anwendungsbeispielen für Client und Server. Da ich keine Standard-Bibliothek nutze, ist diese Referenz extrem wichtig, um neue Teammitglieder einzuarbeiten (oder mein zukünftiges Ich bzw. LLMs daran zu erinnern, wie alles funktioniert).

Die Zahlen dahinter

Damit du ein Gefühl für die Größenordnung des Updates bekommst, hier, was sich in der Codebase geändert hat:

  • 20 unterstützte Sprachen (plus eine Debug-Locale für die Entwicklung).
  • 360 Locale-Dateien erstellt.
  • 139.031 Zeilen Übersetzungs-Code.
  • 3.938 Aufrufe von t() im gesamten Client hinzugefügt.
  • 728 Source-Dateien geändert.
  • 18 englische Quelldateien, die als Source of Truth dienen (16 Spiele + common + misc).

Orchestrieren mit Agents

Von Hand hätte das monatelang gedauert und wäre komplett stumpfe Fleißarbeit gewesen. Stattdessen habe ich über ein Dutzend Cursor-Agents gleichzeitig orchestriert, die die eigentliche Schwerstarbeit übernommen haben.

Ich habe zuerst die Codebase in „Sektionen“ nach Ordnern aufgeteilt. Jedes Spiel auf Foony bekam seinen eigenen Ordner und seinen eigenen Übersetzungs-Namespace. So bleibt die anfängliche Load-Größe klein, weil du nur die Übersetzungen für das Spiel lädst, das du gerade spielst.

Dann habe ich mehrere Cursor-Agents parallel laufen lassen. Jeder Agent bekam eine bestimmte Sektion, zum Beispiel „konvertiere das Schachspiel auf Übersetzungen“, und ist dann Datei für Datei durchgegangen, hat user-facing Strings gesucht und sie durch t('games/chess/some.key') ersetzt.

Der Agent hat den Key anschließend in die passende englische Locale-Datei eingetragen, zusammen mit einem JSDoc-Kommentar, der das „Was“ und „Wo“ des Strings erklärt. Dieser Kontext ist wichtig, wenn später die Übersetzungen für andere Sprachen generiert werden, weil das LLM dadurch besser versteht, ob „Save“ eher „Speichere die Spielkonfiguration“ oder „Speichere deine Draw & Guess Zeichnung“ bedeutet.

Qualitätskontrolle

Ich habe den generierten Code danach schnell durchgeschaut. Die Agents waren erstaunlich gut, aber es gab ein paar Ausrutscher, zum Beispiel dass useTranslation hinter einem frühen return platziert wurde.

Stark typisierte Übersetzungen haben enorm geholfen. So war sichergestellt, dass jede Locale alle richtigen Keys hatte (und keine falschen). Außerdem stellte das sicher, dass Aufrufe von t() und interpolate() nur echte, existierende Übersetzungsstrings verwendeten.

Das Typsystem extrahiert alle möglichen Übersetzungs-Keys 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

Damit bekommst du perfekte TypeScript-Autovervollständigung, und jeder Tippfehler in einem Übersetzungs-Key fliegt schon beim Kompilieren auf. Die Agents können keine Fehler wie t('games/ches/name') machen, weil TypeScript das sofort anmeckert.

Lokalisierung

Als die Umstellung auf Englisch durch war, habe ich die restlichen Locale-Aufgaben aufgeteilt. Jeder Agent war dafür zuständig, genau eine englische Locale-Datei in eine bestimmte Sprache zu übersetzen.

Zum Beispiel habe ich den Agents so einen Prompt gegeben:

Stelle bitte sicher, dass ar/games/dinomight.ts alle Übersetzungen aus en/games/dinomight.ts enthält.
Verwende `export const account: DinomightTranslations = {`.
Iteriere, bis es keine Typfehler mehr für deine Übersetzungsdatei gibt (wenn du Fehler für andere Dateien siehst, ignoriere sie -- du läufst parallel zu anderen Agents, die für diese Dateien zuständig sind).
Deine Übersetzungen müssen hervorragend sein und zum im en-File angegebenen JSDoc-Kontext passen.
Du musst das manuell machen, ohne „Helper“-Skripte zu schreiben und ohne Abkürzungen.

Ich habe kurz darüber nachgedacht, Cursor ein Skript schreiben zu lassen, das jede dieser Dateien in ein LLM schiebt und dort alles generieren lässt, wollte aber ein bisschen LLM-Kosten sparen. Ein Skript zu nutzen, das nur fehlende Übersetzungen aktualisiert, war die bessere Lösung, und ich werde wahrscheinlich in Zukunft wieder etwas Ähnliches machen. Eigentlich würde ich gern tracken, welche Strings ein Update bzw. eine Übersetzung brauchen, aber ich will die Sache schlicht halten. Vielleicht wandert die Übersetzungsarbeit irgendwann in eine Datenbank oder so.

Außerdem habe ich eine „Debug“-Locale hinzugefügt, die nur in der Entwicklung verfügbar ist. Damit kann ich mir alle ersetzten Strings anschauen und prüfen, ob alles funktioniert (und ich finde es einfach cool). Wenn du die Debug-Locale verwendest, gibt t() den Key in Klammern verpackt zurück:

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

Anstatt „Welcome to Foony!“ siehst du dann ⟦welcome⟧. So fallen fehlende Übersetzungen sofort auf.

Zum Schluss hat ein anderer Agent noch das Routing nach /{locale}/** eingebaut, sodass URLs wie /ja/games/chess automatisch in der richtigen Sprache landen (in diesem Fall Japanisch).

Den Blog übersetzen

Die UI-Strings zu übersetzen war die eine Sache, aber was ist mit den Blogposts? Ich wollte nicht noch mehr Agents aufsetzen und verwalten, nur um alle Blogartikel zu übersetzen.

Gelöst habe ich das, indem ich einen Agent ein Skript (scripts/src/generateBlogTranslations.ts) schreiben ließ, das den ganzen Prozess automatisiert.

So funktioniert es:

  1. Es scannt das Verzeichnis client/src/posts/en nach englischen MDX-Dateien.
  2. Es prüft, welche Übersetzungen in den anderen Locale-Ordnern fehlen (z. B. posts/ja, posts/es).
  3. Wenn eine Übersetzung fehlt, liest es den englischen Inhalt ein und schickt ihn mit einem speziellen Prompt an Gemini 3 Pro Preview, um den Text zu übersetzen und dabei das Markdown-Format zu erhalten.
  4. Die neue Datei wird am richtigen Ort gespeichert.

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 lazy-lädt die passende MDX-Datei. Falls eine Übersetzung fehlt (weil ich das Skript noch nicht ausgeführt habe), fällt die Seite entspannt auf Englisch zurück.

Fazit

An diesem Punkt hatte ich eine komplett funktionierende Seite, die in alle 20 Locales übersetzt war!

Die 3 Tage waren ziemlich wild, aber das Ergebnis ist eine komplett lokalisierte Seite, die sich für Nutzer auf der ganzen Welt (meistens) wie nativ anfühlt. Mit einer eigenen, leichten Bibliothek und einer Menge KI-Agents für die langweilige Refactoring-Arbeit habe ich etwas geschafft, das vor einem Jahr noch völlig unrealistisch gewesen wäre: vollständige i18n in 3 Tagen für eine komplexe Website, umgesetzt von nur einer Person. Die Zukunft des Programmierens dreht sich nicht darum, Code möglichst schnell runterzutippen. Es geht darum, KI-Agents zu orchestrieren und genug tiefes Domänenwissen zu haben, um ihre Ergebnisse wirklich beurteilen zu können.

8 Ball Pool online multiplayer billiards icon