I’ve been a big fan of Hugo for a long time because it’s minimalist, static, and super easy to host (I use Netlify).
However, like all static websites, it lacks one thing I miss: comments.
BlueSky 🔗
I started using BlueSky a few weeks ago. It’s a Twitter-like platform, but open-source, with a fascinating protocol that promotes openness and decentralization.
Last week, @emilyliu.me posted something that caught my attention: you can use BlueSky as a comment engine for your blog!
I bookmarked her gist and decided to implement it on my Hugo site.
OpenSource magic✨ 🔗
A few days after Emily’s post, @CoryZue took it one step further and released an npm package for BlueSky comments, based on React.
Integrating BlueSky comments into your site is as easy as running:
npm i bluesky-comments
While it’s a great solution, it requires React. I didn’t want to install React just for this feature, so I kept looking.
That’s when I came across @Basil.updated version of Emily’s code, tailored specifically for Hugo.
Let’s dive into how to integrate it step by step.
Adding Comments to Hugo 🔗
Basil Gist is straightforward, but for those unfamiliar with Hugo, here’s a detailed guide.
The template 🔗
If your theme has a comment.html
file, duplicate it to layouts/partials/comment.html
to override the theme’s default template.
If not, no worries you can use directly the layouts/_default/single.html
and add the following code at the end:
I personally prefer to use a smaller template, like comments.html
because it’s easier to maintain and cleaner (don’t forget to edit your config.yaml
to switch your comments to true if needed!)
The code is pretty simple:
<div id="comments-section" data-bsky-uri="{{ .Params.bsky }}"></div>
{{ $comments := resources.Get "js/comments.js" }}
<script src="{{ $comments.RelPermalink }}"></script>
This code retrieves the bsky parameter (a BlueSky URL) and links it to a JavaScript file that handles fetching and rendering comments.
Link the Bsky URL 🔗
Super easy to setup, just add a new params in your post info, like this:
---
title: "xxx"
date: 2024-12-06T21:12:15+01:00
description: 'xxx'
image: images/xxx.png
draft: false
bsky: "https://bsky.app/profile/basyliq.fyi/post/3lbstuztjqk2a"
---
The javascript 🔗
Create a comments.js
file in the assets/js
directory of your Hugo site and paste the JavaScript provided in the original post.
This script fetches and renders the comments from BlueSky based on the bsky URL.
document.addEventListener("DOMContentLoaded", () => {
const commentsSection = document.getElementById("comments-section");
const bskyWebUrl = commentsSection?.getAttribute("data-bsky-uri");
if (!bskyWebUrl) return;
(async () => {
try {
const atUri = await extractAtUri(bskyWebUrl);
console.log("Extracted AT URI:", atUri);
const thread = await getPostThread(atUri);
if (thread && thread.$type === "app.bsky.feed.defs#threadViewPost") {
renderComments(thread, commentsSection);
} else {
commentsSection.textContent = "Error fetching comments.";
}
} catch (error) {
console.error("Error loading comments:", error);
commentsSection.textContent = "Error loading comments.";
}
})();
});
async function extractAtUri(webUrl) {
try {
const url = new URL(webUrl);
const pathSegments = url.pathname.split("/").filter(Boolean);
if (
pathSegments.length < 4 ||
pathSegments[0] !== "profile" ||
pathSegments[2] !== "post"
) {
throw new Error("Invalid URL format");
}
const handleOrDid = pathSegments[1];
const postID = pathSegments[3];
let did = handleOrDid;
if (!did.startsWith("did:")) {
const resolveHandleURL = `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(
handleOrDid
)}`;
const res = await fetch(resolveHandleURL);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Failed to resolve handle to DID: ${errorText}`);
}
const data = await res.json();
if (!data.did) {
throw new Error("DID not found in response");
}
did = data.did;
}
return `at://${did}/app.bsky.feed.post/${postID}`;
} catch (error) {
console.error("Error extracting AT URI:", error);
throw error;
}
}
async function getPostThread(atUri) {
console.log("getPostThread called with atUri:", atUri);
const params = new URLSearchParams({ uri: atUri });
const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?${params.toString()}`;
console.log("API URL:", apiUrl);
const res = await fetch(apiUrl, {
method: "GET",
headers: {
Accept: "application/json",
},
cache: "no-store",
});
if (!res.ok) {
const errorText = await res.text();
console.error("API Error:", errorText);
throw new Error(`Failed to fetch post thread: ${errorText}`);
}
const data = await res.json();
if (
!data.thread ||
data.thread.$type !== "app.bsky.feed.defs#threadViewPost"
) {
throw new Error("Could not find thread");
}
return data.thread;
}
function renderComments(thread, container) {
container.innerHTML = "";
const postUrl = `https://bsky.app/profile/${
thread.post.author.did
}/post/${thread.post.uri.split("/").pop()}`;
const metaDiv = document.createElement("div");
const link = document.createElement("a");
link.href = postUrl;
link.target = "_blank";
link.textContent = `${thread.post.likeCount ?? 0} likes | ${
thread.post.repostCount ?? 0
} reposts | ${thread.post.replyCount ?? 0} replies`;
metaDiv.appendChild(link);
container.appendChild(metaDiv);
const commentsHeader = document.createElement("h2");
commentsHeader.textContent = "Comments";
container.appendChild(commentsHeader);
const replyText = document.createElement("p");
replyText.textContent = "Reply on Bluesky ";
const replyLink = document.createElement("a");
replyLink.href = postUrl;
replyLink.target = "_blank";
replyLink.textContent = "here";
replyText.appendChild(replyLink);
container.appendChild(replyText);
const divider = document.createElement("hr");
container.appendChild(divider);
if (thread.replies && thread.replies.length > 0) {
const commentsContainer = document.createElement("div");
commentsContainer.id = "comments-container";
const sortedReplies = thread.replies.sort(sortByLikes);
for (const reply of sortedReplies) {
if (isThreadViewPost(reply)) {
commentsContainer.appendChild(renderComment(reply));
}
}
container.appendChild(commentsContainer);
} else {
const noComments = document.createElement("p");
noComments.textContent = "No comments available.";
container.appendChild(noComments);
}
}
function renderComment(comment) {
const { post } = comment;
const { author } = post;
const commentDiv = document.createElement("div");
commentDiv.className = "comment";
const authorDiv = document.createElement("div");
authorDiv.className = "author";
if (author.avatar) {
const avatarImg = document.createElement("img");
avatarImg.src = author.avatar;
avatarImg.alt = "avatar";
avatarImg.className = "avatar";
authorDiv.appendChild(avatarImg);
}
const authorLink = document.createElement("a");
authorLink.href = `https://bsky.app/profile/${author.did}`;
authorLink.target = "_blank";
authorLink.textContent = author.displayName ?? author.handle;
authorDiv.appendChild(authorLink);
const handleSpan = document.createElement("span");
handleSpan.textContent = `@${author.handle}`;
authorDiv.appendChild(handleSpan);
commentDiv.appendChild(authorDiv);
const contentP = document.createElement("p");
contentP.textContent = post.record.text;
commentDiv.appendChild(contentP);
const actionsDiv = document.createElement("div");
actionsDiv.className = "actions";
actionsDiv.textContent = `${post.replyCount ?? 0} replies | ${
post.repostCount ?? 0
} reposts | ${post.likeCount ?? 0} likes`;
commentDiv.appendChild(actionsDiv);
if (comment.replies && comment.replies.length > 0) {
const nestedRepliesDiv = document.createElement("div");
nestedRepliesDiv.className = "nested-replies";
const sortedReplies = comment.replies.sort(sortByLikes);
for (const reply of sortedReplies) {
if (isThreadViewPost(reply)) {
nestedRepliesDiv.appendChild(renderComment(reply));
}
}
commentDiv.appendChild(nestedRepliesDiv);
}
return commentDiv;
}
function sortByLikes(a, b) {
if (!isThreadViewPost(a) || !isThreadViewPost(b)) {
return 0;
}
return (b.post.likeCount ?? 0) - (a.post.likeCount ?? 0);
}
function isThreadViewPost(obj) {
return obj && obj.$type === "app.bsky.feed.defs#threadViewPost";
}
The CSS 🔗
Use your custom.css
file (it should be in static/css
) to add some style on your comments. Add the following css code:
.comment {
padding: 10px 0;
}
.author {
font-weight: bold;
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
vertical-align: middle;
margin-right: 8px;
}
.nested-replies {
margin-left: 20px;
padding-left: 10px;
}
.actions {
font-size: 12px;
color: #666;
}
.nested-replies .comment {
margin-bottom: 0;
}
That’s it! 🔗
If you followed these steps, you should now see BlueSky comments below your posts. Check out @Basil’s Gist for more details or to contribute improvements.
If you enjoyed this guide, feel free to follow me on BlueSky: @xaviercoiffard.com and leave a comment on this post!