Dos idiomas, una taxonomía

Primer plano muy cercano de una página impresa con un muestrario de caracteres tipográficos. Se distinguen la ce cedilla, la o barrada, las ligaduras Æ y Œ, vocales con diéresis y acento circunflejo, las ligaduras fi y fl, comillas inglesas y angulares, signos de admiración e interrogación y varias cifras. Solo una banda diagonal del centro está enfocada; el resto se difumina.
Brett JordanUnsplash License

Este sitio está entero en español y en inglés. Y hacer un sitio bilingüe no es traducir el texto: es decidir qué comparten las dos versiones y qué no. Cada vez que decides «esto es distinto en cada idioma» compras naturalidad para el lector y la pagas en fontanería.

En Un blog que se publica solo conté cómo llega un artículo a internet. Aquí cuento cómo llega dos veces, y por qué casi todos los problemas que salieron acabaron resolviéndose con la misma idea.

Tercera parte. Las dos anteriores cuentan el sitio como si tuviera una sola versión de cada página; a partir de aquí tiene dos.

El planteamiento no es mío

Antes de escribir nada busqué a alguien que ya lo hubiera hecho, y lo encontré en «Internationalization with Eleventy 2.0 and Netlify», de Lene Saile. No es una cita de cortesía: la estructura de todo esto sale de ahí. Su sitio, lenesaile.com, es trilingüe —inglés, alemán y español— y está hecho a mano, sin temas prefabricados, con el rendimiento y la accesibilidad por delante. Por eso encajaba.

El artículo es de 11ty 2.0 y se le nota la edad en un punto concreto: entonces había que instalar el plugin de internacionalización y ahora viene incluido en el propio 11ty. La versión que uso es la 3.1.6, y la línea que lo activa importa el plugin del paquete principal:

const { EleventyI18nPlugin } = require("@11ty/eleventy");

Lo que no ha caducado es el razonamiento sobre las URLs.

Las URLs, la decisión que condiciona el resto

Lo primero que hay que decidir en un sitio bilingüe es la forma de las direcciones, porque es lo único que después no se puede cambiar gratis.

Seguí su recomendación literal: cada idioma lleva su código en la URL, incluido el idioma por defecto.

/es/articulos/blog-que-se-publica-solo/
/en/articles/blog-that-publishes-itself/

La alternativa era dejar el español en la raíz —/articulos/— y meter el inglés en /en/articles/. Es más corto, y es asimétrico: a partir de ahí, cada plantilla que construya un enlace tiene que preguntarse si el idioma actual es el de la raíz o uno de los otros. Ese if no aparece una vez, aparece en todas partes. Con el esquema explícito las dos versiones se tratan igual y no hay ningún caso especial.

Hasta aquí voy detrás de ella. Lo siguiente es donde me separé.

Las URLs se leen, así que las escribí en cada idioma

Mira otra vez ese par de direcciones. El artículo no se llama blog-que-se-publica-solo en las dos: el inglés dice blog-that-publishes-itself, y el directorio que lo contiene se llama articles y no articulos.

Podría haber usado el mismo slug en los dos idiomas. Habría sido mucho más fácil, y esa es exactamente la cuestión: es la decisión que rompe todo lo demás del artículo.

La mantuve porque una URL es texto que se lee. Aparece en la barra del navegador, en los resultados de búsqueda y en cualquier enlace que alguien pegue en otro sitio. Un lector inglés que se encuentre /en/articulos/blog-que-se-publica-solo/ está viendo media traducción, y la mitad que falta es justo la que se enseña fuera del sitio.

Y ahí se acaba el camino trazado. Lene empareja las traducciones por nombre de directorio: su en/about.md y su de/about.md son la misma página porque se llaman igual, y eso es exactamente lo que el plugin sabe hacer. Con nombres distintos no hay nada que emparejar.

