@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 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
- `renderUpdated={(iso, formatted) => …}` takes over the wording entirely.
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
- const response = await fetch(`${apiUrl}?${params}`);
125
+ let payload;
88
126
 
89
- if (!response.ok) {
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(`webmention.io responded with ${response.status}; keeping the current snapshot.`);
132
+ console.error(`${error.message}; keeping the current snapshot.`);
93
133
  process.exit(1);
94
134
  }
95
135
 
96
- const batch = (await response.json())?.children || [];
136
+ const batch = payload?.children || [];
97
137
  collected.push(...batch);
98
138
 
99
139
  if (batch.length < PER_PAGE) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hhkaos/webmentions-widget",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Framework-agnostic, dependency-free widget to fetch and render webmention.io mentions for the current page.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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.json();
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
- 'p',
314
- {key: 'updated', className: classNames.updated},
315
- renderUpdated
316
- ? renderUpdated(generatedAt, formatMentionDate(generatedAt, locale))
317
- : [
318
- renderLabel(labels.updated, 'updated'),
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 = formatMentionDate(updatedAt, locale);
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