Mastodon comments in sphinx based blog#

This blog is using the Sphinx Documentation System, with the ABlog extension. Additionally it uses the PyData Sphinx Theme for the layout and stuff. It’s pretty easy to extend, which makes implementing this solution pretty easy.

I wanted to add comments to the blog but the options readily available are either to use an essentially closed service, or self-host our comments option. We like to work with open source, but are not self-hosting at this time. I still wanted something more open than Disqus and that someone wanting to comment might already have an account with. Mastodon seems like a great choice so I searched for an existing solution there. I found some for Hugo and so later, when I found time to implement a solution for the system I’m using I ended up basing it on a blog by Tony Cheneau.

A dependency issue#

I had some issues with the released DOMPurify during development and testing of this solution. An upgrade of firefox fixed the issue. I’m not really a frontend developer; this library is against cross script attacks, which is important so I’m using the latest. If you experience issues please comment on this post. You can still follow the link to it even if no comments (or a message saying “No Comments”) show up when you press the button.

Where to put the code#

I just put all the dependencies into the _static directory in the website’s main directory. Those are:

  • comment.css: has the content from Tony’s blog

  • purify.min.js: from the DOMPurify project dist directory

  • purify.min.js.map: same place.

The final part is a template for the comment part of the post. I put this in _templates/mastodon-comments.html and it contains a modified version of what he put in layouts/partials/article/article.html.

Minor changes for Sphinx#

In Tony’s Blog he uses matadata in the document. To do the same we can add metadata fields to the document source of the post. In original ReST that’s done with:

This can be anywhere in the document. I’m using MyST so I do the same within the header for the document:

In MyST I could define it to be a mapping like so:

I could not quickly find a way to get that out within the jinja code. I don’t really know sphinx templating so I tabled that for now. It would be nicer but I couldn’t get inner0 or inner1 out of the value even though it showed up in the document as a simple dict when “printed”. So my solution uses variable namespacing:

Hugo and Sphinx use very similar syntax so there’s not a lot to change from Tony’s article.html template. First, the template test to see if the code should be included is slightly different:

{%- if meta is mapping and meta.get("comments_id") is not none %}
...
{%- endif %}

Then, the link to directly reply to the post seems to no longer work, or at least does not work with the instance we are using so I deleted that. If you want to comment you go to the post and reply. No huge difference. Would be nice to solve, but again putting it on back burner so I remove the link.

Finally, everywhere that he says {{ .something }} I instead use {{ meta.get("something") }}. So where he says {{ .host }} I say {{ meta.get("comments_host") }}. The full code is as follows:

{%- if meta is mapping and meta.get("comments_id") is not none  %}
<div class="article-content">
    <link rel="stylesheet" type="text/css" href="/_static/comments.css" />
    <h2>Comments</h2>
  <p>You can use your Mastodon account to reply to this <a class="link" href="https://{{ meta.get("comments_host") }}/@{{ meta.get("comments_user") }}/{{ meta.get("comments_id") }}">post</a>.</p>
  <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="/_static/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;");
   }

    document.getElementById("load-comment").addEventListener("click", function() {
      document.getElementById("load-comment").innerHTML = "Loading";
      fetch('https://{{ meta.get("comments_host") }}/api/v1/statuses/{{ meta.get("comments_id") }}/context', { signal: AbortSignal.timeout(5000) })
        .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.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-comment">
                     <div class="avatar">
                       <img src="${escapeHtml(reply.account.avatar_static)}" height=60 width=60 alt="">
                     </div>
                     <div class="content">
                       <div class="author">
                         <a href="${reply.account.url}" rel="nofollow">
                           <span>${reply.account.display_name}</span>
                           <span class="disabled">${escapeHtml(reply.account.acct)}</span>
                         </a>
                         <a class="date" href="${reply.uri}" rel="nofollow">
                           ${reply.created_at.substr(0, 10)}
                         </a>
                       </div>
                       <div class="mastodon-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> 
{%- endif %}

The final bit#

The final part of all this is to get sphinx to actually inject this code into the page. I do this through configuration options in the PyData Sphinx Theme that allows you to add footer elements to articles:

html_theme_options = {
    // ...,
    "article_footer_items": ["mastodon-comments"],
    // ...
}

And that’s it! Having never touched Sphinx templating it turned out to be pretty easy to bend someone else’s solution for something else into working in it.

Posting#

An automated solution for this would be nice. I’ll probably end up developing one soon because this part is kind of annoying. There’s a chicken-egg thing where you need to have a mastodon post id when you publish the blog post that’ll use it for commenting. So you either make the post empty, get the id, put it in the metadata for the blog post, and then edit your mastodon post to have the actual link to that post after you publish; or you publish without id, get the link, make the mastodon post, and then edit the blog and republish; or you guess what the blog software will generate for the link and type it into the mastodon post to make it.

Concluding#

The code for this website is available here and you are free to copy the code in it. The md content is rights reserved.

Feel free to comment. I attached this entry to the experiment post I made on Strange Crew’s social timeline on techhub.social so it’s already got a couple.

 

Comments

You can use your Mastodon account to reply to this post.