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}