TL;DR
DSPy with Gemini Turbo affords reliable translation at build time. React Context provider, Next routing middleware, and a translation wrapper, affords cheap, fast, and mostly reliable i18n pipeline with simple DevEx. Automate it with GitHub Actions; Voila.
Der Problembereich
Wenn Sie möchten, dass ein Webprodukt Benutzer weltweit erreicht, benötigen Sie eine Möglichkeit, dieselbe Benutzeroberfläche in mehreren Sprachen auszudrücken, ohne den Erstellungsaufwand für jedes unterstützte Gebietsschema zu vervielfachen.
Wenn Sie bereits eine Internationalisierungs-(i18n)-Lösung in eine Web-App integriert haben, sind Ihnen die Probleme der bereits auf dem Markt befindlichen Lösungen wahrscheinlich vertraut.
Ich habe benutzt Crowdin, which is extremely reliable, but it's expensive, slow (you can expect a 48 hour turnaround), and frankly their API is cumbersome to work with. i18next on the other hand, is a popular open-source platform for machine translation, but I've found it to be brittle for what amounts to a simple architectural requirement: mapping keys to strings.
Beyond the overbaked nature of these platforms, there's something to be said about taking ownership over your toolchain, and plenty to learn about how machine translation works in the process.
Bei der Eile, diese Seite fertigzustellen, merkte ich, dass ich eine einfachere Lösung brauchte, die schnelles Feedback und eine zuverlässige Übersetzungsmaschine ermöglicht.
Was ich gewählt habe, ist Buildtime Machine Translation mit DSPy, Gemini und Next.js-Routing, orchestriert durch GitHub Actions. Nachdem diese Einschränkungen dargelegt wurden, wollen wir uns damit befassen.
Die 8-Stunden-Lösung
Anstatt ein großes i18n-Laufzeit-Framework zu übernehmen, habe ich eine kleine Pipeline um eine Kernidee herum aufgebaut: Englisch ist die Quelle der Wahrheit, und jedes andere Gebietsschema ist ein generiertes Artefakt.
At a high level, the system has four parts:
- A canonical English locale file that defines the application's copy.
- A generation step that derives types and identifies untranslated keys.
- A batch translation script that produces locale JSON files at build time.
- A lightweight runtime layer—middleware for locale routing and a React context for dictionary lookup.
Der Kern der Designentscheidung hier ist, dass die Übersetzung nicht happen at request time. By the time the app is deployed, the translated dictionaries already exist. That keeps runtime latency flat, avoids model calls in production, and makes the system much easier to reason about.
Warum Build-Zeit gewonnen hat
Dies ist keine Live-Chat-App, daher konnten wir uns von lästigen Echtzeit-Anliegen befreien. Angesichts der Build-Zeit-Übersetzung gab mir drei Dinge, die mir wichtig waren:
- Vorhersagbarkeit: deploys produce deterministic locale artifacts.
- Leistung: no per-request calls to a model or translation API.
- Einfachheit: the runtime only needs dictionary lookup, interpolation, and locale selection.
At a high level, the workflow looks something like this:
Rendering diagram...
Wir haben statische Inhalte (die Blog-Homepage), die selbst kompiliert werden, daher war einige komplexe GitHub-Aktionstrickerei erforderlich, um diesen statischen Inhalt ebenfalls rendern zu lassen, und die Markdown-Blogs sind eine andere Geschichte.
But the net result is a highly reliable system that essentially runs by itself. Let's look at how the client side acts as a provider and consumer of the i18n engine.
Do as the Romans Do (and use a GD foundation model)
Der zentrale Kolben der Implementierung ist ein Übersetzungsgenerator. Die Ein- und Ausgaben sind reine Strings; English -> Target Language. Pretty simple.
Um die Schlüssel zu generieren, haben wir einen AST-Parser mit ts-morph that digs through our TypeScript for our i18n wrapper, the t() method (more on that below).
For each wrapper found, we extract the wrapped string, and drop it along with the canonicalized English value into /lib/en.json. This file serves as the
For the model harness DSPy library served a Gnadenschuss, enabling structured derivation with a tight contract for the expected inputs and outputs from the model.
Übersetzer
* Note that garbage collection, logging, and context parsing are omitted for brevity.
import osimport jsonimport dspyfrom pydantic import BaseModel, Fieldclass TranslationOutput(BaseModel):translations: dict[str, str] = Field(description="A dictionary mapping the english keys to translated strings")class GenerateTranslations(dspy.Signature):"""Generate professional, localized translations for a web application UI."""source_language: str = dspy.InputField(desc="The source language of the given keys.")keys: list[str] = dspy.InputField(desc="A list of english interface text keys to translate.")target_language: str = dspy.InputField(desc="The target language code (e.g., 'es', 'fr', 'de').")translations: TranslationOutput = dspy.OutputField(desc="A strictly structured JSON output of translations.")class TranslationGenerator(dspy.Module):def __init__(self):super().__init__()PredictComponent = getattr(dspy, "TypedPredictor", dspy.Predict)self.generator = PredictComponent(GenerateTranslations)def forward(self, keys: list[str], target_language: str) -> dict:"""Generates translations from English to the target language for all keys in en.json"""if not os.environ.get("GEMINI_API_KEY"):print(f"Skipping LLM call for {target_language} (no API key). Using mock translations.")return {key: f"[{target_language}] {key}" for key in keys}result = self.generator(source_language="English", keys=keys, target_language=target_language)# Defensively parse out the response from Geminitry:translations = getattr(result, "translations", None)if hasattr(translations, "model_dump"):return translations.model_dump().get("translations", {})elif hasattr(translations, "dict"):return translations.dict().get("translations", {})elif hasattr(translations, "translations"):return translations.translationselif isinstance(translations, dict):return translations.get("translations", translations)else:return {}except Exception as e:print(f"Failed to extract translations for {target_language}. Error: {e}")return {key: f"[{target_language}] {key}" for key in keys}TARGET_LANGUAGES = {"es": "Spanish","fr": "French","de": "German"}def main():generator = TranslationGenerator()current_dir = os.path.dirname(os.path.abspath(__file__))locales_dir = os.path.join(os.path.dirname(os.path.dirname(current_dir)), "packages", "i18n", "locales")os.makedirs(locales_dir, exist_ok=True)en_json_path = os.path.join(locales_dir, "en.json")with open(en_json_path, 'r', encoding='utf-8') as f:en_locale = json.load(f)english_keys = list(en_locale.keys())print(f"Loaded {len(english_keys)} keys from en.json")for lang_code, lang_name in TARGET_LANGUAGES.items():lang_json_path = os.path.join(locales_dir, f"{lang_code}.json")existing_translations = {}if os.path.exists(lang_json_path):with open(lang_json_path, 'r', encoding='utf-8') as f:existing_translations = json.load(f)# Find missing keysmissing_keys = [k for k in english_keys if k not in existing_translations]# Batch LLM processingbatches = batch_missing_keys(missing_keys, 50)for i, batch in enumerate(batches):# calls the `forward` methodnew_translations = generator(keys=batch, target_language=lang_code)# Merge translationsfor k in batch:if k in new_translations:existing_translations[k] = new_translations[k]else:existing_translations[k] = k # fallback to ascii englishwith open(lang_json_path, 'w', encoding='utf-8') as f:json.dump(existing_translations, f, indent=2, ensure_ascii=False)
Die eigentliche Logik ist unkompliziert; iteriere durch die englischen Schlüssel und lasse das Modell für jede Zielsprache seine beste Vermutung einer Übersetzung generieren, die in der zugehörigen JSON-Datei gespeichert wird. Ziemlich clever.
Dennoch gibt es einen Haken, wenn man die MT-Verantwortlichkeiten einem LLM überträgt. Trotz der strikten Typisierung unseres DSPy-Gurtzeugs und der Pydantic-Vertragsgestaltung gibt es keine Garantie, dass das Modell uns das gibt, was wir wollen. Um dem Wahrscheinlichen Rechnung zu tragen,
Nachdem der Übersetzer aus dem Weg ist, wenden wir uns der Client-Seite zu, um besser zu verstehen, wie wir unsere mehrsprachige Prosa bereitstellen.
Context is King
Mit dem Übersetzer an Ort und Stelle benötigten wir ein semantisches Gerüst, das Inhalte erfassen und die relevante Übersetzung dynamisch injizieren kann.
Wir haben einen benutzerdefinierten I18nProvider that wraps our application payload. A lightweight React Context provides the locale, while the actual translation strings are fetched server-side when possible or loaded initially.
Der Kern der Sache ist ein i18n-Wrapper, t( key, fallback, values ).
It defines a key, which defaults to the content passed, a fallback value, if no translation can be found, and the values give us the capacity to interpolate dynamic content into that string, should we so choose.
Schauen wir uns die Implementierung etwas genauer an.
Serverseitige Übersetzung
// /i18n/provider.ts'use client';import type React from 'react';import { createContext, useContext } from 'react';import type { ReactNode } from 'react';import { interpolate } from './interpolate';import type {I18nContextType,LocaleCode,Translations,} from './types';const I18nContext = createContext<I18nContextType | null>(null);export interface I18nProviderProps {children: ReactNode;defaultLocale?: LocaleCode;dictionary?: Translations;}export const I18nProvider: React.FC<I18nProviderProps> = ({children,defaultLocale = 'en',dictionary,}) => {const t = (key: string,fallbackOrValues?: string | Record<string, string | number>,values?: Record<string, string | number>,): string => {let fallback = key;let interpolationValues = values;if (typeof fallbackOrValues === 'string') {fallback = fallbackOrValues;} else if (fallbackOrValues !== undefined) {interpolationValues = fallbackOrValues;}return interpolate(dictionary?.[key] || fallback,interpolationValues,);};const value: I18nContextType = {locale: defaultLocale,t,};return (<I18nContext.Provider value={value}>{children}</I18nContext.Provider>);};export const usei18n = (): I18nContextType => {const context = useContext(I18nContext);if (!context) {throw new Error('usei18n must be used within an I18nProvider');}return context;};
// layout.tsxconst RootLayout = async ({children,params,}: { children: React.ReactNode; params: Promise<any> }) => {const { locale } = (await params) as { locale: LocaleCode };const dictionary = await getDictionary(locale);return (<I18nProvider defaultLocale={locale} dictionary={dictionary}><body className='flex min-h-screen flex-col bg-background text-foreground transition-colors overflow-x-hidden antialiased'><AppMenu />{children}</body></I18nProvider>);};
Dieser Kontext leistet serverseitig die Hauptarbeit, indem er das aktuelle Gebietsschema aus der anfragenden URL liest und das aufgelöste Wörterbuch zur Nutzung in den React-Kontextanbieter injiziert.
Challenges and Extensions
Die Lösung, auf die ich gestoßen bin, hat ihre Grenzen. Unterwegs entdeckte ich, dass die Baseline-MT einen naiven Ansatz darstellte. Aber carpe tauri cornua; let's look at some of the limitations of our approach and how they can be resolved.
Markdown
Fom an implementation standpoint, one hiccup was establishing a means of parsing MDX content (like this blog post) to extract text content without mangling the markdown or React components. The baseline extraction looks for text content that's wrapped in a t() call, but in markdown the semantic structure becomes much looser. Text is interleaved with markup, components, and prose, which makes naïve extraction brittle. To solve that, we had to implement a custom plugin using the Bemerkung library, which exposes a handy API for dealing with arbitrary ASTs.
Isolierter Text
Die am schwierigsten zu übersetzenden Zeichenketten sind oft die kürzesten. Schaltflächen, Beschriftungen, Menüpunkte und Fragmente der Benutzeroberfläche sind in Isolation semantisch dünn.
Ein Modell, das nur Open, Apply, or Charge has to guess which sense of the word you intend. Humans resolve that ambiguity from context; a batch translation pipeline has to provide it explicitly.
An industry standard solution to this problem is adding Code-Kommentare with relevant context about the meaning of a given phrase that can guide the translator. These comments can then be coupled with their keys at generation time to extend the extraction prompt with a nudge to the model about the intended meaning.
(This was such a good idea, I went ahead and baked it in while writing the blog. No longer an eight-hour implementation.)
Ressourcenarme Sprachen
Obwohl außerhalb des Bereichs der i18n-Implementierung als solche, ist es erwähnenswert, dass maschinelle Übersetzung kein gelöstes Problem ist, insbesondere für ressourcenarme Sprachen. Umfangreiche Forschung, wie die Keine Sprache wird zurückgelassen study from 2022, has demonstrated that less popular languages suffer from a lack of high-quality training data, which can lead to poor translation quality. Suffice it to say, mileage may vary, and if you're writing in Zulu and are targeting a Kurmanji audience, you're going to need to hire a human translator.
Das war's dann auch schon
All together it proved a highly instructive experience, and mostly works as one would hope. This project's convinced me that foundation models can serve as a formidable aid in broadcasting my message to a broader audience, and the prototype of a quick hacking session to build a bespoke i18n pipeline has paid dividends.
Das Ergebnis können Sie auf dieser Seite sehen; verwenden Sie die Sprachauswahl in der Menüleiste, um zwischen Englisch, Deutsch, Französisch und Spanisch zu wechseln – Was meinen Sie dazu?