Cómo construí un pipeline de internacionalización en ocho horas

O, por qué delegué la i18n a la traducción automática

  • i18n
  • CI/CD
  • Accessibility
  • UX
  • AI

En resumen

DSPy con Gemini Turbo permite una traducción fiable en tiempo de compilación. El proveedor React Context, el middleware de enrutamiento Next y un envoltorio de traducción, proporcionan un pipeline de i18n barato, rápido y mayormente fiable con una DevEx sencilla. Automatícelo con GitHub Actions;¡Aquí está!.

El Espacio del Problema

Si quieres que un producto web llegue a usuarios de todo el mundo, necesitas una forma de expresar la misma interfaz en varios idiomas sin multiplicar el esfuerzo de autoría por cada idioma que soportes.

Si has integrado una solución de internacionalización (i18n) en una aplicación web antes, probablemente estés familiarizado con los problemas de las soluciones ya existentes en el mercado.

He usado 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. i18nextpor otro lado, es una popular plataforma de código abierto para la traducción automática, pero la he encontrado frágil para lo que equivale a un simple requisito arquitectónico: mapear claves a cadenas.

Más allá de la naturaleza excesivamente elaborada de estas plataformas, hay algo que decir sobre asumir la propiedad de su cadena de herramientas y mucho que aprender sobre cómo funciona la traducción automática en el proceso.

Mientras me apresuraba para tener este sitio listo, me di cuenta de que necesitaba una solución más sencilla que permitiera una retroalimentación rápida y un motor de traducción fiable.

Lo que elegí fue la Traducción Automática en tiempo de compilación con DSPy, Gemini y el enrutamiento de Next, orquestado por acciones de GitHub. Con estas limitaciones expuestas, profundicemos.

La Solución de 8 Horas

En lugar de adoptar un gran framework de i18n en tiempo de ejecución, construí un pequeño pipeline alrededor de una idea central: el inglés es la fuente de la verdad, y cualquier otra configuración regional es un artefacto generado.

A grandes rasgos, el sistema tiene cuatro partes:

  1. Un archivo de localización canónico en inglés que define el texto de la aplicación.
  2. Un paso de generación que deriva tipos e identifica claves sin traducir.
  3. Un script de traducción por lotes que produce archivos JSON de localización en tiempo de compilación.
  4. Una capa de tiempo de ejecución ligera: middleware para el enrutamiento de idiomas y un contexto de React para la búsqueda en diccionarios.

El quid de la decisión de diseño aquí es que la traducción noocurren en el momento de la solicitud. Cuando la aplicación se implementa, los diccionarios traducidos ya existen. Eso mantiene la latencia en tiempo de ejecución plana, evita llamadas a modelos en producción y hace que el sistema sea mucho más fácil de entender.

Por qué Ganó el Tiempo de Compilación

Esta no es una aplicación de chat en vivo, así que podríamos librarnos de cualquier molesta preocupación en tiempo real. Dada la traducción en tiempo de compilación me dio tres cosas que me importaban:

  • Previsibilidad: deploys produce deterministic locale artifacts.
  • Rendimiento:sin llamadas por solicitud a un modelo o API de traducción.
  • Simplicidad:el tiempo de ejecución solo necesita búsqueda en diccionarios, interpolación y selección de idioma.

A grandes rasgos, el flujo de trabajo es algo así:

Rendering diagram...

Tenemos contenido estático (la página de inicio del blog) que se compila a sí mismo, por lo que se requirió cierta astucia con las acciones compuestas de GitHub para incorporar también la renderización de ese contenido estático, y los blogs en markdown son otra historia.

Pero el resultado neto es un sistema altamente fiable que esencialmente funciona por sí mismo. Veamos cómo el lado del cliente actúa como proveedor y consumidor del motor de i18n.

Haz como los romanos (y usa un modelo fundacional GD)

El pistón central de la implementación es un generador de traducciones. Las entradas y salidas son cadenas puras; English -> Target Language. Bastante simple.

Para generar las claves, montamos un analizador AST con ts-morphque busca en nuestro TypeScript nuestro envoltorio de i18n, elt()método (más sobre esto a continuación). Para cada contenedor encontrado, extraemos la cadena contenida y la guardamos junto con el valor canónico en inglés en/lib/en.json. Este archivo sirve como Para el arnés del modeloDSPyla librería sirvió ungolpe de gracia, lo que permite una derivación estructurada con un contrato estricto para las entradas y salidas esperadas del modelo.

Traductor

* Tenga en cuenta que la recolección de basura, el registro y el análisis de contexto se omiten por brevedad.

