background blurbackground mobile blur

1/1/1970

Cómo implementé i18n en 20 idiomas en 3 días

¡Hola! Acabo de terminar una tarea enorme en la que traduje Foony a 20 idiomas diferentes. Ha sido un currazo que me ha obligado a tocar casi todos los archivos de la base de código, pero he conseguido dejarlo todo listo en solo 3 días.

Abajo cuento cómo lo hice, las cifras concretas del cambio y por qué decidí crear mi propia librería de traducción (otra vez) en lugar de usar el estándar de la industria.

¿Por qué no i18next?

Al plantearme por primera vez añadir traducciones, miré el estándar de la industria: i18next y react-i18next.

En lugar de eso, decidí optimizar para la mantenibilidad con IA. i18next es potente, pero la variedad de su API puede hacer que los LLM se inventen cosas o escriban código inconsistente. Al limitar la librería a un simple t() e interpolate(), me aseguré de que más de 10 agentes en paralelo pudieran escribir código 100 % type-safe casi sin intervención humana.

También me daba respeto casarme con un ecosistema grande que pudiera introducir breaking changes más adelante. Después de haber sufrido migraciones dolorosas como React Router v5 y MUI v4 → v5, sé que romper la compatibilidad hacia atrás a toda velocidad es demasiado habitual en el mundo JavaScript. El coste de añadir funciones de pluralización más adelante es menor que el coste de migrar ahora a mano 139k líneas de código.

Quería algo sencillísimo, muy ligero y hecho exactamente a la medida de las necesidades de mi equipo.

Así que me hice el mío.

Construí un subconjunto muy acotado de 3 KB pensado específicamente para permitir refactors autónomos con IA de alta precisión. Esto me permitió actuar como una sola persona ingeniera haciendo en 3 días el trabajo de 3 semanas de un equipo de 5 personas.

La implementación personalizada

Diseñé una librería i18n mínima que se queda en unos 3 KB gzipped. Expone dos funciones principales: getTranslation() para contextos fuera de React y un hook useTranslation() para componentes.

Ambas devuelven t() para sustitución simple de cadenas e interpolate() para cuando necesito inyectar componentes de React en una cadena de traducción (como un enlace o un icono). Las dos funciones admiten sustitución de variables, por ejemplo "Hello {{thing}}", {thing: 'World'}.

Aquí está la función t() principal:

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;
}

Y el hook de React:

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]);
}

El núcleo de toda la librería son solo unas 580 líneas de código. Se encarga de:

  • Cargar perezosamente los archivos de traducción para no enviar los 20 idiomas a cada persona usuaria.
  • Hacer code-splitting de las traducciones por "namespace" (por ejemplo, common, misc, games/{gameId}).
  • Un locale de "debug" que muestra las claves en bruto para poder comprobar que todo está conectado correctamente.

Para que el sistema siga siendo fácil de mantener, también añadí documentación completa en shared/src/i18n/README.md, cubriendo desde la estructura de archivos hasta ejemplos de uso tanto en cliente como en servidor. Como no estoy usando una librería estándar, tener esta referencia es clave para que la gente nueva se pueda incorporar al proyecto (o para recordarle a mi yo del futuro o a los LLM cómo funciona todo).

Las cifras

Para que te hagas una idea de la magnitud de la actualización, esto es lo que cambió en la base de código:

  • 20 idiomas soportados (más un locale de debug para desarrollo).
  • 360 archivos de locale creados.
  • 139,031 líneas de código de traducción.
  • 3,938 llamadas a t() añadidas en el cliente.
  • 728 archivos fuente modificados.
  • 18 archivos fuente en inglés que sirven como fuente de verdad (16 juegos + common + misc).

Orquestando con agentes

Hacer todo esto a mano habría llevado meses de trabajo mecánico y aburridísimo. En lugar de eso, orquesté más de una docena de agentes de Cursor a la vez para que hicieran el trabajo duro.

Empecé dividiendo la base de código en "secciones" según carpetas. Cada juego en Foony tenía su propia carpeta y su propio namespace de traducción. Esto mantiene el tamaño de carga inicial pequeño, ya que solo cargas las traducciones del juego al que estás jugando.

Ejecuté varios agentes de Cursor en paralelo. Le asigné a cada agente una sección concreta, por ejemplo "convierte el juego de Ajedrez para que use traducciones", y el agente iba archivo por archivo, buscando cadenas visibles para la persona usuaria y sustituyéndolas por t('games/chess/some.key').

