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