AT Protocol Comments

Implementing AT Protocol Comments in HUGO: A Complete Integration Guide

Why Choose AT Protocol for Comments?

Decentralized Architecture Benefits

The AT Protocol offers significant advantages for static site comments:

  1. No Backend Required

    • Eliminate database management
    • Reduce hosting costs
    • Simplify deployment
  2. Data Ownership

    • Users retain control of their comments
    • Content lives on the Bluesky network
    • Reduced data privacy concerns
  3. Built-in Moderation

    • Leverage Bluesky’s existing moderation tools
    • Reduce spam management overhead
    • Community-driven content quality

IMAGE: Decentralized Architecture

HUGO Implementation

Configuration Setup

First, add the necessary parameters to your HUGO configuration:

# config.yaml
params:
  article:
    bluesky: "yourusername.bsky.social"
    comments:
      enabled: true
      layout: "partials/comments/bluesky.html"

Create the Comments Partial

<!-- layouts/partials/comments/bluesky.html -->
{{ if and (isset .Params "comments") (isset .Params.comments "id") }}
<div class="article-content">
   <h2>Join the Discussion</h2>

   <!-- Reply Button -->
   <div class="reply-section">
      <a class="button-link"
         href="https://bsky.app/profile/{{ .Site.Params.article.bluesky }}/post/{{ .Params.comments.id }}"
         target="_blank">
         Reply on Bluesky
      </a>
   </div>

   <!-- Comments Component -->
   <bluesky-comments
           handle="{{ $bskyHandle }}"
           post-id="{{ .Params.comments.id }}">
   </bluesky-comments>
   <noscript>JavaScript is required to view comments.</noscript>
</div>
{{ end }}

JavaScript Implementation

Create a new file for the comments component:

// assets/js/bluesky-comments.js
class BlueskyComments extends HTMLElement {
   constructor() {
      super();
      this.config = {
         apiEndpoint: 'https://public.api.bsky.app/xrpc',
         depth: 10,
         sortDirection: 'asc',
         sanitize: true,
         avatarSize: 48,
         iconSize: 18,
         templates: {
            error: '<p class="error">Unable to load comments.</p>',
            loading: '<p class="loading">Loading comments...</p>',
            empty: '<p class="empty">No comments yet. Be the first to reply!</p>'
         }
      };
   }

   async connectedCallback() {
      this.innerHTML = this.config.templates.loading;
      await this.initializeComments();
   }

   async initializeComments() {
      const postData = this.getPostData();
      if (!postData) return this.showError();

      try {
         const thread = await this.fetchCommentThread(postData);
         this.renderCommentThread(thread);
      } catch (error) {
         console.error('Comments fetch failed:', error);
         this.showError();
      }
   }

   getPostData() {
      const meta = document.querySelector('meta[name="bluesky:post"]');
      if (!meta) return null;

      try {
         return JSON.parse(meta.content);
      } catch {
         return null;
      }
   }

   async fetchCommentThread({ handle, postId }) {
      const uri = `at://${handle}/app.bsky.feed.post/${postId}`;
      const endpoint = `${this.config.apiEndpoint}/app.bsky.feed.getPostThread`;
      const response = await fetch(
              `${endpoint}?uri=${encodeURIComponent(uri)}&depth=${this.config.depth}`
      );

      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      return await response.json();
   }

   renderCommentThread(data) {
      if (!data.thread?.replies?.length) {
         this.innerHTML = this.config.templates.empty;
         return;
      }

      const replies = this.sortReplies(data.thread.replies);
      const fragment = document.createDocumentFragment();

      replies.forEach(reply => {
         fragment.appendChild(this.createCommentElement(reply));
      });

      this.innerHTML = '';
      this.appendChild(fragment);
   }

   sortReplies(replies) {
      return [...replies].sort((a, b) => {
         const dateA = new Date(a.post.record.createdAt);
         const dateB = new Date(b.post.record.createdAt);
         return this.config.sortDirection === 'asc' ?
                 dateA - dateB : dateB - dateA;
      });
   }

   createCommentElement(reply) {
      const { author, record, replyCount, repostCount, likeCount } = reply.post;
      const container = document.createElement('div');
      container.className = 'comment';

      container.innerHTML = `
      <div class="comment-header">
        <img src="${author.avatar}" 
             alt="${author.displayName}" 
             class="avatar"
             width="${this.config.avatarSize}"
             height="${this.config.avatarSize}">
        <div class="author-info">
          <a href="https://bsky.app/profile/${author.did}" 
             target="_blank" 
             class="author-name">${author.displayName}</a>
          <span class="author-handle">@${author.handle}</span>
        </div>
      </div>
      <div class="comment-content">
        ${this.processContent(record.text, record.facets)}
      </div>
      ${this.createMetadataSection(replyCount, repostCount, likeCount)}
    `;

      if (reply.replies?.length) {
         container.appendChild(this.createRepliesSection(reply.replies));
      }

      return container;
   }

   processContent(text, facets = []) {
      if (!facets?.length) return text;

      let processed = text;
      let offset = 0;

      facets.sort((a, b) => a.index.byteStart - b.index.byteStart)
              .forEach(facet => {
                 const start = facet.index.byteStart + offset;
                 const end = facet.index.byteEnd + offset;
                 const original = processed.slice(start, end);

                 let replacement = original;
                 facet.features.forEach(feature => {
                    if (feature.$type === 'app.bsky.richtext.facet#link') {
                       replacement = `<a href="${feature.uri}" target="_blank">${original}</a>`;
                    } else if (feature.$type === 'app.bsky.richtext.facet#mention') {
                       replacement = `<a href="https://bsky.app/profile/${feature.did}" target="_blank">${original}</a>`;
                    }
                 });

                 processed = processed.slice(0, start) + replacement +
                         processed.slice(end);
                 offset += replacement.length - original.length;
              });

      return this.config.sanitize ?
              DOMPurify.sanitize(processed) : processed;
   }

