Comentarios en Astro con Giscus y GitHub Discussions

Comentarios en Astro con Giscus y GitHub Discussions

Un blog estático no tiene backend, pero eso no significa que no pueda tener comentarios. Giscus usa GitHub Discussions como almacén de datos: cada vez que alguien comenta en un post, se crea automáticamente una discusión en el repositorio. El resultado es gratuito, sin anuncios y con toda la gestión de moderación desde GitHub.

En este artículo explico cómo lo he integrado en este blog, prestando atención a la performance y a la sincronización del tema oscuro/claro.

Requisitos previos

Antes de tocar código necesitas tres cosas en GitHub:

  1. El repositorio debe ser público.
  2. Activar Discussions en Settings → General → Features → Discussions.
  3. Instalar la GitHub App de Giscus en el repositorio desde github.com/apps/giscus.

Con eso listo, ve a giscus.app, introduce tu repositorio y obtendrás los valores que necesitas:

  • data-repo-id — identificador único del repositorio
  • data-category-id — identificador de la categoría de Discussions

La categoría recomendada es de tipo Announcement: solo Giscus puede crear hilos nuevos, los visitantes únicamente responden.

El componente Comments.astro

El widget de Giscus se carga mediante un <script> externo que inyecta un <iframe>. Para no penalizar el rendimiento, lo cargo de forma perezosa con IntersectionObserver: el script no se solicita hasta que el usuario se acerca a la sección de comentarios.

Este blog usa View Transitions (<ClientRouter />), y eso condiciona por completo cómo hay que escribir el componente. Lo explico en detalle más abajo; primero, el código completo:

<section id="comments" aria-label="Comentarios">
  <h2 class="comments-title">Comentarios</h2>
  <div class="giscus"></div>
</section>

<script is:inline>
  (function () {
    const ORIGIN = 'https://giscus.app';

    const darkMQ = window.matchMedia('(prefers-color-scheme: dark)');

    // Estado por página: se reinicia en cada navegación con View Transitions
    let observer = null;
    let themeSynced = false;

    const getTheme = () => {
      const explicit = document.documentElement.dataset.theme;
      const isDark = explicit ? explicit === 'dark' : darkMQ.matches;
      return isDark ? 'dark_dimmed' : 'light';
    };

    const sendTheme = (theme) => {
      const iframe = document.querySelector('iframe.giscus-frame');
      if (iframe && iframe.contentWindow) {
        iframe.contentWindow.postMessage(
          { giscus: { setConfig: { theme } } },
          ORIGIN
        );
      }
    };

    const loadGiscus = (container) => {
      if (container.querySelector('iframe.giscus-frame')) return;
      if (container.querySelector('script[data-giscus-client]')) return;

      const script = document.createElement('script');
      script.src = ORIGIN + '/client.js';
      script.async = true;
      script.crossOrigin = 'anonymous';
      script.setAttribute('data-giscus-client', '');

      const attrs = {
        'data-repo': 'TU_USUARIO/TU_REPO',
        'data-repo-id': 'TU_REPO_ID',
        'data-category': 'Comentarios',
        'data-category-id': 'TU_CATEGORY_ID',
        'data-mapping': 'pathname',
        'data-strict': '1',
        'data-reactions-enabled': '1',
        'data-emit-metadata': '0',
        'data-input-position': 'bottom',
        'data-theme': getTheme(),
        'data-lang': 'es',
      };
      Object.entries(attrs).forEach(([k, v]) => script.setAttribute(k, v));

      container.appendChild(script);
    };

    // Se ejecuta en la carga inicial y después de cada navegación
    const setup = () => {
      if (observer) {
        observer.disconnect();
        observer = null;
      }
      themeSynced = false;

      const section = document.getElementById('comments');
      const container = section && section.querySelector('.giscus');
      if (!container) return; // página sin comentarios

      observer = new IntersectionObserver(
        function (entries, obs) {
          if (!entries[0].isIntersecting) return;
          obs.disconnect();
          observer = null;
          loadGiscus(container);
        },
        { rootMargin: '200px' }
      );
      observer.observe(section);
    };

    // Los listeners globales viven en window/document, que sobreviven al swap:
    // se registran una sola vez por sesión.
    if (!window.__giscusInit) {
      window.__giscusInit = true;

      // Sincronizar tema cuando el iframe de Giscus está listo
      window.addEventListener('message', function (e) {
        if (e.origin !== ORIGIN || themeSynced) return;
        themeSynced = true;
        sendTheme(getTheme());
      });

      // Toggle manual del sitio
      window.addEventListener('theme-changed', function (e) {
        sendTheme(e.detail.isDark ? 'dark_dimmed' : 'light');
      });

      // Cambio de preferencia del sistema operativo
      darkMQ.addEventListener('change', function () {
        sendTheme(getTheme());
      });

      document.addEventListener('astro:page-load', setup);
      setup();
    }
  })();
