Trabajo 100 % remoto, con clientes de cualquier país, y hasta hace poco este sitio solo hablaba español. Ahora está completo en inglés, demos del Lab incluidas (DEC-024). Quedan fuera el panel /admin/, que solo uso yo, y la hoja de vida en PDF y los certificados, que están en español (la página en inglés lo avisa). La política de privacidad en inglés es una traducción de cortesía: prevalece la española.
Te cuento cómo está armado, porque traducir fue lo fácil: lo difícil era que las dos versiones no se separaran.
Ninguna URL en español cambió
El español sigue en la raíz, con sus URLs de siempre, y el inglés vive bajo /en/ con rutas traducidas. Las páginas fijas están en una tabla:
export const ROUTES = {
home: { es: '/', en: '/en/' },
services: { es: '/servicios/', en: '/en/services/' },
projects: { es: '/proyectos/', en: '/en/projects/' },
lab: { es: '/lab/', en: '/en/lab/' },
quote: { es: '/cotizar/', en: '/en/quote/' },
Las páginas de detalle (un servicio, un proyecto, una demo, este artículo) toman el slug del archivo de contenido de cada idioma: /servicios/paginas-web/ es /en/services/websites/. Descarté lo más simple, repetir las rutas en español bajo /en/, porque un visitante en inglés vería /en/servicios/.
Una vista y dos rutas delgadas
Cada página vive en una sola vista, src/views/<Nombre>View.astro, que recibe lang y arma todo. Las rutas solo la llaman: src/pages/privacidad.astro es <PrivacyView lang="es" /> y src/pages/en/privacy.astro es lo mismo con lang="en". En las páginas de detalle, la ruta toma los slugs de su idioma y le pasa a la vista el id estable de la entrada, nunca el slug, que cambia con el idioma:
export const getStaticPaths = () => detailStaticPaths('services', 'en')
const { id } = Astro.props
El inglés es una capa sobre el español
El contenido (servicios, proyectos, el cotizador…) vive en src/content/. El inglés está en src/content/en/, con los mismos nombres de archivo y solo los textos y los slugs. El servicio de páginas web en inglés no tiene precio: lo hereda del español, así que un precio se cambia en un solo lugar.
Las reglas están en merge.ts. Los objetos se unen clave por clave, y una clave que no existe en español es un error: un error de tipeo no pasa en silencio. Las listas van con el mismo largo y el mismo orden, y si los elementos tienen id, debe coincidir. Lo que no es texto no se toca:
if (kind(overlay) !== kind(base)) throw fail(`expected ${kind(base)}, found ${kind(overlay)}`)
if (typeof base !== 'string' && overlay !== base) {
throw fail(`must stay ${JSON.stringify(base)} as in the Spanish file (only texts are translated)`)
}
El resultado se valida con el mismo esquema que el español: un archivo mal hecho rompe el build, con el archivo y el campo en el error. Además, src/i18n/content.test.ts falla si a un archivo en español le falta su versión en inglés. El blog es la excepción (DEC-025): traducir un artículo es opcional.
Los textos de la interfaz, revisados por el compilador
El menú, los botones y el pie están en es.ts y en.ts. en.ts tiene el tipo Messages, que sale de la forma de es.ts: si falta o sobra una clave, falla pnpm typecheck. Cada isla guarda sus textos en un messages.ts, con la misma revisión:
export function defineMessages<T extends object>(texts: {
es: T
en: NoInfer<Widen<T>>
}): Record<Lang, Widen<T>> {
Widen convierte los textos literales del español en string: el inglés puede decir otra cosa, pero con las mismas claves. Las funciones se conservan, así que si en español un texto recibe un número, en inglés también.
hreflang, canónica y sitemap
Cada vista le pasa a Base.astro un alternate: la ruta de la misma página en el otro idioma. Con él, el layout escribe los hreflang:
const hreflang = versions
? [
{ hreflang: LOCALES.es.htmlLang, href: versions.es },
{ hreflang: LOCALES.en.htmlLang, href: versions.en },
{ hreflang: 'x-default', href: versions.es },
]
: []
x-default apunta al español, y sin alternate (como en /admin/) no hay hreflang. Cada página tiene además su canónica y su og:locale (es_CO o en_US). El sitemap no usa la opción i18n de @astrojs/sitemap, porque supone la misma ruta después de /en/ y aquí las rutas se traducen: lista las dos versiones y los pares van en el HTML. e2e/i18n.e2e.ts revisa cada página del build y, si su traducción existe, que apunte de vuelta. Con slugs traducidos, ahí es muy fácil equivocarse.
Un aviso, no una redirección
No redirijo según el idioma del navegador. El sitio es estático y los buscadores deben ver las dos versiones; redirigir necesitaría una función en cada visita. En vez de eso, si tu navegador prefiere el otro idioma y la página existe en él, aparece un aviso fijo abajo (suggest.ts). No mueve nada (la prueba E2E mide un layout shift de 0) y nunca toma el foco. Si lo cierras o cambias de idioma, la elección se guarda en localStorage, con la clave aim-lang, y el aviso no vuelve. Si el navegador bloquea el almacenamiento, todo funciona igual: el aviso simplemente vuelve a salir.
Ningún enlace roto, en ningún idioma
Con rutas traducidas, un enlace a /en/servicios/ es fácil de escribir y difícil de ver. scripts/check-links.mjs lee todo el HTML del build y revisa que cada href, src y srcset interno exista, incluidas la canónica y los hreflang. Mientras faltaban páginas en inglés fallaba; cuando estuvieron todas, entró a la CI, justo después del build:
pnpm build
step "enlaces internos del build"
Las demos también hablan inglés
- La Automatización en vivo ejecuta un flujo diseñado en n8n. Su nodo «Reglas del flujo» ahora guarda los textos por idioma, en
texts.esytexts.en, y un nodo nuevo, «Textos del idioma», elige los del contacto. Volví a grabar n8n real con 18 contactos, 6 de ellos en inglés: los 11 en español de antes dieron exactamente lo mismo, y la prueba de paridad exige casos aceptados y rechazados en los dos idiomas. - Las APIs devuelven un código con cada error, y en inglés cada isla escribe el mensaje con su
messages.ts. Las de Render, además, responden en el idioma que pideAccept-Language. - El Migrador de datos dejó de devolver frases en español: cada problema de una celda es un
codecon susparams(FieldErroren@aim/contracts), igual en el motor del navegador y en el de Python, y la página escribe la explicación. El costo: la web y el motor de Python se publican juntos, porque la web nueva no acepta la respuesta vieja.
Para revisarlo tú
- Las decisiones y lo que descarté:
docs/DECISIONES.md(DEC-024 y DEC-025). - La guía para agregar una página en los dos idiomas:
docs/IDIOMAS.md. - Las reglas de las traducciones y sus pruebas:
merge.tsymerge.test.ts. - La paridad del flujo en los dos idiomas:
automations/test/parity.test.ts.
Y para verlo funcionando, abre el cotizador y cambia de idioma desde el encabezado: los precios no cambian, porque salen del mismo archivo.