@hhkaos/webmentions-widget 0.1.0 → 0.2.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,39 @@ 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
+
104
137
  ## API
105
138
 
106
139
  ### `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.0",
3
+ "version": "0.2.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
  ],
@@ -49,5 +50,12 @@
49
50
  },
50
51
  "publishConfig": {
51
52
  "access": "public"
53
+ },
54
+ "devDependencies": {
55
+ "react": "^18.3.1",
56
+ "react-dom": "^18.3.1"
57
+ },
58
+ "bin": {
59
+ "webmentions-snapshot": "./bin/snapshot.js"
52
60
  }
53
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,19 @@ 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;
111
+ }
87
112
 
88
- return state;
113
+ if (seededGroups) {
114
+ return {status: 'success', groups: seededGroups, error: fetched.error};
115
+ }
116
+
117
+ return {status: fetched.status, groups: EMPTY_GROUPS, error: fetched.error};
89
118
  }
90
119
 
91
120
  function Face({mention, classNames}) {
@@ -197,6 +226,9 @@ export function Webmentions({
197
226
  locale,
198
227
  maxLength,
199
228
  classNames: classNameOverrides = {},
229
+ // Optional wrapper inside the <aside>, for hosts whose layout CSS keys off a
230
+ // width-constraining element (Docusaurus's `.container`, for example).
231
+ innerClassName = null,
200
232
  renderEmpty = null,
201
233
  renderError = null,
202
234
  ...options
@@ -216,14 +248,12 @@ export function Webmentions({
216
248
  return renderEmpty ? renderEmpty() : null;
217
249
  }
218
250
 
219
- return h(
220
- 'aside',
221
- {className: classNames.root, 'aria-labelledby': `${classNames.title}-heading`},
222
- h('h2', {id: `${classNames.title}-heading`, className: classNames.title}, title),
251
+ const children = [
252
+ h('h2', {key: 'title', id: `${classNames.title}-heading`, className: classNames.title}, title),
223
253
  groups.interactions.length
224
254
  ? h(
225
255
  'ol',
226
- {className: classNames.facepile, 'aria-label': 'Reactions'},
256
+ {key: 'facepile', className: classNames.facepile, 'aria-label': 'Reactions'},
227
257
  groups.interactions.map((mention) => h(Face, {
228
258
  key: mention['wm-id'] || mention.url || mention['wm-source'],
229
259
  mention,
@@ -234,7 +264,7 @@ export function Webmentions({
234
264
  groups.threads.length
235
265
  ? h(
236
266
  'ol',
237
- {className: classNames.threadList},
267
+ {key: 'threads', className: classNames.threadList},
238
268
  groups.threads.map((mention) => h(Thread, {
239
269
  key: mention['wm-id'] || mention.url || mention['wm-source'],
240
270
  mention,
@@ -245,6 +275,12 @@ export function Webmentions({
245
275
  })),
246
276
  )
247
277
  : null,
278
+ ];
279
+
280
+ return h(
281
+ 'aside',
282
+ {className: classNames.root, 'aria-labelledby': `${classNames.title}-heading`},
283
+ innerClassName ? h('div', {className: innerClassName}, children) : children,
248
284
  );
249
285
  }
250
286