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 `
+ +
+

${escapeHtml(title)}

+ ${desc ? `

${escapeHtml(desc)}

` : ""} +

${faviconHtml}${escapeHtml(domain)}

+
+ ${imgHtml} +
+
`; + }); +} 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: {