Inteligencia Artificial

Traducción i18n Automatizada Con IA

Echo Code

Automatiza la traducción técnica de Markdown usando IA y Node.js en el build step. Detecta cambios en español y genera contenido en inglés.

Diccionario inglés abierto en la palabra "Focus".

Delegar la Internacionalización a la IA#

La internacionalización (i18n) de contenido técnico es un problema fundamental de escalabilidad. Para los equipos de ingeniería y desarrolladores que mantienen documentación o blogs técnicos, mantener sincronizados los artículos en español e inglés mediante traducción manual es insostenible. Consume tiempo, introduce errores de contexto y, lo más crítico, retrasa los ciclos de despliegue.

Delegar esta tarea a un modelo de lenguaje (LLM) es la solución lógica, pero la integración suele ser torpe. Copiar y pegar contenido en interfaces web destruye el flujo de trabajo (DevEx). La verdadera automatización ocurre cuando la traducción se convierte en un ciudadano de primera clase dentro del pipeline de CI/CD.

En este artículo, desarrollaremos un script de Node.js pragmático y robusto que se ejecuta como un pre-build hook. Este script detectará cambios en tu directorio de contenido en español, calculará diferencias criptográficas para evitar trabajo redundante, extraerá el contenido de forma segura, llamará a un modelo de IA configurado para traducción técnica estricta y escribirá los archivos Markdown en inglés antes de que el compilador (Astro, Next.js, Vite, etc.) genere el sitio estático.

La Arquitectura de la Solución#

Para que una automatización de build step sea confiable en entornos de producción, debe cumplir tres reglas inquebrantables:

  1. Idempotencia: Ejecutar el script múltiples veces no debe alterar el estado final ni consumir cuota de API innecesariamente.
  2. Preservación de Estructura: El código fuente, los bloques de configuración (YAML/Frontmatter) y los enlaces internos deben permanecer intactos. La IA no debe “alucinar” en los metadatos.
  3. Tolerancia a Fallos: Las llamadas a APIs externas fallan. El script debe manejar rate limits, caídas de red y reintentos automáticos mediante exponential backoff.

Nuestra arquitectura utiliza un sistema de caché local basado en hashes SHA-256. Evaluaremos cada archivo Markdown en el directorio de origen. Si su hash coincide con el almacenado en .i18n-cache.json, lo ignoramos. Si es nuevo o ha sido modificado, lo separamos en Frontmatter y Body usando gray-matter, traducimos el contenido de forma asíncrona, reconstruimos el archivo en el directorio de destino y actualizamos la caché.

Preparando el Entorno y Dependencias#

Necesitaremos un par de dependencias ligeras para manejar el parsing del Markdown y las variables de entorno. Evitaremos frameworks pesados; la librería estándar de Node.js (fs, crypto, path) será el núcleo de nuestro motor.

Inicializa tu proyecto e instala lo siguiente:

Terminal window
npm install gray-matter dotenv
  • gray-matter: El estándar de la industria para parsear Frontmatter (YAML) de archivos Markdown sin romper la estructura.
  • dotenv: Para inyectar nuestra API key del LLM de manera segura.

En este ejemplo utilizaremos la API genérica de OpenAI (compatible con GPT-4o, o con modelos locales vía LM Studio/Ollama si ajustas el endpoint), ya que su estructura de llamada es el estándar de facto.

Crea un archivo .env en la raíz de tu proyecto:

OPENAI_API_KEY="sk-tu-api-key-aqui"

Detección de Cambios: Por Qué mtime Falla en CI/CD#

El instinto inicial de muchos desarrolladores para detectar archivos modificados es usar fs.statSync(file).mtime (la fecha de modificación del archivo). Esto es un antipatrón en pipelines de CI/CD.

Cuando plataformas como Vercel, Netlify o GitHub Actions clonan tu repositorio, no preservan los timestamps originales de Git. Todos los archivos obtienen la marca de tiempo del momento exacto del clonado. Esto causaría que tu script considere todos los archivos como “nuevos” en cada build, consumiendo tu cuota de API instantáneamente.

La solución técnica correcta es calcular un hash criptográfico del contenido del archivo.

Crea un archivo llamado scripts/sync-i18n.js y comencemos con la lógica base:

