@hhkaos/webmentions-widget 0.4.0 → 0.5.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/README.md +6 -1
- package/bin/snapshot.js +45 -5
- package/package.json +1 -1
- package/src/core.js +78 -0
- package/src/react.js +55 -16
- package/src/render.js +30 -1
package/README.md
CHANGED
|
@@ -150,7 +150,12 @@ widget dates what it is showing:
|
|
|
150
150
|
|
|
151
151
|
It renders only when the mentions came from a snapshot and there is at least one
|
|
152
152
|
to qualify — on a live fetch the data is current and dating it would mislead.
|
|
153
|
-
|
|
153
|
+
|
|
154
|
+
While the snapshot is recent the timestamp reads as "3 hours ago"; once it is
|
|
155
|
+
over a day old it becomes an absolute date. Either way the exact moment is one
|
|
156
|
+
hover or one click away, and the `<time datetime>` attribute always carries the
|
|
157
|
+
machine-readable value. `renderUpdated={(iso, label, exact) => …}` takes over
|
|
158
|
+
the wording entirely.
|
|
154
159
|
|
|
155
160
|
The widget owns this because it is the only layer that knows which source the
|
|
156
161
|
mentions came from; the host owns the copy.
|
package/bin/snapshot.js
CHANGED
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
import {readFile, writeFile, mkdir} from 'node:fs/promises';
|
|
19
19
|
import {dirname} from 'node:path';
|
|
20
20
|
|
|
21
|
-
import {getSnapshotMentions, mergeSnapshot} from '../src/core.js';
|
|
21
|
+
import {getSnapshotMentions, mergeSnapshot, parseWebmentionJson} from '../src/core.js';
|
|
22
22
|
|
|
23
23
|
const DEFAULT_API = 'https://webmention.io/api/mentions.jf2';
|
|
24
24
|
const PER_PAGE = 100;
|
|
25
25
|
const PAGE_DELAY_MS = 500;
|
|
26
|
+
const RETRIES = 4;
|
|
27
|
+
const RETRY_DELAY_MS = 2000;
|
|
26
28
|
|
|
27
29
|
function parseArgs(argv) {
|
|
28
30
|
const args = {};
|
|
@@ -49,6 +51,42 @@ async function readExisting(path) {
|
|
|
49
51
|
|
|
50
52
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
53
|
|
|
54
|
+
/**
|
|
55
|
+
* webmention.io returns intermittent 502s. A daily job that gives up on the
|
|
56
|
+
* first one silently skips a whole day, so retry with backoff before deciding
|
|
57
|
+
* the API is really unavailable.
|
|
58
|
+
*/
|
|
59
|
+
async function fetchPage(url) {
|
|
60
|
+
let lastError = null;
|
|
61
|
+
|
|
62
|
+
for (let attempt = 0; attempt <= RETRIES; attempt += 1) {
|
|
63
|
+
if (attempt > 0) {
|
|
64
|
+
await wait(RETRY_DELAY_MS * 2 ** (attempt - 1));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(url);
|
|
69
|
+
|
|
70
|
+
if (response.ok) {
|
|
71
|
+
return parseWebmentionJson(await response.text());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
lastError = new Error(`webmention.io responded with ${response.status}`);
|
|
75
|
+
|
|
76
|
+
// A 4xx will not fix itself; only server errors are worth retrying.
|
|
77
|
+
if (response.status < 500 && response.status !== 429) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
lastError = error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.error(` attempt ${attempt + 1}/${RETRIES + 1} failed: ${lastError.message}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
throw lastError;
|
|
88
|
+
}
|
|
89
|
+
|
|
52
90
|
async function main() {
|
|
53
91
|
const args = parseArgs(process.argv.slice(2));
|
|
54
92
|
const domain = args.domain;
|
|
@@ -84,16 +122,18 @@ async function main() {
|
|
|
84
122
|
params.set('since_id', String(sinceId));
|
|
85
123
|
}
|
|
86
124
|
|
|
87
|
-
|
|
125
|
+
let payload;
|
|
88
126
|
|
|
89
|
-
|
|
127
|
+
try {
|
|
128
|
+
payload = await fetchPage(`${apiUrl}?${params}`);
|
|
129
|
+
} catch (error) {
|
|
90
130
|
// Leave the existing snapshot alone rather than replacing good data with
|
|
91
131
|
// a partial fetch; the next scheduled run picks up where this stopped.
|
|
92
|
-
console.error(
|
|
132
|
+
console.error(`${error.message}; keeping the current snapshot.`);
|
|
93
133
|
process.exit(1);
|
|
94
134
|
}
|
|
95
135
|
|
|
96
|
-
const batch =
|
|
136
|
+
const batch = payload?.children || [];
|
|
97
137
|
collected.push(...batch);
|
|
98
138
|
|
|
99
139
|
if (batch.length < PER_PAGE) {
|
package/package.json
CHANGED
package/src/core.js
CHANGED
|
@@ -147,6 +147,84 @@ export function formatMentionDate(value, locale) {
|
|
|
147
147
|
}).format(date);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
const RELATIVE_UNITS = [
|
|
151
|
+
['second', 1000],
|
|
152
|
+
['minute', 60 * 1000],
|
|
153
|
+
['hour', 60 * 60 * 1000],
|
|
154
|
+
['day', 24 * 60 * 60 * 1000],
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/** Default: fall back to an absolute date once a snapshot is over a day old. */
|
|
158
|
+
export const DEFAULT_RELATIVE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* "3 hours ago" for a recent timestamp, `null` once it is old enough that an
|
|
162
|
+
* absolute date reads better. `null` is the signal to the caller to format the
|
|
163
|
+
* date instead — "27 days ago" is worse than "5 Sept".
|
|
164
|
+
*/
|
|
165
|
+
export function formatRelativeTime(value, locale, {
|
|
166
|
+
now = Date.now(),
|
|
167
|
+
thresholdMs = DEFAULT_RELATIVE_THRESHOLD_MS,
|
|
168
|
+
} = {}) {
|
|
169
|
+
if (!value || typeof Intl?.RelativeTimeFormat !== 'function') {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const date = new Date(value);
|
|
174
|
+
|
|
175
|
+
if (Number.isNaN(date.getTime())) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const elapsed = now - date.getTime();
|
|
180
|
+
|
|
181
|
+
// A clock skew that puts the snapshot slightly in the future should read as
|
|
182
|
+
// "now", not as a negative age.
|
|
183
|
+
if (elapsed > thresholdMs) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const formatter = new Intl.RelativeTimeFormat(locale, {numeric: 'auto'});
|
|
188
|
+
const [unit, unitMs] = [...RELATIVE_UNITS]
|
|
189
|
+
.reverse()
|
|
190
|
+
.find(([, ms]) => Math.abs(elapsed) >= ms) || RELATIVE_UNITS[0];
|
|
191
|
+
const amount = Math.round(elapsed / unitMs);
|
|
192
|
+
|
|
193
|
+
return formatter.format(-amount, unit);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The exact moment, for a tooltip or an expanded view. */
|
|
197
|
+
export function formatDateTime(value, locale) {
|
|
198
|
+
if (!value) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const date = new Date(value);
|
|
203
|
+
|
|
204
|
+
if (Number.isNaN(date.getTime())) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return new Intl.DateTimeFormat(locale, {
|
|
209
|
+
dateStyle: 'long',
|
|
210
|
+
timeStyle: 'short',
|
|
211
|
+
}).format(date);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* What to show for a snapshot timestamp: a relative phrase while it is fresh,
|
|
216
|
+
* an absolute date once it is not, plus the exact moment for the tooltip.
|
|
217
|
+
*/
|
|
218
|
+
export function describeTimestamp(value, locale, options = {}) {
|
|
219
|
+
const relative = formatRelativeTime(value, locale, options);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
label: relative || formatMentionDate(value, locale),
|
|
223
|
+
exact: formatDateTime(value, locale),
|
|
224
|
+
isRelative: Boolean(relative),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
150
228
|
function normalizeUrl(value, base) {
|
|
151
229
|
if (!value) {
|
|
152
230
|
return null;
|
package/src/react.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* buildless — consumers import the source directly.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {createElement as h, useEffect, useMemo, useState} from 'react';
|
|
8
|
+
import {createElement as h, useCallback, useEffect, useMemo, useState} from 'react';
|
|
9
9
|
import {
|
|
10
10
|
FACEPILE_PROPERTIES,
|
|
11
|
+
describeTimestamp,
|
|
11
12
|
fetchWebmentions,
|
|
12
13
|
filterMentionsByTargets,
|
|
13
14
|
formatMentionDate,
|
|
@@ -227,6 +228,51 @@ function Thread({mention, classNames, labels, locale, maxLength}) {
|
|
|
227
228
|
);
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
/**
|
|
232
|
+
* "Updated 3 hours ago" while the snapshot is fresh, an absolute date once it
|
|
233
|
+
* is not. The exact moment is always reachable: as a tooltip on hover, and by
|
|
234
|
+
* clicking — hover does not exist on touch.
|
|
235
|
+
*/
|
|
236
|
+
function UpdatedLine({className, generatedAt, locale, label, render}) {
|
|
237
|
+
const [expanded, setExpanded] = useState(false);
|
|
238
|
+
const toggle = useCallback(() => setExpanded((previous) => !previous), []);
|
|
239
|
+
const {label: relative, exact, isRelative} = useMemo(
|
|
240
|
+
() => describeTimestamp(generatedAt, locale),
|
|
241
|
+
[generatedAt, locale],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
if (render) {
|
|
245
|
+
return h('p', {className}, render(generatedAt, relative, exact));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return h(
|
|
249
|
+
'p',
|
|
250
|
+
{className},
|
|
251
|
+
renderLabel(label, 'updated'),
|
|
252
|
+
' ',
|
|
253
|
+
h(
|
|
254
|
+
'time',
|
|
255
|
+
{
|
|
256
|
+
dateTime: generatedAt,
|
|
257
|
+
title: exact,
|
|
258
|
+
onClick: isRelative ? toggle : undefined,
|
|
259
|
+
role: isRelative ? 'button' : undefined,
|
|
260
|
+
tabIndex: isRelative ? 0 : undefined,
|
|
261
|
+
onKeyDown: isRelative
|
|
262
|
+
? (event) => {
|
|
263
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
toggle();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
: undefined,
|
|
269
|
+
style: isRelative ? {cursor: 'pointer'} : undefined,
|
|
270
|
+
},
|
|
271
|
+
expanded ? exact : relative,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
230
276
|
const REACT_CLASS_NAMES = {
|
|
231
277
|
root: 'webmentions',
|
|
232
278
|
title: 'webmentions-title',
|
|
@@ -309,21 +355,14 @@ export function Webmentions({
|
|
|
309
355
|
// Only when rendering from a snapshot: on a live fetch the mentions are
|
|
310
356
|
// current and dating them would be misleading.
|
|
311
357
|
source === 'snapshot' && generatedAt && (labels.updated || renderUpdated)
|
|
312
|
-
? h(
|
|
313
|
-
'
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
h(
|
|
321
|
-
'time',
|
|
322
|
-
{key: 'updated-time', dateTime: generatedAt},
|
|
323
|
-
formatMentionDate(generatedAt, locale),
|
|
324
|
-
),
|
|
325
|
-
],
|
|
326
|
-
)
|
|
358
|
+
? h(UpdatedLine, {
|
|
359
|
+
key: 'updated',
|
|
360
|
+
className: classNames.updated,
|
|
361
|
+
generatedAt,
|
|
362
|
+
locale,
|
|
363
|
+
label: labels.updated,
|
|
364
|
+
render: renderUpdated,
|
|
365
|
+
})
|
|
327
366
|
: null,
|
|
328
367
|
];
|
|
329
368
|
|
package/src/render.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
FACEPILE_PROPERTIES,
|
|
10
|
+
describeTimestamp,
|
|
10
11
|
fetchWebmentions,
|
|
11
12
|
formatMentionDate,
|
|
12
13
|
getMentionContent,
|
|
@@ -305,9 +306,37 @@ export function renderGroups(groups, {
|
|
|
305
306
|
appendLabel(updatedElement, labels.updated, doc);
|
|
306
307
|
updatedElement.appendChild(doc.createTextNode(' '));
|
|
307
308
|
|
|
309
|
+
// Relative while fresh, absolute once it is not. The exact moment stays
|
|
310
|
+
// reachable by hover and by click — hover does not exist on touch.
|
|
311
|
+
const {label, exact, isRelative} = describeTimestamp(updatedAt, locale);
|
|
308
312
|
const time = doc.createElement('time');
|
|
309
313
|
time.dateTime = updatedAt;
|
|
310
|
-
time.textContent =
|
|
314
|
+
time.textContent = label;
|
|
315
|
+
|
|
316
|
+
if (exact) {
|
|
317
|
+
time.title = exact;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (isRelative && exact) {
|
|
321
|
+
time.setAttribute('role', 'button');
|
|
322
|
+
time.setAttribute('tabindex', '0');
|
|
323
|
+
time.style.cursor = 'pointer';
|
|
324
|
+
|
|
325
|
+
let expanded = false;
|
|
326
|
+
const toggle = () => {
|
|
327
|
+
expanded = !expanded;
|
|
328
|
+
time.textContent = expanded ? exact : label;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
time.addEventListener('click', toggle);
|
|
332
|
+
time.addEventListener('keydown', (event) => {
|
|
333
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
334
|
+
event.preventDefault();
|
|
335
|
+
toggle();
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
311
340
|
updatedElement.appendChild(time);
|
|
312
341
|
}
|
|
313
342
|
|