@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/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
|
+
}
|