Skip to content

Set up a Mastodon-based comment system

Recently did I ask in the Material for MkDocs repository1 about how I could implement a Mastodon-based comment system (#5728).
After looking a bit myself did I manage to setup a working version using some blog post on how to do it.

The post giving the code snippet and explaining it was made for Hugo, so some adjustments were required to make it work.
And since there is no post addressing this for MkDocs and the Material for MkDocs theme am I making this post now.

I already posted a Paste2 with some basic instructions, but I feel like a more in-depth explanation was useful to have, so here we are.

Enjoy!

Prerequisites

To make this comment system work you need...

  • A Mastodon account
  • The Material for MkDocs theme
  • Knowledge in how to extend the theme (Override files)

Limitations and issues

Compared to Giscuss does this setup have one main drawback: It requires a Mastodon Post to already exist.

Since the system relies on a Post ID will you either need to create this blog post, publish it, make a Mastodon post and then edit your blog post, or make a Mastodon post and then create a blog post with the ID.

Other Notes

During this post we will assume the following setups:

  • The custom_dir setting of theme in your mkdocs.yml is set to theme
  • The Mastodon instance is example.com and the user is Example

Adjust those things to your personal need.

Step 1: Creating the files

If you haven't already, you need to extend the Material for MkDocs theme3 and create a new file in the theme extension folder.
The file we need is comments.html which is located in partials/ so the full path - relative to the mkdocs.yml file - would be theme/partials/comments.html

In addition should you make a CSS file in the assets directory (i.e. docs/assets/stylesheets/comments.css) and add it as an entry to the extra_css setting of your mkdocs.yml to get it loaded as CSS for your pages.

Now open the files in a text editor of your choice...

Step 2: Adding content

You need to add some content to the comments.html file and the comments.css file.

comments.html
comments.html
{% if page.meta.comments %}
  <h2 id="__comments">{{ lang.t("meta.comments") }}</h2>
  {% if page.meta.comment_id %}
    <noscript>
      <div class="admonition danger">
        <p class="admonition-title">
          Please enable Javascript to see comments from Mastodon.
        </p>
      </div>
    </noscript>

    <div class="admonition quote">
      <p>Comment system powered by <a href="https://joinmastodon.org/" target="_blank" rel="nofollow noreferrer noopener"><span class="twemoji">{% include ".icons/simple/mastodon.svg" %}</span> Mastodon</a>.<br>
      <a href="https://{{ config.extra.mastodon.host }}/@{{ config.extra.mastodon.user }}/{{ page.meta.comment_id }}" target="_blank" rel="nofollow noreferrer noopener">Leave a comment</a> using Mastodon or another Fediverse-compatible account.</p>
    </div>

    <p id="mastodon-comments-list"></p>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.8/purify.min.js" integrity="sha512-5g2Nj3mqLOgClHi20oat1COW7jWvf7SyqnvwWUsMDwhjHeqeTl0C+uzjucLweruQxHbhDwiPLXlm8HBO0011pA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script type="text/javascript">
      var host = '{{ config.extra.mastodon.host }}';
      var user = '{{ config.extra.mastodon.user }}';
      var id = '{{ page.meta.comment_id }}'

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

      var commentsLoaded = false;

      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.url)}" alt="Emoji ${emoji.shortcode}" title=":${emoji.shortcode}:height="20"       width="20" />`);
        });
        toot.emojis.forEach(emoji => {
          toot.content = toot.content.replace(`:${emoji.shortcode}:`, `<img src="${escapeHtml(emoji.url)}" alt="Emoji ${emoji.shortcode}" title=":${emoji.shortcode}:" height="20" width="20" />`);
        });
        status_date = `${toot.created_at.substr(0, 10)} ${toot.created_at.substr(11, 8)}`
        if (toot.edited_at) {
          status_date = `${status_date}<abbr title="Edited ${toot.edited_at.substr(0, 10)} ${toot.edited_at.substr(11, 8)}">*</abbr>`
        }
        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}" target="_blank" rel="nofollow">${status_date}</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}" target="_blank" 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}" target="_blank" rel="nofollow">${attachment.type}</a>`;
                }
              }).join('')}
            </div>
            <div class="status">
              <div class="replies ${toot_active(toot, 'replies')}">
                <a href="${toot.url}" rel="nofollow">
                  <span class="twemoji">{% include ".icons/fontawesome/solid/reply.svg" %}</span>
                  ${toot_count(toot, 'replies')}
                </a>
              </div>
              <div class="reblogs ${toot_active(toot, 'reblogs')}">
                <a href="${toot.url}" rel="nofollow">
                  <span class="twemoji">{% include ".icons/fontawesome/solid/retweet.svg" %}</span>
                  ${toot_count(toot, 'reblogs')}
                </a>
              </div>
              <div class="favourites ${toot_active(toot, 'favourites')}">
                <a href="${toot.url}" rel="nofollow">
                  <span class="twemoji">{% include ".icons/fontawesome/solid/star.svg" %}</span>
                  ${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...";

        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 = 
              `<div class="admonition info">
                <p class="admonition-title">
                  No comments found. <a href="https://{{ config.extra.mastodon.host }}/@{{ config.extra.mastodon.user }}/{{ page.meta.comment_id }}">Be the first!</a>
                </p>
              </div>
              `;
            }

            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);
      }

      var comments = document.getElementById("mastodon-comments-list");
      respondToVisibility(comments, loadComments);
    </script>
  {% else %}
    <div class="admonition warning">
      <p class="admonition-title">
        No Mastodon post configured for this page. Contact {{ config.site_author | default('the post author', true) }} if you want to comment here.
      </p>
    </div>
  {% endif %}
{% endif %}
comments.css
comments.css
:root{
  --mastodon-comment-indent: 40px;
  --mastodon-comment-border-radius: .2rem;

  --mastodon-comment-bg-color: #313543;
  --mastodon-comment-border-color: #393f4f;

  --mastodon-comment-username-color: #fff;
  --mastodon-comment-usertag-color: #d9e1e8;

  --mastodon-comment-date-color: #606984;
  --mastodon-comment-link-color: #8c8dff;
  --mastodon-comment-hashtag-color: #d9e1e8;

  --mastodon-comment-status-inactive: #606984;
  --mastodon-comment-status-inactive__hover: #707b97;
  --mastodon-comment-status-inactive-bg__hover: rgba(96,105,132,.15);

  --mastodon-comment-status-replies--active: #448aff;
  --mastodon-comment-status-favourite--active: #ca8f04;
  --mastodon-comment-status-reblog--active: #8c8dff;
}

