@hhkaos/webmentions-widget 0.4.0 → 0.5.1

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.
package/bin/snapshot.js CHANGED
@@ -18,11 +18,14 @@
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 = 8;
27
+ const RETRY_DELAY_MS = 3000;
28
+ const MAX_RETRY_DELAY_MS = 30000;
26
29
 
27
30
  function parseArgs(argv) {
28
31
  const args = {};
@@ -49,6 +52,48 @@ async function readExisting(path) {
49
52
 
50
53
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
51
54
 
55
+ /**
56
+ * webmention.io returns intermittent 502s. A daily job that gives up on the
57
+ * first one silently skips a whole day, so retry with backoff before deciding
58
+ * the API is really unavailable.
59
+ */
60
+ async function fetchPage(url) {
61
+ let lastError = null;
62
+
63
+ for (let attempt = 0; attempt <= RETRIES; attempt += 1) {
64
+ if (attempt > 0) {
65
+ // Capped exponential backoff with jitter. webmention.io has spent today
66
+ // failing roughly two requests in three, so a job that gives up after
67
+ // half a minute loses the coin flip often enough to skip whole days; a
68
+ // daily job can afford to be patient for a couple of minutes instead.
69
+ const backoff = Math.min(RETRY_DELAY_MS * 2 ** (attempt - 1), MAX_RETRY_DELAY_MS);
70
+
71
+ await wait(backoff + Math.random() * 1000);
72
+ }
73
+
74
+ try {
75
+ const response = await fetch(url);
76
+
77
+ if (response.ok) {
78
+ return parseWebmentionJson(await response.text());
79
+ }
80
+
81
+ lastError = new Error(`webmention.io responded with ${response.status}`);
82
+
83
+ // A 4xx will not fix itself; only server errors are worth retrying.
84
+ if (response.status < 500 && response.status !== 429) {
85
+ break;
86
+ }
87
+ } catch (error) {
88
+ lastError = error;
89
+ }
90
+
91
+ console.error(` attempt ${attempt + 1}/${RETRIES + 1} failed: ${lastError.message}`);
92
+ }
93
+
94
+ throw lastError;
95
+ }
96
+
52
97
  async function main() {
53
98
  const args = parseArgs(process.argv.slice(2));
54
99
  const domain = args.domain;
@@ -84,16 +129,18 @@ async function main() {
84
129
  params.set('since_id', String(sinceId));
85
130
  }
86
131
 
87
- const response = await fetch(`${apiUrl}?${params}`);
132
+ let payload;
88
133
 
89
- if (!response.ok) {
134
+ try {
135
+ payload = await fetchPage(`${apiUrl}?${params}`);
136
+ } catch (error) {
90
137
  // Leave the existing snapshot alone rather than replacing good data with
91
138
  // 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.`);
139
+ console.error(`${error.message}; keeping the current snapshot.`);
93
140
  process.exit(1);
94
141
  }
95
142
 
96
- const batch = (await response.json())?.children || [];
143
+ const batch = payload?.children || [];
97
144
  collected.push(...batch);
98
145
 
99
146
  if (batch.length < PER_PAGE) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hhkaos/webmentions-widget",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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;
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