Back

Adding comments to your static blog with Mastodon

Update 29.01.2023: Adapted the code to work with Mastodon 4.0 and replaced linuxrocks.online by floss.social

Update 15.03.2023: Thanks to @veronica@mastodon.online, this code now handles replies in a lot nicer way. You might want check out her solution too.

Update 07.07.2023: Thanks to @cassidy@blaede.family, the layout was improved and this now handle emojis

One of the biggest disadvantages of static site generators is that they are static and can’t include comments.

There are multiples solutions to solve this problem. You could add a third party blog engine like Disqus, but this has the drawback of including a third-party tool with a bad privacy record in your website. Another solution would be to host an open-source alternative but this comes at the cost of a higher maintenance burden. Having to host a database was something we wanted to avoid with a static site generator.

In my opinion, a better solution is to leverage the Mastodon and Fediverse platform. Mastodon is a decentralized social network and it allows people to communicate with each other without being on the same server. It is inspired by Twitter, but instead of tweeting, you write toot.

When publishing an article, you now only need to also write a simple toot linking to your article. Then Mastodon has a simple API to fetch the answer to your toot. This is the code I made for my Hugo powered blog, but it is easily adaptable for other static site generators. It will create a button to load comments instead of loading them for every visitor so that it decreases the load on your mastodon server.

{{ 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 }}

You can also found some SCSS rules on my gitlab. If you blog engine doesn’t support converting SCSS to CSS, you can use an online SCSS converter or sass tool from the cli.

This code is using DOMPurify to sanitize the input, since it is not a great idea to load data from third party sources without sanitizing them first. Also thanks to chrismorgan, the code was optimized and is more secure.

In my blog post, I can now add the following information to my frontmatter, to make comments appears magically.

comments:
  host: floss.social
  username: carlschwan
  id: 109774012599031406

Comments

With an account on the Fediverse or Mastodon, you can respond to this post. 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.

Learn how this is implemented here.

Licensed under CC BY-SA 4.0