Refactor Webmentions component to improve display of likes and reposts count
All checks were successful
Deploy / deploy (push) Successful in 1m16s

This commit is contained in:
Adrian Altner 2026-04-22 01:35:13 +02:00
parent c9ad64d217
commit 42521444a8
2 changed files with 41 additions and 46 deletions

View file

@ -42,7 +42,7 @@ const hasAny = facepile.length > 0 || replies.length > 0 || mentions.length > 0;
{likes.length > 0 && (
<div class="facepile">
<h3>
{likes.length} {t(locale, likes.length === 1 ? 'webmentions.like' : 'webmentions.likes')}
{`${likes.length} ${t(locale, likes.length === 1 ? 'webmentions.like' : 'webmentions.likes')}`}
</h3>
<ul>
{likes.map((m) => (
@ -67,8 +67,7 @@ const hasAny = facepile.length > 0 || replies.length > 0 || mentions.length > 0;
{reposts.length > 0 && (
<div class="facepile">
<h3>
{reposts.length}{' '}
{t(locale, reposts.length === 1 ? 'webmentions.repost' : 'webmentions.reposts')}
{`${reposts.length} ${t(locale, reposts.length === 1 ? 'webmentions.repost' : 'webmentions.reposts')}`}
</h3>
<ul>
{reposts.map((m) => (

View file

@ -1,8 +1,11 @@
/**
* 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.
* Requires WEBMENTION_TOKEN in the environment. Token is read-only and
* issued per registered webmention.io domain.
*
* Fetches per target URL (not domain-wide) to avoid inconsistencies when
* a domain is registered more than once at webmention.io.
*/
export type WMProperty = 'in-reply-to' | 'like-of' | 'repost-of' | 'bookmark-of' | 'mention-of';
@ -37,59 +40,52 @@ interface WMResponse {
const API = 'https://webmention.io/api/mentions.jf2';
let cache: WMResponse | null = null;
const perTargetCache = new Map<string, WMEntry[]>();
async function fetchAll(): Promise<WMResponse> {
if (cache) return cache;
async function fetchForTarget(target: string): Promise<WMEntry[]> {
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;
return [];
}
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('target', target);
url.searchParams.set('token', token);
url.searchParams.set('per-page', String(perPage));
url.searchParams.set('page', String(page));
url.searchParams.set('per-page', '100');
const res = await fetch(url);
if (!res.ok) {
console.warn(`[webmentions] API ${res.status} ${res.statusText}`);
break;
console.warn(`[webmentions] API ${res.status} ${res.statusText} for ${target}`);
return [];
}
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 json.children ?? [];
}
/**
* Return all webmentions targeting a given absolute URL.
* Matches both with and without trailing slash; ignores hash/query.
* Return all webmentions targeting a given absolute URL. Tries both the
* canonical URL and its trailing-slash variant, since webmention.io
* indexes targets verbatim.
*/
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;
});
}
const canonical = typeof target === 'string' ? target : target.toString();
const withSlash = canonical.endsWith('/') ? canonical : `${canonical}/`;
const withoutSlash = canonical.replace(/\/+$/, '');
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}`;
const cached = perTargetCache.get(canonical);
if (cached) return cached;
const [a, b] = await Promise.all([fetchForTarget(withSlash), fetchForTarget(withoutSlash)]);
const seen = new Set<number>();
const merged: WMEntry[] = [];
for (const m of [...a, ...b]) {
const id = m['wm-id'];
if (id == null || seen.has(id)) continue;
seen.add(id);
merged.push(m);
}
perTargetCache.set(canonical, merged);
return merged;
}
export function groupMentions(mentions: WMEntry[]): {