Luego el propio agente añadía esa clave al archivo de locale en inglés correspondiente con un comentario JSDoc explicando el "qué" y el "dónde" de la cadena. Ese contexto es importante al generar las traducciones a otros idiomas, porque ayuda al LLM a entender si "Save" significa "Save Game Configuration" o "Save Your Draw & Guess Drawing".

Control de calidad

Revisé rápido todo el código que se generó. Los agentes fueron sorprendentemente buenos, pero de vez en cuando metían la pata, por ejemplo colocando el hook useTranslation después de un return temprano.

Las traducciones con tipado fuerte ayudaron muchísimo. Garantizaban que todas las traducciones de cada locale tuvieran todas las claves correctas (y ninguna que sobrara). Y también que las llamadas a t() e interpolate() usaran cadenas de traducción reales que existieran.

El sistema de tipos extrae todas las posibles claves de traducción a partir de los archivos fuente en inglés:

/**
 * 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

Esto da un autocompletado de TypeScript perfecto, y cualquier typo en una clave de traducción se detecta en tiempo de compilación. Los agentes no pueden cometer errores del tipo t('games/ches/name') porque TypeScript lo señala al instante.

Localización

Cuando terminé la conversión al inglés, dividí las tareas de los demás locales. Hice que cada agente se encargara de convertir un solo archivo de locale en inglés a un idioma concreto.

Por ejemplo, les di a los agentes un prompt como este:

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.

Me planteé hacer que Cursor creara un script para pasar cada uno de esos archivos por un LLM y que este generara las traducciones, pero quería ahorrar un poco en coste de LLM. Usar un script solo para actualizar las traducciones que faltaban era una opción mejor, y probablemente use algo parecido en el futuro. Me gustaría llevar un control de qué cadenas necesitan actualización o traducción, pero quiero mantener las cosas simples. Puede que pase el trabajo de traducción a una base de datos o algo así.

También añadí un locale de "debug" que solo está disponible en desarrollo. Me permite ver todas las cadenas sustituidas para comprobar que todo funciona (además de que queda muy chulo). Cuando usas el locale de debug, t() devuelve la clave entre corchetes:

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

Así que, en vez de ver "Welcome to Foony!", verías ⟦welcome⟧, lo que hace muy fácil detectar si falta alguna traducción.

Por último, otro agente implementó el routing /{locale}/** para que rutas como /ja/games/chess vayan al idioma correcto (en este caso japonés).

Traduciendo el blog

Traducir las cadenas de la interfaz era una cosa, pero ¿qué pasaba con las entradas del blog? No quería poner en marcha y gestionar todavía más agentes para traducir todos mis posts.

Lo solucioné haciendo que un agente creara un script (scripts/src/generateBlogTranslations.ts) que automatiza todo el proceso.

Así funciona:

  1. Escanea el directorio client/src/posts/en en busca de archivos MDX en inglés.
  2. Comprueba qué traducciones faltan en las demás carpetas de locales (por ejemplo, posts/ja, posts/es).
  3. Si falta una traducción, lee el contenido en inglés y se lo pasa a Gemini 3 Pro Preview con un prompt específico para traducir el contenido manteniendo el formato Markdown.
  4. Guarda el nuevo archivo en la ubicación correcta.

En el frontend uso import.meta.glob para importar dinámicamente todos esos archivos MDX. Mi componente PostPage simplemente mira el locale actual de la persona usuaria y hace lazy-load del archivo MDX correcto. Si falta una traducción (porque aún no he ejecutado el script), hace un fallback suave al inglés.

Conclusión

En ese punto ya tenía un sitio totalmente funcional traducido a los 20 locales.

Han sido 3 días bastante locos, pero el resultado es un sitio completamente localizado que se siente (casi) nativo para personas usuarias de todo el mundo. Al construir una librería ligera a medida y apoyarme en agentes de IA para el trabajo de refactor más tedioso, he conseguido algo que habría sido imposible hace solo un año: i18n completo en 3 días para una web compleja hecha por una sola persona ingeniera. El futuro de la programación no va de escribir código rápido, sino de orquestar agentes de IA y tener la profundidad suficiente en el dominio como para validar lo que producen.

8 Ball Pool online multiplayer billiards icon