From c7c0f4e0a40786ded94124fafecab36947cb5a11 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 15 Mar 2026 12:45:55 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 14 +++ eleventy.config.js | 10 +-- js/comments.js | 23 +---- js/webmentions.js | 215 +++++++++++++++++++++------------------------ 4 files changed, 118 insertions(+), 144 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 367cfb6..e8de46c 100644 --- a/CLAUDE.md +++ b/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 diff --git a/eleventy.config.js b/eleventy.config.js index 76394b8..77726dd 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -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) diff --git a/js/comments.js b/js/comments.js index 749650f..c32b13e 100644 --- a/js/comments.js +++ b/js/comments.js @@ -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 = ""; diff --git a/js/webmentions.js b/js/webmentions.js index 2f1a1ba..5ce943a 100644 --- a/js/webmentions.js +++ b/js/webmentions.js @@ -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); - }); }); })();