@hhkaos/webmentions-widget 0.2.0 → 0.4.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 +25 -0
- package/package.json +1 -1
- package/src/core.js +81 -1
- package/src/react.js +54 -4
- package/src/render.js +27 -0
package/README.md
CHANGED
|
@@ -134,6 +134,27 @@ Two things this buys beyond politeness: the section survives a webmention.io
|
|
|
134
134
|
outage, and the committed JSON is a durable copy of your mentions if the
|
|
135
135
|
service ever disappears.
|
|
136
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
|
+
|
|
137
158
|
## API
|
|
138
159
|
|
|
139
160
|
### `fetchWebmentions(options)`
|
|
@@ -189,6 +210,10 @@ assigns remote HTML.
|
|
|
189
210
|
folds them together, so only ever branch on `mention-of`.
|
|
190
211
|
- Bridgy mangles emoji into runs of `?` and `U+FFFD` when extracting plain text.
|
|
191
212
|
`stripMojibake` removes the debris — the emoji is not recoverable.
|
|
213
|
+
- Responses are sometimes **invalid JSON**: source content is copied into string
|
|
214
|
+
literals unescaped, so a backslash or a raw newline in a mention's content
|
|
215
|
+
makes the whole payload unparseable. `parseWebmentionJson` repairs those two
|
|
216
|
+
cases; valid payloads pass through untouched.
|
|
192
217
|
|
|
193
218
|
## Development
|
|
194
219
|
|
package/package.json
CHANGED
package/src/core.js
CHANGED
|
@@ -461,6 +461,86 @@ export function mergeSnapshot(existing, incoming) {
|
|
|
461
461
|
};
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
const VALID_JSON_ESCAPES = new Set(['"', '\\', '/', 'b', 'f', 'n', 'r', 't', 'u']);
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Repair the malformed JSON webmention.io sometimes serves.
|
|
468
|
+
*
|
|
469
|
+
* Its serializer copies source-page content into string literals without
|
|
470
|
+
* escaping it, so a mention whose content contains a backslash (a shell example
|
|
471
|
+
* ending in `\`, say) or a raw newline produces a payload that `JSON.parse`
|
|
472
|
+
* rejects outright. The mention is then invisible — not because the API was
|
|
473
|
+
* down, but because its response could not be read at all.
|
|
474
|
+
*
|
|
475
|
+
* Walks the text tracking whether it is inside a string literal, escaping only
|
|
476
|
+
* the offending characters and leaving valid escapes untouched.
|
|
477
|
+
*/
|
|
478
|
+
export function repairJson(text) {
|
|
479
|
+
let out = '';
|
|
480
|
+
let inString = false;
|
|
481
|
+
|
|
482
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
483
|
+
const char = text[index];
|
|
484
|
+
|
|
485
|
+
if (!inString) {
|
|
486
|
+
if (char === '"') {
|
|
487
|
+
inString = true;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
out += char;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (char === '"') {
|
|
495
|
+
inString = false;
|
|
496
|
+
out += char;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (char === '\\') {
|
|
501
|
+
const next = text[index + 1];
|
|
502
|
+
|
|
503
|
+
if (VALID_JSON_ESCAPES.has(next)) {
|
|
504
|
+
out += char + next;
|
|
505
|
+
index += 1;
|
|
506
|
+
} else {
|
|
507
|
+
out += '\\\\';
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const code = char.charCodeAt(0);
|
|
514
|
+
|
|
515
|
+
if (code < 0x20) {
|
|
516
|
+
if (code === 0x0a) {
|
|
517
|
+
out += '\\n';
|
|
518
|
+
} else if (code === 0x0d) {
|
|
519
|
+
out += '\\r';
|
|
520
|
+
} else if (code === 0x09) {
|
|
521
|
+
out += '\\t';
|
|
522
|
+
} else {
|
|
523
|
+
out += `\\u${code.toString(16).padStart(4, '0')}`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
out += char;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Parse a webmention.io response, repairing it only if it will not parse. */
|
|
536
|
+
export function parseWebmentionJson(text) {
|
|
537
|
+
try {
|
|
538
|
+
return JSON.parse(text);
|
|
539
|
+
} catch {
|
|
540
|
+
return JSON.parse(repairJson(text));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
464
544
|
export class WebmentionFetchError extends Error {
|
|
465
545
|
constructor(message, {status, attempts, cause} = {}) {
|
|
466
546
|
super(message, {cause});
|
|
@@ -612,7 +692,7 @@ export async function fetchWebmentions({
|
|
|
612
692
|
continue;
|
|
613
693
|
}
|
|
614
694
|
|
|
615
|
-
const data = await response.
|
|
695
|
+
const data = parseWebmentionJson(await response.text());
|
|
616
696
|
|
|
617
697
|
return endpoint.json
|
|
618
698
|
? normalizeJsonFeed(data)
|
package/src/react.js
CHANGED
|
@@ -107,14 +107,43 @@ export function useWebmentions(targets, options = {}) {
|
|
|
107
107
|
// A live result wins once it lands; until then the snapshot renders. A failed
|
|
108
108
|
// revalidation never blanks a section the snapshot could still fill.
|
|
109
109
|
if (fetched.groups) {
|
|
110
|
-
return fetched;
|
|
110
|
+
return {...fetched, source: 'network', generatedAt: null};
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
if (seededGroups) {
|
|
114
|
-
return {
|
|
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
|
+
};
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
return {
|
|
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
|
+
));
|
|
118
147
|
}
|
|
119
148
|
|
|
120
149
|
function Face({mention, classNames}) {
|
|
@@ -213,6 +242,7 @@ const REACT_CLASS_NAMES = {
|
|
|
213
242
|
threadAuthor: 'p-author h-card u-url',
|
|
214
243
|
threadContent: 'e-content webmention-content',
|
|
215
244
|
threadSource: 'u-url webmention-source',
|
|
245
|
+
updated: 'webmentions-updated',
|
|
216
246
|
};
|
|
217
247
|
|
|
218
248
|
/**
|
|
@@ -231,10 +261,11 @@ export function Webmentions({
|
|
|
231
261
|
innerClassName = null,
|
|
232
262
|
renderEmpty = null,
|
|
233
263
|
renderError = null,
|
|
264
|
+
renderUpdated = null,
|
|
234
265
|
...options
|
|
235
266
|
}) {
|
|
236
267
|
const classNames = {...REACT_CLASS_NAMES, ...classNameOverrides};
|
|
237
|
-
const {status, groups, error} = useWebmentions(targets, options);
|
|
268
|
+
const {status, groups, error, source, generatedAt} = useWebmentions(targets, options);
|
|
238
269
|
|
|
239
270
|
if (status === 'error') {
|
|
240
271
|
return renderError ? renderError(error) : null;
|
|
@@ -275,6 +306,25 @@ export function Webmentions({
|
|
|
275
306
|
})),
|
|
276
307
|
)
|
|
277
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,
|
|
278
328
|
];
|
|
279
329
|
|
|
280
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;
|