@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,693 +1,9 @@
1
1
  const { AssetCache } = require("@11ty/eleventy-fetch");
2
2
  const { styleText } = require("node:util");
3
3
  const sanitizeHTML = require("sanitize-html");
4
+ const createCore = require("./src/core.cjs");
4
5
 
5
- /**
6
- * @typedef {sanitizeHTML.IOptions} AllowedHTML
7
- */
6
+ const core = createCore({ AssetCache, styleText, sanitizeHTML });
8
7
 
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
- 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
- const getPublished = (webmention) => {
175
- return (
176
- webmention?.["data"]?.["published"] ||
177
- webmention["published"] ||
178
- webmention["wm-received"] ||
179
- webmention["verified_date"]
180
- );
181
- };
182
-
183
- /**
184
- * @param {Webmention} webmention
185
- * @returns {string|undefined}
186
- */
187
- const getReceived = (webmention) => {
188
- return (
189
- webmention["wm-received"] ||
190
- webmention["verified_date"] ||
191
- webmention["published"] ||
192
- webmention?.["data"]?.["published"]
193
- );
194
- };
195
-
196
- /**
197
- * @param {Webmention} webmention
198
- * @returns {string}
199
- */
200
- const getContent = (webmention) => {
201
- return (
202
- webmention?.["contentSanitized"] ||
203
- webmention?.["content"]?.["html"] ||
204
- webmention?.["content"]?.["value"] ||
205
- webmention?.["content"] ||
206
- webmention?.["data"]?.["content"] ||
207
- ""
208
- );
209
- };
210
-
211
- /**
212
- * @param {Webmention} webmention
213
- * @returns {string|undefined}
214
- */
215
- const getSource = (webmention) => {
216
- return (
217
- webmention["wm-source"] ||
218
- webmention["source"] ||
219
- webmention?.["data"]?.["url"] ||
220
- webmention["url"]
221
- );
222
- };
223
-
224
- /**
225
- * @param {Webmention} webmention
226
- * @returns {string|undefined}
227
- */
228
- const getURL = (webmention) => {
229
- return (
230
- webmention?.["data"]?.["url"] ||
231
- webmention["url"] ||
232
- webmention["wm-source"] ||
233
- webmention["source"]
234
- );
235
- };
236
-
237
- /**
238
- * @param {Webmention} webmention
239
- * @returns {string|undefined}
240
- */
241
- const getTarget = (webmention) => {
242
- return webmention["wm-target"] || webmention["target"];
243
- };
244
-
245
- /**
246
- * @param {Webmention} webmention
247
- * @returns {string}
248
- */
249
- const getUniqueKey = (webmention) => {
250
- return JSON.stringify([getSource(webmention), getTarget(webmention)]);
251
- };
252
-
253
- /**
254
- * @param {Webmention} webmention
255
- * @returns {string|undefined}
256
- */
257
- const getType = (webmention) => {
258
- return (
259
- webmention["wm-property"] ||
260
- webmention?.["activity"]?.["type"] ||
261
- webmention["type"]
262
- );
263
- };
264
-
265
- /**
266
- * @param {Array<Webmention>} webmentions
267
- * @param {WebmentionType|Array<WebmentionType>} types
268
- * @returns {Array<Webmention>}
269
- */
270
- const getByTypes = (webmentions, types) => {
271
- return webmentions.filter((webmention) => {
272
- if (typeof types === "string") {
273
- return types === getType(webmention);
274
- }
275
- return types.includes(getType(webmention));
276
- });
277
- };
278
-
279
- /**
280
- * @param {Array<Webmention>} webmentions
281
- * @param {Array<string>} blocklist
282
- * @returns {Array<Webmention>}
283
- */
284
- const processBlocklist = (webmentions, blocklist) => {
285
- return webmentions.filter((webmention) => {
286
- let url = getSource(webmention);
287
- let source = getSource(webmention);
288
- for (let blocklistURL of blocklist) {
289
- if (
290
- url.includes(blocklistURL.replace(/\/?$/, "/")) ||
291
- source.includes(blocklistURL.replace(/\/?$/, "/"))
292
- ) {
293
- return false;
294
- }
295
- }
296
- return true;
297
- });
298
- };
299
-
300
- /**
301
- * @param {Array<Webmention>} webmentions
302
- * @param {Array<string>} allowlist
303
- * @returns {Array<Webmention>}
304
- */
305
- const processAllowlist = (webmentions, allowlist) => {
306
- return webmentions.filter((webmention) => {
307
- let url = getSource(webmention);
308
- let source = getSource(webmention);
309
- for (let allowlistURL of allowlist) {
310
- if (
311
- url.includes(allowlistURL.replace(/\/?$/, "/")) ||
312
- source.includes(allowlistURL.replace(/\/?$/, "/"))
313
- ) {
314
- return true;
315
- }
316
- }
317
- return false;
318
- });
319
- };
320
-
321
- /**
322
- * @param {Options} options
323
- * @param {Array<Webmention>} webmentions
324
- * @param {string} url
325
- * @returns {Promise<{found: number, webmentions: Array<Webmention>}>}
326
- */
327
- const fetchWebmentions = async (options, webmentions, url) => {
328
- return await fetch(url)
329
- .then(async (response) => {
330
- if (!response.ok) {
331
- return Promise.reject(response);
332
- }
333
-
334
- const feed = await response.json();
335
-
336
- if (!(options.key in feed)) {
337
- console.log(
338
- `${styleText("gray", `[${hostname(options.domain)}]`)} ${
339
- options.key
340
- } was not found as a key in the response from ${styleText(
341
- "bold",
342
- hostname(options.feed),
343
- )}!`,
344
- );
345
- return Promise.reject(response);
346
- }
347
-
348
- // Fetched Webmentions replace cached ones with the same
349
- // source / target pair
350
- const fetchedKeys = new Set(
351
- feed[options.key].map(getUniqueKey),
352
- );
353
- webmentions = [
354
- ...feed[options.key],
355
- ...webmentions.filter(
356
- (wm) => !fetchedKeys.has(getUniqueKey(wm)),
357
- ),
358
- ];
359
- // Remove any remaining duplicates by source / target pair
360
- webmentions = removeDuplicates(webmentions);
361
- // Process the blocklist, if it has any entries
362
- if (options.blocklist.length) {
363
- webmentions = processBlocklist(webmentions, options.blocklist);
364
- }
365
- // Process the allowlist, if it has any entries
366
- if (options.allowlist.length) {
367
- webmentions = processAllowlist(webmentions, options.allowlist);
368
- }
369
- // Sort webmentions by received date for getting most recent Webmention on subsequent requests
370
- webmentions = webmentions.sort((a, b) => {
371
- return epoch(getReceived(b)) - epoch(getReceived(a));
372
- });
373
-
374
- return {
375
- found: feed[options.key].length,
376
- webmentions: webmentions,
377
- };
378
- })
379
- .catch((error) => {
380
- console.warn(
381
- `${styleText(
382
- "gray",
383
- `[${hostname(options.domain)}]`,
384
- )} Something went wrong with your Webmention request to ${styleText(
385
- "bold",
386
- hostname(options.feed),
387
- )}!`,
388
- );
389
- console.warn(error instanceof Error ? error.message : error);
390
-
391
- return {
392
- found: 0,
393
- webmentions: webmentions,
394
- };
395
- });
396
- };
397
-
398
- /**
399
- * @param {Options} options
400
- * @returns {Promise<Array<Webmention>>}
401
- */
402
- const retrieveWebmentions = async (options) => {
403
- if (!options.domain) {
404
- throw new Error(
405
- "`domain` is a required field when attempting to retrieve Webmentions. See https://www.npmjs.com/package/@chrisburnell/eleventy-cache-webmentions#installation for more information.",
406
- );
407
- }
408
-
409
- if (!options.feed) {
410
- throw new Error(
411
- "`feed` is a required field when attempting to retrieve Webmentions. See https://www.npmjs.com/package/@chrisburnell/eleventy-cache-webmentions#installation for more information.",
412
- );
413
- }
414
-
415
- if (!options.key) {
416
- throw new Error(
417
- "`key` is a required field when attempting to retrieve Webmentions. See https://www.npmjs.com/package/@chrisburnell/eleventy-cache-webmentions#installation for more information.",
418
- );
419
- }
420
-
421
- let asset = new AssetCache(
422
- options.uniqueKey || `webmentions-${hostname(options.domain)}`,
423
- options.cacheDirectory,
424
- );
425
-
426
- let webmentions = [];
427
-
428
- // Unless specifically getting fresh Webmentions, if there is a cached file
429
- // at all, grab its contents now
430
- if (asset.isCacheValid("9001y") && !options.refresh) {
431
- webmentions = await asset.getCachedValue();
432
- }
433
-
434
- // Get the number of cached Webmentions for diffing against fetched
435
- // Webmentions later
436
- const webmentionsCachedLength = webmentions.length;
437
-
438
- // If there is a cached file but it is outside of expiry, fetch fresh
439
- // results since the most recent Webmention
440
- if (!asset.isCacheValid(options.refresh ? "0s" : options.duration)) {
441
- const performanceStart = process.hrtime();
442
- // Get the received date of the most recent Webmention, if it exists
443
- const since = webmentions.length ? getReceived(webmentions[0]) : false;
444
- // Build the URL for the fetch request
445
- const url = `${options.feed}${
446
- since
447
- ? `${options.feed.includes("?") ? "&" : "?"}since=${since}`
448
- : ""
449
- }`;
450
-
451
- // If using webmention.io, loop through pages until no results found
452
- if (url.includes("https://webmention.io")) {
453
- const urlObject = new URL(url);
454
- const perPage =
455
- Number(urlObject.searchParams.get("per-page")) || 1000;
456
- urlObject.searchParams.delete("per-page");
457
- // Start on page 0, to increment per subsequent request
458
- let page = 0;
459
- // Loop until a break condition is hit
460
- while (true) {
461
- const urlPaginated =
462
- urlObject.href + `&per-page=${perPage}&page=${page}`;
463
- const fetched = await fetchWebmentions(
464
- options,
465
- webmentions,
466
- urlPaginated,
467
- );
468
-
469
- // An error occurred during fetching paged results → break
470
- if (!fetched && !fetched.found && !fetched.webmentions) {
471
- break;
472
- }
473
-
474
- // Page has no Webmentions → break
475
- if (fetched.found === 0) {
476
- break;
477
- }
478
-
479
- webmentions = fetched.webmentions;
480
-
481
- // If there are less Webmentions found than should be in each
482
- // page → break
483
- if (fetched.found < perPage) {
484
- break;
485
- }
486
-
487
- // Increment page
488
- page += 1;
489
- // Throttle next request
490
- await new Promise((resolve) => setTimeout(resolve, 1000));
491
- }
492
- } else {
493
- const fetched = await fetchWebmentions(options, webmentions, url);
494
- webmentions = fetched.webmentions;
495
- }
496
-
497
- // Process the blocklist, if it has any entries
498
- if (options.blocklist.length) {
499
- webmentions = processBlocklist(webmentions, options.blocklist);
500
- }
501
-
502
- // Process the allowlist, if it has any entries
503
- if (options.allowlist.length) {
504
- webmentions = processAllowlist(webmentions, options.allowlist);
505
- }
506
-
507
- await asset.save(webmentions, "json");
508
-
509
- const performance = process.hrtime(performanceStart);
510
-
511
- // Add a console message with the number of fetched and processed Webmentions, if any
512
- if (webmentionsCachedLength < webmentions.length) {
513
- console.log(
514
- `${styleText(
515
- "gray",
516
- `[${hostname(options.domain)}]`,
517
- )} ${styleText(
518
- "bold",
519
- String(webmentions.length - webmentionsCachedLength),
520
- )} new Webmentions fetched into cache in ${styleText(
521
- "bold",
522
- (performance[0] + performance[1] / 1e9).toFixed(3) +
523
- " seconds",
524
- )}.`,
525
- );
526
- }
527
- }
528
-
529
- return webmentions;
530
- };
531
-
532
- /** @type {Array<Webmention>} */
533
- const WEBMENTIONS = {};
534
-
535
- /**
536
- * @param {Options} options
537
- * @returns {Promise<{[key: string]: Array<Webmention>}>}
538
- */
539
- const webmentionsByURL = async (options) => {
540
- if (Object.keys(WEBMENTIONS).length) {
541
- return WEBMENTIONS;
542
- }
543
-
544
- let rawWebmentions = await retrieveWebmentions(options);
545
-
546
- // Fix local URLs based on urlReplacements and sort Webmentions into groups
547
- // by target base URL
548
- rawWebmentions.forEach((webmention) => {
549
- let url = baseURL(
550
- fixURL(
551
- getTarget(webmention).replace(/\/?$/, "/"),
552
- options.urlReplacements,
553
- ),
554
- );
555
-
556
- if (!WEBMENTIONS[url]) {
557
- WEBMENTIONS[url] = [];
558
- }
559
-
560
- WEBMENTIONS[url].push(webmention);
561
- });
562
-
563
- return WEBMENTIONS;
564
- };
565
-
566
- /**
567
- * @param {Options} options
568
- * @param {string} url
569
- * @param {WebmentionType|Array<WebmentionType>} [types]
570
- * @returns {Promise<Array<Webmention>>}
571
- */
572
- const getWebmentions = async (options, url, types = []) => {
573
- const webmentions = await webmentionsByURL(options);
574
- url = absoluteURL(url, options.domain);
575
-
576
- if (!url || !webmentions || !webmentions[url]) {
577
- return [];
578
- }
579
-
580
- return (
581
- webmentions[url]
582
- // Filter webmentions by allowed response post types
583
- .filter((entry) => {
584
- return typeof types === "object" && Object.keys(types).length
585
- ? types.includes(getType(entry))
586
- : typeof types === "string"
587
- ? types === getType(entry)
588
- : true;
589
- })
590
- // Sanitize content of webmentions against HTML limit
591
- .map((entry) => {
592
- const html = getContent(entry);
593
-
594
- if (html.length) {
595
- entry.contentSanitized = sanitizeHTML(
596
- html,
597
- options.allowedHTML,
598
- );
599
- if (html.length > options.maximumHtmlLength) {
600
- entry.contentSanitized = `${
601
- options.maximumHtmlText
602
- } <a href="${getSource(entry)}">${getSource(
603
- entry,
604
- )}</a>`;
605
- }
606
- }
607
-
608
- return entry;
609
- })
610
- // Sort by published
611
- .sort((a, b) => {
612
- return epoch(getPublished(a)) - epoch(getPublished(b));
613
- })
614
- );
615
- };
616
-
617
- /**
618
- * @param {object} eleventyConfig
619
- * @param {Options} [options]
620
- */
621
- const eleventyCacheWebmentions = async (eleventyConfig, options = {}) => {
622
- options = Object.assign(defaults, options);
623
-
624
- const byURL = await webmentionsByURL(options);
625
- const all = Object.values(byURL).reduce(
626
- (array, webmentions) => [...array, ...webmentions],
627
- [],
628
- );
629
-
630
- // Global Data
631
- eleventyConfig.addGlobalData("webmentionsDefaults", defaults);
632
- eleventyConfig.addGlobalData("webmentionsOptions", options);
633
- eleventyConfig.addGlobalData("webmentionsByURL", byURL);
634
- eleventyConfig.addGlobalData("webmentionsByUrl", byURL);
635
- eleventyConfig.addGlobalData("webmentionsAll", all);
636
-
637
- // Liquid Filters
638
- eleventyConfig.addLiquidFilter("getWebmentionsByType", getByTypes);
639
- eleventyConfig.addLiquidFilter("getWebmentionsByTypes", getByTypes);
640
- eleventyConfig.addLiquidFilter("getWebmentionPublished", getPublished);
641
- eleventyConfig.addLiquidFilter("getWebmentionReceived", getReceived);
642
- eleventyConfig.addLiquidFilter("getWebmentionContent", getContent);
643
- eleventyConfig.addLiquidFilter("getWebmentionSource", getSource);
644
- eleventyConfig.addLiquidFilter("getWebmentionURL", getURL);
645
- eleventyConfig.addLiquidFilter("getWebmentionTarget", getTarget);
646
- eleventyConfig.addLiquidFilter("getWebmentionType", getType);
647
-
648
- // Nunjucks Filters
649
- eleventyConfig.addNunjucksFilter("getWebmentionsByType", getByTypes);
650
- eleventyConfig.addNunjucksFilter("getWebmentionsByTypes", getByTypes);
651
- eleventyConfig.addNunjucksFilter("getWebmentionPublished", getPublished);
652
- eleventyConfig.addNunjucksFilter("getWebmentionReceived", getReceived);
653
- eleventyConfig.addNunjucksFilter("getWebmentionContent", getContent);
654
- eleventyConfig.addNunjucksFilter("getWebmentionSource", getSource);
655
- eleventyConfig.addNunjucksFilter("getWebmentionURL", getURL);
656
- eleventyConfig.addNunjucksFilter("getWebmentionTarget", getTarget);
657
- eleventyConfig.addNunjucksFilter("getWebmentionType", getType);
658
- };
659
-
660
- module.exports = eleventyCacheWebmentions;
661
- module.exports.defaults = defaults;
662
- module.exports.getPublished = getPublished;
663
- module.exports.getWebmentionPublished = getPublished;
664
- module.exports.getReceived = getReceived;
665
- module.exports.getWebmentionReceived = getReceived;
666
- module.exports.getContent = getContent;
667
- module.exports.getWebmentionContent = getContent;
668
- module.exports.getSource = getSource;
669
- module.exports.getWebmentionSource = getSource;
670
- module.exports.getURL = getURL;
671
- module.exports.getWebmentionURL = getURL;
672
- module.exports.getTarget = getTarget;
673
- module.exports.getWebmentionTarget = getTarget;
674
- module.exports.getUniqueKey = getUniqueKey;
675
- module.exports.getWebmentionUniqueKey = getUniqueKey;
676
- module.exports.getType = getType;
677
- module.exports.getWebmentionType = getType;
678
- module.exports.getByTypes = getByTypes;
679
- module.exports.getByType = getByTypes;
680
- module.exports.getWebmentionsByTypes = getByTypes;
681
- module.exports.getWebmentionsByType = getByTypes;
682
- module.exports.processBlocklist = processBlocklist;
683
- module.exports.processWebmentionBlocklist = processBlocklist;
684
- module.exports.processWebmentionsBlocklist = processBlocklist;
685
- module.exports.processAllowlist = processAllowlist;
686
- module.exports.processWebmentionAllowlist = processAllowlist;
687
- module.exports.processWebmentionsAllowlist = processAllowlist;
688
- module.exports.fetchWebmentions = fetchWebmentions;
689
- module.exports.retrieveWebmentions = retrieveWebmentions;
690
- module.exports.webmentionsByURL = webmentionsByURL;
691
- module.exports.webmentionsByUrl = webmentionsByURL;
692
- module.exports.filteredWebmentions = webmentionsByURL;
693
- module.exports.getWebmentions = getWebmentions;
8
+ module.exports = core.eleventyCacheWebmentions;
9
+ Object.assign(module.exports, core);