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