@hhkaos/webmentions-widget 0.3.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 +10 -1
- package/bin/snapshot.js +45 -5
- package/package.json +1 -1
- package/src/core.js +159 -1
- 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.
|
|
@@ -210,6 +215,10 @@ assigns remote HTML.
|
|
|
210
215
|
folds them together, so only ever branch on `mention-of`.
|
|
211
216
|
- Bridgy mangles emoji into runs of `?` and `U+FFFD` when extracting plain text.
|
|
212
217
|
`stripMojibake` removes the debris — the emoji is not recoverable.
|
|
218
|
+
- Responses are sometimes **invalid JSON**: source content is copied into string
|
|
219
|
+
literals unescaped, so a backslash or a raw newline in a mention's content
|
|
220
|
+
makes the whole payload unparseable. `parseWebmentionJson` repairs those two
|
|
221
|
+
cases; valid payloads pass through untouched.
|
|
213
222
|
|
|
214
223
|
## Development
|
|
215
224
|
|
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;
|
|
@@ -461,6 +539,86 @@ export function mergeSnapshot(existing, incoming) {
|
|
|
461
539
|
};
|
|
462
540
|
}
|
|
463
541
|
|
|
542
|
+
const VALID_JSON_ESCAPES = new Set(['"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u']);
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Repair the malformed JSON webmention.io sometimes serves.
|
|
546
|
+
*
|
|
547
|
+
* Its serializer copies source-page content into string literals without
|
|
548
|
+
* escaping it, so a mention whose content contains a backslash (a shell example
|
|
549
|
+
* ending in `\`, say) or a raw newline produces a payload that `JSON.parse`
|
|
550
|
+
* rejects outright. The mention is then invisible — not because the API was
|
|
551
|
+
* down, but because its response could not be read at all.
|
|
552
|
+
*
|
|
553
|
+
* Walks the text tracking whether it is inside a string literal, escaping only
|
|
554
|
+
* the offending characters and leaving valid escapes untouched.
|
|
555
|
+
*/
|
|
556
|
+
export function repairJson(text) {
|
|
557
|
+
let out = '';
|
|
558
|
+
let inString = false;
|
|
559
|
+
|
|
560
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
561
|
+
const char = text[index];
|
|
562
|
+
|
|
563
|
+
if (!inString) {
|
|
564
|
+
if (char === '"') {
|
|
565
|
+
inString = true;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
out += char;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (char === '"') {
|
|
573
|
+
inString = false;
|
|
574
|
+
out += char;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (char === '\\') {
|
|
579
|
+
const next = text[index + 1];
|
|
580
|
+
|
|
581
|
+
if (VALID_JSON_ESCAPES.has(next)) {
|
|
582
|
+
out += char + next;
|
|
583
|
+
index += 1;
|
|
584
|
+
} else {
|
|
585
|
+
out += '\\\\';
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const code = char.charCodeAt(0);
|
|
592
|
+
|
|
593
|
+
if (code < 0x20) {
|
|
594
|
+
if (code === 0x0a) {
|
|
595
|
+
out += '\\n';
|
|
596
|
+
} else if (code === 0x0d) {
|
|
597
|
+
out += '\\r';
|
|
598
|
+
} else if (code === 0x09) {
|
|
599
|
+
out += '\\t';
|
|
600
|
+
} else {
|
|
601
|
+
out += `\\u${code.toString(16).padStart(4, '0')}`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
out += char;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return out;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Parse a webmention.io response, repairing it only if it will not parse. */
|
|
614
|
+
export function parseWebmentionJson(text) {
|
|
615
|
+
try {
|
|
616
|
+
return JSON.parse(text);
|
|
617
|
+
} catch {
|
|
618
|
+
return JSON.parse(repairJson(text));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
464
622
|
export class WebmentionFetchError extends Error {
|
|
465
623
|
constructor(message, {status, attempts, cause} = {}) {
|
|
466
624
|
super(message, {cause});
|
|
@@ -612,7 +770,7 @@ export async function fetchWebmentions({
|
|
|
612
770
|
continue;
|
|
613
771
|
}
|
|
614
772
|
|
|
615
|
-
const data = await response.
|
|
773
|
+
const data = parseWebmentionJson(await response.text());
|
|
616
774
|
|
|
617
775
|
return endpoint.json
|
|
618
776
|
? normalizeJsonFeed(data)
|
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
|
|