How to Add BlueSky Comments to Your Hugo Blog: A Step-by-Step Guide

· 1054 words · 5 minute read

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.


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!