Si sois curiosos y alguna vez habéis trasteado con una página web entenderéis un poco el drama de usar un sitio web estático y agregarle un sistema de comentarios.

Si tiramos a leer la documentación de Hugo nos encontramos que no es muy difícil integrar algunos servicios como Disqus. Seguro que lo habéis encontrado bastante si navegáis por los lugares mas indies de internet. Hace un tiempo hice alguna búsqueda rápida para ver alternativas existían, pero acabé un poco desanimado.

Usar un servidor de comentarios de terceros le quita bastante la gracia a esto de tener un blog estático autoalojado. Además, en casi todos, es obligatorio que iniciar sesión con la típica cuenta de Google o Facebook para comentar. Siendo sinceros, mi web tampoco es que vaya a tener mucho tráfico ni muchos comentarios, por lo que me pareció un despropósito montar algo así. Total, que lo dejé bastante de lado.

Pero algo me hizo click este finde viendo el canal de PeerTube de Nick, el señor tras The Linux Experiment. Caí en la cuenta que los comentarios del video venían directamente del Fediverso (obvio, es PeerTube). Si nos fijamos en ellos vemos gente de mastodon.social, fosstodon.org… Me voló un poco la cabeza pensar que podía comentar esos videos directamente con mi cuenta de Mastodon. Y se me ocurrió que estaría muy chulo poder tener algo parecido en mi blog para los comentarios. Se que es posible federar blogs con cosillas como plugins de Wordpress o WriteFreely y sus numerosas instancias, entre las que destacaría Escritura Social. Pero la verdad, me llama bastante más la atención la sencillez de los blog estáticos.

Total, que me puse a buscar. Por que, en el Fediverso, siendo tan loco como es, fijo alguien que sí que sabe lo que hace, ha pensado lo mismo y lo ha colgado en su blog su solución. Y así fue. Carl Schwan, desarrollador en Nextcloud y KDE (un máquina), en este artículo de su blog, explica como hacer justo eso.

Recomiendo leerlo, pero también quiero explicar un poco de manera más detallada como montar todo. En un blog con Hugo debería haber una estructura de carpetas más o menos así. Lo típico.

~/blog$ tree -L1 

.  
├── archetypes  
├── content  
├── hugo.yaml  
├── layouts  
├── public  
├── README.md  
├── static  
└── themes

En mi caso, mi tema ya trae una plantilla /themes/.../layouts/partials/comments.html. Me viene en blanco prácticamente y lo voy a copiar en /layouts/partials/comments.html para poder sobreescribirlo con mis cambios. Antes de seguir, habría que asegurarse de tener este otro snippet. Si hay un comments.html o algo parecido en el tema, esto ya debería venir, pero si no, hay que ponerlo en la sección correspondiente de la web. De todos modos, no viene de más encontrarlo. En mi caso se encuentra al final de /themes/.../layouts/_default/single.html y no lo voy a modificar.

# /themes/.../layouts/_default/single.html

{{- if (.Param "comments") }}
{{- partial "comments.html" . }}
{{- end }}

Ahora sí, el contenido del archivo comments.html es el siguiente. Aquí voy a poner tal cual el de Carl, pero en mi blog lo he toqueteado un pelín y lo he traducido. Mi versión se puede encontrar aquí.

# /layouts/partials/comments.html

