@hhkaos/webmentions-widget 0.1.1 → 0.3.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
@@ -101,6 +101,60 @@ export default function SiteWebmentions() {
101
101
  without the markup. It returns `{status, groups, error}` where `status` is
102
102
  `idle | loading | success | error`.
103
103
 
104
+ ## Build-time snapshot (recommended)
105
+
106
+ By default the widget fetches in the browser, which costs webmention.io one
107
+ request per visitor per page view — and leaves the section empty whenever their
108
+ API is down. A snapshot inverts that: fetch once per day in CI, commit the
109
+ result, and serve it as data.
110
+
111
+ ```sh
112
+ npx webmentions-snapshot --domain example.com --out src/data/webmentions.json
113
+ ```
114
+
115
+ Domain-wide queries need an API token (webmention.io → Settings → API Key),
116
+ read from `WEBMENTION_IO_TOKEN`. The command fetches only what is new since the
117
+ last run (`since_id`), waits between pages, and leaves the existing file
118
+ untouched if the API errors — a bad fetch never replaces good data.
119
+
120
+ Then hand the snapshot to the component:
121
+
122
+ ```jsx
123
+ import snapshot from '@site/src/data/webmentions.json';
124
+
125
+ <Webmentions targets={targets} snapshot={snapshot} />
126
+ ```
127
+
128
+ The component narrows the whole-site snapshot to the current page locally and
129
+ renders with **no network request at all**. Pass `revalidate` to opt back into
130
+ a live fetch on top (the snapshot renders first either way, and a failed
131
+ revalidation never blanks a section the snapshot could fill).
132
+
133
+ Two things this buys beyond politeness: the section survives a webmention.io
134
+ outage, and the committed JSON is a durable copy of your mentions if the
135
+ service ever disappears.
136
+
137
+ ### Saying how fresh it is
138
+
139
+ A snapshot is by definition a little behind. Pass an `updated` label and the
140
+ widget dates what it is showing:
141
+
142
+ ```jsx
143
+ <Webmentions
144
+ targets={targets}
145
+ snapshot={snapshot}
146
+ locale="es"
147
+ labels={{updated: {en: 'Updated', es: 'Actualizado'}}}
148
+ />
149
+ ```
150
+
151
+ It renders only when the mentions came from a snapshot and there is at least one
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.
154
+
155
+ The widget owns this because it is the only layer that knows which source the
156
+ mentions came from; the host owns the copy.
157
+
104
158
  ## API
105
159
 
106
160
  ### `fetchWebmentions(options)`
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Refresh a build-time webmentions snapshot.
5
+ *
6
+ * Queries webmention.io once for the whole domain instead of once per page,
7
+ * and only for what is new since the last run (`since_id`). A site that runs
8
+ * this daily costs webmention.io one or two requests a day, versus one request
9
+ * per visitor per page view when the widget fetches in the browser.
10
+ *
11
+ * Usage:
12
+ * webmentions-snapshot --domain example.com --out src/data/webmentions.json
13
+ *
14
+ * The API token (webmention.io → Settings → API Key) is read from
15
+ * WEBMENTION_IO_TOKEN, or --token. Domain queries require it.
16
+ */
17
+
18
+ import {readFile, writeFile, mkdir} from 'node:fs/promises';
19
+ import {dirname} from 'node:path';
20
+
21
+ import {getSnapshotMentions, mergeSnapshot} from '../src/core.js';
22
+
23
+ const DEFAULT_API = 'https://webmention.io/api/mentions.jf2';
24
+ const PER_PAGE = 100;
25
+ const PAGE_DELAY_MS = 500;
26
+
27
+ function parseArgs(argv) {
28
+ const args = {};
29
+
30
+ argv.forEach((arg, index) => {
31
+ if (!arg.startsWith('--')) {
32
+ return;
33
+ }
34
+
35
+ const [flag, inline] = arg.slice(2).split('=');
36
+ args[flag] = inline ?? (argv[index + 1]?.startsWith('--') ? true : argv[index + 1]);
37
+ });
38
+
39
+ return args;
40
+ }
41
+
42
+ async function readExisting(path) {
43
+ try {
44
+ return JSON.parse(await readFile(path, 'utf8'));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
51
+
52
+ async function main() {
53
+ const args = parseArgs(process.argv.slice(2));
54
+ const domain = args.domain;
55
+ const out = args.out;
56
+ const token = args.token || process.env.WEBMENTION_IO_TOKEN;
57
+ const apiUrl = args.api || DEFAULT_API;
58
+
59
+ if (!domain || !out) {
60
+ console.error('Usage: webmentions-snapshot --domain <domain> --out <file.json> [--token <token>]');
61
+ process.exit(2);
62
+ }
63
+
64
+ if (!token) {
65
+ console.error('Missing API token: set WEBMENTION_IO_TOKEN or pass --token.');
66
+ process.exit(2);
67
+ }
68
+
69
+ const existing = await readExisting(out);
70
+ const sinceId = args.full ? null : existing?.lastId ?? null;
71
+ const collected = [];
72
+
73
+ for (let page = 0; ; page += 1) {
74
+ const params = new URLSearchParams({
75
+ domain,
76
+ token,
77
+ 'per-page': String(PER_PAGE),
78
+ page: String(page),
79
+ 'sort-by': 'created',
80
+ 'sort-dir': 'up',
81
+ });
82
+
83
+ if (sinceId) {
84
+ params.set('since_id', String(sinceId));
85
+ }
86
+
87
+ const response = await fetch(`${apiUrl}?${params}`);
88
+
89
+ if (!response.ok) {
90
+ // Leave the existing snapshot alone rather than replacing good data with
91
+ // 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.`);
93
+ process.exit(1);
94
+ }
95
+
96
+ const batch = (await response.json())?.children || [];
97
+ collected.push(...batch);
98
+
99
+ if (batch.length < PER_PAGE) {
100
+ break;
101
+ }
102
+
103
+ await wait(PAGE_DELAY_MS);
104
+ }
105
+
106
+ const before = getSnapshotMentions(existing).length;
107
+
108
+ if (!collected.length && existing) {
109
+ console.log(`No new mentions since #${sinceId}. Snapshot unchanged (${before}).`);
110
+ return;
111
+ }
112
+
113
+ const snapshot = mergeSnapshot(existing, collected);
114
+
115
+ if (snapshot.count === before) {
116
+ console.log(`Snapshot unchanged (${before} mentions).`);
117
+ return;
118
+ }
119
+
120
+ await mkdir(dirname(out), {recursive: true});
121
+ await writeFile(out, `${JSON.stringify(snapshot, null, 2)}\n`);
122
+
123
+ console.log(`Snapshot updated: ${before} → ${snapshot.count} mentions (lastId ${snapshot.lastId}).`);
124
+ }
125
+
126
+ main().catch((error) => {
127
+ console.error(error);
128
+ process.exit(1);
129
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hhkaos/webmentions-widget",
3
- "version": "0.1.1",
3
+ "version": "0.3.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",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "files": [
32
32
  "src",
33
+ "bin",
33
34
  "README.md",
34
35
  "LICENSE"
35
36
  ],
@@ -53,5 +54,8 @@
53
54
  "devDependencies": {
54
55
  "react": "^18.3.1",
55
56
  "react-dom": "^18.3.1"
57
+ },
58
+ "bin": {
59
+ "webmentions-snapshot": "./bin/snapshot.js"
56
60
  }
57
61
  }
package/src/core.js CHANGED
@@ -391,6 +391,76 @@ export function groupWebmentions(mentions, {facepileProperties = FACEPILE_PROPER
391
391
  };
392
392
  }
393
393
 
394
+ /**
395
+ * Match a mention's stored target against the variants we query for a page.
396
+ * Comparison ignores the trailing slash and the fragment, so a snapshot taken
397
+ * against one variant still matches a page queried under another.
398
+ */
399
+ function targetMatches(mention, normalizedTargets) {
400
+ const target = normalizeUrl(mention?.['wm-target']);
401
+
402
+ return Boolean(target) && normalizedTargets.has(target);
403
+ }
404
+
405
+ /**
406
+ * Narrow a whole-domain snapshot down to one page.
407
+ *
408
+ * A build-time snapshot is fetched once for the entire site (webmention.io's
409
+ * `domain=` query), so each page has to pick out its own mentions locally
410
+ * rather than asking the API again.
411
+ */
412
+ export function filterMentionsByTargets(mentions, targets = []) {
413
+ const normalizedTargets = new Set(
414
+ targets.map((target) => normalizeUrl(target)).filter(Boolean),
415
+ );
416
+
417
+ if (!normalizedTargets.size) {
418
+ return [];
419
+ }
420
+
421
+ return (mentions || []).filter((mention) => targetMatches(mention, normalizedTargets));
422
+ }
423
+
424
+ /**
425
+ * Read a snapshot in either accepted shape: a bare array of entries, or the
426
+ * `{mentions, generatedAt, lastId}` envelope that the refresh job writes.
427
+ */
428
+ export function getSnapshotMentions(snapshot) {
429
+ if (Array.isArray(snapshot)) {
430
+ return snapshot;
431
+ }
432
+
433
+ return snapshot?.mentions || snapshot?.children || [];
434
+ }
435
+
436
+ /**
437
+ * Merge a fresh page of mentions into a snapshot, newest first, deduped by
438
+ * `wm-id`. Used by the refresh job so an incremental `since_id` fetch does not
439
+ * have to re-download everything.
440
+ */
441
+ export function mergeSnapshot(existing, incoming) {
442
+ const byId = new Map();
443
+
444
+ [...getSnapshotMentions(existing), ...(incoming || [])].forEach((mention) => {
445
+ const key = mention?.['wm-id'] ?? mention?.['wm-source'];
446
+
447
+ if (key != null) {
448
+ byId.set(key, mention);
449
+ }
450
+ });
451
+
452
+ const mentions = [...byId.values()].sort((a, b) => (
453
+ (b['wm-id'] ?? 0) - (a['wm-id'] ?? 0)
454
+ ));
455
+
456
+ return {
457
+ generatedAt: new Date().toISOString(),
458
+ lastId: mentions.reduce((max, mention) => Math.max(max, mention['wm-id'] ?? 0), 0) || null,
459
+ count: mentions.length,
460
+ mentions,
461
+ };
462
+ }
463
+
394
464
  export class WebmentionFetchError extends Error {
395
465
  constructor(message, {status, attempts, cause} = {}) {
396
466
  super(message, {cause});
package/src/react.js CHANGED
@@ -5,14 +5,16 @@
5
5
  * buildless — consumers import the source directly.
6
6
  */
7
7
 
8
- import {createElement as h, useEffect, useMemo, useRef, useState} from 'react';
8
+ import {createElement as h, useEffect, useMemo, useState} from 'react';
9
9
  import {
10
10
  FACEPILE_PROPERTIES,
11
11
  fetchWebmentions,
12
+ filterMentionsByTargets,
12
13
  formatMentionDate,
13
14
  getMentionContent,
14
15
  getMentionSourceUrl,
15
16
  getMentionType,
17
+ getSnapshotMentions,
16
18
  groupWebmentions,
17
19
  } from './core.js';
18
20
 
@@ -39,25 +41,42 @@ export function useWebmentions(targets, options = {}) {
39
41
  retryDelayMs,
40
42
  fallbackToJson,
41
43
  initialMentions,
44
+ // A whole-site snapshot, narrowed to these targets locally.
45
+ snapshot,
46
+ // With mentions already in hand, skip the network by default: the point of
47
+ // a snapshot is that a page view costs webmention.io nothing.
48
+ revalidate,
42
49
  } = options;
43
- const [state, setState] = useState(() => ({
44
- status: initialMentions ? 'success' : 'idle',
45
- groups: initialMentions ? groupWebmentions(initialMentions) : EMPTY_GROUPS,
46
- error: null,
47
- }));
48
50
  const targetsKey = targets.join('\n');
49
- const optionsRef = useRef(options);
50
- optionsRef.current = options;
51
+
52
+ // Derived, not stored: on a client-side route change the targets change, and
53
+ // state seeded once in a useState initializer would go stale.
54
+ const seededGroups = useMemo(() => {
55
+ if (initialMentions) {
56
+ return groupWebmentions(initialMentions);
57
+ }
58
+
59
+ if (!snapshot) {
60
+ return null;
61
+ }
62
+
63
+ return groupWebmentions(
64
+ filterMentionsByTargets(getSnapshotMentions(snapshot), targetsKey ? targetsKey.split('\n') : []),
65
+ );
66
+ }, [initialMentions, snapshot, targetsKey]);
67
+
68
+ const shouldFetch = (revalidate ?? !seededGroups) && Boolean(targetsKey);
69
+ const [fetched, setFetched] = useState({status: 'idle', groups: null, error: null});
51
70
 
52
71
  useEffect(() => {
53
- if (!targetsKey) {
72
+ if (!shouldFetch) {
54
73
  return undefined;
55
74
  }
56
75
 
57
76
  const controller = new AbortController();
58
77
  let active = true;
59
78
 
60
- setState((previous) => ({...previous, status: 'loading'}));
79
+ setFetched({status: 'loading', groups: null, error: null});
61
80
 
62
81
  fetchWebmentions({
63
82
  targets: targetsKey.split('\n'),
@@ -70,12 +89,12 @@ export function useWebmentions(targets, options = {}) {
70
89
  })
71
90
  .then((mentions) => {
72
91
  if (active) {
73
- setState({status: 'success', groups: groupWebmentions(mentions), error: null});
92
+ setFetched({status: 'success', groups: groupWebmentions(mentions), error: null});
74
93
  }
75
94
  })
76
95
  .catch((error) => {
77
96
  if (active && error?.name !== 'AbortError') {
78
- setState({status: 'error', groups: EMPTY_GROUPS, error});
97
+ setFetched({status: 'error', groups: null, error});
79
98
  }
80
99
  });
81
100
 
@@ -83,9 +102,48 @@ export function useWebmentions(targets, options = {}) {
83
102
  active = false;
84
103
  controller.abort();
85
104
  };
86
- }, [targetsKey, apiUrl, perPage, retries, retryDelayMs, fallbackToJson]);
105
+ }, [targetsKey, shouldFetch, apiUrl, perPage, retries, retryDelayMs, fallbackToJson]);
106
+
107
+ // A live result wins once it lands; until then the snapshot renders. A failed
108
+ // revalidation never blanks a section the snapshot could still fill.
109
+ if (fetched.groups) {
110
+ return {...fetched, source: 'network', generatedAt: null};
111
+ }
112
+
113
+ if (seededGroups) {
114
+ return {
115
+ status: 'success',
116
+ groups: seededGroups,
117
+ error: fetched.error,
118
+ source: 'snapshot',
119
+ // Only meaningful for a snapshot: how stale the data on screen may be.
120
+ generatedAt: initialMentions ? null : snapshot?.generatedAt ?? null,
121
+ };
122
+ }
87
123
 
88
- return state;
124
+ return {
125
+ status: fetched.status,
126
+ groups: EMPTY_GROUPS,
127
+ error: fetched.error,
128
+ source: null,
129
+ generatedAt: null,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * A label is a plain string, or a `{en, es}` map for sites that ship both
135
+ * languages and toggle them with CSS.
136
+ */
137
+ function renderLabel(label, keyPrefix) {
138
+ if (label == null || typeof label === 'string') {
139
+ return label ?? null;
140
+ }
141
+
142
+ return Object.entries(label).map(([lang, text]) => h(
143
+ 'span',
144
+ {key: `${keyPrefix}-${lang}`, className: `i18n-${lang}`, lang},
145
+ text,
146
+ ));
89
147
  }
90
148
 
91
149
  function Face({mention, classNames}) {
@@ -184,6 +242,7 @@ const REACT_CLASS_NAMES = {
184
242
  threadAuthor: 'p-author h-card u-url',
185
243
  threadContent: 'e-content webmention-content',
186
244
  threadSource: 'u-url webmention-source',
245
+ updated: 'webmentions-updated',
187
246
  };
188
247
 
189
248
  /**
@@ -202,10 +261,11 @@ export function Webmentions({
202
261
  innerClassName = null,
203
262
  renderEmpty = null,
204
263
  renderError = null,
264
+ renderUpdated = null,
205
265
  ...options
206
266
  }) {
207
267
  const classNames = {...REACT_CLASS_NAMES, ...classNameOverrides};
208
- const {status, groups, error} = useWebmentions(targets, options);
268
+ const {status, groups, error, source, generatedAt} = useWebmentions(targets, options);
209
269
 
210
270
  if (status === 'error') {
211
271
  return renderError ? renderError(error) : null;
@@ -246,6 +306,25 @@ export function Webmentions({
246
306
  })),
247
307
  )
248
308
  : null,
309
+ // Only when rendering from a snapshot: on a live fetch the mentions are
310
+ // current and dating them would be misleading.
311
+ 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
+ )
327
+ : null,
249
328
  ];
250
329
 
251
330
  return h(
package/src/render.js CHANGED
@@ -33,6 +33,7 @@ export const DEFAULT_CLASS_NAMES = {
33
33
  threadContent: 'webmentions__content p-content',
34
34
  threadSource: 'webmentions__source u-url',
35
35
  threadPhoto: '',
36
+ updated: 'webmentions__updated',
36
37
  };
37
38
 
38
39
  const FACEPILE_META = {
@@ -253,6 +254,10 @@ export function renderGroups(groups, {
253
254
  classNames: classNameOverrides = {},
254
255
  facepileMode = 'flat',
255
256
  maxLength,
257
+ // Timestamp of the snapshot these groups came from, if any. Rendering it
258
+ // tells a reader how stale the list may be; on a live fetch, leave it unset.
259
+ updatedAt = null,
260
+ updated,
256
261
  document: doc = globalThis.document,
257
262
  } = {}) {
258
263
  const classNames = {...DEFAULT_CLASS_NAMES, ...classNameOverrides};
@@ -287,6 +292,28 @@ export function renderGroups(groups, {
287
292
  })));
288
293
  }
289
294
 
295
+ const updatedElement = resolveElement(updated, containerElement, doc)
296
+ || containerElement?.querySelector(`.${classNames.updated.split(' ')[0]}`);
297
+
298
+ if (updatedElement) {
299
+ const show = Boolean(updatedAt) && labels.updated != null
300
+ && (groups.interactions.length > 0 || groups.threads.length > 0);
301
+
302
+ updatedElement.replaceChildren();
303
+
304
+ if (show) {
305
+ appendLabel(updatedElement, labels.updated, doc);
306
+ updatedElement.appendChild(doc.createTextNode(' '));
307
+
308
+ const time = doc.createElement('time');
309
+ time.dateTime = updatedAt;
310
+ time.textContent = formatMentionDate(updatedAt, locale);
311
+ updatedElement.appendChild(time);
312
+ }
313
+
314
+ updatedElement.hidden = !show;
315
+ }
316
+
290
317
  if (containerElement) {
291
318
  const empty = !groups.interactions.length && !groups.threads.length;
292
319
  containerElement.hidden = empty;