import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import matter from 'gray-matter';
import dotenv from 'dotenv';
dotenv.config();
const CONFIG = {
SOURCE_DIR: path.join(process.cwd(), 'src/content/es'),
TARGET_DIR: path.join(process.cwd(), 'src/content/en'),
CACHE_FILE: path.join(process.cwd(), '.i18n-cache.json'),
API_URL: '[https://api.openai.com/v1/chat/completions](https://api.openai.com/v1/chat/completions)',
MODEL: 'gpt-4o-mini', // Rápido y económico para traducciones
};
// Generador de Hash SHA-256
function calculateHash(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
// Gestor de Caché
async function loadCache() {
try {
const data = await fs.readFile(CONFIG.CACHE_FILE, 'utf-8');
return JSON.parse(data);
} catch (error) {
return {}; // Retorna caché vacía si el archivo no existe
}
}
async function saveCache(cache) {
await fs.writeFile(CONFIG.CACHE_FILE, JSON.stringify(cache, null, 2));
}

Este bloque establece una fundación sólida. Si el contenido exacto del Markdown no cambia, el hash SHA-256 no cambiará, garantizando una idempotencia perfecta independientemente de dónde o cuándo se clone el repositorio.

El Motor de Traducción con IA: Aislamiento y Prompt Engineering#

Pasar un archivo Markdown crudo a un LLM es riesgoso. Los modelos tienden a traducir cosas que no deben, como propiedades de configuración en el Frontmatter (por ejemplo, traducir layout: base a disposición: base, lo cual rompería tu framework estático) o nombres de variables en bloques de código.

La estrategia correcta es la separación de responsabilidades:

  1. Extraer el Frontmatter.
  2. Traducir selectivamente solo los campos necesarios del Frontmatter (como title y summary).
  3. Traducir el cuerpo del Markdown usando un System Prompt fuertemente tipado.
  4. Volver a ensamblar el archivo.

Construyendo la llamada a la API con Resiliencia#

El siguiente código implementa la llamada al LLM con un mecanismo básico de reintentos para soportar fallos de red o límites de tasa.

async function callLLM(systemPrompt, userText, retries = 3) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(CONFIG.API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: CONFIG.MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userText }
],
temperature: 0.1 // Baja temperatura para determinismo y precisión
})
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
console.warn(`[Intento ${attempt}/${retries}] Fallo en la API de IA: ${error.message}`);
if (attempt === retries) throw error;
// Exponential backoff
await new Promise(res => setTimeout(res, 1000 * Math.pow(2, attempt)));
}
}
}

Prompt Engineering para Markdown Técnico#

El System Prompt es el componente más crítico del script. Debemos ser explícitos sobre lo que no se debe hacer. Un prompt pobre resultará en código roto.

const SYSTEM_PROMPT_MARKDOWN = `
You are an expert technical translator specializing in software engineering.
Translate the following Markdown content from Spanish to English.
STRICT RULES:
1. DO NOT translate any code blocks. Keep all code exactly as is.
2. DO NOT translate inline code snippets (e.g., \`const variable\`).
3. DO NOT translate technical terminology that is standard in English (e.g., "build step", "deploy", "frontend", "pipeline").
4. Preserve all Markdown formatting exactly (links, bold, lists, headings).
5. Output ONLY the translated Markdown. Do not include introductory text like "Here is the translation".
`;
const SYSTEM_PROMPT_METADATA = `
You are an expert translator. Translate the following plain text from Spanish to English.
Keep it concise and maintain a professional, technical tone.
Output ONLY the translated text.
`;

Notarás que utilizamos un temperature: 0.1 en la llamada a la API y comandos imperativos (DO NOT, STRICT RULES) en el prompt. En el contexto de automatización, priorizamos la precisión y la preservación del formato sobre la creatividad lingüística.

Procesamiento del Archivo: Frontmatter y Ensamblaje#

Ahora uniremos estas piezas. La siguiente función procesa un archivo individual, extrae sus partes, llama a la IA en paralelo para optimizar el tiempo de compilación y reconstruye el Markdown final.

