feat: add unfurl cards to blog page and homepage recent posts

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.
This commit is contained in:
Ricardo
2026-02-20 16:10:25 +01:00
parent ec02afb611
commit 36f17d1a1f
4 changed files with 127 additions and 78 deletions

View File

@@ -40,11 +40,10 @@
{{ post.date | dateDisplay }}
</time>
</div>
<p class="mt-1">
<a class="u-like-of text-sm text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ likedUrl }}">
{{ likedUrl | unfurlCard | safe }}
<a class="u-like-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ likedUrl }}">
{{ likedUrl }}
</a>
</p>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
@@ -74,11 +73,10 @@
<a class="hover:text-primary-600 dark:hover:text-primary-400" href="{{ post.url }}">{{ post.data.title }}</a>
</h3>
{% endif %}
<p class="mt-1 text-sm">
<a class="u-bookmark-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ bookmarkedUrl }}">
{{ bookmarkedUrl | unfurlCard | safe }}
<a class="u-bookmark-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ bookmarkedUrl }}">
{{ bookmarkedUrl }}
</a>
</p>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
@@ -103,11 +101,10 @@
{{ post.date | dateDisplay }}
</time>
</div>
<p class="mt-1 text-sm">
<a class="u-repost-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ repostedUrl }}">
{{ repostedUrl | unfurlCard | safe }}
<a class="u-repost-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ repostedUrl }}">
{{ repostedUrl }}
</a>
</p>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}
@@ -132,11 +129,10 @@
{{ post.date | dateDisplay }}
</time>
</div>
<p class="mt-1 text-sm">
<a class="u-in-reply-to text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ replyToUrl }}">
{{ replyToUrl | unfurlCard | safe }}
<a class="u-in-reply-to text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ replyToUrl }}">
{{ replyToUrl }}
</a>
</p>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
{{ post.templateContent | safe }}

View File

@@ -73,11 +73,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
</span>
{% endif %}
</div>
<p class="mt-2">
<a class="u-like-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ likedUrl }}">
{% unfurl likedUrl %}
<a class="u-like-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ likedUrl }}">
{{ likedUrl }}
</a>
</p>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
@@ -118,11 +117,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
<a class="hover:text-primary-600 dark:hover:text-primary-400" href="{{ post.url }}">{{ post.data.title }}</a>
</h2>
{% endif %}
<p class="mt-2 text-sm">
<a class="u-bookmark-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ bookmarkedUrl }}">
{% unfurl bookmarkedUrl %}
<a class="u-bookmark-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ bookmarkedUrl }}">
{{ bookmarkedUrl }}
</a>
</p>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
@@ -158,11 +156,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
</span>
{% endif %}
</div>
<p class="mt-2 text-sm">
<a class="u-repost-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ repostedUrl }}">
{% unfurl repostedUrl %}
<a class="u-repost-of text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ repostedUrl }}">
{{ repostedUrl }}
</a>
</p>
{% if post.templateContent %}
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
@@ -198,11 +195,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
</span>
{% endif %}
</div>
<p class="mt-2 text-sm">
<a class="u-in-reply-to text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ replyToUrl }}">
{% unfurl replyToUrl %}
<a class="u-in-reply-to text-xs text-surface-400 dark:text-surface-500 hover:underline break-all mt-1 inline-block" href="{{ replyToUrl }}">
{{ replyToUrl }}
</a>
</p>
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
{{ post.templateContent | safe }}
</div>

View File

@@ -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

View File

@@ -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, "&amp;")
@@ -74,12 +74,12 @@ function escapeHtml(str) {
.replace(/"/g, "&quot;");
}
function renderFallbackLink(url) {
export function renderFallbackLink(url) {
const domain = escapeHtml(extractDomain(url));
return `<a href="${escapeHtml(url)}" rel="noopener" target="_blank">${domain}</a>`;
}
function renderCard(url, metadata) {
export function renderCard(url, metadata) {
const og = metadata.open_graph || {};
const tc = metadata.twitter_card || {};
@@ -118,22 +118,18 @@ function renderCard(url, metadata) {
}
/**
* Register the {% unfurl "URL" %} shortcode on an Eleventy config.
* Fetch unfurl metadata for a URL and populate the disk cache.
* Returns the rendered HTML card (or fallback link on failure).
*/
export default function registerUnfurlShortcode(eleventyConfig) {
eleventyConfig.addAsyncShortcode("unfurl", async function (url) {
export async function prefetchUrl(url) {
if (!url) return "";
// Check cache first (returns undefined if not cached)
// Already cached — skip network fetch
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);
return cached.failed ? renderFallbackLink(url) : renderCard(url, cached.metadata);
}
// Not cached — fetch with concurrency limiting
const metadata = await throttled(async () => {
try {
return await unfurl(url, {
@@ -147,11 +143,32 @@ export default function registerUnfurlShortcode(eleventyConfig) {
});
if (!metadata) {
writeCache(url, null, true); // cache failure for 1 day
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) {
return prefetchUrl(url);
});
}