@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/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
@@ -0,0 +1,2 @@
1
+ export * from './core.js';
2
+ export * from './render.js';