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:
assets/js/i18n.js— 38.435 bytes y 672 líneas con el diccionariodictde toda la interfaz.<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
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)ystorageSet(clave, valor)—localStorageenvuelto entry/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()— consultaprefers-reduced-motion.frame(instante)— el callback derequestAnimationFrame, declarado dentro deretypePage.
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 / totaly 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
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
<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.