↑ top
← Back to blog
EN | ES

Construyendo una Tabla de Contenidos con IntersectionObserver (y el Bug de Entidades HTML Que Me Mordió)

Cómo construir un ToC sticky que resalta la sección activa mientras scrolleas, y el bug de doble-encoding que hace que los apóstrofes se muestren como ' en el navegador.

✦ AI Summary

Aquí está la implementación completa y el fix. El bug genera el ToC server-side, extrae los headings del HTML renderizado, y usa `IntersectionObserver` para el seguimiento del estado activo.

Construyendo una Tabla de Contenidos con IntersectionObserver (y el Bug de Entidades HTML Que Me Mordió)

Una tabla de contenidos que resalta la sección activa mientras scrolleas es una feature pequeña con un impacto de UX desproporcionado en posts largos. Este sitio genera el ToC server-side, extrae los headings del HTML renderizado, y usa IntersectionObserver para el seguimiento del estado activo.

También hay un bug que encontré durante el desarrollo que no he visto documentado en ningún lado: las entidades HTML en los headings se doble-encodean y se renderizan literalmente en el navegador. Aquí está la implementación completa y el fix.

Extracción de Headings Server-Side

El ToC se genera en el Worker antes de que se envíe la respuesta. marked.parse() convierte el Markdown a HTML primero, luego extractHeadings() parsea el HTML renderizado para encontrar los tags <h2> y <h3>.

javascriptfunction extractHeadings(html) {
  const headings = [];
  const re = /<h([23])[^>]*>(.*?)<\/h\1>/gi;
  let m;
  while ((m = re.exec(html)) !== null) {
    const level = parseInt(m[1]);
    const text = m[2].replace(/<[^>]+>/g, "").trim()
      .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
      .replace(/&quot;/g, '"').replace(/&#39;/g, "'");
    const id = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
    headings.push({ level, text, id });
  }
  return headings;
}

Los IDs de heading se derivan del texto — la misma lógica que usa marked para generar IDs de anchor. Un heading "Qué Haría Diferente" se convierte en id qu-hara-diferente. Los links del ToC usan href="#qu-hara-diferente" para scrollear a la sección.

El Bug del Doble-Encoding

Aquí está el bug: marked.parse() encodea caracteres especiales en el texto de los headings. Un apóstrofe ' en un heading se convierte en &#39; en el HTML renderizado.

extractHeadings lee m[2] = What I&#39;d Do Differently. Sin el paso de decode de entidades, escapeHtml(text) convierte el & en &#39; a &amp;, produciendo What I&amp;#39;d Do Differently.

En el navegador, &amp;#39; se renderiza como el string literal &#39; — no como un apóstrofe. El ToC muestra What I&#39;d Do Differently en lugar de What I'd Do Differently.

El fix es el decode de entidades antes de llamar a escapeHtml:

javascriptconst text = m[2].replace(/<[^>]+>/g, "").trim()
  .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
  .replace(/&quot;/g, '"').replace(/&#39;/g, "'");

Ahora escapeHtml recibe un string limpio con un apóstrofe real, y lo encodea una vez como &#39; — que el navegador renderiza correctamente como '.

El Layout CSS

El ToC se asienta en un CSS Grid 1fr 200px junto al contenido del post:

css.post-layout {
  display: grid;
  grid-template-columns: 1fr 200px;
  gap: 3rem;
  align-items: start;
}

.toc-sidebar {
  position: sticky;
  top: 7rem;
  align-self: start;
  max-height: calc(100vh - 9rem);
  overflow-y: auto;
  border-left: 1px solid var(--border);
}

.toc-list a {
  display: block;
  padding: 0.3rem 0.5rem 0.3rem 1rem;
  color: #9b9ba8;
  font-size: 0.75rem;
  border-left: 1px solid transparent;
  margin-left: -1px;
}

.toc-list a.toc-active {
  color: var(--accent);
  border-left: 1px solid var(--accent);
}

El margin-left: -1px en los links activos superpone el borde izquierdo de la sidebar con el borde de acento, creando un efecto limpio de "track con indicador" sin un borde doble.

En móvil, la sidebar se oculta y un bloque <details> colapsable la reemplaza encima del contenido del post.

IntersectionObserver para el Estado Activo

IntersectionObserver observa los elementos de heading y actualiza el link activo del ToC mientras las secciones entran al viewport:

javascriptconst headingEls = document.querySelectorAll('h2[id], h3[id]');
const tocLinks = document.querySelectorAll('[data-toc]');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      tocLinks.forEach(l => l.classList.remove('toc-active'));
      const active = document.querySelector(`[data-toc="${entry.target.id}"]`);
      if (active) active.classList.add('toc-active');
    }
  });
}, {
  rootMargin: '-10% 0px -80% 0px',
  threshold: 0
});

headingEls.forEach(h => observer.observe(h));

El rootMargin: '-10% 0px -80% 0px' encoge la zona de detección de intersección al 20% superior del viewport. Un heading se vuelve "activo" cuando entra en esta zona — aproximadamente cuando el usuario llega a esa sección, no cuando el heading está en la parte inferior de la pantalla.

Sin este margen, el observer se dispara demasiado tarde: el heading ha scrolleado bien más allá del tope antes de que el heading de la siguiente sección entre al viewport.

Lo Que No Funciona Perfectamente

El enfoque de IntersectionObserver resalta la sección que acaba de entrar al viewport desde arriba. Si scrolleas muy rápido, múltiples secciones pueden entrar y salir antes de que se disparen los callbacks del observer, y el estado activo puede quedarse rezagado.

Un enfoque más robusto usa un listener de scroll que calcula qué heading está más cerca del tope del viewport en cada evento scroll. Es más intensivo en CPU pero más preciso. Para el ToC de un blog, el enfoque del observer es suficientemente bueno — los lectores scrollean a velocidades humanas.

El otro caso extremo: la última sección de un post puede nunca disparar el observer si es corta y la página no scrollea lo suficiente. La sección del último heading necesita llenar una porción significativa del viewport para intersectar. Para la mayoría de posts, esto no es un problema. Para posts donde la última sección es un párrafo único, el estado activo puede quedarse en el penúltimo heading.

Was this helpful?