Integrating 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:
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 aMASTODON_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 betrue
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:
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.
Comments
Comments powered by Mastodon. Reply to this status to comment.
(click to copy)