
← Volver al blog
Cómo una persona mantiene siete locales sin editar manualmente cada JSON, y qué mercados de contratación hicieron que valiera la pena construir.
Un portafolio solo en inglés es una elección válida. También lo es enviar siete locales cuando eres una persona que mantiene el repositorio: no es "gratis", pero es legible si tratas el lenguaje como el alcance del producto, no como una casilla de verificación, y automatizas las partes aburridas.
Esta publicación es la versión larga: qué idiomas, por qué los elegí, y exactamente qué hace el código (packages/types, packages/i18n, scripts/translate.mjs, apps/web/scripts/translate-blog.mjs).
No elegí locales porque una plantilla los incluía. No optimicé para "la mayoría de hablantes en todo el mundo" como un solo número. Empecé por la geografía de contratación: miré países y regiones donde se contratan desarrolladores (empleadores amigables con el trabajo remoto, mercados tecnológicos nacionales fuertes, centros de la UE) y pregunté qué idiomas podría enviar de manera realista para que esos lectores pudieran usar el sitio en su lengua materna, no solo en inglés. Esa es una elección de producto sobre quién podría evaluar este portafolio, no decoración.
El inglés como predeterminado sigue siendo no negociable: es el denominador común más amplio para la tecnología. Los seis locales no ingleses en este repositorio son Portugués, Español, Alemán, Polaco, Neerlandés y Estonio, enumerados en LOCALE_CONFIG en packages/types/src/index.ts. Junto con en, eso son siete locales de superficie.
La intención es clara: si alguien que podría contratar o colaborar conmigo aterriza aquí desde Brasil o Portugal, España o América Latina, Alemania, Austria o Suiza, Polonia, los Países Bajos o Estonia, quiero que lean en un idioma que se corresponda con cómo leen realmente cuando importa, mientras acepto que un es cubre muchos países y un pt cubre dos mercados muy diferentes. Esa es una compensación, no ignorancia. Un código de idioma no puede cubrir todos los registros culturales de esos idiomas, y no estoy fingiendo que pueda.
¿Suena cada oración como si un traductor humano hubiera pasado una hora en ella? No. La traducción automática y los lotes asistidos por LLM son parte de la pila. La promesa es más estrecha y, creo, más honesta: cobertura deliberada, diferencias revisables en git, y copia que no es silenciosamente solo en inglés para lectores que prefieren otro idioma principal.
Todo lo que necesita saber "qué idiomas existen" debería derivar de LOCALE_CONFIG: claves en, pt, es, de, pl, nl, et, cada una con name, bcp47, y un código franc para ayudantes de detección de idioma. El paquete i18n re-exporta esa configuración y construye locales como Object.keys(LOCALE_CONFIG); ver packages/i18n/src/index.ts.
Los diccionarios de tiempo de ejecución son JSON estáticos por locale bajo packages/i18n/src/messages/, importados a través de un mapa de importación para que los agrupadores puedan resolver cada archivo. El ayudante htmlLangFromLocale asigna un locale a una etiqueta BCP 47 para <html lang>. Esa es la historia de la "única fuente de verdad": tipos + archivos JSON + aplicación Next se alinean en el mismo enum de locales.
Si agregas un locale, extiendes LOCALE_CONFIG, agregas un nuevo archivo JSON y cableas el importador, luego ejecutas la canalización de traducción. No hay una lista de ejecución oculta en otro lugar que pueda desviarse.
// packages/types/src/index.ts
export const LOCALE_CONFIG = {
en: { name: "English", bcp47: "en", franc: "eng" },
pt: { name: "Português", bcp47: "pt", franc: "por" },
es: { name: "Español", bcp47: "es", franc: "spa" },
de: { name: "Deutsch", bcp47: "de", franc: "deu" },
pl: { name: "Polski", bcp47: "pl", franc: "pol" },
nl: { name: "Nederlands", bcp47: "nl", franc: "nld" },
et: { name: "Eesti", bcp47: "et", franc: "est" },
} as const;
export type Locale = keyof typeof LOCALE_CONFIG;
en.json, archivo de bloqueo, MT/LLM híbridoLa copia de UI autoritativa vive en packages/i18n/src/messages/en.json. Otros idiomas son generados; la regla del proyecto es explícita: editar solo inglés, luego ejecutar pnpm translate desde la raíz del repositorio, que ejecuta scripts/translate.mjs con el entorno cargado desde apps/web/.env.local (ver el script package.json raíz).
translate.mjs es largo por buenas razones. Mantiene scripts/translate.lock.json: hashes por locale de secciones de la fuente inglesa para que cuando cambies una parte de en.json, no envíes ciegamente todo el diccionario a una API. Eso importa cuando en.json crece (sobre páginas, proyectos, experiencia) y te importa costo y repetibilidad.
El script también elimina claves que desaparecieron del inglés para que los archivos de locale no acumulen ramas huérfanas, y puede eliminar términos conocidos no traducibles (sustantivos propios, marcas tecnológicas) antes de una solicitud, luego combinarlos nuevamente; ver el conjunto DO_NOT_TRANSLATE y ayudantes relacionados en el archivo.
La elección de herramientas fue impulsada por el costo y el ajuste. LibreTranslate es autoalojable a través de Docker sin costo por token, lo que lo hace adecuado para cadenas cortas e informativas como nav, meta y errors; esas claves no necesitan la nuance de nivel LLM, y pagar por carácter a DeepL o Google Translate para ellas se acumularía sin agregar calidad. Groq maneja la ruta LLM porque su inferencia es rápida y económica para trabajo en lotes en comparación con OpenAI o Anthropic al mismo volumen; el modelo predeterminado TRANSLATE_MODEL es un modelo pequeño (llama-4-scout) seleccionado específicamente para mantener bajo el gasto por ejecución, no el mismo modelo utilizado para el chat.
El script no envía todo a Groq. Un objeto CONFIG codifica la política real:
groqKeyPatterns (por ejemplo, about.sections.*.body, home.hero.subheadline, projects.items.*.problem, experience.items.*.impact) pasan por Groq para cada locale a través de shouldUseGroqForKey. Los títulos de trabajo (experience.items.*.role) también siempre son Groq, porque la MT pura a menudo devuelve títulos que parecen ingleses sin cambios.libreKeyPrefixes (por ejemplo, meta, nav, errors, experience.labels), con LIBRETRANSLATE_URL predeterminado a un host amigable con Docker local a menos que lo sobrescribas.Entonces: cada locale obtiene calidad LLM en campos narrativos; LibreTranslate maneja las claves informativas donde la traducción automática es lo suficientemente precisa. Esa es una compensación de costo y calidad incorporada en el código, no un comentario en un README.
// scripts/translate.mjs
function shouldUseGroqForKey(localeCode, key) {
return (
matchesDotPattern(key, "experience.items.*.role") ||
CONFIG.groqKeyPatterns.some((pat) => matchesDotPattern(key, pat))
);
}
Las dos claves de Groq son intencionalmente separadas. GROQ_API_KEY vive en producción y sirve el chat del portafolio en tiempo de ejecución; las ejecuciones de lotes de traducción que aumentan el uso de tokens comerían el mismo límite de velocidad y afectarían a los usuarios reales. TRANSLATE_GROQ_API_KEY es una clave diferente utilizada solo en ejecuciones de lotes locales, con su propia cuota. La reserva para GROQ_API_KEY existe para conveniencia cuando no se configura una clave dedicada, no como la configuración prevista.
TRANSLATE_MODEL selecciona el modelo para la traducción en lotes (modelo predeterminado meta-llama/llama-4-scout-17b-16e-instruct en el encabezado del script), independiente de GROQ_MODEL utilizado por el chat. El predeterminado es deliberadamente un modelo pequeño y rápido para mantener bajo el gasto por ejecución. Hay perillas para tamaño de lote, reintentos de 429 y registro; lee el bloque de comentarios en la parte superior del archivo en scripts/translate.mjs antes de ajustarlo.
El uso de API para Groq (y alojar LibreTranslate si lo ejecutas localmente) es dinero real. La ganancia aquí es control y automatización, no magia. El archivo de bloqueo es lo que lo hace manejable: las secciones que no cambiaron en inglés no van a ninguna API en la próxima ejecución.
Las publicaciones de blog no se traducen con el enrutador híbrido. pnpm translate:blog ejecuta apps/web/scripts/translate-blog.mjs, que lee apps/web/content/blog/en/*.mdx y, para cada locale de destino en TARGET_LOCALES (pt, es, de, pl, nl, et), llama a Groq con una solicitud estricta: preservar la estructura de YAML frontmatter, mantener date y hero idénticos a la fuente inglesa, traducir título, descripción, etiquetas y cuerpo, no destrozar URLs. Los archivos de salida aterrizan bajo apps/web/content/blog/<locale>/.
No hay ruta de LibreTranslate en ese script hoy; la traducción de blog es lote LLM por archivo, con --locale, --file, --force, y --delay para reducir el dolor del límite de velocidad. En tiempo de lectura, Next carga el MDX para la ruta del locale; no se hace llamada a Groq cuando un visitante abre una página de blog.
La aplicación Next.js utiliza el segmento [locale] bajo apps/web/app/[locale]/. Los componentes del servidor llaman a normalizeLocale en el parámetro para que los valores desconocidos fallen en en, luego getDictionary(locale) carga el módulo JSON coincidente desde packages/i18n. Por eso cada plantilla de página se ve igual estructuralmente: todas leen copia del diccionario, no cadenas de inglés codificadas en JSX (con raras excepciones que el linter señala).
Las rutas de blog combinan ese locale con la slug: las publicaciones publicadas se leen desde content/blog/<locale>/<slug>.mdx cuando la slug está habilitada en publish.json. Entonces "soportar siete locales" no es solo scripts de traducción; también es rutas estáticas + archivos de contenido estáticos que deben existir para cada idioma que te importe.
La promesa de calidad es más estrecha que el pulido literario: canalizaciones consistentes, salida revisable en git y priorización honesta: campos narrativos pt/es a través de Groq, todo lo demás a través de MT a menos que la clave coincida con el patrón de función. Eso no es un modo de falla; es un alcance establecido.
Comienza desde quién quieres que te lea y qué mercados de contratación importan para ti, no desde "cuántos banderas". La lista de locales es una decisión de producto; trátala como tal.
Comienza pequeño. Un locale extra (probablemente el que tiene conexiones profesionales o está buscando trabajo activamente) es suficiente para validar la canalización. Agrega una entrada LOCALE_CONFIG, agrega el archivo JSON, ejecuta pnpm translate. Si la calidad de la salida es aceptable para ese locale, tienes un camino repetible. No necesitas seis locales no ingleses para demostrar que la arquitectura funciona.
Decide tu política híbrida temprano. LibreTranslate es rápido y gratuito si lo ejecutas localmente; Groq agrega calidad en campos narrativos pero cuesta tokens y agrega latencia. Si solo estás enviando uno o dos locales no ingleses, Groq solo para todo el diccionario es más simple que construir la lógica de enrutamiento en shouldUseGroqForKey. La complejidad híbrida en translate.mjs solo vale la pena cuando tienes suficientes claves y suficientes locales que la variación de calidad de MT realmente importa a través de ellos.
Mantén la fuente inglesa canónica y la salida revisable por diferencias. El archivo de bloqueo en scripts/translate.lock.json no es ceremonia opcional; es lo que te permite ejecutar pnpm translate sin gastar tokens en secciones que no cambiaron. Cualquier canalización que retraduzca todo el diccionario en cada ejecución tendrá problemas de costo y límite de velocidad antes de que tu portafolio tenga un segundo lector. Compromete los archivos de locale generados para que puedas revisar cada cambio de traducción en una solicitud de extracción normal.
Luego implementa una fuente inglesa, archivos satélite generados y scripts que puedes ejecutar en CI o localmente, para que tu portafolio siga siendo un producto que puedes evolucionar, no un montón de copias editadas a mano.
La implementación no es la parte difícil. LOCALE_CONFIG, dos scripts de lotes, un archivo de bloqueo: cualquier ingeniero decente puede construir eso en un fin de semana. La parte más difícil es decidir de antemano que el idioma es una restricción de producto, no una característica que agregas cuando el resto está "hecho".
Si tratas la i18n como decoración, la enviarás exactamente una vez y nunca la tocarás de nuevo. Si la tratas como un alcance, con automatización, diferencias revisables y una lista honesta de lo que no cubre, y permanece mantenible, y estás en control de lo que tu portafolio comunica a los lectores que quizás nunca conozcas en inglés.
Esa es la apuesta real aquí: no que cada oración suene como si un traductor humano hubiera pasado una hora en ella, sino que el sitio funciona para alguien en Varsovia o Tallin que prefiere no leer en inglés cuando importa. Si esa apuesta da sus frutos, está por ver.