A Mastodon/Fediverse comment section for your Zola blog

27.01.2024

This blogpost is about how to add a Mastodon/Fediverse comment section to your Zola blog with just a few simple steps.

Carl Schwan wrote a very nice piece of JavaScript Didn't think I'd ever say something as positive as this about JavaScript. So far my contact points with it have been less than pleasant and I try to avoid it wherever possible. and posted it for everyone to use, re-use, modify, et cetera:

https://carlschwan.eu/2020/12/29/adding-comments-to-your-static-blog-with-mastodon/

This script has been improved multiple times now by different people which is awesome in and of itself. So a big thank you goes not only to Carl Schwan for kicking this thing off but especially also to Veronica Olsen and Daniel Pecos who both took the time and wrote down their experiences with this system.

I love this kind of stuff – someone comes up with a working prototype, others like the ideas or the purpose behind it and also feel inclined to work on it. Funny how nice the world can be sometimes. In the end I did the same and adapted it to my situation.

How-to prepare your blog

Here's what you need to do to be able to implement this functionality on your Zola-powered blog as well.

New comments shortcode

Just add a comments.html shortcode to your Zola blog project. Create a file with this name inside the templates/shortcodes folder. You may need to create these folders first. Copy the following content and paste it into the file:

Note that I've introduced a few simple JavaScript variables to store our Zola variables and use them more conveniently inside the comments.js file. This has no real reason besides readability and personal style preference. It's also a lot easier for you to adapt it to your needs, should you for example want to change your config variable names.
<section id="comments">
  <h2>Comments</h2>
  <p><a href="https://{{ config.extra.mastodon.host }}/@{{ config.extra.mastodon.username }}/{{ id }}">Respond to this post</a> with an account on the Fediverse (like Mastodon).</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://{{ config.extra.mastodon.host }}/@{{ config.extra.mastodon.username }}/{{ id }}">the original post</a> on Mastodon.</p></noscript>
  </div>
  <noscript>You need JavaScript to view the comments.</noscript>
  <script src="/comments/purify.min.js"></script>
  <script type="text/javascript">
    let host = "{{ config.extra.mastodon.host }}";
    let username = "{{ config.extra.mastodon.username }}";
    let id = "{{ id }}";
  </script>
  <script src="/comments/comments.js"></script>
</section>

JavaScript for comments loading

Copy the code below and paste it into a new comments.js file in the static/comments directory.

I've adapted the JavaScript by Carl Schwan a bit to my liking. So much for avoiding JavaScript as much as possible... Somehow the structure of the resulting HTML elements and CSS classes was hard for me to understand and I've decided to rework them. The resulting structure can be found further down in the paragraphs on CSS styling, so I won't explain it here in detail. After a few hours of back and forth between JS and CSS, here's what I came up with:

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 = `@${ status.account.username }@${ 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 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-UK', {
            dateStyle: "long",
            timeStyle: "short",
            });

            let timestamp = document.createElement("time");
            timestamp.setAttribute("datetime", status.created_at);
            timestamp.appendChild(permalink);

            let faves = document.createElement("a");
            faves.className = "faves";
            faves.setAttribute("href", `${ status.url }/favourites`);
            faves.setAttribute("title", `Favorites from ${ instance }`);
            if(status.favourites_count == 1) {
                faves.textContent = `${ status.favourites_count} Like`;
            } else {
                faves.textContent = `${ status.favourites_count} Likes`;
            };

            let headerInfo = document.createElement("div");
            headerInfo.className = "header-info";
            headerInfo.appendChild(display);
            headerInfo.appendChild(instanceBadge);
            headerInfo.appendChild(timestamp);
            headerInfo.appendChild(faves);

            let header = document.createElement("header");
            header.className = "header";
            header.appendChild(avatar);
            header.appendChild(headerInfo);

            let main = document.createElement("main");
            main.setAttribute("itemprop", "text");
            main.innerHTML = status.content;

            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(header);
            comment.appendChild(main);

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

Hygiene treatment

Next, you want to use DOMPurify to sanitise the results. Be sure to grab an up to date version. You can always find the most recent release here. Also don't forget to update your DOMPurify file from time to time. Go ahead and download it and put it in your static/comments/ folder.

If you're unsure how to proceed here: Just download the purify.min.js and purify.min.js.map files from inside the dist folder in the linked git repository and put them in the above-mentioned folder.

Configure all the things

Finally, you need to add some info to your config.toml, namely:

[extra.mastodon]
host = "ruhr.social"
username = "laubblaeser"

Replace the values of host and username with your appropriate information.

Styles ohne Ende

Of course our comment section won't look nice now. Here's my SCSS extension file for good measure. Feel free to take it up from where it is and change it however you like.

I'm no web developer by any means (besides this personal project you're currently visiting) and there's probably some weird and/or redundant stuff in there. Oh, and it's of course also influenced by how my blog's CSS is structured in general. It works for now, so I'm happy after all.

section#comments {
    #comments-wrapper {
        display: flex;
        flex-direction: column;
    }

    article.comment {
        padding: 0;
        margin-bottom: 1rem;
        display: flex;
        flex-direction: column;
        flex-grow: 2;

        p:first-child {
            margin-top: 0;
        }

        p:last-child {
            margin-bottom: 0;
        }

        .header { // all meta information of the comment
            border-radius: 10px 10px 0 0;
            border-width: 2px;
            border-style: solid;
            border-color: $c-fg;
            padding: 0.5rem;
            display: flex;
            flex-direction: row;
            gap: 0.75rem;

            .avatar-link { // the link to the author's profile
                display: flex;
                align-items: center;
                picture { // author's avatar
                    display: flex;
                    img.avatar { // actual author's avatar picture
                        width: 5rem;
                        min-width: 5rem;
                        max-width: 5rem;
                        border: 1px solid $c-fg;
                        border-radius: 50%;
                    }
                }
            }

            .header-info { // displayInstance, timestamp, and faves
                display: flex;
                flex-direction: column;

                .display { // author's displayed name, not username
                    font-weight: bold;
                    font-size: 1.4rem;
                }

                .instance { // author's instance
                }

                time { // timestamp
                }

                .faves { // fav counter
                }
            }
        }

        main { // actual comment content
            border-radius: 0 0 10px 10px;
            border-width: 1px;
            border-style: solid;
            border-color: $c-fg;
            padding: 0.5rem;
        }
    }
}

How-to use it in posts

In your blog posts you can now call the shortcode at some point where you like your comment section to appear. Normally that's the end of the post.

{{ comments(id="123456789") }}

Replace the value of id with your Mastodon post id.

But where to get the id from if you haven't published anything yet? Well, Daniel Pecos has written on this chicken-or-egg problem and ended up with the following workflow:

  1. Publish the post without Fediverse discussion enabled (no toot ID)
  2. Publish the toot containing the URL to the post and grab its ID
  3. Change the post to include the toot ID
  4. Republish the website

This workflow makes sense to me: You want to make your new post public in the fediverse and want people to read it. Whoever comes from there will likely comment there anyways. And those people who stumble upon your website by other means will have to wait a minute or so until the comment section is fully working. That's absolutely fine and a straightforward approach to deal with this.

And that's it, really. Feel free to leave a comment if this post was helpful to you. ;)

Comments

Respond to this post with an account on the Fediverse (like Mastodon).