{{ with .Params.comments }}
<section id="comments" class="article-content">
  <h2>Comments</h2>
  <p>With an account on the Fediverse or Mastodon, you can respond to this <a href="https://{{ .host }}/@{{ .username }}/{{ .id }}">post</a>. Since Mastodon is decentralized, you can use your existing account hosted by another Mastodon server or compatible platform if you don't have an account on this one. Known non-private replies are displayed below.</p>
  <p>Learn how this is implemented <a class="link" href="/2020/12/29/adding-comments-to-your-static-blog-with-mastodon/">here.</a></p>

  <p id="mastodon-comments-list"><button id="load-comment">Load comments</button></p>
  <div id="comments-wrapper">
    <noscript><p>Loading comments relies on JavaScript. Try enabling JavaScript and reloading, or visit <a href="https://{{ .host }}/@{{ .username }}/{{ .id }}">the original post</a> on Mastodon.</p></noscript>
  </div>
  <noscript>You need JavaScript to view the comments.</noscript>
  <script src="/assets/js/purify.min.js"></script>
  <script type="text/javascript">
    function escapeHtml(unsafe) {
      return unsafe
           .replace(/&/g, "&amp;")
           .replace(/</g, "&lt;")
           .replace(/>/g, "&gt;")
           .replace(/"/g, "&quot;")
           .replace(/'/g, "&#039;");
    }
    function emojify(input, emojis) {
      let output = input;

      emojis.forEach(emoji => {
        let picture = document.createElement("picture");

        let source = document.createElement("source");
        source.setAttribute("srcset", escapeHtml(emoji.url));
        source.setAttribute("media", "(prefers-reduced-motion: no-preference)");

        let img = document.createElement("img");
        img.className = "emoji";
        img.setAttribute("src", escapeHtml(emoji.static_url));
        img.setAttribute("alt", `:${ emoji.shortcode }:`);
        img.setAttribute("title", `:${ emoji.shortcode }:`);
        img.setAttribute("width", "20");
        img.setAttribute("height", "20");

        picture.appendChild(source);
        picture.appendChild(img);

        output = output.replace(`:${ emoji.shortcode }:`, picture.outerHTML);
      });

      return output;
    }

    function loadComments() {
      let commentsWrapper = document.getElementById("comments-wrapper");
      document.getElementById("load-comment").innerHTML = "Loading";
      fetch('https://{{ .host }}/api/v1/statuses/{{ .id }}/context')
        .then(function(response) {
          return response.json();
        })
        .then(function(data) {
          let descendants = data['descendants'];
          if(
            descendants &&
            Array.isArray(descendants) &&
            descendants.length > 0
          ) {
            commentsWrapper.innerHTML = "";

            descendants.forEach(function(status) {
                console.log(descendants)
              if( status.account.display_name.length > 0 ) {
                status.account.display_name = escapeHtml(status.account.display_name);
                status.account.display_name = emojify(status.account.display_name, status.account.emojis);
              } else {
                status.account.display_name = status.account.username;
              };

              let instance = "";
              if( status.account.acct.includes("@") ) {
                instance = status.account.acct.split("@")[1];
              } else {
                instance = "{{ .host }}";
              }

              const isReply = status.in_reply_to_id !== "{{ .id }}";

              let op = false;
              if( status.account.acct == "{{ .username }}" ) {
                op = true;
              }

              status.content = emojify(status.content, status.emojis);

              let avatarSource = document.createElement("source");
              avatarSource.setAttribute("srcset", escapeHtml(status.account.avatar));
              avatarSource.setAttribute("media", "(prefers-reduced-motion: no-preference)");

              let avatarImg = document.createElement("img");
              avatarImg.className = "avatar";
              avatarImg.setAttribute("src", escapeHtml(status.account.avatar_static));
              avatarImg.setAttribute("alt", `@${ status.account.username }@${ instance } avatar`);

              let avatarPicture = document.createElement("picture");
              avatarPicture.appendChild(avatarSource);
              avatarPicture.appendChild(avatarImg);

              let avatar = document.createElement("a");
              avatar.className = "avatar-link";
              avatar.setAttribute("href", status.account.url);
              avatar.setAttribute("rel", "external nofollow");
              avatar.setAttribute("title", `View profile at @${ status.account.username }@${ instance }`);
              avatar.appendChild(avatarPicture);

              let instanceBadge = document.createElement("a");
              instanceBadge.className = "instance";
              instanceBadge.setAttribute("href", status.account.url);
              instanceBadge.setAttribute("title", `@${ status.account.username }@${ instance }`);
              instanceBadge.setAttribute("rel", "external nofollow");
              instanceBadge.textContent = instance;

              let display = document.createElement("span");
              display.className = "display";
              display.setAttribute("itemprop", "author");
              display.setAttribute("itemtype", "http://schema.org/Person");
              display.innerHTML = status.account.display_name;

              let header = document.createElement("header");
              header.className = "author";
              header.appendChild(display);
              header.appendChild(instanceBadge);

              let permalink = document.createElement("a");
              permalink.setAttribute("href", status.url);
              permalink.setAttribute("itemprop", "url");
              permalink.setAttribute("title", `View comment at ${ instance }`);
              permalink.setAttribute("rel", "external nofollow");
              permalink.textContent = new Date( status.created_at ).toLocaleString('en-US', {
                dateStyle: "long",
                timeStyle: "short",
              });

              let timestamp = document.createElement("time");
              timestamp.setAttribute("datetime", status.created_at);
              timestamp.appendChild(permalink);

              let main = document.createElement("main");
              main.setAttribute("itemprop", "text");
              main.innerHTML = status.content;

              let interactions = document.createElement("footer");
              if(status.favourites_count > 0) {
                let faves = document.createElement("a");
                faves.className = "faves";
                faves.setAttribute("href", `${ status.url }/favourites`);
                faves.setAttribute("title", `Favorites from ${ instance }`);
                faves.textContent = status.favourites_count;

                interactions.appendChild(faves);
              }

              let comment = document.createElement("article");
              comment.id = `comment-${ status.id }`;
              comment.className = isReply ? "comment comment-reply" : "comment";
              comment.setAttribute("itemprop", "comment");
              comment.setAttribute("itemtype", "http://schema.org/Comment");
              comment.appendChild(avatar);
              comment.appendChild(header);
              comment.appendChild(timestamp);
              comment.appendChild(main);
              comment.appendChild(interactions);

              if(op === true) {
                comment.classList.add("op");

                avatar.classList.add("op");
                avatar.setAttribute(
                  "title",
                  "Blog post author; " + avatar.getAttribute("title")
                );

                instanceBadge.classList.add("op");
                instanceBadge.setAttribute(
                  "title",
                  "Blog post author: " + instanceBadge.getAttribute("title")
                );
              }

              commentsWrapper.innerHTML += DOMPurify.sanitize(comment.outerHTML);
            });
          }
        });
      }
      document.getElementById("load-comment").addEventListener("click", loadComments);
  </script>
</section>
{{ end }}

Hay que saber dos cositas más. Es necesario usar DOMPurify para limpiar un poco el input de los comentarios que se van a cargar con el script. En las primeras líneas se hace referencia al archivo de JavaScript purify.min.js. En el blog, este archivo debería ser accesible en /assets/js/purify.min.js.

# /layouts/partials/comments.html

<script src="/assets/js/purify.min.js"></script>
<script type="text/javascript">

purify.min.js se puede descargar del repo de DOMPurify. El enlace concreto es este. Pues nada, para que al compilar la web con Hugo, sea accesible en la ruta correcta, tenemos que copiar purify.min.js en /static/assets/js/purify.min.js, porque, recuerdo, todo lo que esté en la carpeta /static/, acabará en la raíz / del blog al compilarlo con el comando hugo.

Muy bien, ahora el único problemilla es que funciona, pero queda todo bastante feo. Falta el css. Este paso me ha costado un pelín, porque Carl Schwan comparte sus hojas de estilos escritos en SCSS, no en CSS. Para usarlas en Hugo sin instalar nada adicional hay que compilarlas a CSS. Lo he dejado hecho aquí con unas pequeñas modificaciones para usarlo directamente. De todos modos, si os da curiosidad, como se compila un SCSS, se explica en la web del lenguaje SASS. Yo no tenía ni idea, la verdad.

El archivo comments.css puede ir en cualquier lugar, como en /static/css/comments.css, y hay que referenciarlo en el html del blog de este modo (esto puede escribirse perfectamente en algún sitio de comments.html).

# /layouts/partials/comments.html

<link rel="stylesheet" href="{{ .Site.BaseURL }}/css/comments.css">

Aquí se usa {{ .Site.BaseURL }} para que al compilar el blog, el href quede tal que así href="https://sudodnf.com/css/comments.css". Con esto, lo único que falta, es modificar la configuración en hugo.yaml, y poner lo siguiente en la sección params.

# /hugo.yaml

params:
	comments: true

Y ahora, en el YAML de cada entrada del blog, agregar el enlace de un toot de Mastodon, que es de donde se van a buscar los comentarios al pulsar el botón.

# /content/articulo.md

---
comments:
  host: masto.es
  username: peps
  id: 109774012599031406
---

Así queda todo listo. De todos modos, el código fuente de este blog es accesible, y si no, la entrada de Carl y la documentación de Hugo deberían servir para resolver dudas. También podéis preguntar o escribir cualquier cosilla en la sección de comentarios, guiño guiño.

Y con esto y un bizcocho…