Client-side comments with Mastodon on a static Hugo website

Posted by ads on Thursday, 2024-05-23
Posted in [Hugo][Myself][Online][Social-Networks][Software]

Ever since I moved this blog to Hugo from Serendipity, I missing the comment option a bit. This is something which a static blogging engine does not offer out of the box. And I did not migrate all the comments from my old blog to my new blog.

Inspired by a couple of postings which integrate Mastodon as “comments” into static blogs, I set out to add this to my own blog.

Client-side comments with Mastodon on a static Hugo website

I’ve seen the idea first in a blog posting by Carl Schwan: Adding comments to your static blog with Mastodon, followed by posts by Jan Wildeboer: Client-side comments with Mastodon on a static Jekyll website and by Daniel Pecos Martínez: Mastodon as comment system for your static blog.

The promise

  • Write a blog posting
  • Post about it on Mastodon
  • Show an option to load all comments under this Mastodon posting as comments

Some of the implementations I’ve seen load the comments right away when loading the post. I rather opted for a “click here to load comments” button. This minimizes the number of web requests during website load, and minimizes the number of requests on the Mastodon server.

This blog is using Bootstrap in the theme, I can use CSS to design the button.

Two more features are on my wishlist:

  • Some of the implementations use one Mastodon config for all posts, I rather implement the config per posting: this allows me to post on different accounts, it also allows to move my primary Mastodon account to another server and keep the old postings/comments.
  • I want a basic block list: comments (Mastodon replies under my post) shall not be shown when I deem them unfriendly.

Implementation

Basic configuration and Frontmatter

First I need a basic config, which defines my currently used Mastodon account. This goes into config.toml:

1
2
3
  [params.comments]
  host = "mastodon.social"
  username = "ascherbaum"

This however is only used in the Archetypes template when creating a new post. If both parameters are set, and not empty, this fills in a piece of Frontmatter and sets the currently configured host and username. Both settings can be changed on a per-posting basis later on. The id field is empty for now, can only be filled in once the Mastodon posting is out and the posting id is known.

1
2
3
4
5
6
7
8
9
{{- if and (isset .Site.Params.comments "host") (isset .Site.Params.comments "username") }}
{{- if and (ne .Site.Params.comments.host "") (ne .Site.Params.comments.username "") }}
comments:
  host: {{ .Site.Params.comments.host }}
  username: {{ .Site.Params.comments.username }}
  id: 
  blockedcomments:
{{ end -}}
{{ end -}}

Purify

Next I need the DOMPurify files:

1
2
3
4
curl -sS --no-progress-meter -o static/js/purify.js https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.js
curl -sS --no-progress-meter -o static/js/purify.js.map https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.js.map
curl -sS --no-progress-meter -o static/js/purify.min.js https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.min.js
curl -sS --no-progress-meter -o static/js/purify.min.js.map https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.min.js.map

And because I use a small Makefile to remember and shortcut basic commands in this blog, this is now a Makefile target:

1
2
3
4
5
6
7
assets:
	curl -sS --no-progress-meter -o static/js/purify.js https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.js
	curl -sS --no-progress-meter -o static/js/purify.js.map https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.js.map
	curl -sS --no-progress-meter -o static/js/purify.min.js https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.min.js
	curl -sS --no-progress-meter -o static/js/purify.min.js.map https://raw.githubusercontent.com/cure53/DOMPurify/main/dist/purify.min.js.map

.PHONY:	assets

The files go into static/js, not assets. More about this later.

Add the comments section

The template for a single shown blog posting is in themes/andreas/layouts/_default/single.html. Your path likely differs, I’m using my own theme in my blog. Look for a single.html file in your theme.

At the position where you want to include the comments section, add the following code:

1
{{ partial "comments" . }}

This code checks if:

  • host, username and id are specified in Frontmatter
  • all three values are set (not nil)
  • have a certain length

If all of this is true, the partial comments.html is included.

