diff --git a/eleventy.config.js b/eleventy.config.js
index f38e472..79a813a 100644
--- a/eleventy.config.js
+++ b/eleventy.config.js
@@ -7,6 +7,7 @@ import markdownIt from "markdown-it";
import markdownItAnchor from "markdown-it-anchor";
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
import { minify } from "html-minifier-terser";
+import registerUnfurlShortcode from "./lib/unfurl-shortcode.js";
import { createHash } from "crypto";
import { execFileSync } from "child_process";
import { readFileSync, existsSync } from "fs";
@@ -44,6 +45,8 @@ export default function (eleventyConfig) {
eleventyConfig.watchIgnores.add("pagefind/**");
eleventyConfig.watchIgnores.add(".cache/og");
eleventyConfig.watchIgnores.add(".cache/og/**");
+ eleventyConfig.watchIgnores.add(".cache/unfurl");
+ eleventyConfig.watchIgnores.add(".cache/unfurl/**");
// Configure markdown-it with linkify enabled (auto-convert URLs to links)
const md = markdownIt({
@@ -173,11 +176,15 @@ export default function (eleventyConfig) {
},
mastodon: {
options: {
- server: "mstdn.social",
+ server: "indieweb.social",
},
},
});
+ // Unfurl shortcode — renders any URL as a rich card (OpenGraph/Twitter Card metadata)
+ // Usage in templates: {% unfurl "https://example.com/article" %}
+ registerUnfurlShortcode(eleventyConfig);
+
// Custom transform to convert YouTube links to embeds
eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
if (!outputPath || !outputPath.endsWith(".html")) {
diff --git a/lib/unfurl-shortcode.js b/lib/unfurl-shortcode.js
new file mode 100644
index 0000000..0f34739
--- /dev/null
+++ b/lib/unfurl-shortcode.js
@@ -0,0 +1,114 @@
+import { unfurl } from "unfurl.js";
+import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
+import { resolve } from "path";
+import { createHash } from "crypto";
+
+const CACHE_DIR = resolve(import.meta.dirname, "..", ".cache", "unfurl");
+const CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
+
+function getCachePath(url) {
+ const hash = createHash("md5").update(url).digest("hex");
+ return resolve(CACHE_DIR, `${hash}.json`);
+}
+
+function readCache(url) {
+ const path = getCachePath(url);
+ if (!existsSync(path)) return null;
+ try {
+ const data = JSON.parse(readFileSync(path, "utf-8"));
+ if (Date.now() - data.cachedAt < CACHE_DURATION_MS) {
+ return data.metadata;
+ }
+ } catch {
+ // Corrupt cache file, ignore
+ }
+ return null;
+}
+
+function writeCache(url, metadata) {
+ mkdirSync(CACHE_DIR, { recursive: true });
+ const path = getCachePath(url);
+ writeFileSync(path, JSON.stringify({ cachedAt: Date.now(), metadata }));
+}
+
+function extractDomain(url) {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return url;
+ }
+}
+
+function escapeHtml(str) {
+ if (!str) return "";
+ return str
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+/**
+ * Register the {% unfurl "URL" %} shortcode on an Eleventy config.
+ */
+export default function registerUnfurlShortcode(eleventyConfig) {
+ eleventyConfig.addAsyncShortcode("unfurl", async function (url) {
+ if (!url) return "";
+
+ // Check cache first
+ let metadata = readCache(url);
+
+ if (!metadata) {
+ try {
+ metadata = await unfurl(url, { timeout: 10000 });
+ writeCache(url, metadata);
+ } catch (err) {
+ console.warn(`[unfurl] Failed to fetch ${url}: ${err.message}`);
+ // Fallback: plain link
+ const domain = escapeHtml(extractDomain(url));
+ return `${domain}`;
+ }
+ }
+
+ const og = metadata.open_graph || {};
+ const tc = metadata.twitter_card || {};
+
+ const title = og.title || tc.title || metadata.title || extractDomain(url);
+ const description = og.description || tc.description || metadata.description || "";
+ const image =
+ og.images?.[0]?.url ||
+ tc.images?.[0]?.url ||
+ null;
+ const favicon = metadata.favicon || null;
+ const domain = extractDomain(url);
+
+ // Truncate description
+ const maxDesc = 160;
+ const desc = description.length > maxDesc
+ ? description.slice(0, maxDesc).trim() + "\u2026"
+ : description;
+
+ // Build card HTML
+ const imgHtml = image
+ ? `
+
})
+
`
+ : "";
+
+ const faviconHtml = favicon
+ ? `
`
+ : "";
+
+ return ``;
+ });
+}
diff --git a/package-lock.json b/package-lock.json
index a6a6d93..64882c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,7 +28,8 @@
"markdown-it-anchor": "^9.2.0",
"pagefind": "^1.3.0",
"rss-parser": "^3.13.0",
- "satori": "^0.19.2"
+ "satori": "^0.19.2",
+ "unfurl.js": "^6.4.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.0",
@@ -2736,6 +2737,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
"node_modules/hex-rgb": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",
@@ -2841,6 +2851,18 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
@@ -4226,6 +4248,12 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
"node_modules/sanitize-html": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
@@ -4863,6 +4891,105 @@
"multiformats": "^9.4.2"
}
},
+ "node_modules/unfurl.js": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/unfurl.js/-/unfurl.js-6.4.0.tgz",
+ "integrity": "sha512-DogJFWPkOWMcu2xPdpmbcsL+diOOJInD3/jXOv6saX1upnWmMK8ndAtDWUfJkuInqNI9yzADud4ID9T+9UeWCw==",
+ "license": "ISC",
+ "dependencies": {
+ "debug": "^3.2.7",
+ "he": "^1.2.0",
+ "htmlparser2": "^8.0.1",
+ "iconv-lite": "^0.4.24",
+ "node-fetch": "^2.6.7"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/unfurl.js/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/unfurl.js/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/unfurl.js/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/unfurl.js/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/unfurl.js/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/unfurl.js/node_modules/htmlparser2": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+ "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "entities": "^4.4.0"
+ }
+ },
"node_modules/unicode-segmenter": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz",
diff --git a/package.json b/package.json
index fef1109..355009b 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,8 @@
"markdown-it-anchor": "^9.2.0",
"pagefind": "^1.3.0",
"rss-parser": "^3.13.0",
- "satori": "^0.19.2"
+ "satori": "^0.19.2",
+ "unfurl.js": "^6.4.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.0",
diff --git a/tailwind.config.js b/tailwind.config.js
index 5449496..c3d0a64 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -7,6 +7,7 @@ export default {
"./**/*.md",
"./_includes/**/*.njk",
"./content/**/*.md",
+ "./lib/**/*.js",
],
darkMode: "class",
theme: {