mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 16:44:56 +02:00
feat: add unfurl shortcode for rich link preview cards
Adds {% unfurl "URL" %} shortcode that renders any URL as a rich card
with OpenGraph metadata (title, description, image, favicon). Uses
unfurl.js locally — no external API dependency. Results cached for 1
week in .cache/unfurl/. Also fixes Mastodon embed server config
(mstdn.social → indieweb.social).
This commit is contained in:
@@ -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")) {
|
||||
|
||||
114
lib/unfurl-shortcode.js
Normal file
114
lib/unfurl-shortcode.js
Normal file
@@ -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, ">")
|
||||
.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 `<a href="${escapeHtml(url)}" rel="noopener" target="_blank">${domain}</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
? `<div class="unfurl-card-image shrink-0">
|
||||
<img src="${escapeHtml(image)}" alt="" loading="lazy" decoding="async"
|
||||
class="w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-r-lg" />
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const faviconHtml = favicon
|
||||
? `<img src="${escapeHtml(favicon)}" alt="" class="inline-block w-4 h-4 mr-1 align-text-bottom" loading="lazy" />`
|
||||
: "";
|
||||
|
||||
return `<div class="unfurl-card not-prose my-4 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 overflow-hidden hover:border-primary-300 dark:hover:border-primary-600 transition-colors">
|
||||
<a href="${escapeHtml(url)}" rel="noopener" target="_blank" class="flex no-underline text-inherit hover:text-inherit">
|
||||
<div class="flex-1 p-3 sm:p-4 min-w-0">
|
||||
<p class="font-semibold text-sm sm:text-base text-surface-900 dark:text-surface-100 truncate m-0">${escapeHtml(title)}</p>
|
||||
${desc ? `<p class="text-xs sm:text-sm text-surface-500 dark:text-surface-400 mt-1 m-0 line-clamp-2">${escapeHtml(desc)}</p>` : ""}
|
||||
<p class="text-xs text-surface-400 dark:text-surface-500 mt-2 m-0">${faviconHtml}${escapeHtml(domain)}</p>
|
||||
</div>
|
||||
${imgHtml}
|
||||
</a>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
129
package-lock.json
generated
129
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
"./**/*.md",
|
||||
"./_includes/**/*.njk",
|
||||
"./content/**/*.md",
|
||||
"./lib/**/*.js",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
|
||||
Reference in New Issue
Block a user