@hhkaos/webmentions-widget 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +53 -0
- package/src/core.js +564 -0
- package/src/index.js +2 -0
- package/src/react.js +251 -0
- package/src/render.js +375 -0
package/src/core.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic webmention.io helpers.
|
|
3
|
+
*
|
|
4
|
+
* Nothing in here touches the DOM unless you hand it a document/parser, so the
|
|
5
|
+
* whole module is safe to import during SSR or from a plain Node test run.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_API_URL = 'https://webmention.io/api/mentions.jf2';
|
|
9
|
+
export const DEFAULT_JSON_API_URL = 'https://webmention.io/api/mentions.json';
|
|
10
|
+
export const DEFAULT_MAX_CONTENT_LENGTH = 220;
|
|
11
|
+
export const DEFAULT_PER_PAGE = 20;
|
|
12
|
+
|
|
13
|
+
const CONTEXT_TAGS = ['P', 'LI', 'BLOCKQUOTE'];
|
|
14
|
+
const FALLBACK_LOCALE_PREFIXES = ['/es'];
|
|
15
|
+
|
|
16
|
+
/** Properties rendered as a facepile rather than as a full thread entry. */
|
|
17
|
+
export const FACEPILE_PROPERTIES = ['like-of', 'repost-of', 'bookmark-of'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* webmention.io is inconsistent about the mention property: the jf2 feed uses
|
|
21
|
+
* `mention-of`, some older payloads and the .json endpoint say `mention`.
|
|
22
|
+
* Normalise so consumers only ever branch on one spelling.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeProperty(property) {
|
|
25
|
+
return property === 'mention' ? 'mention-of' : property;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_LABELS = {
|
|
29
|
+
'like-of': 'Like',
|
|
30
|
+
'repost-of': 'Repost',
|
|
31
|
+
'bookmark-of': 'Bookmark',
|
|
32
|
+
'in-reply-to': 'Reply',
|
|
33
|
+
'mention-of': 'Mention',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function getMentionType(mention, labels = {}) {
|
|
37
|
+
const property = normalizeProperty(mention?.['wm-property']);
|
|
38
|
+
|
|
39
|
+
return labels[property]
|
|
40
|
+
|| DEFAULT_LABELS[property]
|
|
41
|
+
|| labels['mention-of']
|
|
42
|
+
|| DEFAULT_LABELS['mention-of'];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function cleanText(value) {
|
|
46
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* webmention.io and Bridgy mangle emoji into runs of "?" (and sometimes U+FFFD)
|
|
51
|
+
* when extracting plain text from a toot or skeet. The emoji is unrecoverable,
|
|
52
|
+
* so drop the debris instead of rendering it.
|
|
53
|
+
*/
|
|
54
|
+
export function stripMojibake(value) {
|
|
55
|
+
return String(value ?? '')
|
|
56
|
+
.replace(/�+/g, '')
|
|
57
|
+
.replace(/\s*\?{3,}\s*/g, ' ')
|
|
58
|
+
.replace(/\s+([.,!?;:…])/g, '$1')
|
|
59
|
+
.replace(/\s{2,}/g, ' ')
|
|
60
|
+
.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getPathnameVariants(pathname, i18n, explicitPrefixes) {
|
|
64
|
+
const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
65
|
+
const configuredLocalePrefixes = (i18n?.locales || [])
|
|
66
|
+
.filter((locale) => locale !== i18n?.defaultLocale)
|
|
67
|
+
.map((locale) => `/${locale}`);
|
|
68
|
+
const localePrefixes = explicitPrefixes
|
|
69
|
+
|| (configuredLocalePrefixes.length > 0 ? configuredLocalePrefixes : FALLBACK_LOCALE_PREFIXES);
|
|
70
|
+
const pathnames = new Set([normalizedPathname]);
|
|
71
|
+
|
|
72
|
+
localePrefixes.forEach((prefix) => {
|
|
73
|
+
if (normalizedPathname === prefix) {
|
|
74
|
+
pathnames.add('/');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (normalizedPathname.startsWith(`${prefix}/`)) {
|
|
79
|
+
pathnames.add(normalizedPathname.slice(prefix.length) || '/');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
pathnames.add(normalizedPathname === '/' ? prefix : `${prefix}${normalizedPathname}`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return [...pathnames];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* webmention.io matches targets by exact string, so a mention stored against
|
|
91
|
+
* `https://example.com/post/` is invisible to a query for
|
|
92
|
+
* `https://www.example.com/post`. Expand every variant we might have published
|
|
93
|
+
* under: www/no-www, trailing slash or not, and locale-prefixed paths.
|
|
94
|
+
*/
|
|
95
|
+
export function getCanonicalTargets({siteUrl, pathname = '/', i18n, localePrefixes} = {}) {
|
|
96
|
+
if (!siteUrl) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const baseUrl = new URL(siteUrl);
|
|
101
|
+
const hosts = baseUrl.hostname.startsWith('www.')
|
|
102
|
+
? [baseUrl.hostname, baseUrl.hostname.replace(/^www\./, '')]
|
|
103
|
+
: [baseUrl.hostname, `www.${baseUrl.hostname}`];
|
|
104
|
+
|
|
105
|
+
return [...new Set(getPathnameVariants(pathname, i18n, localePrefixes).flatMap((pathnameVariant) => (
|
|
106
|
+
hosts.flatMap((hostname) => {
|
|
107
|
+
const targetUrl = new URL(pathnameVariant, siteUrl);
|
|
108
|
+
targetUrl.hostname = hostname;
|
|
109
|
+
targetUrl.hash = '';
|
|
110
|
+
targetUrl.search = '';
|
|
111
|
+
|
|
112
|
+
const canonicalUrl = targetUrl.toString();
|
|
113
|
+
const withoutSlash = canonicalUrl.replace(/\/$/, '');
|
|
114
|
+
const withSlash = canonicalUrl.endsWith('/') ? canonicalUrl : `${canonicalUrl}/`;
|
|
115
|
+
|
|
116
|
+
return [canonicalUrl, withoutSlash, withSlash].filter(Boolean);
|
|
117
|
+
})
|
|
118
|
+
)))];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getCanonicalTargetsFromDocument(doc = globalThis.document) {
|
|
122
|
+
const canonical = doc?.querySelector?.('link[rel="canonical"]')?.href || doc?.location?.href;
|
|
123
|
+
|
|
124
|
+
if (!canonical) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const url = new URL(canonical);
|
|
129
|
+
|
|
130
|
+
return getCanonicalTargets({siteUrl: url.origin, pathname: url.pathname});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function formatMentionDate(value, locale) {
|
|
134
|
+
if (!value) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const date = new Date(value);
|
|
139
|
+
if (Number.isNaN(date.getTime())) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return new Intl.DateTimeFormat(locale, {
|
|
144
|
+
year: 'numeric',
|
|
145
|
+
month: 'short',
|
|
146
|
+
day: 'numeric',
|
|
147
|
+
}).format(date);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeUrl(value, base) {
|
|
151
|
+
if (!value) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const url = new URL(value, base);
|
|
157
|
+
url.hash = '';
|
|
158
|
+
|
|
159
|
+
return url.toString().replace(/\/$/, '');
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeMentionText(value, mention = {}) {
|
|
166
|
+
const text = stripMojibake(cleanText(value)).replace(/^\?{2,}\s*/, '');
|
|
167
|
+
const name = stripMojibake(cleanText(mention.name)).replace(/^\?{2,}\s*/, '');
|
|
168
|
+
|
|
169
|
+
// Mastodon-sourced entries repeat the post name at the head of the content.
|
|
170
|
+
if (name && text.indexOf(name) === 0) {
|
|
171
|
+
return cleanText(text.slice(name.length));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return text
|
|
175
|
+
.replace(/([A-Za-zÁÉÍÓÚÜÑáéíóúüñ])(\d)/g, '$1 $2')
|
|
176
|
+
.replace(/(\d)([A-Za-zÁÉÍÓÚÜÑáéíóúüñ])/g, '$1 $2')
|
|
177
|
+
.replace(/\s+/g, ' ')
|
|
178
|
+
.trim();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function moveStartToWordBoundary(content, start, focusIndex) {
|
|
182
|
+
if (start === 0) {
|
|
183
|
+
return start;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const nextSpace = content.slice(start).search(/\s/);
|
|
187
|
+
const nextStart = nextSpace === -1 ? start : start + nextSpace + 1;
|
|
188
|
+
|
|
189
|
+
return nextStart < focusIndex ? nextStart : start;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function moveEndToWordBoundary(content, end, focusEnd) {
|
|
193
|
+
if (end === content.length) {
|
|
194
|
+
return end;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const previousSpace = content.slice(0, end).lastIndexOf(' ');
|
|
198
|
+
|
|
199
|
+
return previousSpace > focusEnd ? previousSpace : end;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function excerptText(value, {
|
|
203
|
+
focus,
|
|
204
|
+
focusIndex,
|
|
205
|
+
mention,
|
|
206
|
+
maxLength = DEFAULT_MAX_CONTENT_LENGTH,
|
|
207
|
+
} = {}) {
|
|
208
|
+
const content = normalizeMentionText(value, mention);
|
|
209
|
+
|
|
210
|
+
if (!content) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (content.length <= maxLength) {
|
|
215
|
+
return content;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const focusedText = cleanText(focus);
|
|
219
|
+
const resolvedFocusIndex = Number.isInteger(focusIndex)
|
|
220
|
+
? focusIndex
|
|
221
|
+
: focusedText ? content.indexOf(focusedText) : -1;
|
|
222
|
+
|
|
223
|
+
if (resolvedFocusIndex === -1) {
|
|
224
|
+
const end = moveEndToWordBoundary(content, maxLength - 3, 0);
|
|
225
|
+
|
|
226
|
+
return `${content.slice(0, end)}...`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const maxContextLength = maxLength - 6;
|
|
230
|
+
const roomAroundFocus = Math.max(maxContextLength - focusedText.length, 0);
|
|
231
|
+
const focusEnd = resolvedFocusIndex + focusedText.length;
|
|
232
|
+
const start = Math.max(0, resolvedFocusIndex - Math.floor(roomAroundFocus / 2));
|
|
233
|
+
const end = Math.min(content.length, start + maxContextLength);
|
|
234
|
+
const adjustedStart = Math.max(0, end - maxContextLength);
|
|
235
|
+
const wordStart = moveStartToWordBoundary(content, adjustedStart, resolvedFocusIndex);
|
|
236
|
+
const wordEnd = moveEndToWordBoundary(content, end, focusEnd);
|
|
237
|
+
const excerpt = content.slice(wordStart, wordEnd);
|
|
238
|
+
|
|
239
|
+
return `${wordStart > 0 ? '...' : ''}${excerpt}${wordEnd < content.length ? '...' : ''}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function defaultParseHTML(html) {
|
|
243
|
+
if (typeof DOMParser === 'undefined') {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return new DOMParser().parseFromString(html, 'text/html');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getLinkContext(context, link) {
|
|
251
|
+
if (context === link) {
|
|
252
|
+
return {text: cleanText(link.textContent), focusIndex: 0};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const marker = '__webmention_link_context__';
|
|
256
|
+
|
|
257
|
+
link.insertAdjacentText('beforebegin', marker);
|
|
258
|
+
link.insertAdjacentText('afterend', marker);
|
|
259
|
+
|
|
260
|
+
const markedText = cleanText(context.textContent);
|
|
261
|
+
const focusIndex = markedText.indexOf(marker);
|
|
262
|
+
const text = cleanText(markedText.replaceAll(marker, ''));
|
|
263
|
+
|
|
264
|
+
return {text, focusIndex};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Find the anchor in the source page that points back at us and quote the
|
|
269
|
+
* sentence around it, rather than blindly excerpting from the top of the post.
|
|
270
|
+
*/
|
|
271
|
+
function findLinkContext(html, target, source, maxLength, parseHTML) {
|
|
272
|
+
const normalizedTarget = normalizeUrl(target);
|
|
273
|
+
if (!html || !normalizedTarget) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const doc = parseHTML(html);
|
|
278
|
+
if (!doc) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const link = [...doc.querySelectorAll('a[href]')].find((anchor) => (
|
|
283
|
+
normalizeUrl(anchor.getAttribute('href'), source) === normalizedTarget
|
|
284
|
+
|| normalizeUrl(anchor.href) === normalizedTarget
|
|
285
|
+
));
|
|
286
|
+
|
|
287
|
+
if (!link) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const context = CONTEXT_TAGS.includes(link.parentElement?.tagName)
|
|
292
|
+
? link.parentElement
|
|
293
|
+
: link.closest(CONTEXT_TAGS.map((tagName) => tagName.toLowerCase()).join(','));
|
|
294
|
+
const {text, focusIndex} = getLinkContext(context || link, link);
|
|
295
|
+
|
|
296
|
+
return excerptText(text, {focus: link.textContent, focusIndex, maxLength});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function getMentionContent(mention, options = {}) {
|
|
300
|
+
const parseHTML = options.parseHTML || defaultParseHTML;
|
|
301
|
+
|
|
302
|
+
return findLinkContext(
|
|
303
|
+
mention.content?.html,
|
|
304
|
+
mention['wm-target'],
|
|
305
|
+
mention['wm-source'],
|
|
306
|
+
options.maxLength,
|
|
307
|
+
parseHTML,
|
|
308
|
+
)
|
|
309
|
+
|| excerptText(mention.content?.text, {...options, mention})
|
|
310
|
+
|| excerptText(mention.content?.html, {...options, mention});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Deep-link back into the source post at the quoted sentence. */
|
|
314
|
+
export function getMentionSourceUrl(mention, content) {
|
|
315
|
+
const sourceUrl = mention.url || mention['wm-source'];
|
|
316
|
+
const fragmentText = cleanText(content).replace(/^\.\.\./, '').replace(/\.\.\.$/, '');
|
|
317
|
+
|
|
318
|
+
if (!sourceUrl || !fragmentText) {
|
|
319
|
+
return sourceUrl;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const url = new URL(sourceUrl);
|
|
324
|
+
url.hash = `:~:text=${encodeURIComponent(fragmentText)}`;
|
|
325
|
+
|
|
326
|
+
return url.toString();
|
|
327
|
+
} catch {
|
|
328
|
+
return sourceUrl;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function getAuthorKey(mention) {
|
|
333
|
+
const author = mention.author || {};
|
|
334
|
+
|
|
335
|
+
return cleanText(author.url || author.name || author.photo || mention.url || mention['wm-source']);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Split a feed into a facepile and a thread.
|
|
340
|
+
*
|
|
341
|
+
* `interactions` is the flat, deduped facepile kept for backwards
|
|
342
|
+
* compatibility; `byProperty` keeps like/repost/bookmark separate so a renderer
|
|
343
|
+
* can show "3 likes · 1 repost" instead of one undifferentiated pile.
|
|
344
|
+
*/
|
|
345
|
+
export function groupWebmentions(mentions, {facepileProperties = FACEPILE_PROPERTIES} = {}) {
|
|
346
|
+
const facepileSet = new Set(facepileProperties);
|
|
347
|
+
const seenMentions = new Set();
|
|
348
|
+
const interactionAuthors = new Set();
|
|
349
|
+
const byProperty = Object.fromEntries(facepileProperties.map((property) => [property, []]));
|
|
350
|
+
const interactions = [];
|
|
351
|
+
const threads = [];
|
|
352
|
+
|
|
353
|
+
(mentions || []).forEach((mention) => {
|
|
354
|
+
if (!mention || !(mention.url || mention['wm-source'])) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const property = normalizeProperty(mention['wm-property']);
|
|
359
|
+
const mentionKey = mention['wm-id'] ?? `${mention['wm-source']}|${property}`;
|
|
360
|
+
|
|
361
|
+
if (seenMentions.has(mentionKey)) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
seenMentions.add(mentionKey);
|
|
365
|
+
|
|
366
|
+
if (facepileSet.has(property)) {
|
|
367
|
+
const authorKey = `${property}|${getAuthorKey(mention)}`;
|
|
368
|
+
|
|
369
|
+
if (!interactionAuthors.has(authorKey)) {
|
|
370
|
+
interactionAuthors.add(authorKey);
|
|
371
|
+
byProperty[property].push(mention);
|
|
372
|
+
interactions.push(mention);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
threads.push(mention);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const counts = Object.fromEntries(
|
|
382
|
+
Object.entries(byProperty).map(([property, items]) => [property, items.length]),
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
interactions,
|
|
387
|
+
threads,
|
|
388
|
+
byProperty,
|
|
389
|
+
counts: {...counts, thread: threads.length},
|
|
390
|
+
total: interactions.length + threads.length,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export class WebmentionFetchError extends Error {
|
|
395
|
+
constructor(message, {status, attempts, cause} = {}) {
|
|
396
|
+
super(message, {cause});
|
|
397
|
+
this.name = 'WebmentionFetchError';
|
|
398
|
+
this.status = status;
|
|
399
|
+
this.attempts = attempts;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const ACTIVITY_TO_PROPERTY = {
|
|
404
|
+
like: 'like-of',
|
|
405
|
+
repost: 'repost-of',
|
|
406
|
+
bookmark: 'bookmark-of',
|
|
407
|
+
reply: 'in-reply-to',
|
|
408
|
+
link: 'mention-of',
|
|
409
|
+
mention: 'mention-of',
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Reshape a `/api/mentions.json` payload into the jf2 entry shape so the
|
|
414
|
+
* fallback path is invisible to callers.
|
|
415
|
+
*/
|
|
416
|
+
export function normalizeJsonFeed(payload) {
|
|
417
|
+
return (payload?.links || []).map((link) => {
|
|
418
|
+
const data = link.data || {};
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
type: 'entry',
|
|
422
|
+
author: data.author || {},
|
|
423
|
+
url: data.url || link.source,
|
|
424
|
+
name: data.name || null,
|
|
425
|
+
content: data.content ? {text: data.content} : undefined,
|
|
426
|
+
published: data.published || null,
|
|
427
|
+
'wm-received': link.verified_date || null,
|
|
428
|
+
'wm-id': link.id,
|
|
429
|
+
'wm-source': link.source,
|
|
430
|
+
'wm-target': link.target,
|
|
431
|
+
'wm-property': ACTIVITY_TO_PROPERTY[link.activity?.type] || 'mention-of',
|
|
432
|
+
'wm-private': Boolean(link.private),
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isAbortError(error) {
|
|
438
|
+
return error?.name === 'AbortError';
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function wait(ms, signal) {
|
|
442
|
+
return new Promise((resolve, reject) => {
|
|
443
|
+
if (ms <= 0) {
|
|
444
|
+
resolve();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const timer = setTimeout(() => {
|
|
449
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
450
|
+
resolve();
|
|
451
|
+
}, ms);
|
|
452
|
+
|
|
453
|
+
function onAbort() {
|
|
454
|
+
clearTimeout(timer);
|
|
455
|
+
const error = new Error('Aborted');
|
|
456
|
+
error.name = 'AbortError';
|
|
457
|
+
reject(error);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
signal?.addEventListener?.('abort', onAbort, {once: true});
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function buildQuery({targets, perPage, sortBy, sortDir}) {
|
|
465
|
+
const params = new URLSearchParams({
|
|
466
|
+
'per-page': String(perPage),
|
|
467
|
+
'sort-by': sortBy,
|
|
468
|
+
'sort-dir': sortDir,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
targets.forEach((target) => params.append('target[]', target));
|
|
472
|
+
|
|
473
|
+
return params.toString();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Fetch mentions for one or more targets.
|
|
478
|
+
*
|
|
479
|
+
* webmention.io returns intermittent 502s whose nginx error page carries no
|
|
480
|
+
* CORS header, so in a browser a transient upstream blip surfaces as an opaque
|
|
481
|
+
* "Failed to fetch". A single un-retried request therefore blanks the widget
|
|
482
|
+
* for reasons that have nothing to do with the page. Hence: retries with
|
|
483
|
+
* backoff, then a fallback to the older .json endpoint, then a typed throw so
|
|
484
|
+
* the caller can tell "no mentions" from "could not load".
|
|
485
|
+
*/
|
|
486
|
+
export async function fetchWebmentions({
|
|
487
|
+
targets,
|
|
488
|
+
apiUrl = DEFAULT_API_URL,
|
|
489
|
+
jsonApiUrl = DEFAULT_JSON_API_URL,
|
|
490
|
+
perPage = DEFAULT_PER_PAGE,
|
|
491
|
+
sortBy = 'published',
|
|
492
|
+
sortDir = 'down',
|
|
493
|
+
signal,
|
|
494
|
+
retries = 2,
|
|
495
|
+
retryDelayMs = 400,
|
|
496
|
+
fallbackToJson = true,
|
|
497
|
+
fetch: fetchImpl = globalThis.fetch,
|
|
498
|
+
document: doc,
|
|
499
|
+
} = {}) {
|
|
500
|
+
const resolvedTargets = targets?.length ? targets : getCanonicalTargetsFromDocument(doc);
|
|
501
|
+
|
|
502
|
+
if (!resolvedTargets.length) {
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (typeof fetchImpl !== 'function') {
|
|
507
|
+
throw new WebmentionFetchError('No fetch implementation available');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const query = buildQuery({targets: resolvedTargets, perPage, sortBy, sortDir});
|
|
511
|
+
const endpoints = fallbackToJson
|
|
512
|
+
? [{url: apiUrl, json: false}, {url: jsonApiUrl, json: true}]
|
|
513
|
+
: [{url: apiUrl, json: false}];
|
|
514
|
+
|
|
515
|
+
let attempts = 0;
|
|
516
|
+
let lastError = null;
|
|
517
|
+
|
|
518
|
+
for (const endpoint of endpoints) {
|
|
519
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
520
|
+
if (attempts > 0) {
|
|
521
|
+
// Exponential backoff, shared across endpoints so a hard outage does
|
|
522
|
+
// not turn into a request storm.
|
|
523
|
+
await wait(retryDelayMs * 2 ** (attempts - 1), signal);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
attempts += 1;
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
const response = await fetchImpl(`${endpoint.url}?${query}`, {signal});
|
|
530
|
+
|
|
531
|
+
if (!response.ok) {
|
|
532
|
+
lastError = new WebmentionFetchError(
|
|
533
|
+
`webmention.io responded with ${response.status}`,
|
|
534
|
+
{status: response.status, attempts},
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
// 4xx will not fix itself; stop hammering and let the fallback try.
|
|
538
|
+
if (response.status < 500 && response.status !== 429) {
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const data = await response.json();
|
|
546
|
+
|
|
547
|
+
return endpoint.json
|
|
548
|
+
? normalizeJsonFeed(data)
|
|
549
|
+
: Array.isArray(data?.children) ? data.children : [];
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (isAbortError(error)) {
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
lastError = error;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
throw new WebmentionFetchError(
|
|
561
|
+
`Could not load webmentions after ${attempts} attempt(s): ${lastError?.message || 'unknown error'}`,
|
|
562
|
+
{status: lastError?.status, attempts, cause: lastError},
|
|
563
|
+
);
|
|
564
|
+
}
|
package/src/index.js
ADDED