Skip to content

Instantly share code, notes, and snippets.

@tiagovercosa
Last active February 12, 2026 23:44
Show Gist options
  • Select an option

  • Save tiagovercosa/9c52869e09e06a0352ed05e84ee388e0 to your computer and use it in GitHub Desktop.

Select an option

Save tiagovercosa/9c52869e09e06a0352ed05e84ee388e0 to your computer and use it in GitHub Desktop.
JavaScript code necessary to retrieve comments from Mastodon and Bluesky servers for a static Hugo blog.
{{ if or .Params.mastodon_id .Params.bluesky_id }}
<section class="social-comments hx:mt-12 hx:pt-6 hx:border-t hx:border-gray-200 dark:hx:border-neutral-800">
<h2 class="hx:text-2xl hx:font-bold hx:mb-6">Comentários</h2>
<div id="comments-loading" class="hx:text-sm hx:text-gray-500 hx:mb-4 hx:animate-pulse">
Carregando interações...
</div>
{{ if .Params.mastodon_id }}
<div id="mastodon-comments" class="comment-section hx:mb-8" style="display:none;">
<h3 class="hx:font-semibold hx:mb-2 hx:flex hx:items-center hx:gap-2">
{{ partial "utils/icon.html" (dict "name" "mastodon" "attributes" "height=1.2em") }}
No Mastodon
</h3>
<p class="hx:text-sm hx:mb-4 hx:text-gray-600 dark:hx:text-gray-400">
Responda a <a href="https://{{ site.Params.social_comments.mastodon.host }}/@{{ site.Params.social_comments.mastodon.username }}/{{ .Params.mastodon_id }}" target="_blank" class="hx:underline">este post</a> para aparecer aqui.
</p>
<div id="mastodon-replies" class="hx:space-y-4"></div>
</div>
{{ end }}
{{ if .Params.bluesky_id }}
<div id="bluesky-comments" class="comment-section" style="display:none;">
<h3 class="hx:font-semibold hx:mb-2 hx:flex hx:items-center hx:gap-2">
{{ partial "utils/icon.html" (dict "name" "bluesky" "attributes" "height=1.2em") }}
No Bluesky
</h3>
<p class="hx:text-sm hx:mb-4 hx:text-gray-600 dark:hx:text-gray-400">
Responda a <a href="https://bsky.app/profile/{{ site.Params.social_comments.bluesky.did }}/post/{{ .Params.bluesky_id }}" target="_blank" class="hx:underline">este post</a> para aparecer aqui.
</p>
<div id="bluesky-replies" class="hx:space-y-4"></div>
</div>
{{ end }}
</section>
<script>
(function() {
const escapeHtml = (unsafe) => {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Função de renderização unificada para o estilo minimalista
const renderCommentHTML = (account, content, date, depth) => {
const div = document.createElement('div');
div.className = "hx:p-4 hx:rounded-r-lg hx:rounded-l-none hx:bg-gray-50 dark:hx:bg-neutral-900/50 hx:mb-2";
if (depth > 0) {
const indentSize = Math.min(depth, 5) * 1.2;
div.style.marginLeft = `${indentSize}rem`;
div.style.borderLeft = "2px solid #d1d5db"; // Linha clara
if (document.documentElement.classList.contains('dark')) {
div.style.borderLeft = "2px solid #333333"; // Linha escura
}
} else {
div.classList.add('hx:rounded-l-lg');
}
div.innerHTML = `
<div class="hx:flex hx:items-center hx:gap-2 hx:mb-2">
<img src="${escapeHtml(account.avatar)}" class="hx:w-8 hx:h-8 hx:rounded-full" style="width: 32px !important; height: 32px !important; min-width: 32px;" loading="lazy">
<a href="${account.url}" target="_blank" class="hx:font-medium hx:text-sm hx:hover:underline">
${escapeHtml(account.displayName || account.username)}
</a>
<span class="hx:text-xs hx:text-gray-500">${new Date(date).toLocaleDateString()}</span>
</div>
<div class="hx:text-sm prose dark:prose-invert max-w-none">
${content}
</div>
`;
return div;
};
const loadComments = async () => {
const loading = document.getElementById('comments-loading');
// --- MASTODON ---
{{ if .Params.mastodon_id }}
try {
const host = "{{ site.Params.social_comments.mastodon.host }}";
const id = "{{ .Params.mastodon_id }}";
const container = document.getElementById('mastodon-replies');
const section = document.getElementById('mastodon-comments');
const response = await fetch(`https://${host}/api/v1/statuses/${id}/context`);
const data = await response.json();
if(data.descendants && data.descendants.length > 0) {
section.style.display = 'block';
container.innerHTML = '';
const replyMap = {};
data.descendants.forEach(reply => {
const parentId = reply.in_reply_to_id;
if (!replyMap[parentId]) replyMap[parentId] = [];
replyMap[parentId].push(reply);
});
const renderMastodonTree = (parentId, depth) => {
const replies = replyMap[parentId] || [];
replies.forEach(reply => {
if(reply.visibility !== 'public') return;
// Adaptação dos dados do Mastodon para o renderizador padrão
const accountData = {
avatar: reply.account.avatar,
url: reply.account.url,
displayName: reply.account.display_name,
username: reply.account.username
};
const element = renderCommentHTML(accountData, reply.content, reply.created_at, depth);
container.appendChild(element);
renderMastodonTree(reply.id, depth + 1);
});
};
renderMastodonTree(id, 0);
} else {
section.style.display = 'block';
container.innerHTML = '<p class="hx:text-sm hx:italic hx:text-gray-500">Sem comentários ainda.</p>';
}
} catch (e) { console.warn(e); }
{{ end }}
// --- BLUESKY ---
{{ if .Params.bluesky_id }}
try {
const did = "{{ site.Params.social_comments.bluesky.did }}";
const postId = "{{ .Params.bluesky_id }}";
const container = document.getElementById('bluesky-replies');
const section = document.getElementById('bluesky-comments');
// CORREÇÃO: Aumentamos o depth para 10 para pegar respostas das respostas
const atUri = `at://${did}/app.bsky.feed.post/${postId}`;
const apiUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(atUri)}&depth=10`;
const response = await fetch(apiUrl);
const data = await response.json();
if (data.thread && data.thread.replies && data.thread.replies.length > 0) {
section.style.display = 'block';
container.innerHTML = '';
// Ordenar comentários de topo por likes
const topLevelReplies = data.thread.replies.sort((a, b) => (b.post.likeCount || 0) - (a.post.likeCount || 0));
// Função recursiva para percorrer a árvore do Bluesky
const renderBlueskyTree = (replies, depth) => {
if (!replies) return;
replies.forEach(thread => {
if(!thread.post) return;
const author = thread.post.author;
const record = thread.post.record;
// Prepara dados para o renderizador
const accountData = {
avatar: author.avatar,
url: `https://bsky.app/profile/${author.handle}`,
displayName: author.displayName,
username: author.handle
};
// O texto no Bluesky vem plano, envolvemos em <p> para manter padrão visual
const contentHTML = `<p>${escapeHtml(record.text)}</p>`;
const element = renderCommentHTML(accountData, contentHTML, record.createdAt, depth);
container.appendChild(element);
// Se houver respostas dentro desta resposta, chamamos a função novamente (cascata)
if (thread.replies) {
// Opcional: ordenar as respostas filhas por data (antigas primeiro) ou likes
const sortedReplies = thread.replies.sort((a, b) => new Date(a.post.record.createdAt) - new Date(b.post.record.createdAt));
renderBlueskyTree(sortedReplies, depth + 1);
}
});
}
renderBlueskyTree(topLevelReplies, 0);
} else {
section.style.display = 'block';
container.innerHTML = '<p class="hx:text-sm hx:italic hx:text-gray-500">Sem comentários ainda.</p>';
}
} catch (e) { console.warn(e); }
{{ end }}
if(loading) loading.style.display = 'none';
};
loadComments();
})();
</script>
{{ end }}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment