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:
Ricardo
2026-03-15 12:45:55 +01:00
parent 55927722cc
commit c7c0f4e0a4
4 changed files with 118 additions and 144 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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 = "";

View File

@@ -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,37 +97,32 @@
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');
// Append new likes
if (likes.length) {
appendAvatars('.webmention-likes .facepile, .webmention-likes .avatar-row', likes, 'likes');
updateCount('.webmention-likes h3', likes.length, 'Like');
}
// Append new reposts
if (reposts.length) {
appendAvatars('.webmention-reposts .facepile, .webmention-reposts .avatar-row', reposts, 'reposts');
updateCount('.webmention-reposts h3', reposts.length, 'Repost');
}
// Append new replies
if (replies.length) {
appendReplies('.webmention-replies ul', replies);
updateCount('.webmention-replies h3', replies.length, 'Repl', 'ies', 'y');
}
// Append new mentions
if (mentions.length) {
appendMentions('.webmention-mentions ul', mentions);
updateCount('.webmention-mentions h3', mentions.length, 'Mention');
@@ -146,6 +132,75 @@
updateTotalCount(mentionsToShow.length);
}
// Thread owner replies under their parent interaction cards
threadOwnerReplies(ownerReplies);
}
function threadOwnerReplies(ownerReplies) {
if (!ownerReplies || !ownerReplies.length) return;
ownerReplies.forEach(function(reply) {
var parentUrl = reply.parent_url;
if (!parentUrl) return;
// 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)
const cached = getCachedData();
if (cached) {
@@ -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);
});
});
})();