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

@@ -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 `<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 || {};
@@ -117,41 +117,58 @@ function renderCard(url, metadata) {
</div>`;
}
/**
* 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);
});
}