Comments template

The template for the comments goes into layouts/partials/comments.html. Here is the file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
{{- if and (isset .Params.comments "host") (isset .Params.comments "username") (isset .Params.comments "id") }}
{{- if and (.Params.comments.host) (.Params.comments.username) (.Params.comments.id) }}
{{- if and (ge (len (printf "%s" .Params.comments.host)) 5) (ge (len (printf "%s" .Params.comments.username)) 1) (ge (len (printf "%s" .Params.comments.id)) 5) }}
                <hr>
<h2>Comments</h2>

<noscript>
  <div id="error">
    Please enable *JavaScript* to view the comments powered by the Fediverse.
  </div>
</noscript>

<p>With an account on the Fediverse or Mastodon, you can respond to this <a href="https://{{ .Params.comments.host }}/@{{ .Params.comments.username }}/{{ .Params.comments.id }}">post</a>. Known replies are displayed below:</p>

<p id="mastodon-comments-list" class="d-none"><button type="button" class="btn btn-outline-secondary" type="button" onclick="respondToVisibility(comments, loadComments)">Load Comments from Fediverse</button><br/><br/>
Note: This will load data from <a href="https://{{ .Params.comments.host }}/">{{ .Params.comments.host }}</a>.</p>

<p>Learn how this is implemented <a class="link" href="https://andreas.scherbaum.la/post/2024-05-23_client-side-comments-with-mastodon-on-a-static-hugo-website/">here</a>.</p>

<script src="{{ "js/purify.min.js" | relURL }}" ></script>

<script src="{{ "js/comments.js" | relURL }}" ></script>

<script type="text/javascript">
  var host = '{{ .Params.comments.host }}';
  var user = '{{ .Params.comments.username }}';
  var id = '{{ .Params.comments.id }}'

  var commentsLoaded = false;

  var blockedToots = [
{{- if (isset .Params.comments "blockedcomments") }}
{{- if (.Params.comments.blockedcomments) }}
{{ range .Params.comments.blockedcomments }}
{{ . }},
{{ end -}}
{{ end -}}
{{ end -}}
  ];

  var comments = document.getElementById("mastodon-comments-list");

  document.addEventListener('DOMContentLoaded', function() {
    // toggle the "Load Comments" button visible
    if (comments.classList.contains('d-none')) {
      comments.classList.remove('d-none');
    }
  });

</script>

{{ $comments := resources.Get "css/comments.scss" }}
{{ $style := $comments | resources.ToCSS | resources.Fingerprint }}
<link rel="stylesheet" href="{{ $style.Permalink }}" integrity="{{ $style.Data.Integrity }}">
{{ end -}}
{{ end -}}
{{ end -}}

You may want to modify this a bit, maybe change the texts. This file does a couple of tasks:

  • Create a <p> (name: mastodon-comments-list) which is initially hidden, using the Bootstrap d-none class.
    • This <p> includes the Load Comments from Fediverse button
  • It loads the purify.min.js JavaScript
    • By moving the JavaScript loading here, the scripts are only loaded if the Frontmatter specifies that comments are enabled
  • It loads the comments.js JavaScript
  • It defines the data for the comments
  • It creates the block list, if it is specified in Frontmatter
  • It sets the Load Comments from Fediverse button visible
    • By using document.addEventListener() for DOMContentLoaded: this will be executed once all resources are loaded
    • By doing this in JavaScript, I make sure that JavaScript is enabled - otherwise the comments will not work
  • It defines and loads the CSS file comments.scss for styling the comments

By the way, did you know that your Mastodon server provides all the replies and caches all the necessary files? No need for the user to connect more than just your Mastodon server.

JavaScript for loading comments

