jaime-gaming.github.io

Los dos idiomas

El sistema de dos idiomas de la web: el diccionario de 233 claves, los atributos que dirigen la sustitución, la detección, el conmutador y el retecleo.

Última revisión 9 min de lectura Editar esta página en GitHub

Los dos idiomas

La web está entera en español y en inglés, y no son dos sitios ni dos rutas: es un solo HTML que se reescribe en el navegador. Esta página recorre el mecanismo de punta a punta —el diccionario, los atributos, la detección, el conmutador y la animación de retecleo— y termina en lo que esta documentación hereda de él.

Un HTML, dos idiomas

El HTML se sirve siempre en español. Los textos en inglés no viven en otra carpeta ni bajo un prefijo /en/: viajan dentro de la misma página y se aplican cuando alguien pulsa el conmutador.

Dos sitios los transportan:

  1. assets/js/i18n.js — 38.435 bytes y 672 líneas con el diccionario dict de toda la interfaz.
  2. <script type="application/json" id="pageTranslations"> — los campos propios de cada página, que genera el build: título, descripción, cuerpo y fecha.

La consecuencia práctica es que hay una sola URL por página, un solo canonical y un solo sitemap.xml. El idioma es un estado del navegador y no una dirección, así que no existe contenido duplicado que mantener ni enlaces que se crucen entre versiones.

Nota

El precio es que un rastreador que no ejecute JavaScript ve la versión en español. Se compensa reescribiendo los metadatos al cambiar de idioma y no declarando hreflang: no hay dos documentos que relacionar entre sí.

El diccionario: 233 claves

dict tiene dos ramas, es y en, con exactamente las mismas 233 claves. Se agrupan por prefijo, y los diez más poblados dicen dónde está el peso del sitio:

Prefijo Claves De qué son
github.* 52 Calendario de contribuciones, sincronización y sus errores
contact.* 27 Acceso por correo, copiar al portapapeles y redes
status.* 25 La página de estado (status.jaime-gaming.com) y el panel de incident.io
meta.* 16 Título y descripción de cada página, por ruta
project.* 14 Ficha de proyecto: estrellas, forks, demo y fallos de carga
footer.* 11 Pie, columnas de enlaces y aviso legal
hero.* 11 Portada: titular, llamadas a la acción y línea de actividad
about.* 10 Sección «sobre mí» y su tarjeta
discord.* 10 Presencia en Discord y sus estados
aria.* 8 Etiquetas accesibles sin texto visible

Los once prefijos restantes —skills, nav, projects, social, blog, legal, profile, notFound, projectsPage, projectsTeaser y readme— suman las otras 49 claves.

Las funciones del módulo

Ocho funciones declaradas más el bucle de animación, que vive dentro de una de ellas:

  • applyLang(lang, { persist, animate }) — aplica un idioma a toda la página y avisa al resto del sitio.
  • detectDefaultLang() — decide con qué idioma se abre la página.
  • storageGet(clave) y storageSet(clave, valor) — localStorage envuelto en try/catch, porque en modo privado lanza.
  • retypePage() — la animación de retecleo.
  • collectTypingBlocks() — qué bloques se teclean y cuáles no.
  • stopRetyping() — cancela una animación en vuelo y limpia sus rastros.
  • motionAllowed() — consulta prefers-reduced-motion.
  • frame(instante) — el callback de requestAnimationFrame, declarado dentro de retypePage.

Todo queda publicado en window.i18n para que main.js pueda pedir un cambio de idioma o volver a teclear la página después de pintar contenido nuevo.

Los atributos que dirigen la sustitución

Cinco atributos y una consulta más. Los cuatro primeros miran al diccionario; el quinto mira al bloque JSON de la propia página:

Atributo Qué sustituye Detalle
data-i18n="clave" el innerHTML Los valores pueden llevar marcado: <span>, <strong>, <br>
data-i18n-aria="clave" el aria-label Para iconos y botones sin texto visible
data-i18n-title="clave" el title El tooltip sigue al idioma elegido
data-i18n-date el texto de un <time> El datetime se queda en ISO y no se toca
data-content-key="campo" desde pageTranslations El cuerpo de la página, no el diccionario

