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:
-
No Backend Required
- Eliminate database management
- Reduce hosting costs
- Simplify deployment
-
Data Ownership
- Users retain control of their comments
- Content lives on the Bluesky network
- Reduced data privacy concerns
-
Built-in Moderation
- Leverage Bluesky’s existing moderation tools
- Reduce spam management overhead
- Community-driven content quality
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
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
- Export existing comments
- Create corresponding Bluesky posts
- Update post metadata
- Deploy new implementation
Configuration Checklist
- HUGO configuration updates
- Template modifications
- JavaScript implementation
- CSS styling
- Content migration
- Testing and validation