feat: add client-side webmention fetcher for real-time updates
- Add js/webmentions.js to fetch new webmentions from webmention.io API - Supplements build-time cached webmentions with real-time data - Shows new webmentions with 'NEW' badge and visual ring highlight - Uses safe DOM methods to prevent XSS vulnerabilities - Data attributes on webmentions container provide target URL and build time Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 #}
|
||||
<div data-webmentions
|
||||
data-target="{{ absoluteUrl }}"
|
||||
data-domain="{{ site.webmentions.domain }}"
|
||||
data-buildtime="{{ buildTimestamp }}"
|
||||
class="hidden"></div>
|
||||
|
||||
{% if mentions.length %}
|
||||
<section class="webmentions mt-8 pt-8 border-t border-surface-200 dark:border-surface-700" id="webmentions">
|
||||
|
||||
@@ -218,5 +218,7 @@
|
||||
document.documentElement.addEventListener('mouseover', prefetch, { capture: true, passive: true });
|
||||
document.documentElement.addEventListener('touchstart', prefetch, { capture: true, passive: true });
|
||||
</script>
|
||||
{# Client-side webmention fetcher - supplements build-time cache with real-time data #}
|
||||
<script src="/js/webmentions.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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/");
|
||||
|
||||
352
js/webmentions.js
Normal file
352
js/webmentions.js
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user