├── .nvmrc ├── .gitignore ├── .prettierrc ├── .eleventy.js ├── package.json ├── filters.js ├── webmentions.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.13.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /.eleventy.js: -------------------------------------------------------------------------------- 1 | const Webmentions = require("./webmentions"); 2 | const WebmentionFilters = require("./filters"); 3 | 4 | const config = async (eleventyConfig, options = {}) => { 5 | const webmentions = Webmentions(options); 6 | const filters = WebmentionFilters(options); 7 | 8 | const data = webmentions.get(); 9 | 10 | eleventyConfig.addGlobalData("webmentions", async () => { 11 | const { children } = await data; 12 | return children; 13 | }); 14 | 15 | eleventyConfig.addGlobalData("webmentionsLastFetched", async () => { 16 | const { lastFetched } = await data; 17 | return new Date(lastFetched); 18 | }); 19 | 20 | eleventyConfig.addFilter("webmentionsForPage", filters.mentions); 21 | eleventyConfig.addFilter("webmentionCountForPage", filters.count); 22 | }; 23 | 24 | config.defaults = { 25 | ...Webmentions.defaults, 26 | ...WebmentionFilters.defaults, 27 | }; 28 | 29 | module.exports = config; 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eleventy-plugin-webmentions", 3 | "version": "2.1.0", 4 | "description": "An eleventy plugin to fetch webmentions and helper methods to display them", 5 | "main": ".eleventy.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Luke Bonaccorsi", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@tweetback/canonical": "^2.0.27", 13 | "html-entities": "^2.3.3", 14 | "node-fetch": "^2.6.6", 15 | "sanitize-html": "^2.7.0", 16 | "truncate-html": "^1.0.4" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/CodeFoodPixels/eleventy-plugin-webmentions.git" 21 | }, 22 | "keywords": [ 23 | "Eleventy", 24 | "11ty", 25 | "webmentions" 26 | ], 27 | "bugs": { 28 | "url": "https://github.com/CodeFoodPixels/eleventy-plugin-webmentions/issues" 29 | }, 30 | "homepage": "https://github.com/CodeFoodPixels/eleventy-plugin-webmentions#readme" 31 | } 32 | -------------------------------------------------------------------------------- /filters.js: -------------------------------------------------------------------------------- 1 | const { URL } = require("url"); 2 | 3 | const defaults = { 4 | mentionTypes: { 5 | likes: ["like-of"], 6 | reposts: ["repost-of"], 7 | comments: ["mention-of", "in-reply-to"], 8 | }, 9 | }; 10 | 11 | function stripOuterSlashes(str) { 12 | let start = 0; 13 | while (str[start++] === "/"); 14 | let end = str.length; 15 | while (str[--end] === "/"); 16 | return str.slice(start - 1, end + 1); 17 | } 18 | 19 | const filters = ({ 20 | mentionTypes = defaults.mentionTypes, 21 | pageAliases = {}, 22 | }) => { 23 | const cleanedAliases = Object.keys(pageAliases).reduce((cleaned, key) => { 24 | cleaned[stripOuterSlashes(key.toLowerCase())] = 25 | typeof pageAliases[key] === "string" 26 | ? [stripOuterSlashes(pageAliases[key].toLowerCase())] 27 | : pageAliases[key].map((alias) => 28 | stripOuterSlashes(alias.toLowerCase()) 29 | ); 30 | 31 | return cleaned; 32 | }, {}); 33 | 34 | function filterWebmentions(webmentions, page) { 35 | const pageUrl = new URL(page, "https://lukeb.co.uk"); 36 | const normalizedPagePath = stripOuterSlashes( 37 | pageUrl.pathname.toLowerCase() 38 | ); 39 | 40 | const flattenedMentionTypes = Object.values(mentionTypes).flat(); 41 | 42 | return webmentions 43 | .filter((mention) => { 44 | const target = new URL(mention["wm-target"]); 45 | const normalisedTargetPath = stripOuterSlashes( 46 | target.pathname.toLowerCase() 47 | ); 48 | return ( 49 | normalizedPagePath === normalisedTargetPath || 50 | cleanedAliases[normalizedPagePath]?.includes(normalisedTargetPath) 51 | ); 52 | }) 53 | .filter( 54 | (entry) => !!entry.author && (!!entry.author.name || entry.author.url) 55 | ) 56 | .filter((mention) => 57 | flattenedMentionTypes.includes(mention["wm-property"]) 58 | ); 59 | } 60 | 61 | function count(webmentions, pageUrl) { 62 | const page = 63 | pageUrl || 64 | this.page?.url || 65 | this.ctx?.page?.url || 66 | this.context?.environments?.page?.url; 67 | 68 | return filterWebmentions(webmentions, page).length; 69 | } 70 | 71 | function mentions(webmentions, pageUrl) { 72 | const page = 73 | pageUrl || 74 | this.page?.url || 75 | this.ctx?.page?.url || 76 | this.context?.environments?.page?.url; 77 | 78 | const filteredWebmentions = filterWebmentions(webmentions, page); 79 | 80 | const returnedWebmentions = { 81 | total: filteredWebmentions.length, 82 | }; 83 | 84 | Object.keys(mentionTypes).map((type) => { 85 | returnedWebmentions[type] = filteredWebmentions.filter((mention) => 86 | mentionTypes[type].includes(mention["wm-property"]) 87 | ); 88 | }); 89 | 90 | return returnedWebmentions; 91 | } 92 | 93 | return { 94 | count, 95 | mentions, 96 | }; 97 | }; 98 | 99 | filters.defaults = defaults; 100 | 101 | module.exports = filters; 102 | -------------------------------------------------------------------------------- /webmentions.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs").promises; 2 | const fetch = require("node-fetch"); 3 | const truncateHTML = require("truncate-html"); 4 | const sanitizeHTML = require("sanitize-html"); 5 | const { encode } = require("html-entities"); 6 | const canonical = import("@tweetback/canonical"); 7 | 8 | const defaults = { 9 | cacheDirectory: "./_webmentioncache", 10 | cacheTime: 3600, 11 | truncate: true, 12 | maxContentLength: 280, 13 | truncationMarker: "…", 14 | htmlContent: true, 15 | useCanonicalTwitterUrls: true, 16 | sanitizeOptions: { 17 | allowedTags: ["b", "i", "em", "strong", "a", "p"], 18 | allowedAttributes: { 19 | a: ["href"], 20 | }, 21 | }, 22 | sortFunction: (a, b) => 23 | new Date(a.published || a["wm-received"]) - 24 | new Date(b.published || b["wm-received"]), 25 | }; 26 | function Webmentions({ 27 | domain, 28 | token, 29 | cacheDirectory = defaults.cacheDirectory, 30 | cacheTime = defaults.cacheTime, 31 | truncate = defaults.truncate, 32 | maxContentLength = defaults.maxContentLength, 33 | truncationMarker = defaults.truncationMarker, 34 | htmlContent = defaults.htmlContent, 35 | useCanonicalTwitterUrls = defaults.useCanonicalTwitterUrls, 36 | sanitizeOptions = defaults.sanitizeOptions, 37 | sortFunction = defaults.sortFunction, 38 | }) { 39 | if ( 40 | (typeof domain !== "string" && !Array.isArray(domain)) || 41 | domain.length === 0 42 | ) { 43 | throw new Error("Domain must be provided as a string"); 44 | } 45 | 46 | if (!Array.isArray(domain)) { 47 | domain = [domain]; 48 | } 49 | 50 | if ( 51 | (typeof token !== "string" && !Array.isArray(token)) || 52 | token.length === 0 53 | ) { 54 | throw new Error("Token must be provided as a string."); 55 | } 56 | 57 | if (!Array.isArray(token)) { 58 | token = [token]; 59 | } 60 | 61 | function getUrl(idx) { 62 | return `https://webmention.io/api/mentions.jf2?domain=${domain[idx]}&token=${token[idx]}`; 63 | } 64 | 65 | async function fetchWebmentions(idx, since, page = 0) { 66 | const PER_PAGE = 1000; 67 | 68 | const params = `&per-page=${PER_PAGE}&page=${page}${ 69 | since ? `&since=${since}` : "" 70 | }`; 71 | console.log(`Getting ${getUrl(idx)}${params}`); 72 | const response = await fetch(`${getUrl(idx)}${params}`); 73 | 74 | if (response.ok) { 75 | const feed = await response.json(); 76 | if (feed.children.length === PER_PAGE) { 77 | const olderMentions = await fetchWebmentions(idx, since, page + 1); 78 | 79 | return [...feed.children, ...olderMentions]; 80 | } 81 | return feed.children; 82 | } 83 | 84 | return []; 85 | } 86 | 87 | async function writeToCache(data) { 88 | const filePath = `${cacheDirectory}/webmentions.json`; 89 | const fileContent = JSON.stringify(data, null, 2); 90 | 91 | // create cache folder if it doesnt exist already 92 | if (!(await fs.stat(cacheDirectory).catch(() => false))) { 93 | await fs.mkdir(cacheDirectory); 94 | } 95 | // write data to cache json file 96 | await fs.writeFile(filePath, fileContent); 97 | } 98 | 99 | async function readFromCache() { 100 | const filePath = `${cacheDirectory}/webmentions.json`; 101 | 102 | if (await fs.stat(filePath).catch(() => false)) { 103 | const cacheFile = await fs.readFile(filePath); 104 | return JSON.parse(cacheFile); 105 | } 106 | 107 | return { 108 | lastFetched: null, 109 | children: [], 110 | }; 111 | } 112 | 113 | async function clean(entry) { 114 | const { transform } = await canonical; 115 | 116 | if (useCanonicalTwitterUrls) { 117 | entry.url = transform(entry.url); 118 | entry.author.url = transform(entry.author.url); 119 | } 120 | 121 | if (entry.content) { 122 | if (entry.content.html && htmlContent) { 123 | if (useCanonicalTwitterUrls) { 124 | entry.content.html = entry.content.html.replaceAll( 125 | /"(https:\/\/twitter.com\/(.+?))"/g, 126 | function (match, p1) { 127 | return transform(p1); 128 | } 129 | ); 130 | } 131 | 132 | if (!entry.content.html.match(/^<\/?[a-z][\s\S]*>/)) { 133 | const paragraphs = entry.content.html 134 | .split("\n") 135 | .filter((p) => p.length > 0); 136 | 137 | entry.content.html = `

