@chrisburnell/eleventy-cache-webmentions
Version:
Cache webmentions using eleventy-fetch and make them available to use in collections, layouts, pages, etc. in Eleventy.
501 lines (441 loc) • 14.1 kB
JavaScript
const sanitizeHTML = require("sanitize-html");
const { AssetCache } = require("@11ty/eleventy-fetch");
const chalk = require("chalk");
const defaults = {
refresh: false,
duration: "1d",
uniqueKey: "webmentions",
allowedHTML: {
allowedTags: ["b", "i", "em", "strong", "a"],
allowedAttributes: {
a: ["href"],
},
},
allowlist: [],
blocklist: [],
urlReplacements: {},
maximumHtmlLength: 1000,
maximumHtmlText: "mentioned this in",
};
const absoluteURL = (url, domain) => {
try {
return new URL(url, domain).toString();
} catch (e) {
console.error(
`Trying to convert ${chalk.bold(
url,
)} to be an absolute url with base ${chalk.bold(
domain,
)} and failed.`,
);
return url;
}
};
const baseUrl = (url) => {
let hashSplit = url.split("#");
let queryparamSplit = hashSplit[0].split("?");
return queryparamSplit[0];
};
const fixUrl = (url, urlReplacements) => {
return Object.entries(urlReplacements).reduce(
(accumulator, [key, value]) => {
const regex = new RegExp(key, "g");
return accumulator.replace(regex, value);
},
url,
);
};
const hostname = (value) => {
if (typeof value === "string" && value.includes("//")) {
const urlObject = new URL(value);
return urlObject.hostname;
}
return value;
};
const epoch = (value) => {
return new Date(value).getTime();
};
const getPublished = (webmention) => {
return (
webmention?.["data"]?.["published"] ||
webmention["published"] ||
webmention["wm-received"] ||
webmention["verified_date"]
);
};
const getReceived = (webmention) => {
return (
webmention["wm-received"] ||
webmention["verified_date"] ||
webmention["published"] ||
webmention?.["data"]?.["published"]
);
};
const getContent = (webmention) => {
return (
webmention?.["contentSanitized"] ||
webmention?.["content"]?.["html"] ||
webmention?.["content"]?.["value"] ||
webmention?.["content"] ||
webmention?.["data"]?.["content"] ||
""
);
};
const getSource = (webmention) => {
return (
webmention["wm-source"] ||
webmention["source"] ||
webmention?.["data"]?.["url"] ||
webmention["url"]
);
};
const getURL = (webmention) => {
return (
webmention?.["data"]?.["url"] ||
webmention["url"] ||
webmention["wm-source"] ||
webmention["source"]
);
};
const getTarget = (webmention) => {
return webmention["wm-target"] || webmention["target"];
};
const getType = (webmention) => {
return (
webmention["wm-property"] ||
webmention?.["activity"]?.["type"] ||
webmention["type"]
);
};
const getByType = (webmentions, allowedType) => {
return webmentions.filter((webmention) => {
return allowedType === getType(webmention);
});
};
const getByTypes = (webmentions, allowedTypes) => {
return webmentions.filter((webmention) => {
return allowedTypes.includes(getType(webmention));
});
};
const removeDuplicates = (webmentions) => {
return [
...webmentions
.reduce((map, webmention) => {
const key =
webmention === null || webmention === undefined
? webmention
: getSource(webmention);
if (!map.has(key)) {
map.set(key, webmention);
}
return map;
}, new Map())
.values(),
];
};
const processBlocklist = (webmentions, blocklist) => {
return webmentions.filter((webmention) => {
let url = getSource(webmention);
let source = getSource(webmention);
for (let blocklistURL of blocklist) {
if (
url.includes(blocklistURL.replace(/\/?$/, "/")) ||
source.includes(blocklistURL.replace(/\/?$/, "/"))
) {
return false;
}
}
return true;
});
};
const processAllowlist = (webmentions, allowlist) => {
return webmentions.filter((webmention) => {
let url = getSource(webmention);
let source = getSource(webmention);
for (let allowlistURL of allowlist) {
if (
url.includes(allowlistURL.replace(/\/?$/, "/")) ||
source.includes(allowlistURL.replace(/\/?$/, "/"))
) {
return true;
}
}
return false;
});
};
const performFetch = async (options, webmentions, url) => {
return await fetch(url)
.then(async (response) => {
if (!response.ok) {
return Promise.reject(response);
}
const feed = await response.json();
if (!options.key in feed) {
console.log(
`${chalk.grey(`[${hostname(options.domain)}]`)} ${
options.key
} was not found as a key in the response from ${chalkf.bold(
hostname(options.feed),
)}!`,
);
return Promise.reject(response);
}
// Combine newly-fetched Webmentions with cached Webmentions
webmentions = feed[options.key].concat(webmentions);
// Remove duplicates by source URL
webmentions = removeDuplicates(webmentions);
// Process the blocklist, if it has any entries
if (options.blocklist.length) {
webmentions = processBlocklist(webmentions, options.blocklist);
}
// Process the allowlist, if it has any entries
if (options.allowlist.length) {
webmentions = processAllowlist(webmentions, options.allowlist);
}
// Sort webmentions by received date for getting most recent Webmention on subsequent requests
webmentions = webmentions.sort((a, b) => {
return epoch(getReceived(b)) - epoch(getReceived(a));
});
return {
found: feed[options.key].length,
filtered: webmentions,
};
})
.catch((error) => {
console.log(
`${chalk.grey(
`[${hostname(options.domain)}]`,
)} Something went wrong with your request to ${chalk.bold(
hostname(options.feed),
)}!`,
error,
);
});
};
const fetchWebmentions = async (options) => {
if (!options.domain) {
throw new Error(
"`domain` is a required field when attempting to retrieve Webmentions. See https://www.npmjs.com/package/@chrisburnell/eleventy-cache-webmentions#installation for more information.",
);
}
if (!options.feed) {
throw new Error(
"`feed` is a required field when attempting to retrieve Webmentions. See https://www.npmjs.com/package/@chrisburnell/eleventy-cache-webmentions#installation for more information.",
);
}
if (!options.key) {
throw new Error(
"`key` is a required field when attempting to retrieve Webmentions. See https://www.npmjs.com/package/@chrisburnell/eleventy-cache-webmentions#installation for more information.",
);
}
let asset = new AssetCache(
options.uniqueKey || `webmentions-${hostname(options.domain)}`,
options.directory,
);
let webmentions = [];
// Unless specifically getting fresh Webmentions, if there is a cached file
// at all, grab its contents now
if (asset.isCacheValid("9001y") && !options.refresh) {
webmentions = await asset.getCachedValue();
}
// Get the number of cached Webmentions for diffing against fetched
// Webmentions later
const webmentionsCachedLength = webmentions.length;
// If there is a cached file but it is outside of expiry, fetch fresh
// results since the most recent Webmention
if (!asset.isCacheValid(options.refresh ? "0s" : options.duration)) {
const performanceStart = process.hrtime();
// Get the received date of the most recent Webmention, if it exists
const since = webmentions.length ? getReceived(webmentions[0]) : false;
// Build the URL for the fetch request
const url = `${options.feed}${
since
? `${options.feed.includes("?") ? "&" : "?"}since=${since}`
: ""
}`;
// If using webmention.io, loop through pages until no results found
if (url.includes("https://webmention.io")) {
const urlObject = new URL(url);
const perPage =
Number(urlObject.searchParams.get("per-page")) || 1000;
urlObject.searchParams.delete("per-page");
// Start on page 0, to increment per subsequent request
let page = 0;
// Loop until a break condition is hit
while (true) {
const urlPaginated =
urlObject.href + `&per-page=${perPage}&page=${page}`;
const fetched = await performFetch(
options,
webmentions,
urlPaginated,
);
// An error occurred during fetching paged results → break
if (!fetched && !fetched.found && !fetched.filtered) {
break;
}
// Page has no Webmentions → break
if (fetched.found === 0) {
break;
}
webmentions = fetched.filtered;
// If there are less Webmentions found than should be in each
// page → break
if (fetched.found < perPage) {
break;
}
// Increment page
page += 1;
// Throttle next request
await new Promise((resolve) => setTimeout(resolve, 1000));
}
} else {
const fetched = await performFetch(options, webmentions, url);
webmentions = fetched.filtered;
}
// Process the blocklist, if it has any entries
if (options.blocklist.length) {
webmentions = processBlocklist(webmentions, options.blocklist);
}
// Process the allowlist, if it has any entries
if (options.allowlist.length) {
webmentions = processAllowlist(webmentions, options.allowlist);
}
await asset.save(webmentions, "json");
const performance = process.hrtime(performanceStart);
// Add a console message with the number of fetched and processed Webmentions, if any
if (webmentionsCachedLength < webmentions.length) {
console.log(
`${chalk.grey(`[${hostname(options.domain)}]`)} ${chalk.bold(
webmentions.length - webmentionsCachedLength,
)} new Webmentions fetched into cache in ${chalk.bold(
(performance[0] + performance[1] / 1e9).toFixed(3) +
" seconds",
)}.`,
);
}
}
return webmentions;
};
let filtered = {};
const filteredWebmentions = async (options) => {
if (Object.entries(filtered).length) {
return filtered;
}
let rawWebmentions = await fetchWebmentions(options);
// Fix local URLs based on urlReplacements and sort Webmentions into groups
// by target base URL
rawWebmentions.forEach((webmention) => {
let url = baseUrl(
fixUrl(
getTarget(webmention).replace(/\/?$/, "/"),
options.urlReplacements,
),
);
if (!filtered[url]) {
filtered[url] = [];
}
filtered[url].push(webmention);
});
return filtered;
};
const getWebmentions = async (options, url, allowedTypes = {}) => {
const webmentions = await filteredWebmentions(options);
url = absoluteURL(url, options.domain);
if (!url || !webmentions || !webmentions[url]) {
return [];
}
return (
webmentions[url]
// Filter webmentions by allowed response post types
.filter((entry) => {
return typeof allowedTypes === "object" &&
Object.keys(allowedTypes).length
? allowedTypes.includes(getType(entry))
: true;
})
// Sanitize content of webmentions against HTML limit
.map((entry) => {
const html = getContent(entry);
if (html.length) {
entry.contentSanitized = sanitizeHTML(
html,
options.allowedHTML,
);
if (html.length > options.maximumHtmlLength) {
entry.contentSanitized = `${
options.maximumHtmlText
} <a href="${getSource(entry)}">${getSource(
entry,
)}</a>`;
}
}
return entry;
})
// Sort by published
.sort((a, b) => {
return epoch(getPublished(a)) - epoch(getPublished(b));
})
);
};
const eleventyCacheWebmentions = (eleventyConfig, options = {}) => {
options = Object.assign(defaults, options);
// Global Data
eleventyConfig.addGlobalData("webmentionsDefaults", defaults);
eleventyConfig.addGlobalData("webmentionsOptions", options);
const filtered = async () => await filteredWebmentions(options);
eleventyConfig.addGlobalData("webmentionsByUrl", filtered);
const unfiltered = async () =>
await filteredWebmentions(options).then((filtered) =>
Object.values(filtered).reduce(
(array, webmentions) => [...array, ...webmentions],
[],
),
);
eleventyConfig.addGlobalData("webmentionsAll", unfiltered);
// Liquid Filters
eleventyConfig.addLiquidFilter("getWebmentionsByType", getByType);
eleventyConfig.addLiquidFilter("getWebmentionsByTypes", getByTypes);
eleventyConfig.addLiquidFilter("getWebmentionPublished", getPublished);
eleventyConfig.addLiquidFilter("getWebmentionReceived", getReceived);
eleventyConfig.addLiquidFilter("getWebmentionContent", getContent);
eleventyConfig.addLiquidFilter("getWebmentionSource", getSource);
eleventyConfig.addLiquidFilter("getWebmentionURL", getURL);
eleventyConfig.addLiquidFilter("getWebmentionTarget", getTarget);
eleventyConfig.addLiquidFilter("getWebmentionType", getType);
// Nunjucks Filters
eleventyConfig.addNunjucksFilter("getWebmentionsByType", getByType);
eleventyConfig.addNunjucksFilter("getWebmentionsByTypes", getByTypes);
eleventyConfig.addNunjucksFilter("getWebmentionPublished", getPublished);
eleventyConfig.addNunjucksFilter("getWebmentionReceived", getReceived);
eleventyConfig.addNunjucksFilter("getWebmentionContent", getContent);
eleventyConfig.addNunjucksFilter("getWebmentionSource", getSource);
eleventyConfig.addNunjucksFilter("getWebmentionURL", getURL);
eleventyConfig.addNunjucksFilter("getWebmentionTarget", getTarget);
eleventyConfig.addNunjucksFilter("getWebmentionType", getType);
};
module.exports = eleventyCacheWebmentions;
module.exports.defaults = defaults;
module.exports.filteredWebmentions = filteredWebmentions;
module.exports.webmentionsByUrl = filteredWebmentions;
module.exports.fetchWebmentions = fetchWebmentions;
module.exports.performFetch = performFetch;
module.exports.getWebmentions = getWebmentions;
module.exports.getByType = getByType;
module.exports.getWebmentionsByType = getByType;
module.exports.getByTypes = getByTypes;
module.exports.getWebmentionsByTypes = getByTypes;
module.exports.getPublished = getPublished;
module.exports.getWebmentionPublished = getPublished;
module.exports.getReceived = getReceived;
module.exports.getWebmentionReceived = getReceived;
module.exports.getContent = getContent;
module.exports.getWebmentionContent = getContent;
module.exports.getSource = getSource;
module.exports.getWebmentionSource = getSource;
module.exports.getURL = getURL;
module.exports.getWebmentionURL = getURL;
module.exports.getTarget = getTarget;
module.exports.getWebmentionTarget = getTarget;
module.exports.getType = getType;
module.exports.getWebmentionType = getType;