Esto no es un defecto de su método ni una limitación del plugin, y prefiero contarlo bien: su planteamiento funciona; yo quise otra cosa. Todo lo que viene a continuación existe porque tomé esta decisión.

Lo que el hosting no me dejó hacer

Hay una segunda separación, y esta no la elegí.

Cuando alguien entra en la raíz del sitio, lo suyo sería mandarlo al idioma de su navegador. Es lo que hace Lene y está hasta en el título de su artículo: un netlify.toml con una redirección condicional que lee la cabecera Accept-Language y responde un 302 al idioma que toque.

Para eso hace falta un servidor dinámico, uno que ejecute código en cada petición. GitHub Pages es estático: entrega archivos ya escritos y no ejecuta nada. No hay dónde poner esa decisión, y lo que hay en la raíz es esto:

<meta http-equiv="refresh" content="0; url=/es/">
<link rel="canonical" href="/es/">

Funciona en cualquier alojamiento estático y es peor: el visitante descarga una página para inmediatamente descargar otra, y el idioma al que va no depende de él.

transKey: una clave que no sale en ninguna URL

Con slugs distintos, el sitio necesita saber que dos archivos con nombres diferentes son el mismo artículo. Esa es toda la fontanería propia que tiene este sitio, y cabe en una idea: una clave canónica que empareja dos cosas que se ven distintas.

Antes de escribirla comprobé que el plugin no traía nada parecido. No lo supuse: lo miré en su código fuente (src/Plugins/I18nPlugin.js), donde el emparejado se hace cambiando el código de idioma dentro de la ruta del archivo y buscando si existe algo ahí. No hay ninguna propiedad tipo translationKey que se pueda poner en el front matter para decirle «estos dos van juntos».

Así que cada pareja de traducciones comparte una línea en su cabecera:

transKey: blog-que-se-publica-solo

La regla es que transKey es siempre el slug español, aunque el inglés sea otro. Da igual cuál de los dos se elija mientras se elija uno solo; lo que no puede pasar es que cada artículo decida por su cuenta.

Quién comparte clave con quién se calcula durante el build, en el archivo de datos del directorio de artículos:

eleventyComputed: {
  localeLinks: (data) => {
    if (!data.transKey) return false;
    return data.collections.all
      .filter(
        (p) => p.data.transKey === data.transKey && p.data.lang !== data.lang
      )
      .map((p) => ({ url: p.url, lang: p.data.lang }));
  },
},

Que esto sea un eleventyComputed y no un filtro de plantilla es la parte que costó entender. Un dato calculado se evalúa cuando las colecciones ya existen, así que desde dentro puede mirar todas las páginas del sitio a la vez; que es justo lo que hace falta para responder «¿quién más tiene esta clave?».

El idioma de cada página, por cierto, no se escribe en ningún artículo. Lo pone un archivo de datos de directorio —src/es/es.11tydata.js— que dice lang: "es" y se aplica a todo lo que cuelga de ahí. Un artículo nuevo no tiene que acordarse de nada.

La red de seguridad

La plantilla base resuelve los enlaces de idioma en una línea:

{%- set links = localeLinks if localeLinks else (page.url | locale_links) -%}

Si la página tiene transKey, se usa el emparejado propio. Si no lo tiene —la home, que sí comparte dirección en los dos idiomas—, se cae al filtro locale_links del plugin, que para ese caso funciona perfectamente.

Lo que se gana no es ahorrarse código, es tener un solo <nav> para todo el sitio en lugar de dos caminos separados que haya que mantener en paralelo. La plantilla no sabe por cuál de los dos ha venido la lista.

Esa misma lista alimenta las etiquetas que leen los buscadores:

<link rel="alternate" hreflang="es" href="https://…/es/articulos/…/">
<link rel="alternate" hreflang="en" href="https://…/en/articles/…/">
<link rel="alternate" hreflang="x-default" href="https://…/es/articulos/…/">

