@chrisburnell/eleventy-cache-webmentions 2.3.1 → 2.3.3

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