From 8baec25b2c2dc897f1db9c467634b77b69f989c9 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 6 Mar 2026 10:45:55 +0100 Subject: [PATCH] feat: blog filter nav, interactions pagination, note unfurl, pagefind improvements - Replace broken client-side type filter on /blog/ with navigation pill links to dedicated collection pages (with post counts) - Replace Load More with proper prev/next/page-number pagination on Interactions inbound tab (20 per page, filter resets page) - Add auto-unfurl transform for standalone external links in notes - Exclude Digest and Categories pages from Pagefind search index - Add Pagefind search filters for post type, year, and category - Add Pagefind filter metadata to page.njk layout Confab-Link: http://localhost:8080/sessions/956f4251-b4a9-4bc9-b214-53402ad1fe63 --- _includes/layouts/page.njk | 14 +++ blog.njk | 25 ++--- categories-index.njk | 1 + categories.njk | 1 + digest-index.njk | 1 + digest.njk | 1 + eleventy.config.js | 46 +++++++++ interactions.njk | 193 +++++++++++++++++++++---------------- search.njk | 2 +- 9 files changed, 184 insertions(+), 100 deletions(-) diff --git a/_includes/layouts/page.njk b/_includes/layouts/page.njk index cadc492..2907aa4 100644 --- a/_includes/layouts/page.njk +++ b/_includes/layouts/page.njk @@ -133,4 +133,18 @@ withSidebar: true {# Hidden metadata for microformats #} + + {# Pagefind filter metadata #} + diff --git a/blog.njk b/blog.njk index c68087d..16a0857 100644 --- a/blog.njk +++ b/blog.njk @@ -22,20 +22,13 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber

{% if paginatedPosts.length > 0 %} - -
- - -
+ -
{# Pagination controls #} {% if pagination.pages.length > 1 %} diff --git a/categories-index.njk b/categories-index.njk index 3c4d298..13f04c7 100644 --- a/categories-index.njk +++ b/categories-index.njk @@ -2,6 +2,7 @@ layout: layouts/base.njk title: Categories withSidebar: true +pagefindIgnore: true permalink: categories/ eleventyImport: collections: diff --git a/categories.njk b/categories.njk index 087aa52..4407b5a 100644 --- a/categories.njk +++ b/categories.njk @@ -1,6 +1,7 @@ --- layout: layouts/base.njk withSidebar: true +pagefindIgnore: true pagination: data: collections.categories size: 1 diff --git a/digest-index.njk b/digest-index.njk index 959788e..c503312 100644 --- a/digest-index.njk +++ b/digest-index.njk @@ -2,6 +2,7 @@ layout: layouts/base.njk title: Weekly Digest withSidebar: true +pagefindIgnore: true eleventyExcludeFromCollections: true eleventyImport: collections: diff --git a/digest.njk b/digest.njk index aa40bf8..0575cb6 100644 --- a/digest.njk +++ b/digest.njk @@ -1,6 +1,7 @@ --- layout: layouts/base.njk withSidebar: true +pagefindIgnore: true eleventyExcludeFromCollections: true eleventyImport: collections: diff --git a/eleventy.config.js b/eleventy.config.js index 64954c6..3f0b432 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -386,6 +386,52 @@ export default function (eleventyConfig) { return content; }); + // Auto-unfurl standalone external links in note content + // Finds tags that are the primary content of a

tag and injects OG preview cards + eleventyConfig.addTransform("auto-unfurl-notes", async function (content, outputPath) { + if (!outputPath || !outputPath.endsWith(".html")) return content; + // Only process note pages (individual + listing) + if (!outputPath.includes("/notes/")) return content; + + // Match

tags whose content is short text + a single external as the last element + // Pattern:

optional short text ...

+ const linkParagraphRe = /

([^<]{0,80})?]*>[^<]*<\/a>\s*<\/p>/g; + const siteHost = new URL(siteUrl).hostname; + const matches = []; + + let match; + while ((match = linkParagraphRe.exec(content)) !== null) { + const url = match[2]; + try { + const linkHost = new URL(url).hostname; + // Skip same-domain links and common non-content URLs + if (linkHost === siteHost || linkHost.endsWith("." + siteHost)) continue; + matches.push({ fullMatch: match[0], url, index: match.index }); + } catch { + continue; + } + } + + if (matches.length === 0) return content; + + // Unfurl all matched URLs in parallel (uses cache, throttles network) + const cards = await Promise.all(matches.map(m => prefetchUrl(m.url))); + + // Replace in reverse order to preserve indices + let result = content; + for (let i = matches.length - 1; i >= 0; i--) { + const m = matches[i]; + const card = cards[i]; + // Skip if unfurl returned just a fallback link (no OG data) + if (!card || !card.includes("unfurl-card")) continue; + // Insert the unfurl card after the paragraph + const insertPos = m.index + m.fullMatch.length; + result = result.slice(0, insertPos) + "\n" + card + "\n" + result.slice(insertPos); + } + + return result; + }); + // HTML minification — only during initial build, skip during watch rebuilds eleventyConfig.addTransform("htmlmin", async function (content, outputPath) { if (outputPath && outputPath.endsWith(".html") && process.env.ELEVENTY_RUN_MODE === "build") { diff --git a/interactions.njk b/interactions.njk index 206a600..fa78025 100644 --- a/interactions.njk +++ b/interactions.njk @@ -283,14 +283,43 @@ permalink: /interactions/

No webmentions found for this filter.

- {# Pagination / Load more #} -
- + {# Pagination controls #} +
+
@@ -316,14 +345,13 @@ function interactionsApp() { return { activeTab: 'inbound', loading: false, - loadingMore: false, error: null, notConfigured: false, webmentions: [], filterType: 'all', - page: 0, - perPage: 50, - hasMore: true, + currentPage: 1, + displayPerPage: 20, + fetchPerPage: 200, refreshInterval: null, get likes() { @@ -351,14 +379,41 @@ function interactionsApp() { return this.webmentions.filter(wm => wm['wm-property'] === this.filterType); }, + get totalPages() { + return Math.max(1, Math.ceil(this.filteredWebmentions.length / this.displayPerPage)); + }, + get paginatedWebmentions() { - // Client-side pagination of filtered results - return this.filteredWebmentions; + const start = (this.currentPage - 1) * this.displayPerPage; + return this.filteredWebmentions.slice(start, start + this.displayPerPage); + }, + + get pageNumbers() { + const total = this.totalPages; + const current = this.currentPage; + if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); + const pages = []; + pages.push(1); + if (current > 3) pages.push('…'); + for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) { + pages.push(i); + } + if (current < total - 2) pages.push('…'); + pages.push(total); + return pages; + }, + + goToPage(page) { + if (page < 1 || page > this.totalPages) return; + this.currentPage = page; + // Scroll to top of inbound tab + this.$el.closest('[x-show]')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, async init() { + // Reset page when filter changes + this.$watch('filterType', () => { this.currentPage = 1; }); await this.fetchWebmentions(); - // Auto-refresh every 5 minutes (skip if not configured) if (!this.notConfigured) { this.refreshInterval = setInterval(() => this.fetchWebmentions(true), 5 * 60 * 1000); } @@ -367,46 +422,60 @@ function interactionsApp() { async fetchWebmentions(silent = false) { if (!silent) { this.loading = true; - this.page = 0; this.webmentions = []; - this.hasMore = true; } this.error = null; try { - // Fetch from both webmention-io and conversations APIs in parallel - const wmUrl = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`; - const convUrl = `/conversations/api/mentions?per-page=${this.perPage}&page=${this.page}`; + // Fetch all available webmentions by paging through the APIs + let allWm = []; + let allConv = []; + let wmPage = 0; + let wmDone = false; + let wmConfigured = true; - const [wmResult, convResult] = await Promise.allSettled([ - fetch(wmUrl).then(r => { - if (r.status === 404) return { children: [], notConfigured: true }; - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.json(); - }), - fetch(convUrl).then(r => { - if (!r.ok) return { children: [] }; - return r.json(); - }).catch(() => ({ children: [] })), - ]); + // Fetch all pages from both APIs + while (!wmDone) { + const [wmResult, convResult] = await Promise.allSettled([ + fetch(`/webmentions/api/mentions?per-page=${this.fetchPerPage}&page=${wmPage}`).then(r => { + if (r.status === 404) return { children: [], notConfigured: true }; + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }), + fetch(`/conversations/api/mentions?per-page=${this.fetchPerPage}&page=${wmPage}`).then(r => { + if (!r.ok) return { children: [] }; + return r.json(); + }).catch(() => ({ children: [] })), + ]); - const wmData = wmResult.status === 'fulfilled' ? wmResult.value : { children: [] }; - const convData = convResult.status === 'fulfilled' ? convResult.value : { children: [] }; + const wmData = wmResult.status === 'fulfilled' ? wmResult.value : { children: [] }; + const convData = convResult.status === 'fulfilled' ? convResult.value : { children: [] }; - // Check if webmention-io is configured - if (wmData.notConfigured && (!convData.children || !convData.children.length)) { + if (wmData.notConfigured) wmConfigured = false; + + allWm = allWm.concat(wmData.children || []); + allConv = allConv.concat(convData.children || []); + + // Stop if both APIs returned fewer than a full page + const wmCount = (wmData.children || []).length; + const convCount = (convData.children || []).length; + if (wmCount < this.fetchPerPage && convCount < this.fetchPerPage) { + wmDone = true; + } else { + wmPage++; + // Safety cap to prevent infinite loops + if (wmPage > 20) wmDone = true; + } + } + + if (!wmConfigured && allConv.length === 0) { this.notConfigured = true; return; } this.notConfigured = false; - // Merge and deduplicate - conversations items (with platform field) take priority - const merged = this.mergeAndDeduplicate( - wmData.children || [], - convData.children || [] - ); + const merged = this.mergeAndDeduplicate(allWm, allConv); - // Sort by date, newest first merged.sort((a, b) => { const dateA = new Date(a.published || a['wm-received'] || 0); const dateB = new Date(b.published || b['wm-received'] || 0); @@ -414,7 +483,7 @@ function interactionsApp() { }); this.webmentions = merged; - this.hasMore = (wmData.children || []).length === this.perPage; + if (!silent) this.currentPage = 1; } catch (err) { this.error = `Failed to load webmentions: ${err.message}`; console.error('[Interactions]', err); @@ -426,22 +495,18 @@ function interactionsApp() { detectPlatform(item) { const source = item['wm-source'] || ''; const authorUrl = item.author?.url || ''; - // Bridgy source URLs: brid.gy/{action}/{platform}/... if (source.includes('brid.gy/') && source.includes('/mastodon/')) return 'mastodon'; if (source.includes('brid.gy/') && source.includes('/bluesky/')) return 'bluesky'; - // Author URL heuristics if (authorUrl.includes('bsky.app')) return 'bluesky'; if (authorUrl.includes('mstdn') || authorUrl.includes('mastodon') || authorUrl.includes('social.')) return 'mastodon'; return null; }, mergeAndDeduplicate(wmItems, convItems) { - // Build a Set of source URLs from conversations for dedup const convUrls = new Set(convItems.map(c => c.url).filter(Boolean)); const seen = new Set(); const result = []; - // Add all conversations items first (they have richer metadata) for (const item of convItems) { const key = item['wm-id'] || item.url; if (key && !seen.has(key)) { @@ -450,16 +515,11 @@ function interactionsApp() { } } - // Add webmention-io items that aren't duplicated by conversations for (const item of wmItems) { const wmKey = item['wm-id']; if (seen.has(wmKey)) continue; - - // Also check if this webmention's source URL matches a conversations item - // (same interaction from Bridgy webmention AND direct poll) if (item.url && convUrls.has(item.url)) continue; - // Infer platform from Bridgy source URL or author URL if (!item.platform) { const detected = this.detectPlatform(item); if (detected) item.platform = detected; @@ -472,36 +532,6 @@ function interactionsApp() { return result; }, - async loadMore() { - this.loadingMore = true; - this.page++; - - try { - const wmUrl = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`; - const convUrl = `/conversations/api/mentions?per-page=${this.perPage}&page=${this.page}`; - - const [wmResult, convResult] = await Promise.allSettled([ - fetch(wmUrl).then(r => r.ok ? r.json() : { children: [] }), - fetch(convUrl).then(r => r.ok ? r.json() : { children: [] }).catch(() => ({ children: [] })), - ]); - - const wmData = wmResult.status === 'fulfilled' ? wmResult.value : { children: [] }; - const convData = convResult.status === 'fulfilled' ? convResult.value : { children: [] }; - - const merged = this.mergeAndDeduplicate( - wmData.children || [], - convData.children || [] - ); - - this.webmentions = [...this.webmentions, ...merged]; - this.hasMore = (wmData.children || []).length === this.perPage; - } catch (err) { - this.error = `Failed to load more: ${err.message}`; - } finally { - this.loadingMore = false; - } - }, - formatDate(dateStr) { if (!dateStr) return ''; const date = new Date(dateStr); @@ -518,7 +548,6 @@ function interactionsApp() { if (!url) return ''; try { const u = new URL(url); - // Return just the pathname, trimmed let path = u.pathname; if (path.length > 50) { path = path.slice(0, 47) + '...'; diff --git a/search.njk b/search.njk index 73d065c..d57b529 100644 --- a/search.njk +++ b/search.njk @@ -19,7 +19,7 @@ pagefindIgnore: true