diff --git a/_includes/components/webmentions.njk b/_includes/components/webmentions.njk index bb2adb8..4b558c3 100644 --- a/_includes/components/webmentions.njk +++ b/_includes/components/webmentions.njk @@ -1,8 +1,18 @@ {# Webmentions Component #} {# Displays likes, reposts, and replies for a post #} {# Also checks legacy URLs from micro.blog and old blog for historical webmentions #} +{# Client-side JS supplements build-time data with real-time fetches #} {% set mentions = webmentions | webmentionsForUrl(page.url, urlAliases) %} +{% set absoluteUrl = site.url + page.url %} +{% set buildTimestamp = "now" | date("x") %} + +{# Data container for client-side JS to fetch new webmentions #} + {% if mentions.length %}
diff --git a/_includes/layouts/base.njk b/_includes/layouts/base.njk index 312faa7..a5652bf 100644 --- a/_includes/layouts/base.njk +++ b/_includes/layouts/base.njk @@ -218,5 +218,7 @@ document.documentElement.addEventListener('mouseover', prefetch, { capture: true, passive: true }); document.documentElement.addEventListener('touchstart', prefetch, { capture: true, passive: true }); + {# Client-side webmention fetcher - supplements build-time cache with real-time data #} + diff --git a/eleventy.config.js b/eleventy.config.js index 1d15e55..1a16c7c 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -121,6 +121,7 @@ export default function (eleventyConfig) { // Copy static assets to output eleventyConfig.addPassthroughCopy("css"); eleventyConfig.addPassthroughCopy("images"); + eleventyConfig.addPassthroughCopy("js"); // Watch for content changes eleventyConfig.addWatchTarget("./content/"); diff --git a/js/webmentions.js b/js/webmentions.js new file mode 100644 index 0000000..b9ab360 --- /dev/null +++ b/js/webmentions.js @@ -0,0 +1,352 @@ +/** + * Client-side webmention fetcher + * Supplements build-time cached webmentions with real-time data from webmention.io + */ + +(function () { + const container = document.querySelector('[data-webmentions]'); + if (!container) return; + + const target = container.dataset.target; + const domain = container.dataset.domain; + const buildTime = parseInt(container.dataset.buildtime, 10) || 0; + + if (!target || !domain) return; + + // Rate limit: only fetch once per page load + const cacheKey = `wm-fetched-${target}`; + if (sessionStorage.getItem(cacheKey)) return; + sessionStorage.setItem(cacheKey, '1'); + + const apiUrl = `https://webmention.io/api/mentions.jf2?target=${encodeURIComponent(target)}&per-page=100`; + + fetch(apiUrl) + .then((res) => res.json()) + .then((data) => { + if (!data.children || !data.children.length) return; + + // Filter to webmentions received after build time + const newMentions = data.children.filter((wm) => { + const wmTime = new Date(wm['wm-received']).getTime(); + return wmTime > buildTime; + }); + + if (!newMentions.length) return; + + // Group by type + const likes = newMentions.filter((m) => m['wm-property'] === 'like-of'); + const reposts = newMentions.filter((m) => m['wm-property'] === 'repost-of'); + const replies = newMentions.filter((m) => m['wm-property'] === 'in-reply-to'); + const mentions = newMentions.filter((m) => m['wm-property'] === 'mention-of'); + + // Append new likes + if (likes.length) { + appendAvatars('.webmention-likes .avatar-row', likes, 'likes'); + updateCount('.webmention-likes h3', likes.length); + } + + // Append new reposts + if (reposts.length) { + appendAvatars('.webmention-reposts .avatar-row', reposts, 'reposts'); + updateCount('.webmention-reposts h3', reposts.length); + } + + // Append new replies + if (replies.length) { + appendReplies('.webmention-replies ul', replies); + updateCount('.webmention-replies h3', replies.length); + } + + // Append new mentions + if (mentions.length) { + appendMentions('.webmention-mentions ul', mentions); + updateCount('.webmention-mentions h3', mentions.length); + } + + // Update total count in main header + updateTotalCount(newMentions.length); + }) + .catch((err) => { + console.debug('[Webmentions] Error fetching:', err.message); + }); + + function appendAvatars(selector, items, type) { + let row = document.querySelector(selector); + + // Create section if it doesn't exist + if (!row) { + const section = createAvatarSection(type); + const webmentionsSection = document.getElementById('webmentions'); + if (webmentionsSection) { + webmentionsSection.appendChild(section); + row = section.querySelector('.avatar-row'); + } else { + createWebmentionsSection(); + row = document.querySelector(selector); + } + } + + if (!row) return; + + items.forEach((item) => { + const author = item.author || {}; + + const link = document.createElement('a'); + link.href = author.url || '#'; + link.className = 'inline-block'; + link.title = author.name || 'Anonymous'; + link.target = '_blank'; + link.rel = 'noopener'; + link.dataset.new = 'true'; + + const img = document.createElement('img'); + img.src = author.photo || '/images/default-avatar.png'; + img.alt = author.name || 'Anonymous'; + img.className = 'w-8 h-8 rounded-full ring-2 ring-primary-500'; + img.loading = 'lazy'; + + link.appendChild(img); + row.appendChild(link); + }); + } + + function appendReplies(selector, items) { + let list = document.querySelector(selector); + + // Create section if it doesn't exist + if (!list) { + const section = createReplySection(); + const webmentionsSection = document.getElementById('webmentions'); + if (webmentionsSection) { + webmentionsSection.appendChild(section); + list = section.querySelector('ul'); + } else { + createWebmentionsSection(); + list = document.querySelector(selector); + } + } + + if (!list) return; + + items.forEach((item) => { + const author = item.author || {}; + const content = item.content || {}; + const published = item.published || item['wm-received']; + + const li = document.createElement('li'); + li.className = 'p-4 bg-surface-100 dark:bg-surface-800 rounded-lg ring-2 ring-primary-500'; + li.dataset.new = 'true'; + + // Build reply card using DOM methods + const wrapper = document.createElement('div'); + wrapper.className = 'flex gap-3'; + + // Avatar link + const avatarLink = document.createElement('a'); + avatarLink.href = author.url || '#'; + avatarLink.target = '_blank'; + avatarLink.rel = 'noopener'; + + const avatarImg = document.createElement('img'); + avatarImg.src = author.photo || '/images/default-avatar.png'; + avatarImg.alt = author.name || 'Anonymous'; + avatarImg.className = 'w-10 h-10 rounded-full'; + avatarImg.loading = 'lazy'; + avatarLink.appendChild(avatarImg); + + // Content area + const contentDiv = document.createElement('div'); + contentDiv.className = 'flex-1 min-w-0'; + + // Header row + const headerDiv = document.createElement('div'); + headerDiv.className = 'flex items-baseline gap-2 mb-1'; + + const authorLink = document.createElement('a'); + authorLink.href = author.url || '#'; + authorLink.className = 'font-semibold text-surface-900 dark:text-surface-100 hover:underline'; + authorLink.target = '_blank'; + authorLink.rel = 'noopener'; + authorLink.textContent = author.name || 'Anonymous'; + + const dateLink = document.createElement('a'); + dateLink.href = item.url || '#'; + dateLink.className = 'text-xs text-surface-500 hover:underline'; + dateLink.target = '_blank'; + dateLink.rel = 'noopener'; + + const timeEl = document.createElement('time'); + timeEl.dateTime = published; + timeEl.textContent = formatDate(published); + dateLink.appendChild(timeEl); + + const newBadge = document.createElement('span'); + newBadge.className = 'text-xs text-primary-600 dark:text-primary-400 font-medium'; + newBadge.textContent = 'NEW'; + + headerDiv.appendChild(authorLink); + headerDiv.appendChild(dateLink); + headerDiv.appendChild(newBadge); + + // Reply content - use textContent for safety + const replyDiv = document.createElement('div'); + replyDiv.className = 'text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none'; + replyDiv.textContent = content.text || ''; + + contentDiv.appendChild(headerDiv); + contentDiv.appendChild(replyDiv); + + wrapper.appendChild(avatarLink); + wrapper.appendChild(contentDiv); + li.appendChild(wrapper); + + // Prepend to show newest first + list.insertBefore(li, list.firstChild); + }); + } + + function appendMentions(selector, items) { + let list = document.querySelector(selector); + + if (!list) { + const section = createMentionSection(); + const webmentionsSection = document.getElementById('webmentions'); + if (webmentionsSection) { + webmentionsSection.appendChild(section); + list = section.querySelector('ul'); + } else { + createWebmentionsSection(); + list = document.querySelector(selector); + } + } + + if (!list) return; + + items.forEach((item) => { + const author = item.author || {}; + const published = item.published || item['wm-received']; + + const li = document.createElement('li'); + li.dataset.new = 'true'; + + const link = document.createElement('a'); + link.href = item.url || '#'; + link.className = 'text-primary-600 dark:text-primary-400 hover:underline'; + link.target = '_blank'; + link.rel = 'noopener'; + link.textContent = `${author.name || 'Someone'} mentioned this on ${formatDate(published)}`; + + const badge = document.createElement('span'); + badge.className = 'text-xs text-primary-600 dark:text-primary-400 font-medium ml-1'; + badge.textContent = 'NEW'; + + li.appendChild(link); + li.appendChild(badge); + + list.insertBefore(li, list.firstChild); + }); + } + + function updateCount(selector, additionalCount) { + const header = document.querySelector(selector); + if (!header) return; + + const text = header.textContent; + const match = text.match(/(\d+)/); + if (match) { + const currentCount = parseInt(match[1], 10); + const newCount = currentCount + additionalCount; + header.textContent = text.replace(/\d+/, newCount); + } + } + + function updateTotalCount(additionalCount) { + const header = document.querySelector('#webmentions h2'); + if (!header) return; + + const text = header.textContent; + const match = text.match(/\((\d+)\)/); + if (match) { + const currentCount = parseInt(match[1], 10); + const newCount = currentCount + additionalCount; + header.textContent = text.replace(/\(\d+\)/, `(${newCount})`); + } + } + + function createAvatarSection(type) { + const section = document.createElement('div'); + section.className = `webmention-${type} mb-6`; + + const header = document.createElement('h3'); + header.className = 'text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3'; + header.textContent = `0 ${type === 'likes' ? 'Likes' : 'Reposts'}`; + + const row = document.createElement('div'); + row.className = 'avatar-row'; + + section.appendChild(header); + section.appendChild(row); + + return section; + } + + function createReplySection() { + const section = document.createElement('div'); + section.className = 'webmention-replies'; + + const header = document.createElement('h3'); + header.className = 'text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-4'; + header.textContent = '0 Replies'; + + const list = document.createElement('ul'); + list.className = 'space-y-4'; + + section.appendChild(header); + section.appendChild(list); + + return section; + } + + function createMentionSection() { + const section = document.createElement('div'); + section.className = 'webmention-mentions mt-6'; + + const header = document.createElement('h3'); + header.className = 'text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3'; + header.textContent = '0 Mentions'; + + const list = document.createElement('ul'); + list.className = 'space-y-2 text-sm'; + + section.appendChild(header); + section.appendChild(list); + + return section; + } + + function createWebmentionsSection() { + const webmentionForm = document.querySelector('.webmention-form'); + if (!webmentionForm) return; + + const section = document.createElement('section'); + section.className = 'webmentions mt-8 pt-8 border-t border-surface-200 dark:border-surface-700'; + section.id = 'webmentions'; + + const header = document.createElement('h2'); + header.className = 'text-xl font-bold text-surface-900 dark:text-surface-100 mb-6'; + header.textContent = 'Webmentions (0)'; + + section.appendChild(header); + webmentionForm.parentNode.insertBefore(section, webmentionForm); + } + + function formatDate(dateStr) { + if (!dateStr) return ''; + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } +})();