   createMetadataSection(replies, reposts, likes) {
      return `
      <div class="comment-metadata">
        <span class="meta-item">
          ${this.getIcon('reply')} ${replies}
        </span>
        <span class="meta-item">
          ${this.getIcon('repost')} ${reposts}
        </span>
        <span class="meta-item">
          ${this.getIcon('like')} ${likes}
        </span>
      </div>
    `;
   }

   showError() {
      this.innerHTML = this.config.templates.error;
   }
}

customElements.define('bluesky-comments', BlueskyComments);

Styling Implementation

/* assets/css/custom.css */
.comment-container {
   display: flex;
   align-items: flex-start;
   margin-bottom: 15px;
   padding: 15px;
   background-color: var(--card-background-light);
   border-radius: 10px;
   box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
   border: 1px solid var(--border-light);
   transition: all 0.3s ease;
}

.child-comments {
   margin-left: 30px;
   border-left: 2px solid var(--border-light);
   padding-left: 15px;
   margin-top: 10px;
}

.comment-avatar {
   width: 50px;
   height: 50px;
   border-radius: 50%;
   margin-right: 10px;
}

.comment-details {
   max-width: calc(100% - 60px);
}

.comment-header {
   font-weight: bold;
   margin-bottom: 5px;
}

.comment-header span {
   font-size: 0.9em;
   color: gray;
}

.comment-text {
   margin-bottom: 10px;
}

.comment-timestamp {
   font-size: 0.8em;
   color: gray;
}

.button-link {
   display: inline-block;
   padding: 4px 12px;
   font-size: 14px;
   font-weight: bold;
   text-align: center;
   text-decoration: none;
   border-radius: 4px;
   background-color: var(--button-bg-light);
   color: var(--button-text-light);
   transition: background-color 0.3s;
}

.button-link:hover {
   filter: brightness(1.1);
}

@media (prefers-color-scheme: dark) {
   .button-link {
      background-color: var(--button-bg-dark);
      color: var(--button-text-dark);
   }

   .button-link:hover {
      filter: brightness(0.9); /* Slightly decrease brightness on hover */
   }
}
.username-link {
   font-weight: bold;
   color: var(--link-color);
   text-decoration: none;
}

.username-link:hover {
   text-decoration: underline;
}

.comment-meta {
   display: flex;
   gap: 15px;
   margin-top: 10px;
   font-size: 0.9em;
   color: gray;
}

.meta-item {
   display: flex;
   align-items: center;
   gap: 5px;
}

Integration with HUGO Content

Front Matter Configuration

Add the following to your content’s front matter:

---
title: "Your Article Title"
date: 2024-12-30
comments:
  id: "3juzgoizdz22y"
---

Page Template Integration

Update your single post template:

<!-- layouts/_default/single.html -->
{{ define "main" }}
  <article>
    {{ .Content }}
    
    {{ if .Params.comments }}
      {{ partial "comments/bluesky.html" . }}
    {{ end }}
  </article>
{{ end }}

Performance Considerations

Lazy Loading

Implement lazy loading for the comments section:

// assets/js/lazy-comments.js
const observerOptions = {
   root: null,
   rootMargin: '100px',
   threshold: 0.1
};

const observer = new IntersectionObserver((entries) => {
   entries.forEach(entry => {
      if (entry.isIntersecting) {
         const commentsElement = entry.target;

         // Check if component is already defined
         if (customElements.get('bluesky-comments')) {
            const comments = document.createElement('bluesky-comments');
            commentsElement.appendChild(comments);
         } else {
            // Load and define component if not already defined
            import('/js/bluesky-comments.js')
                    .then(() => {
                       const comments = document.createElement('bluesky-comments');
                       commentsElement.appendChild(comments);
                    })
                    .catch(error => {
                       console.error('Failed to load comments:', error);
                       commentsElement.innerHTML = 'Unable to load comments.';
                    });
         }

         observer.unobserve(commentsElement);
      }
   });
}, observerOptions);

// Only observe if element exists and comments aren't already loaded
document.addEventListener('DOMContentLoaded', () => {
   const commentsContainer = document.getElementById('bluesky-comments-list');
   if (commentsContainer && !commentsContainer.querySelector('bluesky-comments')) {
      observer.observe(commentsContainer);
   }
});

Business Benefits

Cost Reduction

  • Eliminate database hosting costs
  • Reduce server maintenance
  • Minimize infrastructure complexity

User Engagement

  • Leverage existing Bluesky audience
  • Cross-pollinate content exposure
  • Build community through federation

Technical Advantages

  • Zero-maintenance comment system
  • Built-in spam protection
  • Automatic scaling

IMAGE: Business Benefits

Future Considerations

Feature Expansion

  • Real-time updates
  • Enhanced moderation tools
  • Analytics integration

Integration Opportunities

  • Multi-platform federation
  • Custom notification systems
  • Extended metadata support

Migration Guide

From Traditional Comments

  1. Export existing comments
  2. Create corresponding Bluesky posts
  3. Update post metadata
  4. Deploy new implementation

Configuration Checklist

  • HUGO configuration updates
  • Template modifications
  • JavaScript implementation
  • CSS styling
  • Content migration
  • Testing and validation

IMAGE: Migration Process

Join the Discussion