@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/react.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Optional React entry point.
3
+ *
4
+ * Written with `createElement` rather than JSX so the package stays
5
+ * buildless — consumers import the source directly.
6
+ */
7
+
8
+ import {createElement as h, useEffect, useMemo, useRef, useState} from 'react';
9
+ import {
10
+ FACEPILE_PROPERTIES,
11
+ fetchWebmentions,
12
+ formatMentionDate,
13
+ getMentionContent,
14
+ getMentionSourceUrl,
15
+ getMentionType,
16
+ groupWebmentions,
17
+ } from './core.js';
18
+
19
+ const EMPTY_GROUPS = {
20
+ interactions: [],
21
+ threads: [],
22
+ byProperty: Object.fromEntries(FACEPILE_PROPERTIES.map((property) => [property, []])),
23
+ counts: {},
24
+ total: 0,
25
+ };
26
+
27
+ /**
28
+ * Fetch mentions for `targets` and keep them in state.
29
+ *
30
+ * `status` distinguishes `'error'` from an empty `'success'`, so a caller can
31
+ * keep the section mounted during a webmention.io outage instead of silently
32
+ * unmounting it.
33
+ */
34
+ export function useWebmentions(targets, options = {}) {
35
+ const {
36
+ apiUrl,
37
+ perPage,
38
+ retries,
39
+ retryDelayMs,
40
+ fallbackToJson,
41
+ initialMentions,
42
+ } = options;
43
+ const [state, setState] = useState(() => ({
44
+ status: initialMentions ? 'success' : 'idle',
45
+ groups: initialMentions ? groupWebmentions(initialMentions) : EMPTY_GROUPS,
46
+ error: null,
47
+ }));
48
+ const targetsKey = targets.join('\n');
49
+ const optionsRef = useRef(options);
50
+ optionsRef.current = options;
51
+
52
+ useEffect(() => {
53
+ if (!targetsKey) {
54
+ return undefined;
55
+ }
56
+
57
+ const controller = new AbortController();
58
+ let active = true;
59
+
60
+ setState((previous) => ({...previous, status: 'loading'}));
61
+
62
+ fetchWebmentions({
63
+ targets: targetsKey.split('\n'),
64
+ apiUrl,
65
+ perPage,
66
+ retries,
67
+ retryDelayMs,
68
+ fallbackToJson,
69
+ signal: controller.signal,
70
+ })
71
+ .then((mentions) => {
72
+ if (active) {
73
+ setState({status: 'success', groups: groupWebmentions(mentions), error: null});
74
+ }
75
+ })
76
+ .catch((error) => {
77
+ if (active && error?.name !== 'AbortError') {
78
+ setState({status: 'error', groups: EMPTY_GROUPS, error});
79
+ }
80
+ });
81
+
82
+ return () => {
83
+ active = false;
84
+ controller.abort();
85
+ };
86
+ }, [targetsKey, apiUrl, perPage, retries, retryDelayMs, fallbackToJson]);
87
+
88
+ return state;
89
+ }
90
+
91
+ function Face({mention, classNames}) {
92
+ const author = mention.author || {};
93
+ const sourceUrl = mention.url || mention['wm-source'] || author.url;
94
+
95
+ return h(
96
+ 'li',
97
+ {className: classNames.facepileItem},
98
+ h(
99
+ 'a',
100
+ {
101
+ className: classNames.facepileLink,
102
+ href: sourceUrl,
103
+ rel: 'nofollow noopener',
104
+ target: '_blank',
105
+ title: `${author.name || 'Someone'} — ${getMentionType(mention)}`,
106
+ },
107
+ author.photo
108
+ ? h('img', {
109
+ className: classNames.facepilePhoto,
110
+ src: author.photo,
111
+ alt: '',
112
+ loading: 'lazy',
113
+ width: 32,
114
+ height: 32,
115
+ })
116
+ : h('span', {'aria-hidden': 'true'}, (author.name || '?').slice(0, 1).toUpperCase()),
117
+ ),
118
+ );
119
+ }
120
+
121
+ function Thread({mention, classNames, labels, locale, maxLength}) {
122
+ const author = mention.author || {};
123
+ const published = mention.published || mention['wm-received'];
124
+ const formattedDate = formatMentionDate(published, locale);
125
+ const content = useMemo(
126
+ () => getMentionContent(mention, {maxLength}),
127
+ [mention, maxLength],
128
+ );
129
+ const sourceUrl = getMentionSourceUrl(mention, content);
130
+
131
+ return h(
132
+ 'li',
133
+ {className: classNames.threadItem},
134
+ author.photo
135
+ ? h('img', {
136
+ className: classNames.threadPhoto,
137
+ src: author.photo,
138
+ alt: '',
139
+ loading: 'lazy',
140
+ width: 40,
141
+ height: 40,
142
+ })
143
+ : null,
144
+ h(
145
+ 'div',
146
+ {className: classNames.threadBody},
147
+ h(
148
+ 'p',
149
+ {className: classNames.threadMeta},
150
+ h(
151
+ 'a',
152
+ {
153
+ className: classNames.threadAuthor,
154
+ href: sourceUrl || author.url,
155
+ rel: 'nofollow noopener',
156
+ target: '_blank',
157
+ },
158
+ h('span', {className: 'p-name'}, author.name || mention.url),
159
+ ),
160
+ ' ',
161
+ h('span', null, getMentionType(mention, labels)),
162
+ formattedDate
163
+ ? h('time', {className: 'dt-published', dateTime: published}, ` · ${formattedDate}`)
164
+ : null,
165
+ ),
166
+ content ? h('p', {className: classNames.threadContent}, content) : null,
167
+ mention.url ? h('a', {className: classNames.threadSource, href: sourceUrl}, mention.url) : null,
168
+ ),
169
+ );
170
+ }
171
+
172
+ const REACT_CLASS_NAMES = {
173
+ root: 'webmentions',
174
+ title: 'webmentions-title',
175
+ facepile: 'webmentions-facepile',
176
+ facepileItem: 'webmention-facepile-item',
177
+ facepileLink: 'webmention-facepile-link',
178
+ facepilePhoto: 'webmention-facepile-photo',
179
+ threadList: 'webmentions-list',
180
+ threadItem: 'h-cite webmention',
181
+ threadPhoto: 'webmention-photo',
182
+ threadBody: 'webmention-body',
183
+ threadMeta: 'webmention-meta',
184
+ threadAuthor: 'p-author h-card u-url',
185
+ threadContent: 'e-content webmention-content',
186
+ threadSource: 'u-url webmention-source',
187
+ };
188
+
189
+ /**
190
+ * Presentational component. Pass `targets` (build them with
191
+ * `getCanonicalTargets`) — routing and site config stay in the host app.
192
+ */
193
+ export function Webmentions({
194
+ targets = [],
195
+ title = 'Webmentions',
196
+ labels = {},
197
+ locale,
198
+ maxLength,
199
+ classNames: classNameOverrides = {},
200
+ renderEmpty = null,
201
+ renderError = null,
202
+ ...options
203
+ }) {
204
+ const classNames = {...REACT_CLASS_NAMES, ...classNameOverrides};
205
+ const {status, groups, error} = useWebmentions(targets, options);
206
+
207
+ if (status === 'error') {
208
+ return renderError ? renderError(error) : null;
209
+ }
210
+
211
+ if (status !== 'success') {
212
+ return null;
213
+ }
214
+
215
+ if (!groups.interactions.length && !groups.threads.length) {
216
+ return renderEmpty ? renderEmpty() : null;
217
+ }
218
+
219
+ return h(
220
+ 'aside',
221
+ {className: classNames.root, 'aria-labelledby': `${classNames.title}-heading`},
222
+ h('h2', {id: `${classNames.title}-heading`, className: classNames.title}, title),
223
+ groups.interactions.length
224
+ ? h(
225
+ 'ol',
226
+ {className: classNames.facepile, 'aria-label': 'Reactions'},
227
+ groups.interactions.map((mention) => h(Face, {
228
+ key: mention['wm-id'] || mention.url || mention['wm-source'],
229
+ mention,
230
+ classNames,
231
+ })),
232
+ )
233
+ : null,
234
+ groups.threads.length
235
+ ? h(
236
+ 'ol',
237
+ {className: classNames.threadList},
238
+ groups.threads.map((mention) => h(Thread, {
239
+ key: mention['wm-id'] || mention.url || mention['wm-source'],
240
+ mention,
241
+ classNames,
242
+ labels,
243
+ locale,
244
+ maxLength,
245
+ })),
246
+ )
247
+ : null,
248
+ );
249
+ }
250
+
251
+ export default Webmentions;
package/src/render.js ADDED
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Imperative DOM renderer, for sites without a component framework.
3
+ *
4
+ * Every remote string goes through `textContent`; nothing here ever assigns
5
+ * remote HTML.
6
+ */
7
+
8
+ import {
9
+ FACEPILE_PROPERTIES,
10
+ fetchWebmentions,
11
+ formatMentionDate,
12
+ getMentionContent,
13
+ getMentionSourceUrl,
14
+ getMentionType,
15
+ groupWebmentions,
16
+ normalizeProperty,
17
+ } from './core.js';
18
+
19
+ export const DEFAULT_CLASS_NAMES = {
20
+ facepile: 'webmentions__facepile',
21
+ facepileGroup: 'webmentions__group',
22
+ facepileFaces: 'webmentions__faces',
23
+ facepileCount: 'webmentions__count',
24
+ facepileGlyph: 'webmentions__glyph',
25
+ facepileItem: 'webmentions__facepile-item',
26
+ facepileLink: 'webmentions__facepile-link',
27
+ facepilePhoto: 'webmentions__facepile-photo',
28
+ threadList: 'webmentions__list',
29
+ threadItem: 'webmentions__item h-cite',
30
+ threadBody: 'webmentions__body',
31
+ threadMeta: 'webmentions__meta',
32
+ threadAuthor: 'webmentions__author p-author h-card u-url',
33
+ threadContent: 'webmentions__content p-content',
34
+ threadSource: 'webmentions__source u-url',
35
+ threadPhoto: '',
36
+ };
37
+
38
+ const FACEPILE_META = {
39
+ 'like-of': {className: 'is-like', glyph: '♥'},
40
+ 'repost-of': {className: 'is-repost', glyph: '↻'},
41
+ 'bookmark-of': {className: 'is-bookmark', glyph: '⚑'},
42
+ };
43
+
44
+ /**
45
+ * A label may be a plain string, or a `{en: '…', es: '…'}` map for sites that
46
+ * ship both languages in the markup and toggle them with CSS.
47
+ */
48
+ function appendLabel(parent, label, doc) {
49
+ if (label == null) {
50
+ return;
51
+ }
52
+
53
+ if (typeof label === 'string') {
54
+ parent.appendChild(doc.createTextNode(label));
55
+ return;
56
+ }
57
+
58
+ Object.entries(label).forEach(([lang, text]) => {
59
+ const span = doc.createElement('span');
60
+ span.className = `i18n-${lang}`;
61
+ span.lang = lang;
62
+ span.textContent = text;
63
+ parent.appendChild(span);
64
+ });
65
+ }
66
+
67
+ function resolveElement(value, root, doc) {
68
+ if (!value) {
69
+ return null;
70
+ }
71
+
72
+ if (typeof value === 'string') {
73
+ return root?.querySelector(value) || doc.querySelector(value);
74
+ }
75
+
76
+ return value;
77
+ }
78
+
79
+ function authorOf(mention) {
80
+ const author = mention.author || {};
81
+ const source = mention.url || mention['wm-source'] || '';
82
+ let host = '';
83
+
84
+ try {
85
+ host = new URL(source).hostname.replace(/^www\./, '');
86
+ } catch {
87
+ host = '';
88
+ }
89
+
90
+ return {
91
+ name: String(author.name || '').trim() || host || 'Someone',
92
+ url: author.url || source,
93
+ photo: author.photo || '',
94
+ };
95
+ }
96
+
97
+ function createAvatar(author, className, doc, size) {
98
+ let element;
99
+
100
+ if (author.photo) {
101
+ element = doc.createElement('img');
102
+ element.src = author.photo;
103
+ element.alt = '';
104
+ element.loading = 'lazy';
105
+ element.width = size;
106
+ element.height = size;
107
+ } else {
108
+ element = doc.createElement('span');
109
+ element.setAttribute('aria-hidden', 'true');
110
+ element.textContent = (author.name.charAt(0) || '?').toUpperCase();
111
+ }
112
+
113
+ if (className) {
114
+ element.className = className;
115
+ }
116
+
117
+ return element;
118
+ }
119
+
120
+ function createFace(mention, classNames, doc) {
121
+ const author = authorOf(mention);
122
+ const item = doc.createElement('li');
123
+ item.className = classNames.facepileItem;
124
+
125
+ const link = doc.createElement('a');
126
+ link.className = classNames.facepileLink;
127
+ link.href = mention.url || mention['wm-source'] || author.url || '#';
128
+ link.rel = 'nofollow noopener';
129
+ link.target = '_blank';
130
+ link.title = `${author.name} — ${getMentionType(mention)}`;
131
+ link.appendChild(createAvatar(author, classNames.facepilePhoto, doc, 32));
132
+
133
+ item.appendChild(link);
134
+
135
+ return item;
136
+ }
137
+
138
+ function createFacepileGroup(property, mentions, {classNames, labels, doc}) {
139
+ const meta = FACEPILE_META[property] || {className: '', glyph: ''};
140
+ const group = doc.createElement('div');
141
+ group.className = `${classNames.facepileGroup} ${meta.className}`.trim();
142
+
143
+ const faces = doc.createElement('ol');
144
+ faces.className = classNames.facepileFaces;
145
+ mentions.forEach((mention) => faces.appendChild(createFace(mention, classNames, doc)));
146
+ group.appendChild(faces);
147
+
148
+ const count = doc.createElement('span');
149
+ count.className = classNames.facepileCount;
150
+
151
+ if (meta.glyph) {
152
+ const glyph = doc.createElement('span');
153
+ glyph.className = classNames.facepileGlyph;
154
+ glyph.setAttribute('aria-hidden', 'true');
155
+ glyph.textContent = meta.glyph;
156
+ count.appendChild(glyph);
157
+ }
158
+
159
+ const number = doc.createElement('span');
160
+ number.textContent = String(mentions.length);
161
+ count.appendChild(number);
162
+ group.appendChild(count);
163
+
164
+ const accessibleName = `${mentions.length} ${getMentionType({'wm-property': property}, labels)}`;
165
+ group.setAttribute('aria-label', accessibleName);
166
+ group.title = accessibleName;
167
+
168
+ return group;
169
+ }
170
+
171
+ function createThreadItem(mention, {classNames, labels, locale, maxLength, doc}) {
172
+ const author = authorOf(mention);
173
+ const published = mention.published || mention['wm-received'];
174
+ const formattedDate = formatMentionDate(published, locale);
175
+ const content = getMentionContent(mention, {maxLength});
176
+ const sourceUrl = getMentionSourceUrl(mention, content);
177
+
178
+ const item = doc.createElement('li');
179
+ item.className = classNames.threadItem;
180
+
181
+ if (classNames.threadPhoto) {
182
+ item.appendChild(createAvatar(author, classNames.threadPhoto, doc, 40));
183
+ }
184
+
185
+ const body = doc.createElement('div');
186
+ body.className = classNames.threadBody;
187
+
188
+ const meta = doc.createElement('p');
189
+ meta.className = classNames.threadMeta;
190
+
191
+ const authorLink = doc.createElement('a');
192
+ authorLink.className = classNames.threadAuthor;
193
+ authorLink.href = sourceUrl || author.url || '#';
194
+ authorLink.rel = 'nofollow noopener';
195
+ authorLink.target = '_blank';
196
+ authorLink.textContent = author.name;
197
+ meta.appendChild(authorLink);
198
+ meta.appendChild(doc.createTextNode(' '));
199
+
200
+ const verb = doc.createElement('span');
201
+ const property = normalizeProperty(mention['wm-property']);
202
+ appendLabel(verb, labels[property] ?? getMentionType(mention, labels), doc);
203
+ meta.appendChild(verb);
204
+
205
+ if (formattedDate) {
206
+ const time = doc.createElement('time');
207
+ time.className = 'dt-published';
208
+ time.dateTime = published;
209
+ time.textContent = ` · ${formattedDate}`;
210
+ meta.appendChild(time);
211
+ }
212
+
213
+ body.appendChild(meta);
214
+
215
+ if (content || labels.fallbackContent) {
216
+ const contentElement = doc.createElement('p');
217
+ contentElement.className = classNames.threadContent;
218
+
219
+ if (content) {
220
+ contentElement.textContent = content;
221
+ } else {
222
+ appendLabel(contentElement, labels.fallbackContent, doc);
223
+ }
224
+
225
+ body.appendChild(contentElement);
226
+ }
227
+
228
+ if (sourceUrl && labels.viewSource) {
229
+ const sourceLink = doc.createElement('a');
230
+ sourceLink.className = classNames.threadSource;
231
+ sourceLink.href = sourceUrl;
232
+ sourceLink.rel = 'nofollow noopener';
233
+ sourceLink.target = '_blank';
234
+ appendLabel(sourceLink, labels.viewSource, doc);
235
+ body.appendChild(sourceLink);
236
+ }
237
+
238
+ item.appendChild(body);
239
+
240
+ return item;
241
+ }
242
+
243
+ /**
244
+ * Paint an already-fetched feed. Split out from `renderWebmentions` so a site
245
+ * can hydrate from a build-time snapshot without touching the network.
246
+ */
247
+ export function renderGroups(groups, {
248
+ container,
249
+ facepile,
250
+ list,
251
+ labels = {},
252
+ locale,
253
+ classNames: classNameOverrides = {},
254
+ facepileMode = 'flat',
255
+ maxLength,
256
+ document: doc = globalThis.document,
257
+ } = {}) {
258
+ const classNames = {...DEFAULT_CLASS_NAMES, ...classNameOverrides};
259
+ const containerElement = resolveElement(container, null, doc);
260
+ const facepileElement = resolveElement(facepile, containerElement, doc)
261
+ || containerElement?.querySelector(`.${classNames.facepile.split(' ')[0]}`);
262
+ const listElement = resolveElement(list, containerElement, doc)
263
+ || containerElement?.querySelector(`.${classNames.threadList.split(' ')[0]}`);
264
+
265
+ if (facepileElement) {
266
+ const children = facepileMode === 'grouped'
267
+ ? FACEPILE_PROPERTIES
268
+ .filter((property) => groups.byProperty?.[property]?.length)
269
+ .map((property) => createFacepileGroup(property, groups.byProperty[property], {
270
+ classNames,
271
+ labels,
272
+ doc,
273
+ }))
274
+ : groups.interactions.map((mention) => createFace(mention, classNames, doc));
275
+
276
+ facepileElement.replaceChildren(...children);
277
+ facepileElement.hidden = children.length === 0;
278
+ }
279
+
280
+ if (listElement) {
281
+ listElement.replaceChildren(...groups.threads.map((mention) => createThreadItem(mention, {
282
+ classNames,
283
+ labels,
284
+ locale,
285
+ maxLength,
286
+ doc,
287
+ })));
288
+ }
289
+
290
+ if (containerElement) {
291
+ const empty = !groups.interactions.length && !groups.threads.length;
292
+ containerElement.hidden = empty;
293
+ containerElement.setAttribute('aria-hidden', empty ? 'true' : 'false');
294
+ }
295
+
296
+ return groups;
297
+ }
298
+
299
+ /**
300
+ * Fetch and render in one call.
301
+ *
302
+ * On failure the container is left untouched (so server-rendered or cached
303
+ * markup survives an outage) and `onError` is invoked. The promise resolves
304
+ * rather than rejects: a dead third-party API should not produce an unhandled
305
+ * rejection on every page view.
306
+ */
307
+ export async function renderWebmentions({
308
+ container,
309
+ facepile,
310
+ list,
311
+ targets,
312
+ labels = {},
313
+ locale,
314
+ classNames = {},
315
+ facepileMode = 'flat',
316
+ maxLength,
317
+ perPage,
318
+ retries,
319
+ retryDelayMs,
320
+ fallbackToJson,
321
+ apiUrl,
322
+ jsonApiUrl,
323
+ sortBy,
324
+ sortDir,
325
+ signal,
326
+ onError,
327
+ fetch: fetchImpl,
328
+ document: doc = globalThis.document,
329
+ } = {}) {
330
+ const containerElement = resolveElement(container, null, doc);
331
+
332
+ if (!containerElement) {
333
+ return {status: 'error', groups: null, error: new Error('No container element')};
334
+ }
335
+
336
+ try {
337
+ const mentions = await fetchWebmentions({
338
+ targets,
339
+ apiUrl,
340
+ jsonApiUrl,
341
+ perPage,
342
+ sortBy,
343
+ sortDir,
344
+ retries,
345
+ retryDelayMs,
346
+ fallbackToJson,
347
+ signal,
348
+ fetch: fetchImpl,
349
+ document: doc,
350
+ });
351
+ const groups = groupWebmentions(mentions);
352
+
353
+ renderGroups(groups, {
354
+ container: containerElement,
355
+ facepile,
356
+ list,
357
+ labels,
358
+ locale,
359
+ classNames,
360
+ facepileMode,
361
+ maxLength,
362
+ document: doc,
363
+ });
364
+
365
+ return {status: 'success', groups, error: null};
366
+ } catch (error) {
367
+ if (error?.name === 'AbortError') {
368
+ return {status: 'aborted', groups: null, error};
369
+ }
370
+
371
+ onError?.(error);
372
+
373
+ return {status: 'error', groups: null, error};
374
+ }
375
+ }