Implement Webmention functionality: add Webmentions component, update deploy script, and enhance UI with social links and localization

This commit is contained in:
Adrian Altner 2026-04-21 23:46:18 +02:00
parent abbf2d9a0b
commit 934a9f2338
8 changed files with 460 additions and 10 deletions

126
src/lib/webmentions.ts Normal file
View file

@ -0,0 +1,126 @@
/**
* Build-time Webmention fetcher via webmention.io API.
*
* Requires WEBMENTION_TOKEN in the environment (see .env.local / .env.production).
* Token is read-only and is issued per registered webmention.io domain.
*/
export type WMProperty = 'in-reply-to' | 'like-of' | 'repost-of' | 'bookmark-of' | 'mention-of';
export interface WMAuthor {
type?: string;
name?: string;
url?: string;
photo?: string;
}
export interface WMEntry {
type: 'entry';
author?: WMAuthor;
url: string;
published?: string;
'wm-received'?: string;
'wm-id'?: number;
'wm-source'?: string;
'wm-target'?: string;
'wm-property'?: WMProperty;
'wm-private'?: boolean;
content?: { text?: string; html?: string };
name?: string;
}
interface WMResponse {
type: 'feed';
name?: string;
children: WMEntry[];
}
const API = 'https://webmention.io/api/mentions.jf2';
let cache: WMResponse | null = null;
async function fetchAll(): Promise<WMResponse> {
if (cache) return cache;
const token = import.meta.env.WEBMENTION_TOKEN;
if (!token) {
console.warn('[webmentions] WEBMENTION_TOKEN is not set — skipping fetch.');
cache = { type: 'feed', children: [] };
return cache;
}
const entries: WMEntry[] = [];
let page = 0;
const perPage = 100;
while (true) {
const url = new URL(API);
url.searchParams.set('domain', 'adrian-altner.de');
url.searchParams.set('token', token);
url.searchParams.set('per-page', String(perPage));
url.searchParams.set('page', String(page));
const res = await fetch(url);
if (!res.ok) {
console.warn(`[webmentions] API ${res.status} ${res.statusText}`);
break;
}
const json = (await res.json()) as WMResponse;
entries.push(...(json.children ?? []));
if (!json.children || json.children.length < perPage) break;
page += 1;
}
cache = { type: 'feed', children: entries };
return cache;
}
/**
* Return all webmentions targeting a given absolute URL.
* Matches both with and without trailing slash; ignores hash/query.
*/
export async function getMentionsFor(target: string | URL): Promise<WMEntry[]> {
const { children } = await fetchAll();
const wanted = normalize(target);
return children.filter((m) => {
const t = m['wm-target'];
if (!t) return false;
return normalize(t) === wanted;
});
}
function normalize(u: string | URL): string {
const url = typeof u === 'string' ? new URL(u) : new URL(u.toString());
url.hash = '';
url.search = '';
const path = url.pathname.replace(/\/+$/, '');
return `${url.origin}${path}`;
}
export function groupMentions(mentions: WMEntry[]): {
likes: WMEntry[];
reposts: WMEntry[];
bookmarks: WMEntry[];
replies: WMEntry[];
mentions: WMEntry[];
} {
const likes: WMEntry[] = [];
const reposts: WMEntry[] = [];
const bookmarks: WMEntry[] = [];
const replies: WMEntry[] = [];
const others: WMEntry[] = [];
for (const m of mentions) {
switch (m['wm-property']) {
case 'like-of':
likes.push(m);
break;
case 'repost-of':
reposts.push(m);
break;
case 'bookmark-of':
bookmarks.push(m);
break;
case 'in-reply-to':
replies.push(m);
break;
default:
others.push(m);
}
}
return { likes, reposts, bookmarks, replies, mentions: others };
}