${paragraphs.join("

")}

`; 138 | } 139 | 140 | const sanitizedContent = sanitizeHTML( 141 | entry.content.html, 142 | sanitizeOptions 143 | ); 144 | 145 | if (truncate) { 146 | const truncatedContent = truncateHTML( 147 | sanitizedContent, 148 | maxContentLength, 149 | { ellipsis: truncationMarker, decodeEntities: true } 150 | ); 151 | 152 | entry.content.value = truncatedContent.replace( 153 | encode(truncationMarker), 154 | truncationMarker 155 | ); 156 | } else { 157 | entry.content.value = sanitizedContent; 158 | } 159 | } else { 160 | entry.content.value = 161 | truncate && entry.content.text.length > maxContentLength 162 | ? `${entry.content.text.substr( 163 | 0, 164 | maxContentLength 165 | )}${truncationMarker}` 166 | : entry.content.text; 167 | 168 | if (htmlContent) { 169 | const paragraphs = entry.content.value 170 | .split("\n") 171 | .filter((p) => p.length > 0); 172 | 173 | entry.content.value = `

${paragraphs.join("

")}

`; 174 | } 175 | } 176 | } 177 | 178 | return entry; 179 | } 180 | 181 | async function get() { 182 | const webmentions = await readFromCache(); 183 | 184 | if ( 185 | !webmentions.lastFetched || 186 | Date.now() - new Date(webmentions.lastFetched) >= cacheTime * 1000 187 | ) { 188 | const feed = await Promise.all( 189 | domain.map((domain, idx) => 190 | fetchWebmentions(idx, webmentions.lastFetched) 191 | ) 192 | ).then((feeds) => feeds.flat()); 193 | 194 | if (feed.length > 0) { 195 | webmentions.lastFetched = new Date().toISOString(); 196 | webmentions.children = [...feed, ...webmentions.children]; 197 | 198 | await writeToCache(webmentions); 199 | } 200 | } 201 | 202 | webmentions.children = await Promise.all( 203 | webmentions.children.sort(sortFunction).map(clean) 204 | ); 205 | 206 | return webmentions; 207 | } 208 | 209 | return { get }; 210 | } 211 | 212 | Webmentions.defaults = defaults; 213 | 214 | module.exports = Webmentions; 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eleventy-plugin-webmentions 2 | 3 | A plugin for eleventy to fetch and filter [webmentions](https://indieweb.org/Webmention) from [Webmention.io](https://webmention.io). 4 | 5 | ## Install 6 | 7 | Available on [npm](https://www.npmjs.com/package/eleventy-plugin-webmentions). 8 | 9 | `npm install --save-dev eleventy-plugin-webmentions` 10 | 11 | ## Usage 12 | 13 | In your Eleventy config file (probably `.eleventy.js`), load the plugin module and use `.addPlugin` to add it to Eleventy with an options object that defines the `domain` and the Webmention.io `token`. Like this: 14 | 15 | ```javascript 16 | const Webmentions = require("eleventy-plugin-webmentions"); 17 | 18 | module.exports = function (eleventyConfig) { 19 | eleventyConfig.addPlugin(Webmentions, { 20 | domain: "lukeb.co.uk", 21 | token: "ABC123XYZ987", 22 | }); 23 | }; 24 | ``` 25 | 26 | REMEMBER: You’re only allowed one `module.exports` in your configuration file, so make sure you only copy the `require` and the `.addPlugin` lines above! (Including the configuration options) 27 | 28 | The plugin then adds 2 global data objects. One is called `webmentionsLastFetched` and is a `Date` object with the date that the plugin last fetched webmentions, and the other is called `webmentions` and is an array of webmention objects that look similar to this: 29 | 30 | ```javascript 31 | { 32 | type: 'entry', 33 | author: { 34 | type: 'card', 35 | name: 'Zach Leatherman', 36 | photo: 'https://webmention.io/avatar/pbs.twimg.com/d9711a9ad30ae05a761e4a728883bcbdd852cbf7d41437925b0afc47a8589795.jpg', 37 | url: 'https://twitter.com/zachleat' 38 | }, 39 | url: 'https://twitter.com/zachleat/status/1524800520208142337', 40 | published: '2022-05-12T17:15:48+00:00', 41 | 'wm-received': '2022-05-13T00:05:16Z', 42 | 'wm-id': 1397424, 43 | 'wm-source': 'https://brid.gy/comment/twitter/CodeFoodPixels/1524795680966991874/1524800520208142337', 44 | 'wm-target': 'https://lukeb.co.uk/blog/2022/01/17/pixelated-rounded-corners-with-css-clip-path/', 45 | content: { 46 | html: 'The step-by-step here was/is incredible detailed!\n' + 47 | '\n' + 48 | '', 49 | text: 'The step-by-step here was/is incredible detailed!', 50 | value: 'The step-by-step here was/is incredible detailed! ' 51 | }, 52 | 'in-reply-to': 'https://lukeb.co.uk/blog/2022/01/17/pixelated-rounded-corners-with-css-clip-path/', 53 | 'wm-property': 'in-reply-to', 54 | 'wm-private': false 55 | } 56 | ``` 57 | 58 | It also adds 2 filters: 59 | 60 | - `webmentionsForPage` will return the webmentions for that page, in the structure defined by the `mentionTypes` option. 61 | - `webmentionCountForPage` will return the number of webmentions for a page, filtered by the types used in the `mentionTypes` option. 62 | 63 | Here is an example of using the filters in nunjucks: 64 | 65 | ```nunjucks 66 | {# Get the webmentions for the current page #} 67 | {%- set currentPostMentions = webmentions | webmentionsForPage -%} 68 | 69 | {# Get the webmentions for a specific page #} 70 | {%- set postMentions = webmentions | webmentionsForPage(post.url) -%} 71 | 72 | {# Get the webmention count for the current page #} 73 | {%- set currentPostMentionCount = webmentions | webmentionCountForPage -%} 74 | 75 | {# Get the webmention count for a page #} 76 | {%- set postMentionCount = webmentions | webmentionCountForPage(post.url) -%} 77 | 78 | ``` 79 | 80 | ## Configuration 81 | 82 | Below are all the options that can be passed to the plugin: 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 100 | 101 | 106 | 111 | 112 | 113 | 114 | 115 | 120 | 121 | 126 | 131 | 132 | 133 | 134 | 135 | 140 | 141 | 142 | 147 | 148 | 149 | 150 | 151 | 156 | 157 | 158 | 163 | 164 | 165 | 166 | 167 | 172 | 173 | 174 | 179 | 180 | 181 | 182 | 183 | 188 | 189 | 190 | 195 | 196 | 197 | 198 | 199 | 204 | 205 | 206 | 211 | 212 | 213 | 214 | 215 | 220 | 221 | 222 | 227 | 228 | 229 | 230 | 231 | 236 | 237 | 238 | 243 | 248 | 249 | 250 | 251 | 256 | 257 | 258 | 263 | 268 | 269 | 270 | 271 | 276 | 277 | 278 | 292 | 297 | 298 | 299 | 300 | 305 | 306 | 307 | 319 | 324 | 325 | 326 | 327 | 332 | 333 | 334 | 343 | 344 | 345 | 346 |
OptionTypeRequired?DefaultDescription
96 | 97 | `domain` 98 | 99 | string 102 | 103 | **Required** 104 | 105 | 107 | 108 | `undefined` 109 | 110 | The domain you wish to get the webmentions for.
116 | 117 | `token` 118 | 119 | string 122 | 123 | **Required** 124 | 125 | 127 | 128 | `undefined` 129 | 130 | The webmention.io token (found at the bottom of [the webmention.io settings page](https://webmention.io/settings)).
136 | 137 | `cacheDirectory` 138 | 139 | stringOptional 143 | 144 | `./_webmentioncache` 145 | 146 | The directory for webmentions to be cached to.
152 | 153 | `cacheTime` 154 | 155 | integerOptional 159 | 160 | `3600` 161 | 162 | The time in seconds for the cached webmentions to be considered "fresh".
168 | 169 | `truncate` 170 | 171 | booleanOptional 175 | 176 | `true` 177 | 178 | Whether or not to truncate the webmentions
184 | 185 | `maxContentLength` 186 | 187 | integerOptional 191 | 192 | `280` 193 | 194 | The length to truncate webmentions to if `truncate` is true
200 | 201 | `truncationMarker` 202 | 203 | stringOptional 207 | 208 | `…` 209 | 210 | The string to truncate the content with
216 | 217 | `htmlContent` 218 | 219 | booleanOptional 223 | 224 | `true` 225 | 226 | Whether or not to return HTML content from the webmentions. If `false`, just text content will be returned.
232 | 233 | `useCanonicalTwitterUrls` 234 | 235 | booleanOptional 239 | 240 | `true` 241 | 242 | 244 | 245 | Whether or not to convert Twitter URLs using [tweetback-canonical](https://github.com/tweetback/tweetback-canonical) 246 | 247 |
252 | 253 | `pageAliases` 254 | 255 | objectOptional 259 | 260 | `{}` 261 | 262 | 264 | 265 | An object keyed by page path, with the values either being a string of a page that is an alias of that page (e.g an old page that has been redirected) or an array of strings. 266 | 267 |
272 | 273 | `mentionTypes` 274 | 275 | objectOptional 279 | 280 | ```javascript 281 | { 282 | likes: ["like-of"], 283 | reposts: ["repost-of"], 284 | comments: [ 285 | "mention-of", 286 | "in-reply-to" 287 | ] 288 | } 289 | ``` 290 | 291 | 293 | 294 | A single layer object with groupings and types that should be returned for that grouping. The object can have any keys you wish (doesn't have to be `likes`, `reposts` and `comments` like the default) but each value should be an array of webmention types.[You can find a list of possible types here](https://github.com/aaronpk/webmention.io#find-links-of-a-specific-type-to-a-specific-page) 295 | 296 |
301 | 302 | `sanitizeOptions` 303 | 304 | objectOptional 308 | 309 | ```javascript 310 | { 311 | allowedTags: ["b", "i", "em", "strong", "a", "p"], 312 | allowedAttributes: { 313 | a: ["href"], 314 | }, 315 | } 316 | ``` 317 | 318 | 320 | 321 | A set of options passed to `sanitize-html`. You can find a full list of available options here [You can find a full list of available options here](https://github.com/apostrophecms/sanitize-html) 322 | 323 |
328 | 329 | `sortFunction` 330 | 331 | functionOptional 335 | 336 | ```javascript 337 | (a, b) => { 338 | new Date(a.published || a["wm-received"]) - 339 | new Date(b.published || b["wm-received"]) 340 | ``` 341 | 342 | A function to use when sorting the webmentions. By default, the webmentions will be sorted in date ascending order, either by when they were published or when they were recieved.
347 | 348 | ### Defaults 349 | 350 | All of the defaults are exposed on the `defaults` property of the module, so they can be used in your config if necessary. 351 | 352 | Here is an example of extending the `sanitizeOptions` object: 353 | 354 | ```javascript 355 | const Webmentions = require("eleventy-plugin-webmentions"); 356 | 357 | module.exports = function (eleventyConfig) { 358 | eleventyConfig.addPlugin(Webmentions, { 359 | domain: "lukeb.co.uk", 360 | token: "ABC123XYZ987", 361 | sanitizeOptions: { 362 | ...Webmentions.defaults.sanitizeOptions, 363 | allowedTags: [ 364 | ...Webmentions.defaults.sanitizeOptions.allowedTags, 365 | "iframe", 366 | "marquee", 367 | ], 368 | disallowedTagsMode: "escape", 369 | }, 370 | }); 371 | }; 372 | ``` 373 | --------------------------------------------------------------------------------