diff --git a/_includes/components/sections/recent-posts.njk b/_includes/components/sections/recent-posts.njk
index a0fbc58..5bee174 100644
--- a/_includes/components/sections/recent-posts.njk
+++ b/_includes/components/sections/recent-posts.njk
@@ -40,11 +40,10 @@
{{ post.date | dateDisplay }}
-
{{ post.templateContent | safe }}
@@ -74,11 +73,10 @@
{{ post.data.title }}
{% endif %}
-
-
- {{ bookmarkedUrl }}
-
-
+ {{ bookmarkedUrl | unfurlCard | safe }}
+
+ {{ bookmarkedUrl }}
+
{% if post.templateContent %}
{{ post.templateContent | safe }}
@@ -103,11 +101,10 @@
{{ post.date | dateDisplay }}
-
-
- {{ repostedUrl }}
-
-
+ {{ repostedUrl | unfurlCard | safe }}
+
+ {{ repostedUrl }}
+
{% if post.templateContent %}
{{ post.templateContent | safe }}
@@ -132,11 +129,10 @@
{{ post.date | dateDisplay }}
-
-
- {{ replyToUrl }}
-
-
+ {{ replyToUrl | unfurlCard | safe }}
+
+ {{ replyToUrl }}
+
{% if post.templateContent %}
{{ post.templateContent | safe }}
diff --git a/blog.njk b/blog.njk
index acd8622..16a8c4c 100644
--- a/blog.njk
+++ b/blog.njk
@@ -73,11 +73,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{% endif %}
-
-
- {{ likedUrl }}
-
-
+ {% unfurl likedUrl %}
+
+ {{ likedUrl }}
+
{% if post.templateContent %}
{{ post.templateContent | safe }}
@@ -118,11 +117,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{{ post.data.title }}
{% endif %}
-
-
- {{ bookmarkedUrl }}
-
-
+ {% unfurl bookmarkedUrl %}
+
+ {{ bookmarkedUrl }}
+
{% if post.templateContent %}
{{ post.templateContent | safe }}
@@ -158,11 +156,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{% endif %}
-
-
- {{ repostedUrl }}
-
-
+ {% unfurl repostedUrl %}
+
+ {{ repostedUrl }}
+
{% if post.templateContent %}
{{ post.templateContent | safe }}
@@ -198,11 +195,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{% endif %}
-
-
- {{ replyToUrl }}
-
-
+ {% unfurl replyToUrl %}
+
+ {{ replyToUrl }}
+
{{ post.templateContent | safe }}
diff --git a/eleventy.config.js b/eleventy.config.js
index 69b9503..0720c7b 100644
--- a/eleventy.config.js
+++ b/eleventy.config.js
@@ -7,10 +7,11 @@ 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 registerUnfurlShortcode, { getCachedCard, prefetchUrl } from "./lib/unfurl-shortcode.js";
+import matter from "gray-matter";
import { createHash } from "crypto";
import { execFileSync } from "child_process";
-import { readFileSync, existsSync } from "fs";
+import { readFileSync, readdirSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
@@ -185,6 +186,11 @@ export default function (eleventyConfig) {
// Usage in templates: {% unfurl "https://example.com/article" %}
registerUnfurlShortcode(eleventyConfig);
+ // Synchronous unfurl filter — reads from pre-populated disk cache.
+ // Safe for deeply nested includes where async shortcodes fail silently.
+ // Usage: {{ url | unfurlCard | safe }}
+ eleventyConfig.addFilter("unfurlCard", getCachedCard);
+
// Custom transform to convert YouTube links to embeds
eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
if (!outputPath || !outputPath.endsWith(".html")) {
@@ -622,6 +628,40 @@ export default function (eleventyConfig) {
}
});
+ // Pre-fetch unfurl metadata for all interaction URLs in content files.
+ // Populates the disk cache BEFORE templates render, so the synchronous
+ // unfurlCard filter (used in nested includes like recent-posts) has data.
+ eleventyConfig.on("eleventy.before", async () => {
+ const contentDir = resolve(__dirname, "content");
+ if (!existsSync(contentDir)) return;
+
+ const urls = new Set();
+ const interactionProps = [
+ "likeOf", "like_of", "bookmarkOf", "bookmark_of",
+ "repostOf", "repost_of", "inReplyTo", "in_reply_to",
+ ];
+
+ const walk = (dir) => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = resolve(dir, entry.name);
+ if (entry.isDirectory()) { walk(full); continue; }
+ if (!entry.name.endsWith(".md")) continue;
+ try {
+ const { data } = matter(readFileSync(full, "utf-8"));
+ for (const prop of interactionProps) {
+ if (data[prop]) urls.add(data[prop]);
+ }
+ } catch { /* skip unparseable files */ }
+ }
+ };
+ walk(contentDir);
+
+ if (urls.size === 0) return;
+ console.log(`[unfurl] Pre-fetching ${urls.size} interaction URLs...`);
+ await Promise.all([...urls].map((url) => prefetchUrl(url)));
+ console.log(`[unfurl] Pre-fetch complete.`);
+ });
+
// WebSub hub notification after each full build (skip on incremental rebuilds)
// Note: Pagefind indexing is handled by start.sh for reliability (eleventy.after
// is unreliable when the initial build is OOM-killed or when --incremental flag
diff --git a/lib/unfurl-shortcode.js b/lib/unfurl-shortcode.js
index b196e69..dfb9252 100644
--- a/lib/unfurl-shortcode.js
+++ b/lib/unfurl-shortcode.js
@@ -35,7 +35,7 @@ function getCachePath(url) {
return resolve(CACHE_DIR, `${hash}.json`);
}
-function readCache(url) {
+export function readCache(url) {
const path = getCachePath(url);
if (!existsSync(path)) return undefined; // undefined = not cached
try {
@@ -57,7 +57,7 @@ function writeCache(url, metadata, failed = false) {
writeFileSync(path, JSON.stringify({ cachedAt: Date.now(), metadata, failed }));
}
-function extractDomain(url) {
+export function extractDomain(url) {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
@@ -65,7 +65,7 @@ function extractDomain(url) {
}
}
-function escapeHtml(str) {
+export function escapeHtml(str) {
if (!str) return "";
return str
.replace(/&/g, "&")
@@ -74,12 +74,12 @@ function escapeHtml(str) {
.replace(/"/g, """);
}
-function renderFallbackLink(url) {
+export function renderFallbackLink(url) {
const domain = escapeHtml(extractDomain(url));
return `
${domain}`;
}
-function renderCard(url, metadata) {
+export function renderCard(url, metadata) {
const og = metadata.open_graph || {};
const tc = metadata.twitter_card || {};
@@ -117,41 +117,58 @@ function renderCard(url, metadata) {
`;
}
+/**
+ * Fetch unfurl metadata for a URL and populate the disk cache.
+ * Returns the rendered HTML card (or fallback link on failure).
+ */
+export async function prefetchUrl(url) {
+ if (!url) return "";
+
+ // Already cached — skip network fetch
+ const cached = readCache(url);
+ if (cached !== undefined) {
+ return cached.failed ? renderFallbackLink(url) : renderCard(url, cached.metadata);
+ }
+
+ const metadata = await throttled(async () => {
+ try {
+ return await unfurl(url, {
+ timeout: 20000,
+ headers: { "User-Agent": USER_AGENT },
+ });
+ } catch (err) {
+ console.warn(`[unfurl] Failed to fetch ${url}: ${err.message}`);
+ return null;
+ }
+ });
+
+ if (!metadata) {
+ writeCache(url, null, true);
+ return renderFallbackLink(url);
+ }
+
+ writeCache(url, metadata, false);
+ return renderCard(url, metadata);
+}
+
+/**
+ * Synchronous cache-only lookup. Returns the rendered card HTML if cached,
+ * a fallback link if cached as failed, or empty string if not cached.
+ * Safe to use in deeply nested Nunjucks includes where async isn't supported.
+ */
+export function getCachedCard(url) {
+ if (!url) return "";
+ const cached = readCache(url);
+ if (cached === undefined) return renderFallbackLink(url);
+ if (cached.failed) return renderFallbackLink(url);
+ return renderCard(url, cached.metadata);
+}
+
/**
* 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 (returns undefined if not cached)
- const cached = readCache(url);
- if (cached !== undefined) {
- // Cached failure → render fallback link
- if (cached.failed) return renderFallbackLink(url);
- // Cached success → render card
- return renderCard(url, cached.metadata);
- }
-
- // Not cached — fetch with concurrency limiting
- const metadata = await throttled(async () => {
- try {
- return await unfurl(url, {
- timeout: 20000,
- headers: { "User-Agent": USER_AGENT },
- });
- } catch (err) {
- console.warn(`[unfurl] Failed to fetch ${url}: ${err.message}`);
- return null;
- }
- });
-
- if (!metadata) {
- writeCache(url, null, true); // cache failure for 1 day
- return renderFallbackLink(url);
- }
-
- writeCache(url, metadata, false);
- return renderCard(url, metadata);
+ return prefetchUrl(url);
});
}