@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hhkaos/webmentions-widget",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",
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.json();
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 {status: 'success', groups: seededGroups, error: fetched.error};
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 {status: fetched.status, groups: EMPTY_GROUPS, error: fetched.error};
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;