diff --git a/app/blog/entry.js b/app/blog/entry.js
index 10492421f7c..6a205e9e97d 100644
--- a/app/blog/entry.js
+++ b/app/blog/entry.js
@@ -38,6 +38,7 @@ module.exports = function (request, response, next) {
) {
delete blog.plugins.commento;
delete blog.plugins.disqus;
+ delete blog.plugins.blueskyComments;
}
// Redirect this entry to the file from which it was generated
@@ -83,7 +84,7 @@ module.exports = function (request, response, next) {
// Don't show plugin HTML on a preview subdomain.
// This is to prevent Disqus getting stuck on one URL.
if (entry.draft || request.preview) {
- pluginHTML = "";
+ pluginHTML = !!pluginHTML ? "
Comments are hidden on site previews.
" : '';
}
response.locals.partials.pluginHTML = pluginHTML;
diff --git a/app/build/plugins/blueskyComments/entry.html b/app/build/plugins/blueskyComments/entry.html
new file mode 100644
index 00000000000..bb4c622eeb6
--- /dev/null
+++ b/app/build/plugins/blueskyComments/entry.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ See more on Bluesky
+
+
+
+
+
+ Comments
+ Post on Bluesky to start the discussion!
+
diff --git a/app/build/plugins/blueskyComments/form.html b/app/build/plugins/blueskyComments/form.html
new file mode 100644
index 00000000000..14ebf4a7e35
--- /dev/null
+++ b/app/build/plugins/blueskyComments/form.html
@@ -0,0 +1,6 @@
+
+ Bluesky profile URL:
+
+
+Your Bluesky profile URL (e.g., https://bsky.app/profile/example.bsky.social). This will be used to automatically find your posts linking to each page when no Bluesky metadata is set on the entry.
+
diff --git a/app/build/plugins/blueskyComments/index.js b/app/build/plugins/blueskyComments/index.js
new file mode 100644
index 00000000000..4a4f6149adb
--- /dev/null
+++ b/app/build/plugins/blueskyComments/index.js
@@ -0,0 +1,8 @@
+module.exports = {
+ description: "Bluesky comments",
+ category: "external",
+ isDefault: false,
+ options: {
+ authorURI: "",
+ },
+};
diff --git a/app/build/plugins/blueskyComments/public.css b/app/build/plugins/blueskyComments/public.css
new file mode 100644
index 00000000000..23e4d814062
--- /dev/null
+++ b/app/build/plugins/blueskyComments/public.css
@@ -0,0 +1,83 @@
+.comment-container {
+ display: flex;
+}
+
+.comment-container > a {
+ flex-shrink: 0;
+}
+
+/* Post Actions */
+.comment-container a,
+#main-post-actions {
+ text-decoration: none;
+ color: var(--highlight2);
+}
+
+.post-actions {
+ display: flex;
+ gap: 1em;
+}
+
+.post-actions svg {
+ width: 12px;
+ height: 12px;
+}
+
+.post-actions div svg,
+.post-actions div span {
+ vertical-align: middle;
+ display: inline-block;
+}
+
+.post-actions div:hover {
+ color: var(--accent);
+}
+
+.post-actions div:hover svg {
+ stroke: var(--accent);
+}
+
+/* Comment Section */
+#comments .comment {
+ position: relative; /* Needed for pseudo-element positioning */
+}
+
+#comments .comment .comment-container {
+ margin-bottom: 24px;
+}
+
+#comments .comment-container img {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ margin-right: 16px;
+}
+
+#comments .comment .comment-container p {
+ color: var(--accent);
+ margin: 0.25em 0;
+}
+
+#comments .comment .replies {
+ padding-left: calc(32px + 16px);
+}
+
+#comments #see-more {
+ position: absolute;
+ left: 50%;
+ transform: translate(-50%);
+ padding: 24px;
+}
+
+.comment:has(.replies)::after {
+ content: "";
+ position: absolute;
+ top: calc(32px + 0.5em);
+ left: 16px;
+ width: 20px;
+ height: calc(100% - 41px - 0.5em);
+ border: 2px solid var(--highlight);
+ border-top: none;
+ border-right: none;
+ border-radius: 0 0 0 20px;
+}
diff --git a/app/build/plugins/blueskyComments/public.js b/app/build/plugins/blueskyComments/public.js
new file mode 100644
index 00000000000..d7c8def0882
--- /dev/null
+++ b/app/build/plugins/blueskyComments/public.js
@@ -0,0 +1,237 @@
+const convertToAtUri = (url) => {
+ const match = url.match(
+ /https:\/\/bsky\.app\/profile\/([^/]+)\/post\/([^/]+)/
+ );
+ if (!match) throw new Error("Invalid Bluesky URL format.");
+ const did = match[1];
+ const rkey = match[2];
+ return `at://${did}/app.bsky.feed.post/${rkey}`;
+};
+
+const extractHandleFromProfileUrl = (profileUrl) => {
+ const match = profileUrl.match(/https?:\/\/bsky\.app\/profile\/([^/?]+)/);
+ if (match) {
+ return match[1];
+ }
+ throw new Error("Invalid Bluesky profile URL format.");
+};
+
+const getRkeyFromUri = (uri) => {
+ return uri.split("/").pop();
+};
+
+const getPostUrl = (authorDid, rkey) => {
+ return `https://bsky.app/profile/${authorDid}/post/${rkey}`;
+};
+
+const sortByLikeCount = (a, b) => (b.post.likeCount || 0) - (a.post.likeCount || 0);
+
+const getTemplate = (id) => {
+ const template = document.getElementById(id);
+ if (!template) throw new Error(`Template ${id} not found`);
+ return template.content.cloneNode(true);
+};
+
+const renderPostActions = (post) => {
+ const fragment = getTemplate("template-post-actions");
+ fragment.querySelector("[data-like-count]").textContent = post.likeCount || 0;
+ fragment.querySelector("[data-repost-count]").textContent = post.repostCount || 0;
+ fragment.querySelector("[data-reply-count]").textContent = post.replyCount || 0;
+ return fragment;
+};
+
+const renderCommentContainer = (post) => {
+ const author = post.author;
+ const rkey = getRkeyFromUri(post.uri);
+ const postUrl = getPostUrl(author.did, rkey);
+
+ const fragment = getTemplate("template-comment-container");
+ const links = fragment.querySelectorAll("[data-post-url]");
+ links.forEach(link => {
+ link.href = postUrl;
+ });
+ fragment.querySelector("[data-avatar]").src = author.avatar;
+ fragment.querySelector("[data-avatar]").alt = `${author.displayName}'s avatar`;
+ fragment.querySelector("[data-display-name]").textContent = author.displayName;
+ fragment.querySelector("[data-text]").textContent = post.record?.text || "";
+
+ // Add post actions
+ const actionsContainer = fragment.querySelector(".comment-container > div");
+ actionsContainer.appendChild(renderPostActions(post));
+
+ return fragment;
+};
+
+const renderComment = (post) => {
+ const fragment = getTemplate("template-comment");
+ const containerPlaceholder = fragment.querySelector("[data-comment-container]");
+ const commentContainer = renderCommentContainer(post);
+ containerPlaceholder.replaceWith(commentContainer.querySelector(".comment-container"));
+ return fragment;
+};
+
+const renderThread = (thread) => {
+ const fragment = renderComment(thread.post);
+ const repliesContainer = fragment.querySelector("[data-replies]");
+
+ const replies = thread.replies
+ .sort(sortByLikeCount)
+ .slice(0, 3)
+ .map((reply) => renderComment(reply.post));
+
+ if (replies.length > 0) {
+ replies.forEach(reply => {
+ repliesContainer.appendChild(reply);
+ });
+ } else {
+ repliesContainer.remove();
+ }
+
+ return fragment;
+};
+
+const renderCommentsHeader = (postUrl) => {
+ const fragment = getTemplate("template-comments-header");
+ fragment.querySelector("[data-post-url]").href = postUrl;
+ return fragment;
+};
+
+const renderErrorMessage = (message) => {
+ const fragment = getTemplate("template-error-message");
+ fragment.querySelector("[data-message]").textContent = message;
+ return fragment;
+};
+
+const renderReplyLink = (postUrl) => {
+ const fragment = getTemplate("template-reply-link");
+ fragment.querySelector("[data-post-url]").href = postUrl;
+ return fragment;
+};
+
+const loadThread = (uri, container, originalUrl) => {
+ fetch(
+ `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(
+ uri
+ )}`,
+ {
+ method: "GET",
+ headers: { Accept: "application/json" },
+ }
+ )
+ .then(async (response) => {
+ if (!response.ok) throw new Error(await response.text());
+
+ const { thread } = await response.json();
+ const replies = (thread.replies || []).sort(sortByLikeCount);
+
+ // Add main post actions if element exists
+ const mainPostActions = document.getElementById("main-post-actions");
+ if (mainPostActions) {
+ mainPostActions.innerHTML = "";
+ mainPostActions.appendChild(renderPostActions(thread.post));
+ }
+
+ // Append top 25 comments
+ replies.slice(0, 25).forEach(reply => {
+ container.appendChild(renderThread(reply));
+ });
+
+ // Add "Show More" button if there are more comments
+ if (replies.length > 25 && !container.querySelector("#see-more")) {
+ const postUrl =
+ originalUrl ||
+ getPostUrl(thread.post.author.did, getRkeyFromUri(uri));
+ container.appendChild(renderReplyLink(postUrl));
+ }
+ })
+ .catch((error) => {
+ console.error("Error loading thread:", error);
+ container.innerHTML = "";
+ container.appendChild(renderErrorMessage("Error loading comments."));
+ });
+};
+
+const init = () => {
+ const container = document.getElementById("comments");
+
+ if (!container) return;
+
+ const dataUri = container.dataset.uri?.trim();
+ const dataAuthor = container.dataset.author?.trim();
+
+ // Priority 1: Use Bluesky metadata if set (non-empty URL)
+ if (dataUri) {
+ const uri = convertToAtUri(dataUri);
+ // Add initial content for metadata case
+ const replyLink = document.createElement("p");
+ const link = document.createElement("a");
+ link.href = dataUri;
+ link.target = "_blank";
+ link.textContent = "Reply on Bluesky";
+ replyLink.appendChild(link);
+ container.appendChild(replyLink);
+ loadThread(uri, container, dataUri);
+ }
+ // Priority 2: Auto-discover using author search (non-empty URL)
+ else if (dataAuthor) {
+ // Add loading message
+ const loadingMsg = document.createElement("p");
+ loadingMsg.textContent = "Loading comments...";
+ container.appendChild(loadingMsg);
+
+ const author = extractHandleFromProfileUrl(dataAuthor);
+ const fetchPost = async () => {
+ const currentUrl = window.location.href;
+ const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?q=*&url=${encodeURIComponent(
+ currentUrl
+ )}&author=${encodeURIComponent(author)}&sort=top`;
+
+ try {
+ const response = await fetch(apiUrl);
+ const data = await response.json();
+
+ if (data.posts && data.posts.length > 0) {
+ const post = data.posts[0];
+ const uri = post.uri;
+ const rkey = getRkeyFromUri(uri);
+ const postUrl = getPostUrl(post.author.did, rkey);
+
+ // Update container with comments header
+ container.innerHTML = "";
+ container.appendChild(renderCommentsHeader(postUrl));
+
+ // Add main post actions element
+ const mainPostActions = document.createElement("a");
+ mainPostActions.id = "main-post-actions";
+ mainPostActions.target = "_blank";
+ mainPostActions.href = postUrl;
+ container.appendChild(mainPostActions);
+
+ // Load the thread
+ loadThread(uri, container, postUrl);
+ } else {
+ container.innerHTML = "";
+ container.appendChild(renderErrorMessage(
+ "No Bluesky post found for this page."
+ ));
+ }
+ } catch (err) {
+ console.error("Error fetching post:", err);
+ container.innerHTML = "";
+ container.appendChild(renderErrorMessage(
+ "Error searching for Bluesky post."
+ ));
+ }
+ };
+
+ fetchPost();
+ }
+ // Priority 3: Show error message if neither is configured
+ else {
+ const errorMsg = document.createElement("p");
+ errorMsg.innerHTML = `Bluesky comments are not configured. Please set a Bluesky metadata field on this entry, or configure your Bluesky author handle in the plugin settings.`;
+ container.appendChild(errorMsg);
+ }
+};
+
+init();
diff --git a/app/build/plugins/index.js b/app/build/plugins/index.js
index 8e712750aa5..dddc7911fcd 100644
--- a/app/build/plugins/index.js
+++ b/app/build/plugins/index.js
@@ -22,6 +22,7 @@ var loaded = loadPlugins({
analytics: require("./analytics"),
autoImage: require("./autoImage"),
bluesky: require("./bluesky"),
+ blueskyComments: require("./blueskyComments"),
codeHighlighting: require("./codeHighlighting"),
commento: require("./commento"),
disqus: require("./disqus"),
diff --git a/app/views/how/configure/bluesky-comments.html b/app/views/how/configure/bluesky-comments.html
new file mode 100644
index 00000000000..092b5004bc7
--- /dev/null
+++ b/app/views/how/configure/bluesky-comments.html
@@ -0,0 +1,33 @@
+Bluesky comments
+
+
+ Blot’s Bluesky comments plugin lets you embed the discussion for a Bluesky
+ post at the end of any entry. The comments are rendered by the
+ Bluesky comments widget
+ and require the identifier of the Bluesky post you want to attach.
+
+
+
+ Open the Services page and enable Bluesky comments .
+
+ Enter the default Bluesky handle you would like to use when loading the widget.
+ This should usually be the author of the blog or the account responsible for the
+ linked discussion.
+
+
+ For every post that should show Bluesky comments, include a
+ Bluesky field in the entry’s metadata set to the full post URI
+ (for example at://did:plc:example/app.bsky.feed.post/123).
+
+
+
+
+ Without the Bluesky metadata field the widget will not appear, even if the
+ plugin is enabled. Comments remain hidden on pages unless you explicitly set
+ Comments: Yes in the page metadata.
+
+
+
+ You can combine the plugin with other comment systems and switch between them
+ whenever you need from the Services page.
+
diff --git a/app/views/how/configure/comments.html b/app/views/how/configure/comments.html
index f3807106e2c..50d7300ad61 100644
--- a/app/views/how/configure/comments.html
+++ b/app/views/how/configure/comments.html
@@ -13,8 +13,20 @@ Comments
Disqus
+
+
+ Bluesky comments
+
+
+ See the Bluesky comments guide
+ for details on how to attach a Bluesky thread to a post. Each entry needs a
+ Bluesky metadata field pointing to the post’s AT URI for the
+ widget to appear.
+
+
Controlling comments on individual entries
When enabled, comments are shown on posts and are not shown on pages. You can
diff --git a/app/views/tools/comments/bluesky.html b/app/views/tools/comments/bluesky.html
new file mode 100644
index 00000000000..d2c10cfc15b
--- /dev/null
+++ b/app/views/tools/comments/bluesky.html
@@ -0,0 +1,12 @@
+---
+Link: https://bsky.app/
+---
+
+
Bluesky comments
+
+
+ Embed Bluesky discussion threads at the end of your posts. Enable the plugin on
+ the Services page, add your default Bluesky
+ handle, and include a Bluesky metadata field with the AT URI of the
+ post you want to attach.
+
diff --git a/app/views/tools/icons/bluesky.png b/app/views/tools/icons/bluesky.png
new file mode 100644
index 00000000000..09393a74131
Binary files /dev/null and b/app/views/tools/icons/bluesky.png differ
Comments
+