The JavaScript file resides in static/js/comments.js. I first tried to load this as Hugo Asset but apparently then the script is loaded too late, and the functions in the file are not visible in DOM. Hence it’s in static now. Here is the file:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
  function escapeHtml(unsafe) {
    return unsafe
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#039;");
  }

  function toot_active(toot, what) {
    var count = toot[what+'_count'];
    return count > 0 ? 'active' : '';
  }

  function toot_count(toot, what) {
    var count = toot[what+'_count'];
    return count > 0 ? count : '';
  }

  function user_account(account) {
    var result =`@${account.acct}`;
    if (account.acct.indexOf('@') === -1) {
      var domain = new URL(account.url)
      result += `@${domain.hostname}`
    }
    return result;
  }

  function render_toots(toots, in_reply_to, depth) {
    var tootsToRender = toots
      .filter(toot => toot.in_reply_to_id === in_reply_to)
      .sort((a, b) => a.created_at.localeCompare(b.created_at));
    tootsToRender.forEach(toot => render_toot(toots, toot, depth));
  }

  function render_toot(toots, toot, depth) {
    toot.account.display_name = escapeHtml(toot.account.display_name);
    toot.account.emojis.forEach(emoji => {
      toot.account.display_name = toot.account.display_name.replace(`:${emoji.shortcode}:`, `<img src="${escapeHtml(emoji.static_url)}" alt="Emoji ${emoji.shortcode}" height="20" width="20" />`);
    });
    if (!blockedToots.includes(toot.url)) {
      mastodonComment =
        `<div class="mastodon-comment" style="margin-left: calc(var(--mastodon-comment-indent) * ${depth})">
          <div class="author">
            <div class="avatar">
              <img src="${escapeHtml(toot.account.avatar_static)}" height=60 width=60 alt="">
            </div>
            <div class="details">
              <a class="name" href="${toot.account.url}" rel="nofollow">${toot.account.display_name}</a>
              <a class="user" href="${toot.account.url}" rel="nofollow">${user_account(toot.account)}</a>
            </div>
            <a class="date" href="${toot.url}" rel="nofollow">${toot.created_at.substr(0, 10)} ${toot.created_at.substr(11, 8)}</a>
          </div>
          <div class="content">${toot.content}</div>
          <div class="attachments">
            ${toot.media_attachments.map(attachment => {
              if (attachment.type === 'image') {
                return `<a href="${attachment.url}" rel="nofollow"><img src="${attachment.preview_url}" alt="${attachment.description}" /></a>`;
              } else if (attachment.type === 'video') {
                return `<video controls><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
              } else if (attachment.type === 'gifv') {
                return `<video autoplay loop muted playsinline><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
              } else if (attachment.type === 'audio') {
                return `<audio controls><source src="${attachment.url}" type="${attachment.mime_type}"></audio>`;
              } else {
                return `<a href="${attachment.url}" rel="nofollow">${attachment.type}</a>`;
              }
            }).join('')}
          </div>
          <div class="status">
            <div class="replies ${toot_active(toot, 'replies')}">
              <a href="${toot.url}" rel="nofollow"><i class="fa fa-reply fa-fw"></i>${toot_count(toot, 'replies')}</a>
            </div>
            <div class="reblogs ${toot_active(toot, 'reblogs')}">
              <a href="${toot.url}" rel="nofollow"><i class="fa fa-retweet fa-fw"></i>${toot_count(toot, 'reblogs')}</a>
            </div>
            <div class="favourites ${toot_active(toot, 'favourites')}">
              <a href="${toot.url}" rel="nofollow"><i class="fa fa-star fa-fw"></i>${toot_count(toot, 'favourites')}</a>
            </div>
          </div>
        </div>`;
      document.getElementById('mastodon-comments-list').appendChild(DOMPurify.sanitize(mastodonComment, {'RETURN_DOM_FRAGMENT': true}));
    }

    render_toots(toots, toot.id, depth + 1)
  }

  function loadComments() {
    if (commentsLoaded) return;

    document.getElementById("mastodon-comments-list").innerHTML = "Loading comments from the Fediverse...";

    console.log('Comment URL: https://' + host + '/api/v1/statuses/' + id + '/context');
    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 = "";
            render_toots(data['descendants'], id, 0)
        } else {
          document.getElementById('mastodon-comments-list').innerHTML = "<p>No comments found</p>";
        }

        commentsLoaded = true;
      });
  }

  function respondToVisibility(element, callback) {
    var options = {
      root: null,
    };

    var observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        if (entry.intersectionRatio > 0) {
          callback();
        }
      });
    }, options);

    observer.observe(element);
  }

