Update Webmentions.astro

This commit is contained in:
2026-08-27 22:21:28 +08:00
parent b2982d16ca
commit 19d38b1ac0
+78 -5
View File
@@ -373,16 +373,23 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
content: string; content: string;
author: string; author: string;
siteName: string; siteName: string;
favicon: string; favicons: string[];
type: string; type: string;
}; };
type WebmentionClientState = { type WebmentionClientState = {
bound?: boolean; bound?: boolean;
controller?: AbortController; controller?: AbortController;
metadataCache?: Map<string, Promise<SourceMetadata | null>>;
observer?: IntersectionObserver; observer?: IntersectionObserver;
}; };
type SourceMetadata = {
title: string;
siteName: string;
favicon: string;
};
declare global { declare global {
interface Window { interface Window {
__miragesWebmentions?: WebmentionClientState; __miragesWebmentions?: WebmentionClientState;
@@ -390,6 +397,7 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
} }
const state = window.__miragesWebmentions ??= {}; const state = window.__miragesWebmentions ??= {};
const metadataCache = state.metadataCache ??= new Map<string, Promise<SourceMetadata | null>>();
const getString = (record: Record<string, unknown>, key: string) => { const getString = (record: Record<string, unknown>, key: string) => {
const value = record[key]; const value = record[key];
@@ -421,6 +429,12 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
return (parsed.body.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 1200); return (parsed.body.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 1200);
}; };
const objectValue = (value: unknown) => (
value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
);
const normalizeMention = (value: unknown): WebmentionRecord | null => { const normalizeMention = (value: unknown): WebmentionRecord | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null; if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>; const record = value as Record<string, unknown>;
@@ -434,7 +448,11 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
const favicon = safeAsset( const favicon = safeAsset(
getString(record, 'favicon') || getString(record, 'site_icon') || getString(record, 'icon'), getString(record, 'favicon') || getString(record, 'site_icon') || getString(record, 'icon'),
source source
) || new URL('/favicon.ico', source).href; );
const favicons = Array.from(new Set([
favicon,
`https://favicon.im/${encodeURIComponent(source.hostname)}?larger=true`
].filter(Boolean)));
return { return {
id: getString(record, 'id'), id: getString(record, 'id'),
source: source.href, source: source.href,
@@ -444,7 +462,7 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
content: plainText(getString(record, 'content')), content: plainText(getString(record, 'content')),
author: author || source.hostname, author: author || source.hostname,
siteName, siteName,
favicon, favicons,
type: getString(record, 'type').toLowerCase() type: getString(record, 'type').toLowerCase()
}; };
}; };
@@ -467,6 +485,46 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
const displayTitle = (mention: WebmentionRecord) => mention.title || mention.siteName || mention.hostname; const displayTitle = (mention: WebmentionRecord) => mention.title || mention.siteName || mention.hostname;
const sourceMetadata = (mention: WebmentionRecord) => {
const cached = metadataCache.get(mention.source);
if (cached) return cached;
const url = new URL('https://api.microlink.io/');
url.searchParams.set('url', mention.source);
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 8000);
const request = fetch(url, {
headers: { Accept: 'application/json' },
signal: controller.signal
}).then(async (response): Promise<SourceMetadata | null> => {
if (!response.ok) return null;
const payload = objectValue(await response.json());
const data = objectValue(payload?.data);
if (!data) return null;
const logo = objectValue(data.logo);
return {
title: plainText(typeof data.title === 'string' ? data.title : ''),
siteName: plainText(typeof data.publisher === 'string' ? data.publisher : ''),
favicon: safeAsset(typeof logo?.url === 'string' ? logo.url : '', new URL(mention.source))
};
}).catch(() => null).finally(() => {
window.clearTimeout(timeout);
});
metadataCache.set(mention.source, request);
return request;
};
const enrichMention = async (mention: WebmentionRecord) => {
const metadata = await sourceMetadata(mention);
if (!metadata) return mention;
return {
...mention,
title: mention.title || metadata.title,
siteName: metadata.siteName || mention.siteName,
favicons: Array.from(new Set([metadata.favicon, ...mention.favicons].filter(Boolean)))
};
};
const sourceLink = (mention: WebmentionRecord, className: string) => { const sourceLink = (mention: WebmentionRecord, className: string) => {
const link = document.createElement('a'); const link = document.createElement('a');
link.className = className; link.className = className;
@@ -486,7 +544,6 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
fallback.textContent = initialFor(mention.siteName || mention.hostname); fallback.textContent = initialFor(mention.siteName || mention.hostname);
const image = document.createElement('img'); const image = document.createElement('img');
image.src = mention.favicon;
image.alt = ''; image.alt = '';
image.loading = 'lazy'; image.loading = 'lazy';
image.decoding = 'async'; image.decoding = 'async';
@@ -494,11 +551,23 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
image.addEventListener('load', () => { image.addEventListener('load', () => {
wrapper.dataset.iconLoaded = 'true'; wrapper.dataset.iconLoaded = 'true';
}); });
let faviconIndex = 0;
image.addEventListener('error', () => { image.addEventListener('error', () => {
faviconIndex += 1;
const nextFavicon = mention.favicons[faviconIndex];
if (nextFavicon) {
image.src = nextFavicon;
return;
}
image.remove(); image.remove();
}); });
wrapper.append(fallback, image); wrapper.append(fallback);
const firstFavicon = mention.favicons[0];
if (firstFavicon) {
image.src = firstFavicon;
wrapper.append(image);
}
return wrapper; return wrapper;
}; };
@@ -627,6 +696,10 @@ const getEndpoint = endpoint ? getWebmentionEndpoint(endpoint, 'get') : '';
? data.map(normalizeMention).filter((mention): mention is WebmentionRecord => mention !== null) ? data.map(normalizeMention).filter((mention): mention is WebmentionRecord => mention !== null)
: []; : [];
renderMentions(root, mentions); renderMentions(root, mentions);
if (!mentions.length) return;
void Promise.all(mentions.map(enrichMention)).then((enriched) => {
if (root.isConnected) renderMentions(root, enriched);
});
}) })
.catch(() => { .catch(() => {
if (controller.signal.aborted || !root.isConnected) return; if (controller.signal.aborted || !root.isConnected) return;