Back

Adding comments to your static blog with Mastodon

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.

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 }}
<div class="article-content">
  <h2>Comments</h2>
  <p>You can use your Mastodon account to reply to this <a class="link" href="https://{{ .host }}/@{{ .username }}/{{ .id }}">post</a>.</p>
  <p><button id="replyButton" href="https://{{ .host }}/@{{ .username }}/{{ .id }}">Reply</button></p>
  <p id="mastodon-comments-list"><button id="load-comment">Load comments</button></p>
  <dialog id="toot-reply" class="mastodon" data-component="dialog">
    <h3>Reply to {{ .username }}'s post</h3>
    <p>
      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.
    </p>
    <p>Copy and paste this URL into the search field of your favourite Fediverse app or the web interface of your Mastodon server.</p>
    <div class="copypaste">
      <input type="text" readonly="" value="https://{{ .host }}/@{{ .username }}/{{ .id }}">
      <button class="button" id="copyButton">Copy</button>
      <button class="button" id="cancelButton">Close</button>
    </div>
  </dialog>
  <p id="mastodon-comments-list"><button id="load-comment">Load comments</button></p>
  <noscript><p>You need JavaScript to view the comments.</p></noscript>
  <script src="/assets/js/purify.min.js"></script>
  <script type="text/javascript">

    const dialog = document.querySelector('dialog');

    document.getElementById('replyButton').addEventListener('click', () => {
      dialog.showModal();
    });

    document.getElementById('copyButton').addEventListener('click', () => {
      navigator.clipboard.writeText('https://{{ .host }}/@{{ .username }}/{{ .id }}');
    });

    document.getElementById('cancelButton').addEventListener('click', () => {
      dialog.close();
    });

    dialog.addEventListener('keydown', e => {
      if (e.key === 'Escape') dialog.close();
    });

    const dateOptions = {
      year: "numeric",
      month: "numeric",
      day: "numeric",
      hour: "numeric",
      minute: "numeric",
    };

    function escapeHtml(unsafe) {
      return unsafe
           .replace(/&/g, "&amp;")
           .replace(/</g, "&lt;")
           .replace(/>/g, "&gt;")
           .replace(/"/g, "&quot;")
           .replace(/'/g, "&#039;");
   }

    document.getElementById("load-comment").addEventListener("click", function() {
      document.getElementById("load-comment").innerHTML = "Loading";
      fetch('https://{{ .host }}/api/v1/statuses/{{ .id }}/context')
        .then(function(response) {
          return response.json();
        })
        .then(function(data) {
          if(data['descendants'] &&
             Array.isArray(data['descendants']) && 
            data['descendants'].length > 0) {
              document.getElementById('mastodon-comments-list').innerHTML = "";
              data['descendants'].forEach(function(reply) {
                reply.account.display_name = escapeHtml(reply.account.display_name);
                reply.account.reply_class = reply.in_reply_to_id == "{{ .id }}" ? "reply-original" : "reply-child";
                reply.created_date = new Date(reply.created_at);
                reply.account.emojis.forEach(emoji => {
                  reply.account.display_name = reply.account.display_name.replace(`:${emoji.shortcode}:`,
                    `<img src="${escapeHtml(emoji.static_url)}" alt="Emoji ${emoji.shortcode}" height="20" width="20" />`);
                });
                mastodonComment =
                    `
<div class="mastodon-wrapper">
  <div class="comment-level ${reply.account.reply_class}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
    <path fill="currentColor" stroke="currentColor" d="m 307,477.17986 c -11.5,-5.1 -19,-16.6 -19,-29.2 v -64 H 176 C 78.8,383.97986 -4.6936293e-8,305.17986 -4.6936293e-8,207.97986 -4.6936293e-8,94.679854 81.5,44.079854 100.2,33.879854 c 2.5,-1.4 5.3,-1.9 8.1,-1.9 10.9,0 19.7,8.9 19.7,19.7 0,7.5 -4.3,14.4 -9.8,19.5 -9.4,8.8 -22.2,26.4 -22.2,56.700006 0,53 43,96 96,96 h 96 v -64 c 0,-12.6 7.4,-24.1 19,-29.2 11.6,-5.1 25,-3 34.4,5.4 l 160,144 c 6.7,6.2 10.6,14.8 10.6,23.9 0,9.1 -3.9,17.7 -10.6,23.8 l -160,144 c -9.4,8.5 -22.9,10.6 -34.4,5.4 z" />
  </svg></div>
  <div class="mastodon-comment">
    <div class="comment">
      <div class="comment-avatar"><img src="${escapeHtml(reply.account.avatar_static)}" alt=""></div>
      <div class="comment-author">
        <div class="comment-author-name"><a href="${reply.account.url}" rel="nofollow">${reply.account.display_name}</a></div>
        <div class="comment-author-reply"><a href="${reply.account.url}" rel="nofollow">${escapeHtml(reply.account.acct)}</a></div>
      </div>
      <div class="comment-author-date">${reply.created_date.toLocaleString(navigator.language, dateOptions)}</div>
    </div>
    <div class="comment-content">${reply.content}</div> 
  </div>
</div>
`;
                document.getElementById('mastodon-comments-list').appendChild(DOMPurify.sanitize(mastodonComment, {'RETURN_DOM_FRAGMENT': true}));
              });
          } else {
            document.getElementById('mastodon-comments-list').innerHTML = "<p>Not comments found</p>";
          }
        });
      });
  </script>
</div>
{{ end }}

You can also found some CSS rules on my gitlab.

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

Update from the 29th Jan 2023: Adapted the code to work with Mastodon 4.0 and replaced linuxrocks.online by floss.social

Comments

You can use your Mastodon account to reply to this post. Learn how this is implemented here.

Reply to carlschwan's post

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.

Copy and paste this URL into the search field of your favourite Fediverse app or the web interface of your Mastodon server.

Licensed under CC BY-SA 4.0