331 lines
8.1 KiB
Text
331 lines
8.1 KiB
Text
---
|
|
import { readFileSync } from 'node:fs';
|
|
import { DEFAULT_LOCALE, type Locale } from '~/consts';
|
|
import { getLocaleFromUrl, t } from '~/i18n/ui';
|
|
|
|
let fileDebug = '';
|
|
function readTokenFromFile(): string | undefined {
|
|
const paths = ['/app/.webmention-token', '.webmention-token'];
|
|
const logs: string[] = [];
|
|
logs.push(`cwd=${process.cwd()}`);
|
|
for (const p of paths) {
|
|
try {
|
|
const raw = readFileSync(p, 'utf-8');
|
|
logs.push(`${p}:ok(${raw.length})`);
|
|
const t = raw.trim();
|
|
if (t) {
|
|
fileDebug = logs.join(';');
|
|
return t;
|
|
}
|
|
} catch (err) {
|
|
logs.push(`${p}:err(${(err as Error).code ?? 'unknown'})`);
|
|
}
|
|
}
|
|
fileDebug = logs.join(';');
|
|
return undefined;
|
|
}
|
|
|
|
const FILE_TOKEN = readTokenFromFile();
|
|
const WEBMENTION_TOKEN = FILE_TOKEN ?? (import.meta.env as Record<string, string | undefined>).WEBMENTION_TOKEN;
|
|
|
|
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> {
|
|
const token = WEBMENTION_TOKEN;
|
|
const tokenLen = typeof token === 'string' ? token.length : 0;
|
|
const source = FILE_TOKEN ? 'file' : 'env';
|
|
if (!token) return { mentions: [], debug: `no-token(src=${source},len=${tokenLen},probe=${fileDebug})` };
|
|
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(src=${source},len=${tokenLen},probe=${fileDebug}) 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>
|