Integrating Mastodon and Ghost

Integrating Mastodon and Ghost
An image featuring the logos for Mastodon and Ghost

I'm a big fan of the Mastodon ecosystem and wanted to find a way to bring it into my blog. I'm showing a list of recent toots on the homepage and using Mastodon to power the comments on each post. Let's talk about it.

All the code for my theme is at https://git.sd.ai/simon/ghost-theme-sd.ai.

Mastodon support is entirely in the front end. I've chosen not to add a JavaScript framework like Vue or React, so all of this is done in vanilla JS (with a couple of imports). We are scraping only public data from Mastodon, so we don't need to use any API credentials.

How it works

We use the public Mastodon API to download a list of toots and render them into the DOM when it loads. For this, you need to know the hostname of your instance and your Mastodon account ID.

On the homepage, the code queries for the public toots associated with your configured Mastodon account and renders them into the sidebar. I'm using the "publication info" sidebar provided by the Source theme. This needs to be enabled in the theme settings. Helpfully, it disappears on smaller displays, so we don't clog up the mobile experience.

On post pages, we specify a specific toot ID for each post. We use this ID to load the replies from the API and render them underneath the post. We provide a link to the original toot, and replying to that adds a comment. The resulting UI looks like this:

A screenshot of the comments section. They include a link to the toot to reply to, a clickable link to copy the URL to the clipboard, and the top part of the first reply.

Implementation

Most of the exciting stuff is in mastodon.js, so we'll break this down shortly. Styling is provided in mastodon.css, and we have some other small modifications to post-list.hbs and post.hbs to provide a place to put the comments once they are rendered.

Breaking down mastodon.js:

We start by hardcoding the account ID and instance host we're interested in. See the article linked above on how to get hold of your account ID. (Note: These could also be added as custom settings to the config section of package.json)

const MASTODON_ACCOUNT_ID = '109285376472065471'
const MASTODON_HOST = 'social.sd.ai'

The main activity is triggered by adding an event listener to DOMContentLoaded. We know that we'll have any necessary script loaded and that it's safe to manipulate the DOM. This will fire when any page is loaded.

We start by setting up some variables:

// when the page has finished loading, send a request for the toots
document.addEventListener("DOMContentLoaded", async (event) => {
    let url, isComments
    // if we're being crawled, don't render comments - may help against spam
    const isBot = /bot|google|baidu|bing|msn|teoma|slurp|yandex/i
        .test(navigator.userAgent)

    // if there is a sidebar, we're expecting to load the toots from the main account
    if (document.getElementsByClassName('gh-sidebar').length > 0) {
        url = `https://${MASTODON_HOST}/api/v1/accounts/${MASTODON_ACCOUNT_ID}/statuses?exclude_replies=true&exclude_reblogs=true`
    }
    // if there's a post ID and we're not a bot, we're expecting to load the replies from a specific toot
    if (MASTODON_POST_ID && !isBot) {
        url = `https://${MASTODON_HOST}/api/v1/statuses/${MASTODON_POST_ID}/context`
        isComments = true
    }

// ...
  • isBot is a guess at whether a search engine is crawling us. In this case, we don't want to load the comments because someone may be trying to inject spam.
  • url is the API endpoint that we will query for the list of toots. If there is a MASTODON_POST_ID set (using the "code injection" mechanism on the post in Ghost), we get ready to load comments for it. Otherwise, if a sidebar is present, we'll load the top public toots into that.
  • isComments will be true if we're using Mastodon for comments. This lets us tweak the UI when rendering these vs. on the sidebar.

Each page should have an element with an id of mastodon-comments-list if it plans to display toots somewhere. We check for this element and then populate it from the previously-defined url:

    // find the element to append the content to - if there isn't one, we don't need to query
    const element = document.getElementById('mastodon-comments-list')
    if (url && element) {
        // populate the link to the source toot, if necessary (for replies)
        const linkElement = document.getElementById('toot-link-top')
        const clipElement = document.getElementById('toot-link-clip')
        const tootUrl = `https://${MASTODON_HOST}/@s/${MASTODON_POST_ID}`
        if (linkElement) {
            linkElement.href = tootUrl
        }
        if (clipElement) {
            clipElement.innerText = tootUrl
        }
        // fetch the data from Mastodon
        const response = await fetch(url)
        let content = await response.json()
        if (isComments) {
            content = content.descendants
        }
        // render the content into the page
        const header = document.getElementById('mastodon-comments-header')
        if (header) {
            header.style.display = ''
        }
        return renderMastodonContent(content, element, isComments)
    }

Here, linkElement and clipElement should link to the source toot if it's present. This is just for comments and renders the link per the screenshot above.

When fetching the response, the endpoints give us slightly different things depending on context. We get an array of toot objects for the list of top-level toots for my account. When handling replies, however, we get a single toot and are instead interested in its descendants. This will be an array of toot objects in the same format as the array of top-level toots.

Finally, we render the mastodon content into the mastodon-comments-list element:

// renders the content - provides an array of toots, the element to add the content to and whether to show a copyable link for replies
function renderMastodonContent(toots, parentElement, showLink) {
    // clear the parent element so that we can add the new content
    parentElement.innerHTML = ''
    // add a simple "no comments" message if there are no toots
    if (!Array.isArray(toots) || toots.length === 0) {
        document.getElementById('mastodon-comments-list').innerHTML = "<div class='mastodon-comment'>No comments (yet)!</div>"
        return
    }

In this first part, we clear the content of the element. If the array is empty, we insert some placeholder text in anticipation of the first reply.

        // sanitise the toot content for display, including correctly rendering custom emojis
        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}" class="mastodon-emoji" />`);
        })
        toot.emojis.forEach(emoji => {
            toot.content = toot.content.replace(`:${emoji.shortcode}:`,
                `<img src="${escapeHtml(emoji.static_url)}" alt="Emoji ${emoji.shortcode}" class="mastodon-emoji" />`);
        })