</script>

Hay algunas decisiones que merece la pena explicar.

La trampa: View Transitions

La primera versión que escribí de este componente hacía todo el trabajo en el momento de ejecutarse el script: buscaba #comments, montaba el IntersectionObserver y registraba los listeners, todo de corrido dentro de la IIFE. Funcionaba con una recarga completa y fallaba en cuanto navegabas de una entrada a otra.

Con <ClientRouter />, una navegación no recarga la página: el router pide el HTML nuevo, sustituye el <body> y reconcilia el <head>. De ahí salen tres problemas encadenados:

  1. El script no se vuelve a ejecutar. El router compara los <script> de la página nueva con los de la anterior y solo ejecuta los que son distintos. Como todas las entradas emiten el mismo is:inline byte a byte, en una navegación entrada → entrada no se ejecuta ninguna vez más. Todo lo que estuviera escrito “en plano” dentro de la IIFE deja de correr.
  2. El DOM anterior ya no existe. El #comments que estaba observando el IntersectionObserver se ha ido con el <body> viejo, junto con el iframe. El observer sigue vivo apuntando a un nodo huérfano que nunca volverá a intersectar.
  3. Una guardia global lo empeora. Un if (window.__giscusInit) return al principio de la función significa que, en el caso en el que el script se re-ejecuta (por ejemplo, al llegar desde una página sin comentarios), sale por la primera línea y no monta nada.

La solución es separar dos tipos de estado:

  • Lo que sobrevive al swap — los listeners sobre window y document — se registra una sola vez por sesión. Para eso sí sirve la guardia __giscusInit.
  • Lo que muere con el swap — el observer y la referencia al contenedor — se reconstruye en cada navegación desde setup().

El enganche es astro:page-load, que dispara tanto en la carga inicial como al final de cada navegación. Al registrarlo sobre document, que no se sustituye nunca, da igual que el is:inline no se vuelva a ejecutar: el listener sigue ahí desde la primera vez.

document.addEventListener('astro:page-load', setup);
setup(); // por si el componente se usa sin ClientRouter

Y setup() es idempotente: lo primero que hace es desconectar el observer anterior y resetear el flag de tema, así que da igual cuántas veces se llame.

Por qué is:inline

Astro procesa los <script> normales como módulos ES: los bundlea con Vite y los deduplica entre páginas. Un módulo ya cargado no se vuelve a evaluar, así que el efecto es el mismo que hemos visto arriba. is:inline mantiene el script literal en el HTML, sin pasar por el bundler, lo que hace obvio qué se ejecuta y cuándo.

Lo importante es no confundir “está en el HTML de cada página” con “se ejecuta en cada página”: con View Transitions no es lo mismo. Si necesitas forzar la re-ejecución de un inline en cada navegación existe el atributo data-astro-rerun, pero para este caso el par astro:page-load + guardia global es más limpio, porque evita registrar listeners duplicados.

Dónde se inyecta el <script> de Giscus

Mi primera versión hacía document.head.appendChild(script). Mal sitio por dos motivos.

El primero, que Giscus inserta su widget junto a su propio <script> — por eso la documentación oficial te dice que pongas el tag exactamente donde quieres que aparezcan los comentarios.

El segundo, específico de View Transitions: el router reconcilia el <head> en cada navegación, así que el tag inyectado desaparece, mientras que el iframe vivía en el <body> y se iba con el swap. Quedaba un estado inconsistente entre navegaciones.

La versión correcta lo mete dentro del propio contenedor:

container.appendChild(script);

Así el iframe aparece donde toca y, al navegar, el swap se lleva script e iframe juntos. La siguiente entrada arranca de cero y reinyecta un client.js limpio que lee el nuevo pathname — imprescindible porque uso data-mapping="pathname" para asociar cada entrada con su discusión.

Lazy load con IntersectionObserver

El <script> de Giscus pesa varios kilobytes y hace peticiones a la API de GitHub. Si lo incluimos directamente en el <head> bloquearía o retrasaría recursos críticos. Con IntersectionObserver y rootMargin: '200px', el script empieza a cargarse cuando el usuario lleva el viewport a 200px del área de comentarios, consiguiendo carga anticipada sin afectar al LCP.

