mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 08:44:56 +02:00
- Add skip-to-main-content link and main content ID target - Add prefers-reduced-motion media queries for all animations - Enhance visible focus indicators (2px offset, high-contrast ring) - Replace ~160 text-surface-500 instances with text-surface-600/dark:text-surface-400 for 4.5:1+ contrast ratio compliance - Add aria-hidden="true" to ~30+ decorative SVG icons across sidebars/widgets - Convert facepile containers from div to semantic ul/li with role="list" - Add aria-label to icon-only buttons (share, sort controls) - Add sr-only labels to form inputs (webmention, search) - Add aria-live="polite" to dynamically loaded webmentions - Add aria-label with relative+absolute date to time-difference component - Add keyboard handlers (Enter/Space) to custom interactive elements - Add aria-label to nav landmarks (table of contents) - Fix modal focus trap and dialog accessibility - Fix lightbox keyboard navigation and screen reader announcements Confab-Link: http://localhost:8080/sessions/edb1b7b0-da66-4486-bd9c-d1cfa7553b88
475 lines
16 KiB
JavaScript
475 lines
16 KiB
JavaScript
/**
|
|
* Client-side webmention fetcher
|
|
* Supplements build-time cached webmentions with real-time data from proxy API
|
|
*/
|
|
|
|
(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;
|
|
|
|
// 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)
|
|
const targetWithSlash = target.endsWith('/') ? target : target + '/';
|
|
const targetWithoutSlash = target.endsWith('/') ? target.slice(0, -1) : target;
|
|
const apiUrl1 = `/webmentions/api/mentions?target=${encodeURIComponent(targetWithSlash)}&per-page=100`;
|
|
const apiUrl2 = `/webmentions/api/mentions?target=${encodeURIComponent(targetWithoutSlash)}&per-page=100`;
|
|
|
|
// Check if build-time webmentions section exists
|
|
const hasBuildTimeSection = document.getElementById('webmentions') !== null;
|
|
|
|
// Cache API responses in sessionStorage (5 min TTL) so webmentions
|
|
// persist across page refreshes without re-fetching every time
|
|
const cacheKey = `wm-data-${target}`;
|
|
const cacheTTL = 5 * 60 * 1000; // 5 minutes
|
|
|
|
function getCachedData() {
|
|
try {
|
|
const cached = sessionStorage.getItem(cacheKey);
|
|
if (!cached) return null;
|
|
const parsed = JSON.parse(cached);
|
|
if (Date.now() - parsed.ts > cacheTTL) {
|
|
sessionStorage.removeItem(cacheKey);
|
|
return null;
|
|
}
|
|
return parsed.children;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setCachedData(children) {
|
|
try {
|
|
sessionStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), children: children }));
|
|
} catch {
|
|
// sessionStorage full or unavailable - no problem
|
|
}
|
|
}
|
|
|
|
function processWebmentions(allChildren) {
|
|
if (!allChildren || !allChildren.length) return;
|
|
|
|
let mentionsToShow;
|
|
if (hasBuildTimeSection) {
|
|
// Build-time section exists - only show NEW webmentions to avoid duplicates.
|
|
// Both webmention.io and conversations items are included at build time,
|
|
// so filter all by timestamp (only show items received after the build).
|
|
mentionsToShow = allChildren.filter((wm) => {
|
|
const wmTime = new Date(wm['wm-received']).getTime();
|
|
return wmTime > buildTime;
|
|
});
|
|
} else {
|
|
// No build-time section - show ALL webmentions from API
|
|
mentionsToShow = allChildren;
|
|
}
|
|
|
|
if (!mentionsToShow.length) return;
|
|
|
|
// 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');
|
|
}
|
|
|
|
// Update total count in main header
|
|
updateTotalCount(mentionsToShow.length);
|
|
}
|
|
|
|
// Try cached data first (renders instantly on refresh)
|
|
const cached = getCachedData();
|
|
if (cached) {
|
|
processWebmentions(cached);
|
|
}
|
|
|
|
// Conversations API URLs (dual-fetch for enriched data)
|
|
const convApiUrl1 = `/conversations/api/mentions?target=${encodeURIComponent(targetWithSlash)}&per-page=100`;
|
|
const convApiUrl2 = `/conversations/api/mentions?target=${encodeURIComponent(targetWithoutSlash)}&per-page=100`;
|
|
|
|
// Always fetch fresh data from both APIs (updates cache for next refresh)
|
|
Promise.all([
|
|
fetch(apiUrl1).then((res) => res.json()).catch(() => ({ children: [] })),
|
|
fetch(apiUrl2).then((res) => res.json()).catch(() => ({ children: [] })),
|
|
fetch(convApiUrl1).then((res) => res.ok ? res.json() : { children: [] }).catch(() => ({ children: [] })),
|
|
fetch(convApiUrl2).then((res) => res.ok ? res.json() : { children: [] }).catch(() => ({ children: [] })),
|
|
])
|
|
.then(([wmData1, wmData2, convData1, convData2]) => {
|
|
// Collect all items from both APIs
|
|
const wmItems = [...(wmData1.children || []), ...(wmData2.children || [])];
|
|
const convItems = [...(convData1.children || []), ...(convData2.children || [])];
|
|
|
|
// 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();
|
|
const allChildren = [];
|
|
|
|
// Add conversations items first (they have platform provenance)
|
|
for (const wm of convItems) {
|
|
const key = wm['wm-id'] || wm.url;
|
|
if (key && !seen.has(key)) {
|
|
seen.add(key);
|
|
allChildren.push(wm);
|
|
}
|
|
}
|
|
|
|
// Build set of author+action keys from conversations for cross-source dedup
|
|
const authorActions = new Set();
|
|
for (const wm of convItems) {
|
|
const authorUrl = (wm.author && wm.author.url) || wm.url || '';
|
|
const action = wm['wm-property'] || 'mention';
|
|
if (authorUrl) authorActions.add(authorUrl + '::' + action);
|
|
}
|
|
|
|
// Add webmention-io items, skipping duplicates
|
|
for (const wm of wmItems) {
|
|
const key = wm['wm-id'];
|
|
if (seen.has(key)) continue;
|
|
// Also skip if same source URL exists in conversations
|
|
if (wm.url && convUrls.has(wm.url)) continue;
|
|
// Skip if same author + same action type already from conversations
|
|
const authorUrl = (wm.author && wm.author.url) || wm.url || '';
|
|
const action = wm['wm-property'] || 'mention';
|
|
if (authorUrl && authorActions.has(authorUrl + '::' + action)) continue;
|
|
seen.add(key);
|
|
allChildren.push(wm);
|
|
}
|
|
|
|
// Cache the merged results
|
|
setCachedData(allChildren);
|
|
|
|
// Only render if we didn't already render from cache
|
|
if (!cached) {
|
|
processWebmentions(allChildren);
|
|
}
|
|
})
|
|
.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);
|
|
let webmentionsSection = document.getElementById('webmentions');
|
|
if (!webmentionsSection) {
|
|
createWebmentionsSection();
|
|
webmentionsSection = document.getElementById('webmentions');
|
|
}
|
|
if (webmentionsSection) {
|
|
webmentionsSection.appendChild(section);
|
|
row = section.querySelector('.facepile');
|
|
}
|
|
}
|
|
|
|
if (!row) return;
|
|
|
|
items.forEach((item) => {
|
|
const author = item.author || {};
|
|
|
|
const li = document.createElement('li');
|
|
li.className = 'inline';
|
|
|
|
const link = document.createElement('a');
|
|
link.href = author.url || '#';
|
|
link.className = 'facepile-avatar';
|
|
link.setAttribute('aria-label', (author.name || 'Anonymous') + ' (opens in new tab)');
|
|
link.target = '_blank';
|
|
link.rel = 'noopener';
|
|
link.dataset.new = 'true';
|
|
|
|
const img = document.createElement('img');
|
|
img.src = author.photo || '/images/default-avatar.svg';
|
|
img.alt = '';
|
|
img.className = 'w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900';
|
|
img.loading = 'lazy';
|
|
|
|
link.appendChild(img);
|
|
li.appendChild(link);
|
|
row.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function appendReplies(selector, items) {
|
|
let list = document.querySelector(selector);
|
|
|
|
// Create section if it doesn't exist
|
|
if (!list) {
|
|
const section = createReplySection();
|
|
let webmentionsSection = document.getElementById('webmentions');
|
|
if (!webmentionsSection) {
|
|
createWebmentionsSection();
|
|
webmentionsSection = document.getElementById('webmentions');
|
|
}
|
|
if (webmentionsSection) {
|
|
webmentionsSection.appendChild(section);
|
|
list = section.querySelector('ul');
|
|
}
|
|
}
|
|
|
|
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-accent-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.svg';
|
|
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-600 dark:text-surface-400 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-accent-600 dark:text-accent-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();
|
|
let webmentionsSection = document.getElementById('webmentions');
|
|
if (!webmentionsSection) {
|
|
createWebmentionsSection();
|
|
webmentionsSection = document.getElementById('webmentions');
|
|
}
|
|
if (webmentionsSection) {
|
|
webmentionsSection.appendChild(section);
|
|
list = section.querySelector('ul');
|
|
}
|
|
}
|
|
|
|
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-accent-600 dark:text-accent-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-accent-600 dark:text-accent-400 font-medium ml-1';
|
|
badge.textContent = 'NEW';
|
|
|
|
li.appendChild(link);
|
|
li.appendChild(badge);
|
|
|
|
list.insertBefore(li, list.firstChild);
|
|
});
|
|
}
|
|
|
|
function updateCount(selector, additionalCount, noun, pluralSuffix, singularSuffix) {
|
|
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;
|
|
if (noun) {
|
|
// Rebuild the header text with correct pluralization
|
|
var suffix = pluralSuffix || 's';
|
|
var singSuffix = singularSuffix || '';
|
|
header.textContent = newCount + ' ' + noun + (newCount !== 1 ? suffix : singSuffix);
|
|
} else {
|
|
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('ul');
|
|
row.className = 'facepile';
|
|
row.setAttribute('role', 'list');
|
|
|
|
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';
|
|
section.setAttribute('aria-live', 'polite');
|
|
section.setAttribute('aria-label', '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',
|
|
});
|
|
}
|
|
})();
|