adrian-altner.de/src/components/Webmentions.astro
Adrian Altner 0614688d2e
All checks were successful
Deploy / deploy (push) Successful in 1m18s
Refactor fetchMentions function to enhance token retrieval and debug output
2026-04-22 02:47:25 +02:00

315 lines
7.9 KiB
Text

---
import { WEBMENTION_TOKEN } from 'astro:env/server';
import { DEFAULT_LOCALE, type Locale } from '~/consts';
import { getLocaleFromUrl, t } from '~/i18n/ui';
interface WMAuthor {
name?: string;
url?: string;
photo?: string;
}
interface WMEntry {
author?: WMAuthor;
url: string;
published?: string;
'wm-received'?: string;
'wm-id'?: number;
'wm-property'?: string;
content?: { text?: string };
}
interface Props {
target: string | URL;
locale?: Locale;
}
const { target, locale = getLocaleFromUrl(Astro.url) ?? DEFAULT_LOCALE } = Astro.props;
interface FetchResult {
mentions: WMEntry[];
debug: string;
}
async function fetchMentions(target: string): Promise<FetchResult> {
// Probe multiple env sources to see which one has the token at build time.
const fromAstroEnv = WEBMENTION_TOKEN;
const procEnv = typeof process !== 'undefined' ? process.env : {};
const procKeyCount = Object.keys(procEnv).length;
const hasWebmentionKey = 'WEBMENTION_TOKEN' in procEnv;
const procValLen =
hasWebmentionKey && typeof procEnv['WEBMENTION_TOKEN'] === 'string'
? (procEnv['WEBMENTION_TOKEN'] as string).length
: 0;
const token = fromAstroEnv || (hasWebmentionKey ? (procEnv['WEBMENTION_TOKEN'] as string) : undefined);
const tokenLen = typeof token === 'string' ? token.length : 0;
const probe = `ae=${fromAstroEnv ? (fromAstroEnv as string).length : 0},pk=${procKeyCount},phas=${hasWebmentionKey},pv=${procValLen}`;
if (!token) return { mentions: [], debug: `no-token(${probe})` };
const withSlash = target.endsWith('/') ? target : `${target}/`;
const withoutSlash = target.replace(/\/+$/, '');
const fetchOne = async (t: string) => {
const url = new URL('https://webmention.io/api/mentions.jf2');
url.searchParams.set('target', t);
url.searchParams.set('token', token);
url.searchParams.set('per-page', '100');
const res = await fetch(url);
if (!res.ok) return { entries: [] as WMEntry[], status: res.status };
const json = (await res.json()) as { children?: WMEntry[] };
return { entries: json.children ?? [], status: 200 };
};
const [a, b] = await Promise.all([fetchOne(withSlash), fetchOne(withoutSlash)]);
const seen = new Set<number>();
const merged: WMEntry[] = [];
for (const m of [...a.entries, ...b.entries]) {
const id = m['wm-id'];
if (id == null || seen.has(id)) continue;
seen.add(id);
merged.push(m);
}
return {
mentions: merged,
debug: `ok(${probe}) slash=${a.status}:${a.entries.length} noslash=${b.status}:${b.entries.length}`,
};
}
const targetStr = target.toString();
const { mentions: all, debug: fetchDebug } = await fetchMentions(targetStr);
const likes = all.filter((m) => m['wm-property'] === 'like-of');
const reposts = all.filter((m) => m['wm-property'] === 'repost-of');
const replies = all.filter((m) => m['wm-property'] === 'in-reply-to');
const mentions = all.filter(
(m) => !['like-of', 'repost-of', 'in-reply-to', 'bookmark-of'].includes(m['wm-property'] ?? ''),
);
const facepile = [...likes, ...reposts];
function authorInitial(m: WMEntry) {
return m.author?.name?.trim()?.[0]?.toUpperCase() ?? '?';
}
function formatDate(iso?: string) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
const hasAny = facepile.length > 0 || replies.length > 0 || mentions.length > 0;
---
<div data-webmentions-debug data-target={targetStr} data-fetch={fetchDebug} data-all={all.length} data-facepile={facepile.length} data-hasany={String(hasAny)} hidden></div>
{
hasAny && (
<section class="webmentions" aria-labelledby="webmentions-heading">
<h2 id="webmentions-heading">{t(locale, 'webmentions.title')}</h2>
{facepile.length > 0 && (
<div class="facepile-group">
{likes.length > 0 && (
<div class="facepile">
<h3>
{`${likes.length} ${t(locale, likes.length === 1 ? 'webmentions.like' : 'webmentions.likes')}`}
</h3>
<ul>
{likes.map((m) => (
<li>
<a
href={m.url}
title={m.author?.name ?? m.url}
rel="noopener nofollow external"
>
{m.author?.photo ? (
<img src={m.author.photo} alt="" loading="lazy" />
) : (
<span class="avatar-fallback">{authorInitial(m)}</span>
)}
</a>
</li>
))}
</ul>
</div>
)}
{reposts.length > 0 && (
<div class="facepile">
<h3>
{`${reposts.length} ${t(locale, reposts.length === 1 ? 'webmentions.repost' : 'webmentions.reposts')}`}
</h3>
<ul>
{reposts.map((m) => (
<li>
<a
href={m.url}
title={m.author?.name ?? m.url}
rel="noopener nofollow external"
>
{m.author?.photo ? (
<img src={m.author.photo} alt="" loading="lazy" />
) : (
<span class="avatar-fallback">{authorInitial(m)}</span>
)}
</a>
</li>
))}
</ul>
</div>
)}
</div>
)}
{replies.length > 0 && (
<div class="replies">
<h3>{t(locale, 'webmentions.replies')}</h3>
<ol>
{replies.map((m) => (
<li>
<div class="meta">
{m.author?.photo && (
<img src={m.author.photo} alt="" class="avatar" loading="lazy" />
)}
<a
href={m.author?.url ?? m.url}
rel="noopener nofollow external"
class="author"
>
{m.author?.name ?? m.url}
</a>
<a href={m.url} rel="noopener nofollow external" class="permalink">
<time datetime={m['wm-received'] ?? m.published}>
{formatDate(m['wm-received'] ?? m.published)}
</time>
</a>
</div>
{m.content?.text && <p>{m.content.text}</p>}
</li>
))}
</ol>
</div>
)}
{mentions.length > 0 && (
<div class="mentions">
<h3>{t(locale, 'webmentions.mentions')}</h3>
<ul>
{mentions.map((m) => (
<li>
<a href={m.url} rel="noopener nofollow external">
{m.author?.name ?? m.url}
</a>
{m['wm-received'] && (
<>
{' · '}
<time datetime={m['wm-received']}>{formatDate(m['wm-received'])}</time>
</>
)}
</li>
))}
</ul>
</div>
)}
</section>
)
}
<style>
.webmentions {
margin-top: 3em;
padding-top: 2em;
border-top: 1px solid rgba(var(--gray-light), 1);
}
.webmentions h2 {
margin: 0 0 1em;
font-size: 1.25em;
}
.webmentions h3 {
margin: 0 0 0.5em;
font-size: 1em;
color: rgb(var(--gray));
font-weight: 600;
}
.facepile-group {
display: flex;
flex-wrap: wrap;
gap: 2em;
margin-bottom: 2em;
}
.facepile ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.facepile a {
display: inline-block;
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
background: rgba(var(--gray-light), 1);
}
.facepile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-weight: 600;
color: rgb(var(--gray-dark));
}
.replies ol {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.25em;
}
.replies li {
padding: 0.75em 1em;
background: rgba(var(--gray-light), 0.4);
border-radius: 8px;
}
.replies .meta {
display: flex;
align-items: center;
gap: 0.6em;
font-size: 0.9em;
margin-bottom: 0.25em;
}
.replies .avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.replies .author {
font-weight: 600;
}
.replies .permalink {
margin-left: auto;
color: rgb(var(--gray));
font-size: 0.85em;
}
.replies p {
margin: 0;
}
.mentions ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.3em;
font-size: 0.9em;
}
</style>