mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 16:44:56 +02:00
refactor: unified owner reply threading via conversations API
- Remove self-mention filter (siteOrigin, isSelfMention) from webmentions.js - Remove build-time self-mention filter from eleventy.config.js - processWebmentions() now separates is_owner items and threads them under parent interaction cards via threadOwnerReplies() - owner:detected handler reduced to wireReplyButtons() only - Remove loadOwnerReplies() and Alpine.store replies from comments.js - Owner replies now come from conversations API with parent_url metadata Confab-Link: http://localhost:8080/sessions/184584f4-67e1-485a-aba8-02ac34a600fe
This commit is contained in:
14
CLAUDE.md
14
CLAUDE.md
@@ -509,6 +509,20 @@ export default async function () {
|
||||
3. **CSS utilities:** Add custom utilities to `css/tailwind.css`
|
||||
4. **Rebuild CSS:** `npm run build:css` (or `make build:css` in parent repo)
|
||||
|
||||
## Performance Debugging
|
||||
|
||||
For diagnosing and fixing Eleventy build performance issues, see the comprehensive guide at `/home/rick/code/indiekit-dev/docs/eleventy-debugging-guide.md`.
|
||||
|
||||
**Quick diagnostic steps:**
|
||||
|
||||
1. **Baseline:** `time npx @11ty/eleventy --quiet` (run 3x, take median)
|
||||
2. **Benchmark:** `DEBUG=Eleventy:Benchmark* npx @11ty/eleventy` — find entries >15% of total or with call count matching page count
|
||||
3. **Classify:** Network requests (high avg, low count) vs. redundant computation (low avg, high count) vs. client-side bloat (fast build, low Lighthouse)
|
||||
4. **Fix:** Timeout + cache for network; memoize with `Map` for per-page computation; Web Components for client-side bloat
|
||||
5. **Verify:** Re-measure against baseline
|
||||
|
||||
**Relevant to this theme:** Data files in `_data/` that fetch from external APIs (GitHub, Mastodon, Bluesky, YouTube, Funkwhale, Last.fm) are Pattern A candidates — always use `eleventy-fetch` with appropriate `duration` and handle failures gracefully. The OG image generation hook is a Pattern B candidate — it already uses batch spawning and manifest caching to manage memory and avoid redundant work.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
1. ❌ **Forgetting to update submodule** after changes
|
||||
|
||||
@@ -820,14 +820,8 @@ export default function (eleventyConfig) {
|
||||
urlsToCheck.add(`${siteUrl}${contentUrl}`.replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
// Filter merged data matching any of our URLs, excluding self-mentions
|
||||
// (owner replies sent via webmention-sender appear as webmentions on own posts)
|
||||
const matched = merged.filter((wm) => {
|
||||
if (!urlsToCheck.has(wm["wm-target"])) return false;
|
||||
const source = wm["wm-source"] || wm.url || "";
|
||||
if (source.startsWith(siteUrl)) return false;
|
||||
return true;
|
||||
});
|
||||
// Filter merged data matching any of our URLs
|
||||
const matched = merged.filter((wm) => urlsToCheck.has(wm["wm-target"]));
|
||||
|
||||
// Deduplicate cross-source: same author + same interaction type = same mention
|
||||
// (webmention.io and conversations API may both report the same like/reply)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Client-side comments component (Alpine.js)
|
||||
* Handles IndieAuth flow, comment submission, display, and owner replies
|
||||
* Handles IndieAuth flow, comment submission, display, and owner detection
|
||||
*
|
||||
* Registered via Alpine.data() so the component is available
|
||||
* regardless of script loading order.
|
||||
@@ -12,7 +12,6 @@ document.addEventListener("alpine:init", () => {
|
||||
isOwner: false,
|
||||
profile: null,
|
||||
syndicationTargets: {},
|
||||
replies: [],
|
||||
});
|
||||
|
||||
Alpine.data("commentsSection", (targetUrl) => ({
|
||||
@@ -40,9 +39,7 @@ document.addEventListener("alpine:init", () => {
|
||||
await this.checkOwner();
|
||||
await this.loadComments();
|
||||
if (this.isOwner) {
|
||||
await this.loadOwnerReplies();
|
||||
// Notify webmentions.js that owner state + replies are ready
|
||||
// (alpine:initialized fires before these async fetches resolve)
|
||||
// Notify webmentions.js that owner is detected (for reply buttons)
|
||||
document.dispatchEvent(new CustomEvent("owner:detected"));
|
||||
}
|
||||
this.handleAuthReturn();
|
||||
@@ -84,7 +81,7 @@ document.addEventListener("alpine:init", () => {
|
||||
Alpine.store("owner").syndicationTargets = this.syndicationTargets;
|
||||
|
||||
// Note: owner:detected event is dispatched from init() after
|
||||
// loadOwnerReplies() completes, so replies are available in the store
|
||||
// this completes, so the Alpine store is populated before the event fires
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -92,20 +89,6 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
},
|
||||
|
||||
async loadOwnerReplies() {
|
||||
try {
|
||||
const url = `/comments/api/owner-replies?target=${encodeURIComponent(this.targetUrl)}`;
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Store for webmentions component to use via Alpine store
|
||||
Alpine.store("owner").replies = data.children || [];
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
},
|
||||
|
||||
startReply(commentId, platform, replyUrl, syndicateTo) {
|
||||
this.replyingTo = { commentId, platform, replyUrl, syndicateTo };
|
||||
this.replyText = "";
|
||||
|
||||
@@ -13,11 +13,6 @@
|
||||
|
||||
if (!target || !domain) return;
|
||||
|
||||
// Extract site origin for filtering self-mentions
|
||||
// (owner replies sent via webmention-sender appear as webmentions on own posts)
|
||||
var siteOrigin = '';
|
||||
try { siteOrigin = new URL(target).origin; } catch(e) {}
|
||||
|
||||
// Use server-side proxy to keep webmention.io token secure
|
||||
// Fetch both with and without trailing slash since webmention.io
|
||||
// stores targets inconsistently (Bridgy sends different formats)
|
||||
@@ -60,13 +55,9 @@
|
||||
function processWebmentions(allChildren) {
|
||||
if (!allChildren || !allChildren.length) return;
|
||||
|
||||
// Filter out self-mentions (may exist in older cached data)
|
||||
allChildren = allChildren.filter(function(wm) {
|
||||
if (!siteOrigin) return true;
|
||||
var source = wm['wm-source'] || wm.url || '';
|
||||
return !source.startsWith(siteOrigin);
|
||||
});
|
||||
if (!allChildren.length) return;
|
||||
// Separate owner replies (threaded under parent) from regular interactions
|
||||
var ownerReplies = allChildren.filter(function(wm) { return wm.is_owner && wm.parent_url; });
|
||||
var regularItems = allChildren.filter(function(wm) { return !wm.is_owner; });
|
||||
|
||||
let mentionsToShow;
|
||||
if (hasBuildTimeSection) {
|
||||
@@ -94,7 +85,7 @@
|
||||
if (li.dataset.wmUrl) renderedReplies.add(li.dataset.wmUrl);
|
||||
});
|
||||
|
||||
mentionsToShow = allChildren.filter(function(wm) {
|
||||
mentionsToShow = regularItems.filter(function(wm) {
|
||||
var prop = wm['wm-property'] || 'mention-of';
|
||||
if (prop === 'in-reply-to') {
|
||||
// Skip replies whose source URL is already rendered
|
||||
@@ -106,44 +97,108 @@
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
// No build-time section - show ALL webmentions from API
|
||||
mentionsToShow = allChildren;
|
||||
// No build-time section - show ALL regular webmentions from API
|
||||
mentionsToShow = regularItems;
|
||||
}
|
||||
|
||||
if (!mentionsToShow.length) return;
|
||||
if (mentionsToShow.length) {
|
||||
// Group by type
|
||||
const likes = mentionsToShow.filter((m) => m['wm-property'] === 'like-of');
|
||||
const reposts = mentionsToShow.filter((m) => m['wm-property'] === 'repost-of');
|
||||
const replies = mentionsToShow.filter((m) => m['wm-property'] === 'in-reply-to');
|
||||
const mentions = mentionsToShow.filter((m) => m['wm-property'] === 'mention-of');
|
||||
|
||||
// Group by type
|
||||
const likes = mentionsToShow.filter((m) => m['wm-property'] === 'like-of');
|
||||
const reposts = mentionsToShow.filter((m) => m['wm-property'] === 'repost-of');
|
||||
const replies = mentionsToShow.filter((m) => m['wm-property'] === 'in-reply-to');
|
||||
const mentions = mentionsToShow.filter((m) => m['wm-property'] === 'mention-of');
|
||||
if (likes.length) {
|
||||
appendAvatars('.webmention-likes .facepile, .webmention-likes .avatar-row', likes, 'likes');
|
||||
updateCount('.webmention-likes h3', likes.length, 'Like');
|
||||
}
|
||||
|
||||
// Append new likes
|
||||
if (likes.length) {
|
||||
appendAvatars('.webmention-likes .facepile, .webmention-likes .avatar-row', likes, 'likes');
|
||||
updateCount('.webmention-likes h3', likes.length, 'Like');
|
||||
if (reposts.length) {
|
||||
appendAvatars('.webmention-reposts .facepile, .webmention-reposts .avatar-row', reposts, 'reposts');
|
||||
updateCount('.webmention-reposts h3', reposts.length, 'Repost');
|
||||
}
|
||||
|
||||
if (replies.length) {
|
||||
appendReplies('.webmention-replies ul', replies);
|
||||
updateCount('.webmention-replies h3', replies.length, 'Repl', 'ies', 'y');
|
||||
}
|
||||
|
||||
if (mentions.length) {
|
||||
appendMentions('.webmention-mentions ul', mentions);
|
||||
updateCount('.webmention-mentions h3', mentions.length, 'Mention');
|
||||
}
|
||||
|
||||
// Update total count in main header
|
||||
updateTotalCount(mentionsToShow.length);
|
||||
}
|
||||
|
||||
// Append new reposts
|
||||
if (reposts.length) {
|
||||
appendAvatars('.webmention-reposts .facepile, .webmention-reposts .avatar-row', reposts, 'reposts');
|
||||
updateCount('.webmention-reposts h3', reposts.length, 'Repost');
|
||||
}
|
||||
// Thread owner replies under their parent interaction cards
|
||||
threadOwnerReplies(ownerReplies);
|
||||
}
|
||||
|
||||
// Append new replies
|
||||
if (replies.length) {
|
||||
appendReplies('.webmention-replies ul', replies);
|
||||
updateCount('.webmention-replies h3', replies.length, 'Repl', 'ies', 'y');
|
||||
}
|
||||
function threadOwnerReplies(ownerReplies) {
|
||||
if (!ownerReplies || !ownerReplies.length) return;
|
||||
|
||||
// Append new mentions
|
||||
if (mentions.length) {
|
||||
appendMentions('.webmention-mentions ul', mentions);
|
||||
updateCount('.webmention-mentions h3', mentions.length, 'Mention');
|
||||
}
|
||||
ownerReplies.forEach(function(reply) {
|
||||
var parentUrl = reply.parent_url;
|
||||
if (!parentUrl) return;
|
||||
|
||||
// Update total count in main header
|
||||
updateTotalCount(mentionsToShow.length);
|
||||
// Find the interaction card whose URL matches the parent
|
||||
var matchingLi = document.querySelector('.webmention-replies li[data-wm-url="' + CSS.escape(parentUrl) + '"]');
|
||||
if (!matchingLi) return;
|
||||
|
||||
var slot = matchingLi.querySelector('.wm-owner-reply-slot');
|
||||
if (!slot) return;
|
||||
|
||||
// Skip if already rendered (dedup by reply URL)
|
||||
if (slot.querySelector('[data-reply-url="' + CSS.escape(reply.url) + '"]')) return;
|
||||
|
||||
var replyCard = document.createElement('div');
|
||||
replyCard.className = 'p-3 bg-surface-100 dark:bg-surface-900 rounded-lg border-l-2 border-amber-400 dark:border-amber-600';
|
||||
replyCard.dataset.replyUrl = reply.url || '';
|
||||
|
||||
var innerDiv = document.createElement('div');
|
||||
innerDiv.className = 'flex items-start gap-2';
|
||||
|
||||
var avatar = document.createElement('div');
|
||||
avatar.className = 'w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900 flex-shrink-0 flex items-center justify-center text-xs font-bold';
|
||||
avatar.textContent = (reply.author && reply.author.name ? reply.author.name[0] : 'O').toUpperCase();
|
||||
|
||||
var contentArea = document.createElement('div');
|
||||
contentArea.className = 'flex-1';
|
||||
|
||||
var headerRow = document.createElement('div');
|
||||
headerRow.className = 'flex items-center gap-2 flex-wrap';
|
||||
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'font-medium text-sm';
|
||||
nameSpan.textContent = (reply.author && reply.author.name) || 'Owner';
|
||||
|
||||
var authorBadge = document.createElement('span');
|
||||
authorBadge.className = 'inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full';
|
||||
authorBadge.textContent = 'Author';
|
||||
|
||||
var timeEl = document.createElement('time');
|
||||
timeEl.className = 'text-xs text-surface-600 dark:text-surface-400 font-mono';
|
||||
timeEl.dateTime = reply.published || '';
|
||||
timeEl.textContent = formatDate(reply.published);
|
||||
|
||||
headerRow.appendChild(nameSpan);
|
||||
headerRow.appendChild(authorBadge);
|
||||
headerRow.appendChild(timeEl);
|
||||
|
||||
var textDiv = document.createElement('div');
|
||||
textDiv.className = 'mt-1 text-sm prose dark:prose-invert';
|
||||
textDiv.textContent = (reply.content && reply.content.text) || '';
|
||||
|
||||
contentArea.appendChild(headerRow);
|
||||
contentArea.appendChild(textDiv);
|
||||
|
||||
innerDiv.appendChild(avatar);
|
||||
innerDiv.appendChild(contentArea);
|
||||
replyCard.appendChild(innerDiv);
|
||||
slot.appendChild(replyCard);
|
||||
});
|
||||
}
|
||||
|
||||
// Try cached data first (renders instantly on refresh)
|
||||
@@ -168,13 +223,6 @@
|
||||
const wmItems = [...(wmData1.children || []), ...(wmData2.children || [])];
|
||||
const convItems = [...(convData1.children || []), ...(convData2.children || [])];
|
||||
|
||||
// Filter out self-mentions (owner's own replies appearing as webmentions)
|
||||
function isSelfMention(wm) {
|
||||
if (!siteOrigin) return false;
|
||||
var source = wm['wm-source'] || wm.url || '';
|
||||
return source.startsWith(siteOrigin);
|
||||
}
|
||||
|
||||
// Build dedup sets from conversations items (richer metadata, take priority)
|
||||
const convUrls = new Set(convItems.map(c => c.url).filter(Boolean));
|
||||
const seen = new Set();
|
||||
@@ -182,7 +230,6 @@
|
||||
|
||||
// Add conversations items first (they have platform provenance)
|
||||
for (const wm of convItems) {
|
||||
if (isSelfMention(wm)) continue;
|
||||
const key = wm['wm-id'] || wm.url;
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
@@ -198,9 +245,8 @@
|
||||
if (authorUrl) authorActions.add(authorUrl + '::' + action);
|
||||
}
|
||||
|
||||
// Add webmention-io items, skipping duplicates and self-mentions
|
||||
// Add webmention-io items, skipping duplicates
|
||||
for (const wm of wmItems) {
|
||||
if (isSelfMention(wm)) continue;
|
||||
const key = wm['wm-id'];
|
||||
if (seen.has(key)) continue;
|
||||
// Also skip if same source URL exists in conversations
|
||||
@@ -765,72 +811,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Show reply buttons and wire click handlers if owner is detected
|
||||
// Show reply buttons when owner is detected
|
||||
// Listen for custom event dispatched by comments.js after async owner check
|
||||
document.addEventListener('owner:detected', function() {
|
||||
var ownerStore = Alpine.store && Alpine.store('owner');
|
||||
if (!ownerStore || !ownerStore.isOwner) return;
|
||||
|
||||
wireReplyButtons();
|
||||
|
||||
// Render threaded owner replies under matching webmention cards
|
||||
var ownerReplies = ownerStore.replies || [];
|
||||
ownerReplies.forEach(function(reply) {
|
||||
var inReplyTo = reply['in-reply-to'];
|
||||
if (!inReplyTo) return;
|
||||
|
||||
// Find the webmention card whose URL matches
|
||||
var matchingLi = document.querySelector('.webmention-replies li[data-wm-url="' + CSS.escape(inReplyTo) + '"]');
|
||||
if (!matchingLi) return;
|
||||
|
||||
var slot = matchingLi.querySelector('.wm-owner-reply-slot');
|
||||
if (!slot) return;
|
||||
|
||||
// Build owner reply card
|
||||
var replyCard = document.createElement('div');
|
||||
replyCard.className = 'p-3 bg-surface-100 dark:bg-surface-900 rounded-lg border-l-2 border-amber-400 dark:border-amber-600';
|
||||
|
||||
var innerDiv = document.createElement('div');
|
||||
innerDiv.className = 'flex items-start gap-2';
|
||||
|
||||
var avatar = document.createElement('div');
|
||||
avatar.className = 'w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900 flex-shrink-0 flex items-center justify-center text-xs font-bold';
|
||||
avatar.textContent = (reply.author && reply.author.name ? reply.author.name[0] : 'O').toUpperCase();
|
||||
|
||||
var contentArea = document.createElement('div');
|
||||
contentArea.className = 'flex-1';
|
||||
|
||||
var headerRow = document.createElement('div');
|
||||
headerRow.className = 'flex items-center gap-2 flex-wrap';
|
||||
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'font-medium text-sm';
|
||||
nameSpan.textContent = (reply.author && reply.author.name) || 'Owner';
|
||||
|
||||
var authorBadge = document.createElement('span');
|
||||
authorBadge.className = 'inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full';
|
||||
authorBadge.textContent = 'Author';
|
||||
|
||||
var timeEl = document.createElement('time');
|
||||
timeEl.className = 'text-xs text-surface-600 dark:text-surface-400 font-mono';
|
||||
timeEl.dateTime = reply.published || '';
|
||||
timeEl.textContent = formatDate(reply.published);
|
||||
|
||||
headerRow.appendChild(nameSpan);
|
||||
headerRow.appendChild(authorBadge);
|
||||
headerRow.appendChild(timeEl);
|
||||
|
||||
var textDiv = document.createElement('div');
|
||||
textDiv.className = 'mt-1 text-sm prose dark:prose-invert';
|
||||
textDiv.textContent = (reply.content && reply.content.text) || '';
|
||||
|
||||
contentArea.appendChild(headerRow);
|
||||
contentArea.appendChild(textDiv);
|
||||
|
||||
innerDiv.appendChild(avatar);
|
||||
innerDiv.appendChild(contentArea);
|
||||
replyCard.appendChild(innerDiv);
|
||||
slot.appendChild(replyCard);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user