data-content-key y data-content-language

La interfaz cabe en un diccionario; el cuerpo de un post o el texto de una página legal, no. Esos campos van en pageTranslations y se aplican por data-content-key. Cuando además el elemento lleva data-content-language, el contenido se trata como HTML y se ajusta el atributo lang del elemento, para que un lector de pantalla pronuncie el inglés como inglés aunque el documento declare lang="es".

data-i18n-date

Las fechas no se traducen: se formatean. data-i18n-date sobre un <time datetime="2026-03-14"> pasa por Intl.DateTimeFormat con la locale es-ES o en-US según el idioma activo, y el datetime —el que leen los buscadores y las hojas de estilo— no cambia nunca.

Detección, persistencia y varias pestañas

function detectDefaultLang() {
  const saved = storageGet("lang");
  if (saved && dict[saved]) return saved;
  const browserLang = (navigator.language || "es").slice(0, 2).toLowerCase();
  return dict[browserLang] ? browserLang : "en";
}

El orden es preferencia guardada, idioma del navegador e inglés como caída. La preferencia se guarda en localStorage con la clave lang, y un listener del evento storage hace que varias pestañas abiertas cambien a la vez: elegir inglés en una reescribe las demás sin recargarlas.

Esa clave es la misma que usa esta documentación, y no es casualidad: el idioma elegido en la portada se mantiene al entrar en /docs/ y al revés.

El conmutador

La bandera y su gesto

El botón #langSwitch lleva dibujadas una bandera española y una británica como patrones SVG, con su degradado, sus tres círculos y dos <use>, y muestra una u otra según data-active. Su aria-label viene de aria.langSwitch y anuncia el destino, no el estado: «Cambiar a inglés» cuando se está en español. Al lado, #languageName dice «Español» o «English» en texto.

Al pulsarlo se añade la clase is-switching durante 760 ms, que dispara @keyframes globe-spin —el globo da una vuelta— y globe-ring —su anillo se expande y se apaga—. La clase se quita, se fuerza el reflujo leyendo offsetWidth y se vuelve a poner: sin ese paso, dos pulsaciones seguidas no reproducirían la animación.

El retecleo

Cambiar de idioma no sustituye los textos en seco: los borra y los vuelve a teclear. Es la animación más característica del sitio y tiene sus propias cifras:

  • Cada bloque empieza 15 ms después del anterior, con un tope de 380 ms para que una página con cien textos no tarde en arrancar.
  • La velocidad va de 3 a 13 ms por carácter, calculada como 900 / total y acotada, así que un texto corto se lee como un tecleo y uno largo no se eterniza.
  • Solo se teclean bloques de entre 2 y 320 caracteres: menos de 2 no se nota y más de 320 tardaría segundos.
  • Antes de vaciar un bloque se mide su altura y se reserva con min-height; si no, la página entera se encoge y vuelve a crecer mientras se teclea y quien leía pierde el sitio.
  • Mientras dura, el bloque lleva aria-busy="true", que es lo que necesita un lector de pantalla para no leerlo a medias.

Importante

Tres guardas la desactivan: la primera aplicación de idioma nunca se anima, prefers-reduced-motion: reduce la apaga entera y una pestaña en segundo plano no teclea. Quedan fuera además #typedRole, que ya tiene su propio tecleo, [data-content-language], [aria-live] —reteclear una región viva le grita cada carácter a quien escucha—, .jg-loader, pre, code y textarea.

Traducir contenido, no solo interfaz

El blog

Cada post son dos archivos, content/blog/<slug>.md y <slug>.en.md, y el build no perdona que falte uno ni que se hayan separado:

if (!["es", "en"].includes(post.lang)) throw new Error(`Unsupported blog language: ${post.lang}`);
if (translations.es.date !== translations.en.date) throw new Error(`Translation dates differ: ${slug}`);

La segunda comprobación es la interesante: dos traducciones con fechas distintas producirían dos entradas en la lista del blog para el mismo post, y es un error que nadie vería mirando la página.

Las páginas legales

Los textos legales son HTML completo y versionado en los dos idiomas: terms.es.html (13.184 bytes) con terms.en.html (12.264), y privacy.es.html (11.575) con privacy.en.html (10.877). Están narrados enteros en cada idioma en vez de traducidos frase a frase, y se aplican a todos los proyectos sin nombrar ninguno.

Metadatos y buscadores

Un cambio de idioma reescribe también lo que no se ve:

Qué De dónde sale
document.title meta.<ruta>.title
description, og:description, twitter:description meta.<ruta>.description
og:title y twitter:title el mismo título
og:locale es_ES o en_US
JSON-LD person.description e person.inLanguage el bloque #personStructuredData

Reescribir el dato estructurado es lo que hace que un resultado enriquecido coincida con lo que la página muestra después de cambiar de idioma.

Lo que hereda esta documentación

El modelo se copió entero, con dos adaptaciones que vienen de ser un sitio de documentación y no un portfolio:

Pieza En la web En esta documentación
Diccionario Dentro de i18n.js, 233 claves Un bloque #uiStrings por página, con claves derivadas del registro
Cuerpo de la página pageTranslations Igual, más índice lateral, tiempo de lectura y enlace de edición
Atributos data-i18n-title data-i18n-placeholder, para el campo del buscador
Clave guardada localStorage["lang"] La misma, compartida por la web y /docs/
Retecleo Sí Sí, portado con sus cifras
Conmutador Globo con dos banderas El mismo globo y los mismos 760 ms

Esa última columna merece un matiz. El bloque no lleva solo los textos escritos a mano: scripts/docs/dict.mjs une las 140 claves de content/docs-ui.mjs con las que derivan del registro —el nombre, el lema, el resumen y el estado de cada proyecto, el título de cada guía y el título y la descripción de cada grupo—, y el bloque suma 220 claves. Así el índice lateral, las tarjetas y los filtros se escriben una sola vez en HTML y se traducen en el navegador como cualquier otro texto.

La tercera fila es la única diferencia real de contrato: aquí ningún elemento necesita un title traducido —los seis que hay son nombres propios de proyecto— y en cambio el buscador necesita traducir su placeholder.

Para editar los textos de la interfaz, el camino está en Editar el contenido; para ver de dónde salen los datos que se traducen, en Arquitectura del sitio.

Las pruebas que lo vigilan

Ocho de las 35 pruebas de tests/site.test.mjs tratan solo de idiomas:

  • Que el globo alterna el estado de la bandera con una etiqueta de destino accesible y persiste la elección.
  • Que todas las etiquetas y metadatos generados tienen traducción en los dos idiomas.
  • Que el perfil y los repositorios públicos se pintan de verdad y sobreviven al cambio de idioma.
  • Que el blog traduce el artículo completo, las fechas, las descripciones de imagen y el SEO sin duplicar posts.
  • Que los textos legales están narrados enteros y reflejados en los dos idiomas.
  • Que las etiquetas accesibles de un README siguen el idioma elegido sin modificar el documento original.
  • Que cambiar de idioma reteclea la página y respeta prefers-reduced-motion.
  • Que el payload localizado de una página no puede cerrar su propio elemento <script> de JSON, que es la vía por la que un texto traducido rompería la página.

Consejo

La última es la que conviene no olvidar al añadir claves: cualquier valor del diccionario acaba serializado dentro de un <script type="application/json">, así que una secuencia </script> en un texto —aunque venga de un README— cerraría el bloque antes de tiempo.

Esta documentación aplica el mismo patrón y lo comprueba igual: sus pruebas cargan una página generada en jsdom con sus propios scripts y pulsan el conmutador de verdad. El recorrido completo del sitio está en Visión general.

Documentación escrita a mano para este sitio, basada en el README y los docs del repositorio.