:root [data-md-color-scheme="default"] {
  --mastodon-comment-bg-color: #fff;
  --mastodon-comment-border-color: #c0cdd9;

  --mastodon-comment-username-color: #000;
  --mastodon-comment-usertag-color: #282c37;

  --mastodon-comment-date-color: #444b5d;
  --mastodon-comment-link-color: #3a3bff;
  --mastodon-comment-hashtag-color: #3a3bff;

  --mastodon-comment-status-inactive__hover: #51596f;
}

@media only screen and (max-width: 1024px){
  :root{
    --mastodon-comment-indent: 20px;
  }
}

@media only screen and (max-width: 640px){
  :root{
    --mastodon-comment-indent: 0px;
  }
}

.mastodon-comment{
  background-color: var(--mastodon-comment-bg-color);
  border-radius: var(--mastodon-comment-border-radius);
  border: 1px var(--mastodon-comment-border-color) solid;
  padding: 20px;
  margin-bottom: 1.5rem;
  display: flex;
  flex-direction: column;
}

.mastodon-comment p{
  margin-bottom: 0px;
}

.mastodon-comment .content{
  margin: 15px 20px;
}

.mastodon-comment .content p:first-child{
  margin-top: 0;
  margin-bottom: 0;
}

.mastodon-comment .content a {
  color: var(--mastodon-comment-link-color);
}

.mastodon-comment .attachments{
  max-width: 0px 10px;
}

.mastodon-comment .attachments > *{
  max-width: 0px 10px;
}

.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;
  color: var(--mastodon-comment-username-color);
}

a.name:hover {
  text-decoration: underline;
}

a.mention.hashtag {
  color: var(--mastodon-comment-hashtag-color);
}

.mastodon-comment .author .details .user{
  color: var(--mastodon-comment-usertag-color);
}

.mastodon-comment .author .date{
  margin-left: auto;
  font-size: small;
  color: var(--mastodon-comment-date-color);
}

.mastodon-comment .status > div{
  display: inline-block;
  margin-right: 15px;
}

.mastodon-comment .status {
  margin-left: 15px;
}

.mastodon-comment .status a{
  color: var(--mastodon-comment-status-inactive);
  text-decoration: none;

  padding: .2rem;
  border-radius: .2rem;
}

.mastodon-comment .status a:hover {
  color: var(--mastodon-comment-status-inactive__hover);
  background-color: var(--mastodon-comment-status-inactive-bg__hover);
  transition: all .2s ease-out;
}

.mastodon-comment .status .replies.active *{
  color: var(--mastodon-comment-status-replies--active);
}

.mastodon-comment .status .reblogs.active a{
  color: var(--mastodon-comment-status-reblog--active);
}

.mastodon-comment .status .favourites.active a{
  color: var(--mastodon-comment-status-favourite--active);
}

.mastodon-comment .status svg{
  margin: 0 0.2rem;
  vertical-align: middle;
}

Final steps

What is left to do is add the necessary data to the mkdocs.yml file and the pages you want comments for.

In the mkdocs.yml you need to add the following:

extra:
  mastodon:
    user: example     # Replace with your account name
    host: example.com # Replace with the instance you use

Why not include this in the comments.html?

I've chosen this setup as it would allow a relatively easy adjustment for the case of you switching instances.
Although, old comments would no longer work for that matter.

On the pages you want to have comments enabled, you need to add these frontmatter options:

  • comments: true to enable the comments feature. Only needed if you're not enabling it by default (i.e. by not including the if check in the partials file or by using the meta plugin to enable it for a folder)
  • comment_id: <id> with <id> being an id to a valid post on the Mastodon instance and from the user you defined in your mkdocs.yml file.

This should be everything and once you publish your pages (or check them in the live preview using mkdocs serve) should the comment system load your posts or otherwise show admonition boxes with further info.


Changelog

Date is in the format dd.mm.yyyy.

05.09.2023

  • Made comments look more like Mastodon comments in terms of style.
  • Updated CSS to include a white-theme variant to use with Material for MkDocs default theme.

11.09.2023

  • Added rendering of custom Emojis within a Comment's content itself.
  • Replaced the "Comment on this blog post" message with a "Note" details block to have more info without unnecessary screen height increase.

06.01.2024

  • Replaced details about commenting with a simple quote admonition box.
  • Added target="_blank" and some rel attributes to some links for the comments (i.e. attachment urls).
  • Added Indicator for when a post was edited, similar to how Mastodon displays it (Asterisk with Abbreviation).
  • Updated Dompurify version shown.

Footnotes


Last update: 27. February 2024 ()

Comments

Comment system powered by Mastodon.
Leave a comment using Mastodon or another Fediverse-compatible account.