@chrisburnell/eleventy-cache-webmentions 2.3.2 → 2.3.4

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/src/core.cjs ADDED
@@ -0,0 +1,708 @@
1
+ /**
2
+ * @typedef {object} AllowedHTML
3
+ * @property {Array<string>} [allowedTags]
4
+ * @property {{[attribute: string]: Array<string>}} [allowedAttributes]
5
+ */
6
+
7
+ /**
8
+ * @typedef {object} OptionsDefaults
9
+ * @property {boolean} refresh
10
+ * @property {string} duration
11
+ * @property {string} uniqueKey
12
+ * @property {string} [cacheDirectory]
13
+ * @property {AllowedHTML} allowedHTML
14
+ * @property {Array<string>} allowlist
15
+ * @property {Array<string>} blocklist
16
+ * @property {{[key: string]: string}} urlReplacements
17
+ * @property {number} maximumHtmlLength
18
+ * @property {string} maximumHtmlText
19
+ * @property {boolean} paginate
20
+ */
21
+
22
+ /**
23
+ * @typedef {object} OptionsUserInput
24
+ * @property {string} domain
25
+ * @property {string} feed
26
+ * @property {string} key
27
+ */
28
+
29
+ /**
30
+ * @typedef {OptionsDefaults & OptionsUserInput} Options
31
+ */
32
+
33
+ /**
34
+ * @typedef {object} Webmention
35
+ * @property {string} [source]
36
+ * @property {string} [url]
37
+ * @property {string} [target]
38
+ * @property {string} [published]
39
+ * @property {string} [contentSanitized]
40
+ * @property {object} [content]
41
+ * @property {string} [content.html]
42
+ * @property {string} [content.value]
43
+ * @property {object} [data]
44
+ * @property {string} [data.title]
45
+ * @property {string} [data.url]
46
+ * @property {string} [data.published]
47
+ * @property {string} [data.content]
48
+ * @property {string} [type]
49
+ * @property {object} [activity]
50
+ * @property {string} [activity.type]
51
+ * @property {boolean} [verified]
52
+ * @property {string} ["wm-property"]
53
+ * @property {string} ["wm-received"]
54
+ * @property {string} ["wm-source"]
55
+ * @property {string} ["wm-target"]
56
+ * @property {string} ["verified_date"]
57
+ */
58
+
59
+ /**
60
+ * @typedef {"bookmark-of"|"like-of"|"repost-of"|"mention-of"|"in-reply-to"} WebmentionType
61
+ */
62
+
63
+ /**
64
+ * @param {object} deps
65
+ * @param {typeof import("@11ty/eleventy-fetch").AssetCache} deps.AssetCache
66
+ * @param {typeof import("node:util").styleText} deps.styleText
67
+ * @param {typeof import("sanitize-html")} deps.sanitizeHTML
68
+ * @returns {object}
69
+ */
70
+ module.exports = ({ AssetCache, styleText, sanitizeHTML }) => {
71
+ /**
72
+ * @type {OptionsDefaults}
73
+ */
74
+ const defaults = {
75
+ refresh: false,
76
+ duration: "1d",
77
+ uniqueKey: "webmentions",
78
+ allowedHTML: {
79
+ allowedTags: ["a", "b", "em", "i", "strong"],
80
+ allowedAttributes: {
81
+ a: ["href"],
82
+ },
83
+ },
84
+ allowlist: [],
85
+ blocklist: [],
86
+ urlReplacements: {},
87
+ maximumHtmlLength: 1000,
88
+ maximumHtmlText: "mentioned this in",
89
+ paginate: false,
90
+ };
91
+
92
+ /**
93
+ * @param {string} url
94
+ * @param {string} domain
95
+ * @returns {string}
96
+ */
97
+ const absoluteURL = (url, domain) => {
98
+ try {
99
+ return new URL(url, domain).toString();
100
+ } catch (error) {
101
+ console.error(
102
+ `Trying to convert ${styleText(
103
+ "bold",
104
+ url,
105
+ )} to be an absolute url with base ${styleText(
106
+ "bold",
107
+ domain,
108
+ )} and failed.`,
109
+ error,
110
+ );
111
+ return url;
112
+ }
113
+ };
114
+
115
+ /**
116
+ * @param {string} url
117
+ * @returns {string}
118
+ */
119
+ const baseURL = (url) => {
120
+ let hashSplit = url.split("#");
121
+ let queryparamSplit = hashSplit[0].split("?");
122
+ return queryparamSplit[0];
123
+ };
124
+
125
+ /**
126
+ * @param {string} url
127
+ * @param {{[key: string]: string}} [urlReplacements]
128
+ * @returns {string}
129
+ */
130
+ const fixURL = (url, urlReplacements) => {
131
+ return Object.entries(urlReplacements).reduce(
132
+ (accumulator, [key, value]) => accumulator.replaceAll(key, value),
133
+ url,
134
+ );
135
+ };
136
+
137
+ /**
138
+ * @param {string} url
139
+ * @returns {string}
140
+ */
141
+ const hostname = (url) => {
142
+ if (typeof url === "string" && url.includes("//")) {
143
+ const urlObject = new URL(url);
144
+ return urlObject.hostname;
145
+ }
146
+ return url;
147
+ };
148
+
149
+ /**
150
+ * @param {string|number|Date} date
151
+ * @returns {number}
152
+ */
153
+ const epoch = (date) => {
154
+ return new Date(date).getTime();
155
+ };
156
+
157
+ /**
158
+ * @param {Array<Webmention>} webmentions
159
+ * @returns {Array<Webmention>}
160
+ */
161
+ const removeDuplicates = (webmentions) => {
162
+ return [
163
+ ...webmentions
164
+ .reduce((map, webmention) => {
165
+ const key =
166
+ webmention === null || webmention === undefined
167
+ ? webmention
168
+ : getUniqueKey(webmention);
169
+ if (!map.has(key)) {
170
+ map.set(key, webmention);
171
+ }
172
+ return map;
173
+ }, new Map())
174
+ .values(),
175
+ ];
176
+ };
177
+
178
+ /**
179
+ * @param {Webmention} webmention
180
+ * @returns {string|undefined}
181
+ */
182
+ const getPublished = (webmention) => {
183
+ return (
184
+ webmention?.["data"]?.["published"] ||
185
+ webmention["published"] ||
186
+ webmention["wm-received"] ||
187
+ webmention["verified_date"]
188
+ );
189
+ };
190
+ const getWebmentionPublished = getPublished;
191
+
192
+ /**
193
+ * @param {Webmention} webmention
194
+ * @returns {string|undefined}
195
+ */
196
+ const getReceived = (webmention) => {
197
+ return (
198
+ webmention["wm-received"] ||
199
+ webmention["verified_date"] ||
200
+ webmention["published"] ||
201
+ webmention?.["data"]?.["published"]
202
+ );
203
+ };
204
+ const getWebmentionReceived = getReceived;
205
+
206
+ /**
207
+ * @param {Webmention} webmention
208
+ * @returns {string}
209
+ */
210
+ const getContent = (webmention) => {
211
+ return (
212
+ webmention?.["contentSanitized"] ||
213
+ webmention?.["content"]?.["html"] ||
214
+ webmention?.["content"]?.["value"] ||
215
+ webmention?.["content"] ||
216
+ webmention?.["data"]?.["content"] ||
217
+ ""
218
+ );
219
+ };
220
+ const getWebmentionContent = getContent;
221
+
222
+ /**
223
+ * @param {Webmention} webmention
224
+ * @returns {string|undefined}
225
+ */
226
+ const getSource = (webmention) => {
227
+ return (
228
+ webmention["wm-source"] ||
229
+ webmention["source"] ||
230
+ webmention?.["data"]?.["url"] ||
231
+ webmention["url"]
232
+ );
233
+ };
234
+ const getWebmentionSource = getSource;
235
+
236
+ /**
237
+ * @param {Webmention} webmention
238
+ * @returns {string|undefined}
239
+ */
240
+ const getURL = (webmention) => {
241
+ return (
242
+ webmention?.["data"]?.["url"] ||
243
+ webmention["url"] ||
244
+ webmention["wm-source"] ||
245
+ webmention["source"]
246
+ );
247
+ };
248
+ const getWebmentionURL = getURL;
249
+
250
+ /**
251
+ * @param {Webmention} webmention
252
+ * @returns {string|undefined}
253
+ */
254
+ const getTarget = (webmention) => {
255
+ return webmention["wm-target"] || webmention["target"];
256
+ };
257
+ const getWebmentionTarget = getTarget;
258
+
259
+ /**
260
+ * @param {Webmention} webmention
261
+ * @returns {string}
262
+ */
263
+ const getUniqueKey = (webmention) => {
264
+ return JSON.stringify([getSource(webmention), getTarget(webmention)]);
265
+ };
266
+ const getWebmentionUniqueKey = getUniqueKey;
267
+
268
+ /**
269
+ * @param {Webmention} webmention
270
+ * @returns {string|undefined}
271
+ */
272
+ const getType = (webmention) => {
273
+ return (
274
+ webmention["wm-property"] ||
275
+ webmention?.["activity"]?.["type"] ||
276
+ webmention["type"]
277
+ );
278
+ };
279
+ const getWebmentionType = getType;
280
+
281
+ /**
282
+ * @param {Array<Webmention>} webmentions
283
+ * @param {WebmentionType|Array<WebmentionType>} types
284
+ * @returns {Array<Webmention>}
285
+ */
286
+ const getByTypes = (webmentions, types) => {
287
+ return webmentions.filter((webmention) => {
288
+ if (typeof types === "string") {
289
+ return types === getType(webmention);
290
+ }
291
+ return types.includes(getType(webmention));
292
+ });
293
+ };
294
+ const getByType = getByTypes;
295
+ const getWebmentionsByTypes = getByTypes;
296
+ const getWebmentionsByType = getByTypes;
297
+
298
+ /**
299
+ * @param {Array<Webmention>} webmentions
300
+ * @param {Array<string>} blocklist
301
+ * @returns {Array<Webmention>}
302
+ */
303
+ const processBlocklist = (webmentions, blocklist) => {
304
+ return webmentions.filter((webmention) => {
305
+ let url = getSource(webmention);
306
+ let source = getSource(webmention);
307
+ for (let blocklistURL of blocklist) {
308
+ if (
309
+ url.includes(blocklistURL.replace(/\/?$/, "/")) ||
310
+ source.includes(blocklistURL.replace(/\/?$/, "/"))
311
+ ) {
312
+ return false;
313
+ }
314
+ }
315
+ return true;
316
+ });
317
+ };
318
+ const processWebmentionBlocklist = processBlocklist;
319
+ const processWebmentionsBlocklist = processBlocklist;
320
+
321
+ /**
322
+ * @param {Array<Webmention>} webmentions
323
+ * @param {Array<string>} allowlist
324
+ * @returns {Array<Webmention>}
325
+ */
326
+ const processAllowlist = (webmentions, allowlist) => {
327
+ return webmentions.filter((webmention) => {
328
+ let url = getSource(webmention);
329
+ let source = getSource(webmention);
330
+ for (let allowlistURL of allowlist) {
331
+ if (
332
+ url.includes(allowlistURL.replace(/\/?$/, "/")) ||
333
+ source.includes(allowlistURL.replace(/\/?$/, "/"))
334
+ ) {
335
+ return true;
336
+ }
337
+ }
338
+ return false;
339
+ });
340
+ };
341
+ const processWebmentionAllowlist = processAllowlist;
342
+ const processWebmentionsAllowlist = processAllowlist;
343
+
344
+ /**
345
+ * @param {Options} options
346
+ * @param {Array<Webmention>} webmentions
347
+ * @param {string} url
348
+ * @returns {Promise<{found: number, webmentions: Array<Webmention>}>}
349
+ */
350
+ const fetchWebmentions = async (options, webmentions, url) => {
351
+ return await fetch(url)
352
+ .then(async (response) => {
353
+ if (!response.ok) {
354
+ return Promise.reject(response);
355
+ }
356
+
357
+ const feed = await response.json();
358
+
359
+ if (!(options.key in feed)) {
360
+ console.log(
361
+ `${styleText("gray", `[${hostname(options.domain)}]`)} ${
362
+ options.key
363
+ } was not found as a key in the response from ${styleText(
364
+ "bold",
365
+ hostname(options.feed),
366
+ )}!`,
367
+ );
368
+ return Promise.reject(response);
369
+ }
370
+
371
+ // Fetched Webmentions replace cached ones with the same
372
+ // source / target pair
373
+ const fetchedKeys = new Set(feed[options.key].map(getUniqueKey));
374
+ webmentions = [
375
+ ...feed[options.key],
376
+ ...webmentions.filter((wm) => !fetchedKeys.has(getUniqueKey(wm))),
377
+ ];
378
+ // Remove any remaining duplicates by source / target pair
379
+ webmentions = removeDuplicates(webmentions);
380
+ // Process the blocklist, if it has any entries
381
+ if (options.blocklist.length) {
382
+ webmentions = processBlocklist(webmentions, options.blocklist);
383
+ }
384
+ // Process the allowlist, if it has any entries
385
+ if (options.allowlist.length) {
386
+ webmentions = processAllowlist(webmentions, options.allowlist);
387
+ }
388
+ // Sort webmentions by received date for getting most recent Webmention on subsequent requests
389
+ webmentions = webmentions.sort((a, b) => {
390
+ return epoch(getReceived(b)) - epoch(getReceived(a));
391
+ });
392
+
393
+ return {
394
+ found: feed[options.key].length,
395
+ webmentions: webmentions,
396
+ };
397
+ })
398
+ .catch((error) => {
399
+ console.warn(
400
+ `${styleText(
401
+ "gray",
402
+ `[${hostname(options.domain)}]`,
403
+ )} Something went wrong with your Webmention request to ${styleText(
404
+ "bold",
405
+ hostname(options.feed),
406
+ )}!`,
407
+ );
408
+ console.warn(error instanceof Error ? error.message : error);
409
+
410
+ return {
411
+ found: 0,
412
+ webmentions: webmentions,
413
+ };
414
+ });
415
+ };
416
+
417
+ /**
418
+ * @param {Options} options
419
+ * @returns {Promise<Array<Webmention>>}
420
+ */
421
+ const retrieveWebmentions = async (options) => {
422
+ if (!options.domain) {
423
+ throw new Error(
424
+ "`domain` is a required field when attempting to retrieve Webmentions. See https://www.npmjs.com/package/@chrisburnell/eleventy-cache-webmentions#installation for more information.",
425
+ );
426
+ }
427
+
428
+ if (!options.feed) {
429
+ throw new Error(
430
+ "`feed` is a required field when attempting to retrieve Webmentions. See https://www.npmjs.com/package/@chrisburnell/eleventy-cache-webmentions#installation for more information.",
431
+ );
432
+ }
433
+
434
+ if (!options.key) {
435
+ throw new Error(
436
+ "`key` is a required field when attempting to retrieve Webmentions. See https://www.npmjs.com/package/@chrisburnell/eleventy-cache-webmentions#installation for more information.",
437
+ );
438
+ }
439
+
440
+ let asset = new AssetCache(
441
+ options.uniqueKey || `webmentions-${hostname(options.domain)}`,
442
+ options.cacheDirectory,
443
+ );
444
+
445
+ let webmentions = [];
446
+
447
+ // Unless specifically getting fresh Webmentions, if there is a cached file
448
+ // at all, grab its contents now
449
+ if (asset.isCacheValid("*") && !options.refresh) {
450
+ webmentions = await asset.getCachedValue();
451
+ }
452
+
453
+ // Get the number of cached Webmentions for diffing against fetched
454
+ // Webmentions later
455
+ const webmentionsCachedLength = webmentions.length;
456
+
457
+ // If there is a cached file but it is outside of expiry, fetch fresh
458
+ // results since the most recent Webmention
459
+ if (!asset.isCacheValid(options.refresh ? "0s" : options.duration)) {
460
+ const performanceStart = process.hrtime();
461
+ // Get the received date of the most recent Webmention, if it exists
462
+ const since = webmentions.length ? getReceived(webmentions[0]) : false;
463
+ // Build the URL for the fetch request
464
+ const url = `${options.feed}${
465
+ since ? `${options.feed.includes("?") ? "&" : "?"}since=${since}` : ""
466
+ }`;
467
+
468
+ // If using webmention.io or paginate or per-page in URL
469
+ const urlObject = new URL(url);
470
+ if (
471
+ url.includes("https://webmention.io") ||
472
+ options.paginate ||
473
+ urlObject.searchParams.has("per-page")
474
+ ) {
475
+ const perPage = Number(urlObject.searchParams.get("per-page")) || 1000;
476
+ urlObject.searchParams.delete("per-page");
477
+ // Start on page 0, to increment per subsequent request
478
+ let page = 0;
479
+ // Loop until a break condition is hit
480
+ while (true) {
481
+ const urlPaginated =
482
+ urlObject.href + `&per-page=${perPage}&page=${page}`;
483
+ const fetched = await fetchWebmentions(
484
+ options,
485
+ webmentions,
486
+ urlPaginated,
487
+ );
488
+
489
+ // Page has no Webmentions or an error occurred → break
490
+ if (fetched.found === 0) {
491
+ break;
492
+ }
493
+
494
+ webmentions = fetched.webmentions;
495
+
496
+ // If there are less Webmentions found than should be in each
497
+ // page → break
498
+ if (fetched.found < perPage) {
499
+ break;
500
+ }
501
+
502
+ // Increment page
503
+ page += 1;
504
+ // Throttle next request
505
+ await new Promise((resolve) => setTimeout(resolve, 1000));
506
+ }
507
+ } else {
508
+ const fetched = await fetchWebmentions(options, webmentions, url);
509
+ webmentions = fetched.webmentions;
510
+ }
511
+
512
+ // Process the blocklist, if it has any entries
513
+ if (options.blocklist.length) {
514
+ webmentions = processBlocklist(webmentions, options.blocklist);
515
+ }
516
+
517
+ // Process the allowlist, if it has any entries
518
+ if (options.allowlist.length) {
519
+ webmentions = processAllowlist(webmentions, options.allowlist);
520
+ }
521
+
522
+ await asset.save(webmentions, "json");
523
+
524
+ const performance = process.hrtime(performanceStart);
525
+
526
+ // Add a console message with the number of fetched and processed Webmentions, if any
527
+ if (webmentionsCachedLength < webmentions.length) {
528
+ console.log(
529
+ `${styleText(
530
+ "gray",
531
+ `[${hostname(options.domain)}]`,
532
+ )} ${styleText(
533
+ "bold",
534
+ String(webmentions.length - webmentionsCachedLength),
535
+ )} new Webmentions fetched into cache in ${styleText(
536
+ "bold",
537
+ (performance[0] + performance[1] / 1e9).toFixed(3) + " seconds",
538
+ )}.`,
539
+ );
540
+ }
541
+ }
542
+
543
+ return webmentions;
544
+ };
545
+
546
+ const webmentionsByURLCache = new Map();
547
+
548
+ /**
549
+ * @param {Options} options
550
+ * @returns {Promise<{[key: string]: Array<Webmention>}>}
551
+ */
552
+ const webmentionsByURL = async (options) => {
553
+ const cacheKey = JSON.stringify([
554
+ options.domain,
555
+ options.feed,
556
+ options.key,
557
+ options.uniqueKey,
558
+ options.refresh,
559
+ ]);
560
+
561
+ if (webmentionsByURLCache.has(cacheKey)) {
562
+ return webmentionsByURLCache.get(cacheKey);
563
+ }
564
+
565
+ const promise = retrieveWebmentions(options).then((rawWebmentions) => {
566
+ // Fix local URLs based on urlReplacements and sort Webmentions into
567
+ // groups by target base URL
568
+ const webmentions = {};
569
+ rawWebmentions.forEach((webmention) => {
570
+ const target = getTarget(webmention);
571
+ if (!target) {
572
+ return;
573
+ }
574
+
575
+ let url = baseURL(
576
+ fixURL(target.replace(/\/?$/, "/"), options.urlReplacements),
577
+ );
578
+
579
+ if (!webmentions[url]) {
580
+ webmentions[url] = [];
581
+ }
582
+
583
+ webmentions[url].push(webmention);
584
+ });
585
+
586
+ return webmentions;
587
+ });
588
+
589
+ webmentionsByURLCache.set(cacheKey, promise);
590
+
591
+ return promise;
592
+ };
593
+ const webmentionsByUrl = webmentionsByURL;
594
+ const filteredWebmentions = webmentionsByURL;
595
+
596
+ /**
597
+ * @param {Options} options
598
+ * @param {string} url
599
+ * @param {WebmentionType|Array<WebmentionType>} [types]
600
+ * @returns {Promise<Array<Webmention>>}
601
+ */
602
+ const getWebmentions = async (options, url, types = []) => {
603
+ const webmentions = await webmentionsByURL(options);
604
+ url = absoluteURL(url, options.domain);
605
+
606
+ if (!url || !webmentions || !webmentions[url]) {
607
+ return [];
608
+ }
609
+
610
+ return webmentions[url]
611
+ // Filter webmentions by allowed response post types
612
+ .filter((entry) => {
613
+ return typeof types === "object" && Object.keys(types).length
614
+ ? types.includes(getType(entry))
615
+ : typeof types === "string"
616
+ ? types === getType(entry)
617
+ : true;
618
+ })
619
+ // Sanitize content of webmentions against HTML limit
620
+ .map((entry) => {
621
+ const html = getContent(entry);
622
+
623
+ if (html.length) {
624
+ entry.contentSanitized = sanitizeHTML(html, options.allowedHTML);
625
+ if (html.length > options.maximumHtmlLength) {
626
+ entry.contentSanitized = `${
627
+ options.maximumHtmlText
628
+ } <a href="${getSource(entry)}">${getSource(entry)}</a>`;
629
+ }
630
+ }
631
+
632
+ return entry;
633
+ })
634
+ // Sort by published
635
+ .sort((a, b) => {
636
+ return epoch(getPublished(a)) - epoch(getPublished(b));
637
+ });
638
+ };
639
+
640
+ /**
641
+ * @param {object} eleventyConfig
642
+ * @param {Options} [options]
643
+ */
644
+ const eleventyCacheWebmentions = async (eleventyConfig, options = {}) => {
645
+ options = Object.assign({}, defaults, options);
646
+
647
+ const byURL = await webmentionsByURL(options);
648
+ const all = Object.values(byURL).reduce(
649
+ (array, webmentions) => [...array, ...webmentions],
650
+ [],
651
+ );
652
+
653
+ // Global Data
654
+ eleventyConfig.addGlobalData("webmentionsDefaults", defaults);
655
+ eleventyConfig.addGlobalData("webmentionsOptions", options);
656
+ eleventyConfig.addGlobalData("webmentionsByURL", byURL);
657
+ eleventyConfig.addGlobalData("webmentionsByUrl", byURL);
658
+ eleventyConfig.addGlobalData("webmentionsAll", all);
659
+
660
+ // Universal Filters
661
+ eleventyConfig.addFilter("getWebmentionsByType", getByTypes);
662
+ eleventyConfig.addFilter("getWebmentionsByTypes", getByTypes);
663
+ eleventyConfig.addFilter("getWebmentionPublished", getPublished);
664
+ eleventyConfig.addFilter("getWebmentionReceived", getReceived);
665
+ eleventyConfig.addFilter("getWebmentionContent", getContent);
666
+ eleventyConfig.addFilter("getWebmentionSource", getSource);
667
+ eleventyConfig.addFilter("getWebmentionURL", getURL);
668
+ eleventyConfig.addFilter("getWebmentionTarget", getTarget);
669
+ eleventyConfig.addFilter("getWebmentionType", getType);
670
+ };
671
+
672
+ return {
673
+ defaults,
674
+ getPublished,
675
+ getWebmentionPublished,
676
+ getReceived,
677
+ getWebmentionReceived,
678
+ getContent,
679
+ getWebmentionContent,
680
+ getSource,
681
+ getWebmentionSource,
682
+ getURL,
683
+ getWebmentionURL,
684
+ getTarget,
685
+ getWebmentionTarget,
686
+ getUniqueKey,
687
+ getWebmentionUniqueKey,
688
+ getType,
689
+ getWebmentionType,
690
+ getByTypes,
691
+ getByType,
692
+ getWebmentionsByTypes,
693
+ getWebmentionsByType,
694
+ processBlocklist,
695
+ processWebmentionBlocklist,
696
+ processWebmentionsBlocklist,
697
+ processAllowlist,
698
+ processWebmentionAllowlist,
699
+ processWebmentionsAllowlist,
700
+ fetchWebmentions,
701
+ retrieveWebmentions,
702
+ webmentionsByURL,
703
+ webmentionsByUrl,
704
+ filteredWebmentions,
705
+ getWebmentions,
706
+ eleventyCacheWebmentions,
707
+ };
708
+ };
@@ -1 +0,0 @@
1
- {"cachedAt":1784559047504,"type":"json","metadata":{}}
@@ -1 +0,0 @@
1
- [{"type":"entry","author":{"type":"card","name":"Jane Doe","url":"https://example.com"},"url":"https://example.com/post1/","published":"2024-01-01T12:00:00Z","wm-received":"2024-01-01T12:00:00Z","wm-id":123456,"wm-source":"https://example.com/post1/","wm-target":"https://example.com/page1/","wm-protocol":"webmention","name":"Example Post","content":{"content-type":"text/html","value":"<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>","html":"<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit."},"mention-of":"https://example.com/page1/","wm-property":"mention-of","wm-private":false},{"type":"entry","author":{"type":"card","name":"Jane Doe","url":"https://example.com"},"url":"https://example.com/post2/","published":"2024-01-01T12:00:00Z","wm-received":"2024-01-01T12:00:00Z","wm-id":234567,"wm-source":"https://example.com/post2/","wm-target":"https://example.com/page2/","wm-protocol":"webmention","name":"Example Post","content":{"content-type":"text/html","value":"<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>","html":"<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit."},"in-reply-to":"https://example.com/page2/","wm-property":"in-reply-to","wm-private":false},{"type":"entry","author":{"type":"card","name":"Jane Doe","url":"https://example.com"},"url":"https://example.com/post3/","published":"2024-01-01T12:00:00Z","wm-received":"2024-01-01T12:00:00Z","wm-id":345678,"wm-source":"https://example.com/post3/","wm-target":"https://example.com/page2/","wm-protocol":"webmention","name":"Example Post","content":{"content-type":"text/html","value":"<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>","html":"<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>","text":"Lorem ipsum dolor sit amet, consectetur adipiscing elit."},"mention-of":"https://example.com/page2/","wm-property":"mention-of","wm-private":false}]