From 36f17d1a1f6a373e1bddbdaa951b7bf43146859f Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 20 Feb 2026 16:10:25 +0100 Subject: [PATCH] feat: add unfurl cards to blog page and homepage recent posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use two-strategy approach to work around async shortcode limitation in deeply nested Nunjucks includes: - blog.njk: async {% unfurl %} shortcode (top-level, works fine) - recent-posts.njk: sync {{ url | unfurlCard | safe }} filter (reads from pre-populated disk cache) eleventy.before hook scans content files and pre-fetches all interaction URLs before templates render, ensuring the sync filter always has data — even on first build. --- .../components/sections/recent-posts.njk | 36 ++++---- blog.njk | 36 ++++---- eleventy.config.js | 44 ++++++++- lib/unfurl-shortcode.js | 89 +++++++++++-------- 4 files changed, 127 insertions(+), 78 deletions(-) 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 }} -

- - {{ likedUrl }} - -

+ {{ likedUrl | unfurlCard | safe }} + + {{ likedUrl }} + {% if post.templateContent %}
{{ 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); }); }