basyliq 🌿

Integrate Bluesky replies as your blog's comment section in gohugo.io framework

Integrate Bluesky’s dynamic comment section into your Hugo website! This solution is built with JavaScript and supports real-time comment updates.


Layout: layouts/_default/single.html

1<div id="comments-section" data-bsky-uri="{{ .Params.bsky }}"></div>
2{{ $comments := resources.Get "js/comments.js" }}
3<script src="{{ $comments.RelPermalink }}"></script>

JavaScript: assets/js/comments.js

  1document.addEventListener("DOMContentLoaded", () => {
  2  const commentsSection = document.getElementById("comments-section");
  3  const bskyWebUrl = commentsSection?.getAttribute("data-bsky-uri");
  4
  5  if (!bskyWebUrl) return;
  6
  7  (async () => {
  8    try {
  9      const atUri = await extractAtUri(bskyWebUrl);
 10      console.log("Extracted AT URI:", atUri);
 11
 12      const thread = await getPostThread(atUri);
 13
 14      if (thread && thread.$type === "app.bsky.feed.defs#threadViewPost") {
 15        renderComments(thread, commentsSection);
 16      } else {
 17        commentsSection.textContent = "Error fetching comments.";
 18      }
 19    } catch (error) {
 20      console.error("Error loading comments:", error);
 21      commentsSection.textContent = "Error loading comments.";
 22    }
 23  })();
 24});
 25
 26async function extractAtUri(webUrl) {
 27  try {
 28    const url = new URL(webUrl);
 29    const pathSegments = url.pathname.split("/").filter(Boolean);
 30
 31    if (
 32      pathSegments.length < 4 ||
 33      pathSegments[0] !== "profile" ||
 34      pathSegments[2] !== "post"
 35    ) {
 36      throw new Error("Invalid URL format");
 37    }
 38
 39    const handleOrDid = pathSegments[1];
 40    const postID = pathSegments[3];
 41    let did = handleOrDid;
 42
 43    if (!did.startsWith("did:")) {
 44      const resolveHandleURL = `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(
 45        handleOrDid,
 46      )}`;
 47      const res = await fetch(resolveHandleURL);
 48      if (!res.ok) {
 49        const errorText = await res.text();
 50        throw new Error(`Failed to resolve handle to DID: ${errorText}`);
 51      }
 52      const data = await res.json();
 53      if (!data.did) {
 54        throw new Error("DID not found in response");
 55      }
 56      did = data.did;
 57    }
 58
 59    return `at://${did}/app.bsky.feed.post/${postID}`;
 60  } catch (error) {
 61    console.error("Error extracting AT URI:", error);
 62    throw error;
 63  }
 64}
 65
 66async function getPostThread(atUri) {
 67  console.log("getPostThread called with atUri:", atUri);
 68  const params = new URLSearchParams({ uri: atUri });
 69  const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?${params.toString()}`;
 70
 71  console.log("API URL:", apiUrl);
 72
 73  const res = await fetch(apiUrl, {
 74    method: "GET",
 75    headers: {
 76      Accept: "application/json",
 77    },
 78    cache: "no-store",
 79  });
 80
 81  if (!res.ok) {
 82    const errorText = await res.text();
 83    console.error("API Error:", errorText);
 84    throw new Error(`Failed to fetch post thread: ${errorText}`);
 85  }
 86
 87  const data = await res.json();
 88
 89  if (
 90    !data.thread ||
 91    data.thread.$type !== "app.bsky.feed.defs#threadViewPost"
 92  ) {
 93    throw new Error("Could not find thread");
 94  }
 95
 96  return data.thread;
 97}
 98
 99function renderComments(thread, container) {
100  container.innerHTML = "";
101
102  const postUrl = `https://bsky.app/profile/${thread.post.author.did}/post/${thread.post.uri.split("/").pop()}`;
103
104  const metaDiv = document.createElement("div");
105  const link = document.createElement("a");
106  link.href = postUrl;
107  link.target = "_blank";
108  link.textContent = `${thread.post.likeCount ?? 0} likes | ${thread.post.repostCount ?? 0} reposts | ${thread.post.replyCount ?? 0} replies`;
109  metaDiv.appendChild(link);
110
111  container.appendChild(metaDiv);
112
113  const commentsHeader = document.createElement("h2");
114  commentsHeader.textContent = "Comments";
115  container.appendChild(commentsHeader);
116
117  const replyText = document.createElement("p");
118  replyText.textContent = "Reply on Bluesky ";
119  const replyLink = document.createElement("a");
120  replyLink.href = postUrl;
121  replyLink.target = "_blank";
122  replyLink.textContent = "here";
123  replyText.appendChild(replyLink);
124  container.appendChild(replyText);
125
126  const divider = document.createElement("hr");
127  container.appendChild(divider);
128
129  if (thread.replies && thread.replies.length > 0) {
130    const commentsContainer = document.createElement("div");
131    commentsContainer.id = "comments-container";
132
133    const sortedReplies = thread.replies.sort(sortByLikes);
134    for (const reply of sortedReplies) {
135      if (isThreadViewPost(reply)) {
136        commentsContainer.appendChild(renderComment(reply));
137      }
138    }
139
140    container.appendChild(commentsContainer);
141  } else {
142    const noComments = document.createElement("p");
143    noComments.textContent = "No comments available.";
144    container.appendChild(noComments);
145  }
146}
147
148function renderComment(comment) {
149  const { post } = comment;
150  const { author } = post;
151
152  const commentDiv = document.createElement("div");
153  commentDiv.className = "comment";
154
155  const authorDiv = document.createElement("div");
156  authorDiv.className = "author";
157
158  if (author.avatar) {
159    const avatarImg = document.createElement("img");
160    avatarImg.src = author.avatar;
161    avatarImg.alt = "avatar";
162    avatarImg.className = "avatar";
163    authorDiv.appendChild(avatarImg);
164  }
165
166  const authorLink = document.createElement("a");
167  authorLink.href = `https://bsky.app/profile/${author.did}`;
168  authorLink.target = "_blank";
169  authorLink.textContent = author.displayName ?? author.handle;
170  authorDiv.appendChild(authorLink);
171
172  const handleSpan = document.createElement("span");
173  handleSpan.textContent = `@${author.handle}`;
174  authorDiv.appendChild(handleSpan);
175
176  commentDiv.appendChild(authorDiv);
177
178  const contentP = document.createElement("p");
179  contentP.textContent = post.record.text;
180  commentDiv.appendChild(contentP);
181
182  const actionsDiv = document.createElement("div");
183  actionsDiv.className = "actions";
184  actionsDiv.textContent = `${post.replyCount ?? 0} replies | ${post.repostCount ?? 0} reposts | ${post.likeCount ?? 0} likes`;
185  commentDiv.appendChild(actionsDiv);
186
187  if (comment.replies && comment.replies.length > 0) {
188    const nestedRepliesDiv = document.createElement("div");
189    nestedRepliesDiv.className = "nested-replies";
190
191    const sortedReplies = comment.replies.sort(sortByLikes);
192    for (const reply of sortedReplies) {
193      if (isThreadViewPost(reply)) {
194        nestedRepliesDiv.appendChild(renderComment(reply));
195      }
196    }
197
198    commentDiv.appendChild(nestedRepliesDiv);
199  }
200
201  return commentDiv;
202}
203
204function sortByLikes(a, b) {
205  if (!isThreadViewPost(a) || !isThreadViewPost(b)) {
206    return 0;
207  }
208  return (b.post.likeCount ?? 0) - (a.post.likeCount ?? 0);
209}
210
211function isThreadViewPost(obj) {
212  return obj && obj.$type === "app.bsky.feed.defs#threadViewPost";
213}

Post Header

1---
2bsky: "LINK_TO_YOUR_BSKY_POST"
3---

Styles

 1.comment {
 2    padding: 10px 0;
 3}
 4.author {
 5    font-weight: bold;
 6}
 7.avatar {
 8    width: 24px;
 9    height: 24px;
10    border-radius: 50%;
11    vertical-align: middle;
12    margin-right: 8px;
13}
14.nested-replies {
15    margin-left: 20px;
16    padding-left: 10px;
17}
18.actions {
19    font-size: 12px;
20    color: #666;
21}
22.nested-replies .comment {
23    margin-bottom: 0;
24}

#go #hugo #bsky #bluesky