De los hreflang hay tres reglas que es fácil incumplir, y las tres las incumplí antes de leerlas bien. Las URLs tienen que ser absolutas, porque el buscador lee estas etiquetas fuera del contexto de la página. La lista tiene que incluir a la propia página, no solo a las otras versiones. Y x-default dice a dónde mandar a quien no encaje en ningún idioma declarado.

De la plantilla a los datos

Hay una diferencia entre un sitio traducido y un sitio bilingüe, y está en dónde vive el texto que no es el artículo.

El menú es un archivo de datos, no una plantilla:

module.exports = {
  es: [
    { title: "Home", url: "/es/" },
    { title: "Artículos", url: "/es/articulos/" },
    …
  ],
  en: [ … ],
};

Añadir una entrada al menú es una línea, y sale en los dos idiomas o no sale en ninguno. Escrito a mano en la plantilla, lo normal es que se añada en uno y se olvide en el otro.

Lo mismo con los textos del sitio: el lema, los nombres de los idiomas y —esto es lo que más se olvida— los textos de interfaz que no se ven. El botón del menú, el nombre de cada zona de navegación, las tres opciones del selector de tema: nada de eso aparece en pantalla, pero un lector de pantalla lo anuncia. Escrito directamente en el HTML se queda en un solo idioma sin que nadie lo note, porque mirando la página no se nota.

Dos textos se quedan fuera de todo esto a propósito: el nombre del sitio, que es la marca, y la firma del pie, que es la persona que responde de lo que hay aquí. Ninguno de los dos cambia al cambiar de idioma.

La separación que decidí no hacer: las fechas

Una fecha es de los detalles que más delatan a un sitio traducido a medias. Traducir el texto y dejar debajo 2026-09-04, o peor, September 4, canta.

Así que escribí los dos filtros que hacen falta para separarla. dateISO produce el formato de máquina, que es obligatorio en el atributo datetime del <time> porque es lo que leen el navegador y el buscador. dateReadable produce el formato del lector, distinto en cada idioma:

new Date(date).toLocaleDateString(lang === "en" ? "en-US" : "es-ES", {
  year: "numeric", month: "long", day: "numeric",
});

Y no lo usa nadie. El sitio enseña la fecha en ISO en los cinco sitios donde aparece: la cabecera del artículo, los dos índices y las dos páginas de tema.

La razón está en el índice. Ahí las fechas van en una columna, y una columna queda a plomo solo si todas las fechas miden lo mismo — cosa que «4 de septiembre de 2026» y «1 de mayo de 2026» no hacen. El ISO se ganó su sitio ahí por la alineación. En la cabecera del artículo no hay ninguna columna que alinear, así que ahí el ISO solo tiene su coste: se lee como un dato de máquina.

Podría usar cada formato donde le conviene. No lo hago porque una fecha con dos formas según la página es una inconsistencia que el lector sí nota, y la que se lee como dato de máquina ya la había aceptado en el índice.

Los temas: la misma idea, otra vez

El sitio tiene una taxonomía de temas, y cada tema necesita nombre y URL en cada idioma. Cuando llegué a esto ya sabía cómo se resolvía, porque es el mismo problema con otra ropa:

module.exports = {
  development: {
    es: { label: "Desarrollo", slug: "desarrollo" },
    en: { label: "Development", slug: "development" },
  },
  …
};

La clave —development— es un id canónico neutro, igual en los dos idiomas y que no se ve en ninguna parte. Cada idioma cuelga de él su etiqueta visible y su trozo de URL. Es transKey aplicado a otra cosa: una clave interna emparejando dos cosas que se ven distintas.

Los artículos guardan en su cabecera los ids, nunca las etiquetas:

topics: [development, learning]

Y las páginas de tema no existen como archivos. Se generan por paginación sobre ese diccionario, una por entrada y por idioma, con la URL construida desde el slug del idioma que toque. Añadir un tema es una entrada más en un archivo; no hay que crear dos páginas ni acordarse de enlazarlas entre ellas.