async function processFile(filePath) {
const content = await fs.readFile(filePath, 'utf-8');
// Extraer Frontmatter y Body
const { data: frontmatter, content: markdownBody } = matter(content);
console.log(`\nTraduciendo: ${frontmatter.title || path.basename(filePath)}...`);
// Ejecutar traducciones en paralelo para mejorar rendimiento
const [translatedTitle, translatedSummary, translatedBody] = await Promise.all([
frontmatter.title
? callLLM(SYSTEM_PROMPT_METADATA, frontmatter.title)
: Promise.resolve(undefined),
frontmatter.summary
? callLLM(SYSTEM_PROMPT_METADATA, frontmatter.summary)
: Promise.resolve(undefined),
callLLM(SYSTEM_PROMPT_MARKDOWN, markdownBody)
]);
// Actualizar metadatos
const newFrontmatter = {
...frontmatter,
...(translatedTitle && { title: translatedTitle.replace(/['"]/g, '') }), // Limpiar comillas espurias
...(translatedSummary && { summary: translatedSummary }),
language: 'en' // Inyectar flag de idioma
};
// Ensamblar el nuevo archivo usando gray-matter
const translatedContent = matter.stringify(translatedBody, newFrontmatter);
return { translatedContent, originalHash: calculateHash(content) };
}

Usar Promise.all aquí es una decisión de arquitectura vital. Si procesamos título, resumen y cuerpo de forma secuencial, el tiempo total de ejecución del script podría duplicarse innecesariamente, retrasando el build pipeline.

Orquestando el Loop Principal#

El paso final dentro de nuestro script es el orquestador. Necesitamos iterar sobre todos los archivos en el directorio origen de forma recursiva (o plana, dependiendo de tu estructura), verificar los hashes contra la caché, disparar la traducción si es necesario y escribir los resultados.

async function main() {
console.log('Iniciando sincronización i18n automatizada...');
// Asegurar que el directorio de destino exista
await fs.mkdir(CONFIG.TARGET_DIR, { recursive: true });
const cache = await loadCache();
const files = await fs.readdir(CONFIG.SOURCE_DIR);
let translatedCount = 0;
for (const file of files) {
if (!file.endsWith('.md') && !file.endsWith('.mdx')) continue;
const sourcePath = path.join(CONFIG.SOURCE_DIR, file);
const targetPath = path.join(CONFIG.TARGET_DIR, file);
const fileContent = await fs.readFile(sourcePath, 'utf-8');
const currentHash = calculateHash(fileContent);
// Detección de cambios basada en estado
if (cache[file] === currentHash) {
console.log(`[Caché] Sin cambios en: ${file}. Omitiendo traducción.`);
continue;
}
try {
const { translatedContent, originalHash } = await processFile(sourcePath);
await fs.writeFile(targetPath, translatedContent, 'utf-8');
// Actualizar caché en memoria
cache[file] = originalHash;
translatedCount++;
console.log(`[Éxito] Archivo guardado: ${targetPath}`);
} catch (error) {
console.error(`[Error] Falló el procesamiento de ${file}:`, error);
process.exit(1); // Detener el build si falla la traducción
}
}
// Persistir el nuevo estado de la caché
await saveCache(cache);
console.log(`\nSincronización completa. Archivos traducidos: ${translatedCount}`);
}
main();

Este loop es defensivo. Nota que si falla una traducción (catch (error)), utilizamos process.exit(1). En un entorno CI/CD, un error en la traducción debe quebrar el build (fail-fast). Es preferible no desplegar nada a desplegar una web a medio traducir o con contenido inconsistente.

Integración en el Pipeline (Build Step)#

El script ya está listo. Para integrarlo de forma elegante en tu ciclo de vida de desarrollo, modifica tu archivo package.json. Añade o ajusta los scripts para que el proceso de traducción ocurra estrictamente antes del comando de compilación principal de tu framework.

Ejemplo en package.json para un proyecto Astro o Next.js:

{
"scripts": {
"dev": "npm run i18n && astro dev",
"i18n": "node scripts/sync-i18n.js",
"build": "npm run i18n && astro build",
"preview": "astro preview"
}
}

A partir de este momento, ejecutar npm run build ejecutará el script, resolverá las traducciones necesarias y finalmente empaquetará la web.

Consideraciones Finales de Seguridad y Control de Versiones#

  1. Ignora el directorio de salida (opcional): Dependiendo de tu flujo de trabajo, puedes añadir src/content/en/ a tu .gitignore. Esto convierte a los archivos en inglés en artefactos efímeros generados exclusivamente durante el build, manteniendo tu repositorio de Git limpio y enfocado en la fuente de la verdad (español). Sin embargo, si prefieres revisar las traducciones antes del despliegue, hazles commit.
  2. El archivo de caché: .i18n-cache.json debe ser commiteado al repositorio. Esto es esencial. Si este archivo es ignorado, el servidor de CI/CD nunca sabrá el estado anterior y terminará forzando la traducción de todos los archivos en cada despliegue.

Automatizar el proceso de i18n en el build step transforma lo que solía ser un trabajo manual, tedioso y propenso a errores, en un flujo de trabajo asíncrono, predecible y altamente escalable. Has abstraído por completo la barrera del idioma, permitiéndote concentrarte en el código fuente.