La doble guardia de loadGiscus() — comprobar tanto el iframe como el script[data-giscus-client] — evita inyectar dos veces si el observer se dispara mientras el script anterior todavía está descargando.

Sincronización de tema

Este blog usa Cobalt para los design tokens, con dos selectores que activan el modo oscuro:

// En tokens.config.mjs:
{ mode: "dark", selectors: [
  "@media (prefers-color-scheme: dark)",
  '[data-theme="dark"]'
]}

A nivel de CSS el modo oscuro se activa cuando cualquiera de las dos condiciones es cierta, pero en JavaScript la lógica no es un ||. El script theme-init.js que evita el FOUC escribe siempre un data-theme explícito (leído de localStorage o, en su defecto, de la preferencia del sistema), y applyTheme() lo reescribe en cada toggle. Es decir: data-theme es la fuente de verdad, y matchMedia solo hace de red de seguridad por si aún no se ha aplicado ninguno.

const getTheme = () => {
  const explicit = document.documentElement.dataset.theme;
  const isDark = explicit ? explicit === 'dark' : darkMQ.matches;
  return isDark ? 'dark_dimmed' : 'light';
};

Escribirlo como dataset.theme === 'dark' || darkMQ.matches es un bug sutil que tuve durante un tiempo: si tienes el sistema operativo en oscuro pero has elegido el tema claro en el blog, la condición sigue siendo cierta y Giscus se pinta en dark_dimmed sobre una página blanca.

Para los cambios en caliente usamos dos vías:

  • Toggle manual: theme-toggle.js emite un CustomEvent('theme-changed') desde applyTheme(). El componente lo escucha y envía el nuevo tema al iframe via postMessage.
  • Preferencia del sistema: un listener en matchMedia('prefers-color-scheme: dark') detecta cambios a nivel de OS y sincroniza Giscus sin que el usuario tenga que hacer nada.

El postMessage al iframe sigue la API oficial de Giscus:

iframe.contentWindow.postMessage(
  { giscus: { setConfig: { theme: 'dark_dimmed' } } },
  'https://giscus.app'
);

Hay un tercer momento en el que hay que sincronizar: justo después de que el iframe termine de cargar. Giscus avisa con un message, y la tentación es escuchar una sola vez y desregistrarse:

// No hagas esto con View Transitions
window.addEventListener('message', function onReady(e) {
  if (e.origin !== ORIGIN) return;
  sendTheme(getTheme());
  window.removeEventListener('message', onReady); // ← solo una vez por sesión
});

El problema es el de siempre: el listener vive en window, sobrevive al swap y se elimina para siempre tras el primer iframe. La segunda entrada que visites monta un iframe nuevo que ya no recibe nada. La solución es dejar el listener permanente y mover el “ya está” a una variable que setup() resetea en cada navegación:

let themeSynced = false; // reseteado en cada astro:page-load

window.addEventListener('message', function (e) {
  if (e.origin !== ORIGIN || themeSynced) return;
  themeSynced = true;
  sendTheme(getTheme());
});

Añadir el componente al layout del post

Con el componente creado, basta importarlo en src/pages/blog/[...slug].astro:

---
import Comments from "@components/Comments.astro";
---

<article>
  <!-- contenido del post -->
  <Comments />
</article>

Resumen: la regla con View Transitions

Si tuviera que quedarme con una sola idea de todo esto, sería esta: con <ClientRouter />, el ciclo de vida de un script deja de coincidir con el ciclo de vida de la página. Antes de dar por bueno cualquier componente con JavaScript, pregúntate por cada línea si va en un sitio o en el otro:

Vive en…Sobrevive al swapDónde va
window / document (listeners globales)Registrar una vez, con guardia
El DOM de la página (nodos, observers, iframes)NoReconstruir en astro:page-load

Todo lo que toque el DOM va dentro de una función que se vuelve a llamar en cada navegación, y esa función tiene que ser idempotente: limpiar lo anterior antes de montar lo nuevo.

Resultado

  • Los comentarios se cargan solo cuando el usuario llega a esa zona de la página.
  • Funcionan igual en carga directa que navegando entre entradas con View Transitions, y cada entrada carga su propia discusión gracias al pathname.
  • El tema cambia en tiempo real tanto al pulsar el toggle del header como al cambiar la preferencia del sistema operativo.
  • Los comentarios quedan guardados en GitHub Discussions: visibles, buscables y gestionables desde el propio repositorio.
  • Sin coste, sin anuncios, sin cookies de terceros adicionales más allá del iframe de giscus.app.

Comentarios