This section ensures the name and body are HTML-safe and inserts any custom emojis into the text instead of their shortcodes.

        // create a block of HTML content including the toot data
        const comment =
            `<div class="mastodon-comment">
               <div class="mastodon-avatar">
                 <img src="${escapeHtml(toot.account.avatar_static)}" height=60 width=60 alt="${escapeHtml(toot.account.display_name)}'s avatar">
               </div>
               <div class="mastodon-body">
                 <div class="mastodon-meta">
                   <div class="mastodon-author">
                     <div class="mastodon-author-link">
                       <a href="${toot.account.url}" target="_blank" rel="nofollow">
                         <span>${toot.account.display_name}</span>
                       </a>
                       <br/>
                       <span class="mastodon-author-uid">(@${escapeHtml(toot.account.acct === 's' ? '[email protected]' : toot.account.acct)})</span>
                     </div>
                   </div>
                   <div class="toot-link">
                     <a class="date" href="${toot.uri}" rel="nofollow" target="_blank">
                      ${toot.created_at.substring(0, 10)}
                    </a>
                    <br/>
                  </div>
                 </div>
                 <div class="mastodon-comment-content">
                   ${toot.content}
                   <span class="tootlink" ${showLink ? '' : 'style="display: none;"'}>${toot.uri}</span>
                 </div>
              </div>
            </div>`

Here, we're building the DOM content from an HTML template. There isn't any framework in place so we're using good old-fashioned string interpolation to provide the content.

        // Use DOMPurify to create a sanitised element for the toot
        const child = DOMPurify.sanitize(comment, {'RETURN_DOM_FRAGMENT': true});

        // make all toot links clickable
        const links = child.querySelectorAll('.tootlink');
        for (const link of links) {
            link.onclick = function() { return copyElementTextToClipboard(this); }
        }

        // insert the toot into the DOM
        parentElement.appendChild(child);

Finally, we're creating a DOM node from the content and appending it to the element before moving on to the next toot.

Adding to the theme

For the main page, we modify the sidebar section in post-list.hbs:

        {{#if showSidebar}}
            <aside class="gh-sidebar">
                <h3 class="gh-card-title is-title"><a href="https://social.sd.ai/@s" target="_blank" rel="noopener">Latest From Mastodon</a></h3>
                <div id="mastodon-comments-list"></div>
            </aside>
        {{/if}}

For the main post.hbs template, we add a section with more instructions for how to reply as well as having the element ready to load the comments into:

    <section class="gh-comments gh-canvas">

        <div id="mastodon-comments-header" style="display: none;">

            <div class="mastodon-comments-top">
                <hr/>
            </div>

            <h2>Comments</h2>
            <p>Comments powered by Mastodon. Reply to <a id="toot-link-top">this status</a> to comment.</p>
            <div class="tootlink-tip">
                <b>Tip:</b> paste this URL into the search bar of your Mastodon client:<br/>
                <span id="toot-link-clip" class="tootlink" onclick="copyElementTextToClipboard(this)"></span> (click to copy)
            </div>
        </div>

        <noscript><p>You need JavaScript to view the comments.</p></noscript>
        <div id="mastodon-comments-list"></div>
    </section>

Posting

Finally, when it's ready to publish your post, you must also post a toot from your account. You then copy the ID into the "code injection" field like this:

Screenshot of the 'code injection' field with a <script> tag that sets MASTODON_POST_ID to a numeric string.

Next Steps

There are a couple of obvious improvements that can be made:

  • Pagination: We don't currently have a way to break down the comments into pages. I'm not used to getting hundreds of replies, so this isn't a huge concern. Perhaps this will become necessary in the future.
  • Threading: Replies to the top-level comment appear in the order they were posted, with no concept of replies in threads. We could use the in_reply_to field in the response data to determine where replies should be threaded and visually represent this.
  • Media: Currently, we're not displaying any image attachments or media previews from the source toot. It would be nice to display these or at least indicate they are present.

A big improvement from the Mastodon side would be having a MIME type representing a toot. The reply mechanism here is cumbersome because it involves navigating to your 'home' instance and making the reply from there. Having a way to link to a toot in the same way that you can link to an email address with a mailto: link would provide a more ergonomic mechanism for getting to one's own instance to send the reply.