Git-Pages #1 - Mastodon comments in Hugo
In this age of the internet (AD 2026) people don’t usually like to receive comments, let alone negative ones. But this is a period in which I make anachronistic and stupid decisions, so here I am integrating the Fediverse into those pages. I’ve chosen Mastodon, simply because I already have an account, but I think these steps can be adapted to any platform that uses ActivityPub.
First of all, thanks to Andreas, Jan, Carl, John and to many others for trying this out before me and sharing their knowledge with all of us.
Making the pages go Mastodon
I’m using BLZR’s hermit-V2 theme, so there is already a comments section set up to work with Disqus.
We can override the comments.html creating a new one under layouts/_default/_partials.
You can view my guess at “htmling” here:
{{ if .Params.comments }}
{{ $comments := .Params.comments }}
<section class="comment-section">
<h3 class="comment-title">Comments</h3>
<p class="comment-intro">
You can use your Mastodon or other fediverse account to comment on this article by replying to the
<a href="https://{{ $comments.host }}/@{{ $comments.username }}/{{ $comments.id }}" target="_blank"
rel="noopener noreferrer" class="comment-link-ext">
associated post
</a>.
</p>
<button id="load-comments" class="comment-btn">Load comments from the Fediverse</button>
<div id="comments-list" class="comments-container"></div>
<noscript>
<p class="no-js-msg">JavaScript is required to display comments.</p>
</noscript>
</section>
<style>
.comment-section {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--border-color);
}
.comment-title {
font-size: 1.4rem;
margin-bottom: 0.6rem;
}
.comment-intro {
font-size: 0.9rem;
opacity: 0.8;
margin-bottom: 1rem;
}
.comment-link-ext {
font-weight: 600;
text-decoration: none;
}
.comment-link-ext:hover {
text-decoration: underline;
}
.comment-btn {
font-size: 0.9rem;
padding: 0.45rem 0.9rem;
border-radius: 6px;
border: 1px solid var(--border-color);
background: rgba(120, 120, 120, 0.35);
color: inherit;
cursor: pointer;
transition: background 0.15s ease;
font-weight: 500;
}
.comment-btn:hover:not(:disabled) {
background: rgba(120, 120, 120, 0.20);
}
.comment-btn:disabled {
opacity: 0.6;
}
.comments-container {
margin-top: 1.5rem;
}
.comment {
display: flex;
gap: 0.8rem;
margin: 1rem 0;
padding-bottom: 1rem;
}
.comment-avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
}
.comment-body {
flex: 1;
min-width: 0;
}
.comment-header {
font-size: 0.85rem;
margin-bottom: 0.3rem;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.comment-header a {
font-weight: 600;
text-decoration: none;
}
.comment-header a:hover {
text-decoration: underline;
}
.comment-header time {
opacity: 0.7;
font-size: 0.8rem;
}
.comment-content {
font-size: 0.95rem;
line-height: 1.5;
margin-top: 0.3rem;
}
.comment-actions {
display: flex;
gap: 12px;
margin-top: 6px;
font-size: 0.85rem;
}
.comment-actions a {
text-decoration: none;
opacity: 0.75;
}
.comment-actions span {
opacity: 0.75;
}
.comment-actions a:hover {
opacity: 1;
}
.comment[data-depth="1"] {
margin-left: 20px;
}
.comment[data-depth="2"] {
margin-left: 40px;
}
.comment[data-depth="3"] {
margin-left: 60px;
}
.comment[data-depth="4"] {
margin-left: 80px;
}
.comment[data-depth] {
border-left: 2px solid var(--border-color);
padding-left: 0.8rem;
}
.no-js-msg,
.no-comments,
.error-msg {
font-size: 0.9rem;
opacity: 0.8;
margin-top: 0.8rem;
}
@media (max-width:700px) {
.comment-avatar img {
width: 34px;
height: 34px;
}
}
</style>
<script src="/js/purify.min.js"></script>
<script type="text/javascript">
(function () {
'use strict';
const btn = document.getElementById('load-comments');
const list = document.getElementById('comments-list');
const host = '{{ $comments.host }}';
const id = '{{ $comments.id }}';
btn.addEventListener('click', async function () {
btn.textContent = "Loading...";
btn.disabled = true;
try {
const response = await fetch(`https://${host}/api/v1/statuses/${id}/context`, {
headers: { 'Accept': 'application/json' }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (!data.descendants || data.descendants.length === 0) {
list.innerHTML = '<p class="no-comments">No comments yet.</p>';
return;
}
const replies = data.descendants.sort(
(a, b) => new Date(a.created_at) - new Date(b.created_at)
);
const map = {};
replies.forEach(r => map[r.id] = { ...r, children: [] });
const roots = [];
replies.forEach(r => {
if (r.in_reply_to_id && map[r.in_reply_to_id]) {
map[r.in_reply_to_id].children.push(map[r.id]);
}
else {
roots.push(map[r.id]);
}
});
function actionIcons(reply) {
return `
<a href="${reply.url}" target="_blank">
<span title="Replies">💬 ${reply.replies_count}</span>
</a>
<span title="Boosts">🔁 ${reply.reblogs_count}</span>
<span title="Favorites">⭐ ${reply.favourites_count}</span>
`;
}
function renderComment(reply, depth = 0) {
const name = reply.account.display_name || reply.account.username;
const avatar = reply.account.avatar_static || reply.account.avatar;
const safeContent = DOMPurify.sanitize(reply.content);
let html = `
<article class="comment" data-depth="${depth}">
<div class="comment-avatar">
<img src="${avatar}" alt="${name}" loading="lazy">
</div>
<div class="comment-body">
<header class="comment-header">
<a href="${reply.account.url}" target="_blank" rel="noopener noreferrer">
${name}
</a>
<time datetime="${reply.created_at}">
${new Date(reply.created_at).toLocaleString()}
</time>
</header>
<div class="comment-content">
${safeContent}
</div>
<div class="comment-actions">
${actionIcons(reply)}
</div>
</div>
</article>
`;
reply.children.forEach(child => {
html += renderComment(child, depth + 1);
});
return html;
}
list.innerHTML = roots.map(r => renderComment(r)).join('');
}
catch (error) {
console.error("Mastodon API error:", error);
list.innerHTML =
'<p class="error-msg">Unable to load comments.</p>';
}
finally {
btn.textContent = "Reload comments";
btn.disabled = false;
}
});
})();
</script>
{{ end }}
Just as something in this wall of code may have suggested I’m not a web developer so it’s a mash of things all in one file.
The other thing you need to do is download and create the purify.min.js1 file in the static/js/ folder. Be careful with the static folder in the one in the bloody root of the project. Don’t do what I did and put it in the assets folder for only god knows why.
Now, in the toml at the top of your post, add:
[comments]
host = 'yourinstance.org'
username = 'yourusername'
id = '1234567890'
The parameters are self explanatory but per sicurezza2:
- host: the hostname of your Mastodon instance
- username: your username in that instance
- id: the ID of your mastodon toot
What? My post ID? Yes, as my professor always said, ‘computer science is not an exact science’. To view the comments associated with a post, you need the toot ID in your Mastodon account first. The idea is that this ID is the one of the toot you will create to share the post on the fediverse.
You can find the ID opening the detail of your toot:
https://mastodon.social/@username/116205479816064809
The customization will retrieve the toot and associated responses via the API and display them.
It is up to you to decide which flow to implement. You can publish, share with a toot and then edit the post with the ID, or create the toot first (you can easily guess the URL of the post) and then publish the post.
If you have any suggestions on how to improve the script, please leave a comment. That way, we can see if it actually works.
And that’s all for now. Ciao!
Comments
You can use your Mastodon or other fediverse account to comment on this article by replying to the associated post .