import os
import json
import dspy
from pydantic import BaseModel, Field
class 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 Gemini
try:
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.translations
elif 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 keys
missing_keys = [k for k in english_keys if k not in existing_translations]
# Batch LLM processing
batches = batch_missing_keys(missing_keys, 50)
for i, batch in enumerate(batches):
# calls the `forward` method
new_translations = generator(keys=batch, target_language=lang_code)
# Merge translations
for k in batch:
if k in new_translations:
existing_translations[k] = new_translations[k]
else:
existing_translations[k] = k # fallback to ascii english
with open(lang_json_path, 'w', encoding='utf-8') as f:
json.dump(existing_translations, f, indent=2, ensure_ascii=False)

La lógica real es sencilla; iterar a través de las claves en inglés, y para cada idioma de destino, hacer que el modelo genere su mejor estimación de traducción, almacenándola en el archivo json asociado. Bastante ingenioso.

Aun así, hay un inconveniente al ceder las responsabilidades de la TA a un LLM. A pesar del tipado estricto de nuestro arnés DSPy y la contratación Pydantic, no hay garantía de que el modelo nos dé lo que queremos. Para tener en cuenta lo probabilístico

Con el traductor resuelto, dirijamos nuestra atención al lado del cliente para entender mejor cómo servimos nuestra prosa políglota.

El contexto es clave

Con el traductor en su lugar, necesitábamos un arnés semántico que pudiera capturar contenido e inyectar dinámicamente la traducción relevante.

Construimos un I18nProviderque envuelve la carga útil de nuestra aplicación. Un Contexto React ligero proporciona la configuración regional, mientras que las cadenas de traducción reales se obtienen del lado del servidor cuando es posible o se cargan inicialmente.

El quid de la cuestión es un wrapper de i18n, t( key, fallback, values ). Define una clave, que por defecto es el contenido pasado, un valor de reserva, si no se encuentra ninguna traducción, y elvaluesnos dan la capacidad de interpolar contenido dinámico en esa cadena, si así lo deseamos.

Veamos un poco más de cerca la implementación.

Traducción en el lado del servidor
// /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.tsx
const 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>
);
};

Este contexto está haciendo el trabajo pesado en el lado del servidor, leyendo la configuración regional actual de la URL solicitante e inyectando el diccionario resuelto en el proveedor de contexto de React para su consumo.

Desafíos y extensiones

La solución que adopté no está exenta de limitaciones. En el camino, descubrí que la TA de referencia representaba un enfoque ingenuo. Pero carpe tauri cornua; veamos algunas de las limitaciones de nuestro enfoque y cómo se pueden resolver.

Markdown

Desde el punto de vista de la implementación, un problema fue establecer un medio para analizar contenido MDX (como esta publicación de blog) para extraer contenido de texto sin estropear el markdown o los componentes de React. La extracción de referencia busca contenido de texto que esté envuelto en unt() 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 Observaciónlibrería, que expone una práctica API para tratar con ASTs arbitrarios.

Copia Aislada

Las cadenas más difíciles de traducir suelen ser las más cortas. Botones, etiquetas, elementos de menú y fragmentos de la interfaz de usuario son semánticamente escasos en aislamiento. Un modelo mostrado solo Open, Apply, o Chargetiene que adivinar qué sentido de la palabra pretendes. Los humanos resuelven esa ambigüedad a partir del contexto; un pipeline de traducción por lotes debe proporcionarlo explícitamente.

Una solución estándar de la industria a este problema es añadircomentarios de códigocon contexto relevante sobre el significado de una frase determinada que pueda guiar al traductor. Estos comentarios pueden luego acoplarse con sus claves en el momento de la generación para extender la solicitud de extracción con una indicación al modelo sobre el significado previsto.

(Esta fue tan buena idea que la incorporé mientras escribía el blog. Ya no es una implementación de ocho horas.)

Idiomas con Pocos Recursos

Aunque fuera del alcance de la implementación de i18n como tal, vale la pena señalar que la Traducción Automática no es un problema resuelto, particularmente para idiomas con pocos recursos. Abundante investigación, como la Ningún Idioma Queda Atrásun estudio de 2022 ha demostrado que los idiomas menos populares sufren de una falta de datos de entrenamiento de alta calidad, lo que puede llevar a una mala calidad de traducción. Basta decir que los resultados pueden variar, y si escribes en zulú y te diriges a una audiencia kurmanji, vas a necesitar contratar un traductor humano.


Eso fue todo

En conjunto, resultó una experiencia muy instructiva y, en su mayor parte, funciona como cabría esperar. Este proyecto me ha convencido de que los modelos fundacionales pueden servir como una ayuda formidable para transmitir mi mensaje a una audiencia más amplia, y el prototipo de una rápida sesión de hacking para construir un pipeline de i18n a medida ha dado sus frutos.

Puedes ver el resultado en esta página; usa el selector de idioma en la barra de menú para cambiar entre inglés, alemán, francés y español– Dígame, ¿qué le parece?