The entry point for loading the comments is the loadComments() function, which will be called when clicking the Load Comments from Fediverse button.

This first removes the button, and adds a “loading” message. Then loads and shows all the comments.

The render_toot() function includes the blocklist part: it checks if any of the comment URLs are in the defined block list, and if yes then this Toot is not shown.

CSS

Last but not least this also needs some styling. This happens in assets/css/comments.css:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
.mastodon-comment {
  background-color: var(--block-background-color);
  border-radius: var(--block-border-radius);
  border: 1px var(--block-border-color) solid;
  padding: 20px;
  margin-bottom: 1.5rem;
  display: flex;
  flex-direction: column;
  color: var(--font-color);
  font-size: var(--font-size);
}
.mastodon-comment p {
  margin-bottom: 0px;
}
.mastodon-comment .author {
  padding-top:0;
  display:flex;
}
.mastodon-comment .author a {
  text-decoration: none;
}
.mastodon-comment .author .avatar img {
  margin-right:1rem;
  min-width:60px;
  border-radius: 5px;
}
.mastodon-comment .author .details {
  display: flex;
  flex-direction: column;
}
.mastodon-comment .author .details .name {
  font-weight: bold;
}
.mastodon-comment .author .details .user {
  color: #5d686f;
  font-size: medium;
}
.mastodon-comment .author .date {
  margin-left: auto;
  font-size: small;
}
.mastodon-comment .content {
  margin: 15px 20px;
}
.mastodon-comment .attachments {
  margin: 0px 10px;
}
.mastodon-comment .attachments > * {
  margin: 0px 10px;
}
.mastodon-comment .content p:first-child {
  margin-top:0;
  margin-bottom:0;
}
.mastodon-comment .status > div {
  display: inline-block;
  margin-right: 15px;
}
.mastodon-comment .status a {
  color: #5d686f;
  text-decoration: none;
}
.mastodon-comment .status .replies.active a {
  color: #003eaa;
}
.mastodon-comment .status .reblogs.active a {
  color: #8c8dff;
}
.mastodon-comment .status .favourites.active a {
  color: #ca8f04;
}

Modify and style as you like.

Summary

With a few enhancements in my theme, and a couple of additional files, I have a way to add comments for blog postings. All you need is a Mastodon or Fediverse account on a server of your choice.

Feel free to copy this code, and implement in your own blog. And please leave a comment.

I exported my entire Mastodon history, and used a script to add the comment details to each post which was previously posted on Mastodon. This blog is using Tags to link similar posts to each other. The comment-option hashtag is added to all posts which have the comment option (aka: have been posted on Mastodon).

Wrote a small tool find-postings-without-comment-id.py (Source) which reminds me when I forgot to add the comments id to a blog posting.

What is Hugo

Hugo is a popular and open source static website generator. This blog is created using Hugo.

Development started around 2013, and the software is written in Go. The content of the website is written in Markdown, and then compiled into a website using the pre-defined layout. During development of new content, Hugo offers a life preview using the built-in server. It is very flexible, and offers a wide range of available themes and shortcuts.

Header Image

Image by Werner Moser from Pixabay, modifications by me.


Categories: [Hugo] [Myself] [Online] [Social-Networks] [Software]

Comments

With an account on the Fediverse or Mastodon, you can respond to this post. Known replies are displayed below:



Note: This will load data from mastodon.social.

Learn how this is implemented here.


Share: