@hhkaos/webmentions-widget 0.1.1 → 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 +33 -0
- package/bin/snapshot.js +129 -0
- package/package.json +5 -1
- package/src/core.js +70 -0
- package/src/react.js +43 -14
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)`
|
package/bin/snapshot.js
ADDED
|
@@ -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.
|
|
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
|
],
|
|
@@ -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,
|
|
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
|
-
|
|
50
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
112
|
+
|
|
113
|
+
if (seededGroups) {
|
|
114
|
+
return {status: 'success', groups: seededGroups, error: fetched.error};
|
|
115
|
+
}
|
|
87
116
|
|
|
88
|
-
return
|
|
117
|
+
return {status: fetched.status, groups: EMPTY_GROUPS, error: fetched.error};
|
|
89
118
|
}
|
|
90
119
|
|
|
91
120
|
function Face({mention, classNames}) {
|