Quitarlo o renombrarlo es lo caro, y ahí no hay truco que valga: el slug de un tema es una URL publicada, y el id está escrito en la cabecera de todos los artículos que lo llevan. Cambiarlo obliga a editarlos uno a uno. Me pasó al retirar un tema que venía de la fase de pruebas, y lo conté en la parte 1 porque el problema que destapó era otro.

Las cuatro cosas que se rompieron

El selector de idioma salió vacío

El primer síntoma de todo lo anterior fue este: entrar en un artículo y no ver el enlace a la otra versión. No fallaba, no avisaba, simplemente no había nada que pintar.

Es la consecuencia directa de haber elegido slugs distintos, y es el fallo que obligó a escribir transKey. Lo cuento aquí y no en la sección de decisiones porque así es como apareció.

El índice se listaba a sí mismo, y la solución obvia rompía los idiomas

El índice de artículos aparecía dentro de su propia lista.

La causa es una comodidad que se paga: el archivo de datos del directorio de artículos aplica la etiqueta de colección a todo lo que hay dentro, y el índice vive dentro. La solución evidente es la propiedad que 11ty trae justo para eso:

eleventyExcludeFromCollections: true

Y esa propiedad saca la página de todas las colecciones, incluida collections.all. Que es exactamente donde localeLinks busca las parejas por transKey. Arreglaba el índice y dejaba el índice inglés sin su enlace al español: un arreglo de tres palabras que rompía otra cosa a dos archivos de distancia.

Lo comprobé en el código de 11ty antes de descartarlo, no lo supuse. La solución que quedó es más tonta y no tiene efectos a distancia: dentro del bucle, saltarse la entrada cuya URL es la de la propia página.

{%- if post.url !== page.url %}

El índice no sabía estar vacío

Ese mismo archivo tiene otra consecuencia menos evidente: la colección nunca está vacía, porque el índice siempre se cuenta a sí mismo. Preguntar por su tamaño para decidir si enseñar un «todavía no hay artículos» no funciona, ya que sin ningún artículo la lista todavía mide uno.

El fallo solo se pudo ver el día que el blog se quedó a cero, y hasta entonces no había habido nunca un caso en que la lista no tuviera nada que enseñar. La bandera se levanta ahora dentro del bucle, ya pasado el filtro.

La portada compartida tiene que escribirse igual en los dos idiomas

Las dos versiones de un artículo enseñan la misma foto, y la foto vive una sola vez, en el directorio del artículo español. La versión inglesa la referencia:

portada: /es/articulos/dos-idiomas-una-taxonomia/foto.jpg

Con ruta absoluta en los dos. Si en el español se escribe la ruta relativa —que es lo natural, porque el archivo está justo al lado— y en el inglés la absoluta, el plugin de imágenes las trata como dos imágenes distintas y genera dos juegos de variantes idénticos, uno junto al artículo y otro en el directorio común. El sitio se ve perfecto y pesa el doble en imágenes.

Lo que aprendí

Los problemas de este artículo no se parecen entre ellos: un selector vacío, un índice que se listaba a sí mismo, unos temas que necesitaban nombre en dos idiomas. Los tres se resolvieron con la misma idea, y la inventé dos veces sin darme cuenta de que era la misma.

Una clave canónica que no se ve en ninguna parte, emparejando dos cosas que sí se ven y que se ven distintas. transKey empareja artículos; el id de tema empareja temas. Las dos veces el problema era el mismo: había decidido que la parte visible fuera distinta en cada idioma, y algo tenía que quedar igual para poder relacionarlas.

Eso es lo que cuesta un sitio bilingüe, y no es la traducción. Es que cada cosa que decides mostrar distinta necesita, por detrás, algo que siga siendo igual.

← Volver a los artículos

Jestemrique

Desarrollo, proyectos, aprendizaje…

Tema