How to Add Bluesky Comments to Your Hugo Website

Adding Bluesky comments to your Hugo site is a great way to integrate decentralized discussions into your platform. This guide outlines the steps required, using JavaScript and Hugo templates to fetch and display comments from Bluesky.

Step 1: Prepare the Environment

Ensure your Hugo website is set up and ready for customization. Familiarity with Hugo templates and JavaScript is necessary.

Step 2: Create the Comment Display Section in Hugo Templates

Edit your single.html file (or equivalent). Add a placeholder for Bluesky comments, along with necessary scripts.

<div class="bluesky-stats">
  <small>
    💬 <span id="reply-count">0</span>
    🔄 <span id="repost-count">0</span>
    ♥️ <span id="like-count">0</span>
  </small>
</div>

<h2>Comments</h2>
<p class="comment-prompt">
    Reply on Bluesky <a href="#" target="_blank" rel="noopener noreferrer">here</a> to join the conversation.
</p>
<div id="bluesky-comments"></div>
<script src="/js/bluesky-comments.js"></script>

Step 3: Fetch and Display Comments Using JavaScript

Use a custom JavaScript file to interact with Bluesky’s API. Below is an overview of bluesky-comments.js, already optimized to fetch and display comments. Key functionalities include:

  • Fetching Post Data: Use app.bsky.feed.searchPosts to locate posts linked to your page’s URL.
  • Fetching Threads: Retrieve threads of comments with app.bsky.feed.getPostThread.
  • Sorting and Displaying: Sort comments chronologically and dynamically populate the comment section.
function unorphanize(element, count = 1) {
  // Get HTML content
  let html = element.innerHTML;
  
  // Store HTML tags
  const tags = html.match(/<([A-Z][A-Z0-9]*)\b[^>]*>/gi) || [];
  const placeholders = tags.map((_, i) => `__${i}__`);
  
  // Replace tags with placeholders
  tags.forEach((tag, i) => {
    html = html.replace(tag, placeholders[i]);
  });
  
  // Add non-breaking spaces
  for (let i = 0; i < count; i++) {
    const lastSpaceIndex = html.lastIndexOf(' ');
    if (lastSpaceIndex > 0) {
      html = html.substring(0, lastSpaceIndex) + 
             '&nbsp;' + 
             html.substring(lastSpaceIndex + 1);
    }
  }
  
  // Restore tags
  tags.forEach((tag, i) => {
    html = html.replace(placeholders[i], tag);
  });
  
  element.innerHTML = html;
}

// First, add a script variable to store the original post URL
let blueskyPostUrl = '';

async function loadBlueskyComments() {
  const currentUrl = window.location.href;
  const commentsDiv = document.getElementById('bluesky-comments');
  // Clear existing content
  commentsDiv.innerHTML = '';
  const commentsList = document.createElement('ul');
  commentsDiv.appendChild(commentsList);

  try {
    const searchParams = new URLSearchParams({ q: currentUrl });
    const searchResponse = await fetch(
      `https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts?${searchParams}`,
      { headers: { Accept: "application/json" } }
    );

    if (!searchResponse.ok) throw new Error("Failed to search posts");
    const searchData = await searchResponse.json();

    // Update stats and post URL for the first matching post
    if (searchData.posts && searchData.posts[0]) {
      const post = searchData.posts[0];
      // Update stats
      document.getElementById('reply-count').textContent = post.replyCount || 0;
      document.getElementById('repost-count').textContent = post.repostCount || 0;
      document.getElementById('like-count').textContent = post.likeCount || 0;
      // Update comment link
      blueskyPostUrl = `https://bsky.app/profile/${post.author.did}/post/${post.uri.split('/').pop()}`;
      const commentPromptLink = document.querySelector('.comment-prompt a');
      if (commentPromptLink) {
        commentPromptLink.href = blueskyPostUrl;
      }
    }

    // For each post found, fetch its thread
    const allComments = [];
    for (const post of searchData.posts) {
      const threadParams = new URLSearchParams({ uri: post.uri });
      const threadResponse = await fetch(
        `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?${threadParams}`,
        { headers: { Accept: "application/json" } }
      );

      if (threadResponse.ok) {
        const threadData = await threadResponse.json();
        if (threadData.thread?.replies) {
          allComments.push(...threadData.thread.replies);
        }
      }
    }

    // Sort all comments by time
    const sortedComments = allComments.sort((a, b) =>
      new Date(a.post.indexedAt) - new Date(b.post.indexedAt)
    );

    sortedComments.forEach(reply => {
      if (!reply?.post?.record?.text) return;
      const author = reply.post.author;

      const li = document.createElement('li');
      li.innerHTML = `
        <small>
          <a href="https://bsky.app/profile/${author.did}" target="_blank">
            ${author.displayName || author.handle}
          </a>
          <span class="author-handle">@${author.handle}</span>
        </small>
        <p>${reply.post.record.text}</p>
        <small>
          💬 ${reply.post.replyCount || 0}&nbsp;
          🔄 ${reply.post.repostCount || 0}&nbsp;
          ♥️ ${reply.post.likeCount || 0}&nbsp;
          <a href="https://bsky.app/profile/${reply.post.author.did}/post/${reply.post.uri.split('/').pop()}" target="_blank">
            Link
          </a>
        </small>
      `;
      // Apply unorphanize to the list item
      unorphanize(li);
      commentsList.appendChild(li);
    });
  } catch (error) {
    commentsDiv.innerHTML = `<p>Error loading comments: ${error.message}</p>`;
  }
}

document.addEventListener('DOMContentLoaded', loadBlueskyComments);

Step 4: Add CSS for Styling (Optional)

Customize the appearance of your comments section using CSS.

.bluesky-stats {
  margin: 10px 0;
}
.comment-prompt {
  margin-bottom: 20px;
}

Step 5: Test and Debug

Ensure the functionality works across different pages by testing:

  1. Comments load as expected.
  2. The stats (likes, replies, reposts) update correctly.
  3. Error handling displays meaningful messages if Bluesky’s API fails.

Piesakies jaunumiem

Pievienojies abonentiem un saņem jaunāko saturu no manis tieši savā e-pastā. Nekāda spama. Tikai noderīgs saturs.