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:
@@ -40,11 +40,10 @@
|
|||||||
{{ post.date | dateDisplay }}
|
{{ post.date | dateDisplay }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1">
|
{{ likedUrl | unfurlCard | safe }}
|
||||||
<a class="u-like-of text-sm text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ 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 }}
|
{{ likedUrl }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
|
||||||
{% if post.templateContent %}
|
{% if post.templateContent %}
|
||||||
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
|
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
|
||||||
{{ post.templateContent | safe }}
|
{{ 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>
|
<a class="hover:text-primary-600 dark:hover:text-primary-400" href="{{ post.url }}">{{ post.data.title }}</a>
|
||||||
</h3>
|
</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="mt-1 text-sm">
|
{{ bookmarkedUrl | unfurlCard | safe }}
|
||||||
<a class="u-bookmark-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ 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 }}
|
{{ bookmarkedUrl }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
|
||||||
{% if post.templateContent %}
|
{% if post.templateContent %}
|
||||||
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
|
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
|
||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
@@ -103,11 +101,10 @@
|
|||||||
{{ post.date | dateDisplay }}
|
{{ post.date | dateDisplay }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm">
|
{{ repostedUrl | unfurlCard | safe }}
|
||||||
<a class="u-repost-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ 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 }}
|
{{ repostedUrl }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
|
||||||
{% if post.templateContent %}
|
{% if post.templateContent %}
|
||||||
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
|
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
|
||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
@@ -132,11 +129,10 @@
|
|||||||
{{ post.date | dateDisplay }}
|
{{ post.date | dateDisplay }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm">
|
{{ replyToUrl | unfurlCard | safe }}
|
||||||
<a class="u-in-reply-to text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ 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 }}
|
{{ replyToUrl }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
|
||||||
{% if post.templateContent %}
|
{% if post.templateContent %}
|
||||||
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
|
<div class="e-content prose dark:prose-invert prose-sm mt-2 max-w-none line-clamp-3">
|
||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
|
|||||||
@@ -73,11 +73,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2">
|
{% unfurl likedUrl %}
|
||||||
<a class="u-like-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ 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 }}
|
{{ likedUrl }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
|
||||||
{% if post.templateContent %}
|
{% if post.templateContent %}
|
||||||
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
|
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
|
||||||
{{ post.templateContent | safe }}
|
{{ 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>
|
<a class="hover:text-primary-600 dark:hover:text-primary-400" href="{{ post.url }}">{{ post.data.title }}</a>
|
||||||
</h2>
|
</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="mt-2 text-sm">
|
{% unfurl bookmarkedUrl %}
|
||||||
<a class="u-bookmark-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ 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 }}
|
{{ bookmarkedUrl }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
|
||||||
{% if post.templateContent %}
|
{% if post.templateContent %}
|
||||||
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
|
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
|
||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
@@ -158,11 +156,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm">
|
{% unfurl repostedUrl %}
|
||||||
<a class="u-repost-of text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ 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 }}
|
{{ repostedUrl }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
|
||||||
{% if post.templateContent %}
|
{% if post.templateContent %}
|
||||||
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
|
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
|
||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
@@ -198,11 +195,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm">
|
{% unfurl replyToUrl %}
|
||||||
<a class="u-in-reply-to text-primary-600 dark:text-primary-400 hover:underline break-all" href="{{ 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 }}
|
{{ replyToUrl }}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
|
||||||
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
|
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">
|
||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+42
-2
@@ -7,10 +7,11 @@ import markdownIt from "markdown-it";
|
|||||||
import markdownItAnchor from "markdown-it-anchor";
|
import markdownItAnchor from "markdown-it-anchor";
|
||||||
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
|
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
|
||||||
import { minify } from "html-minifier-terser";
|
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 { createHash } from "crypto";
|
||||||
import { execFileSync } from "child_process";
|
import { execFileSync } from "child_process";
|
||||||
import { readFileSync, existsSync } from "fs";
|
import { readFileSync, readdirSync, existsSync } from "fs";
|
||||||
import { resolve, dirname } from "path";
|
import { resolve, dirname } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
@@ -185,6 +186,11 @@ export default function (eleventyConfig) {
|
|||||||
// Usage in templates: {% unfurl "https://example.com/article" %}
|
// Usage in templates: {% unfurl "https://example.com/article" %}
|
||||||
registerUnfurlShortcode(eleventyConfig);
|
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
|
// Custom transform to convert YouTube links to embeds
|
||||||
eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
|
eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
|
||||||
if (!outputPath || !outputPath.endsWith(".html")) {
|
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)
|
// WebSub hub notification after each full build (skip on incremental rebuilds)
|
||||||
// Note: Pagefind indexing is handled by start.sh for reliability (eleventy.after
|
// 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
|
// is unreliable when the initial build is OOM-killed or when --incremental flag
|
||||||
|
|||||||
+53
-36
@@ -35,7 +35,7 @@ function getCachePath(url) {
|
|||||||
return resolve(CACHE_DIR, `${hash}.json`);
|
return resolve(CACHE_DIR, `${hash}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCache(url) {
|
export function readCache(url) {
|
||||||
const path = getCachePath(url);
|
const path = getCachePath(url);
|
||||||
if (!existsSync(path)) return undefined; // undefined = not cached
|
if (!existsSync(path)) return undefined; // undefined = not cached
|
||||||
try {
|
try {
|
||||||
@@ -57,7 +57,7 @@ function writeCache(url, metadata, failed = false) {
|
|||||||
writeFileSync(path, JSON.stringify({ cachedAt: Date.now(), metadata, failed }));
|
writeFileSync(path, JSON.stringify({ cachedAt: Date.now(), metadata, failed }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractDomain(url) {
|
export function extractDomain(url) {
|
||||||
try {
|
try {
|
||||||
return new URL(url).hostname.replace(/^www\./, "");
|
return new URL(url).hostname.replace(/^www\./, "");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -65,7 +65,7 @@ function extractDomain(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
export function escapeHtml(str) {
|
||||||
if (!str) return "";
|
if (!str) return "";
|
||||||
return str
|
return str
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -74,12 +74,12 @@ function escapeHtml(str) {
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFallbackLink(url) {
|
export function renderFallbackLink(url) {
|
||||||
const domain = escapeHtml(extractDomain(url));
|
const domain = escapeHtml(extractDomain(url));
|
||||||
return `<a href="${escapeHtml(url)}" rel="noopener" target="_blank">${domain}</a>`;
|
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 og = metadata.open_graph || {};
|
||||||
const tc = metadata.twitter_card || {};
|
const tc = metadata.twitter_card || {};
|
||||||
|
|
||||||
@@ -117,41 +117,58 @@ function renderCard(url, metadata) {
|
|||||||
</div>`;
|
</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.
|
* Register the {% unfurl "URL" %} shortcode on an Eleventy config.
|
||||||
*/
|
*/
|
||||||
export default function registerUnfurlShortcode(eleventyConfig) {
|
export default function registerUnfurlShortcode(eleventyConfig) {
|
||||||
eleventyConfig.addAsyncShortcode("unfurl", async function (url) {
|
eleventyConfig.addAsyncShortcode("unfurl", async function (url) {
|
||||||
if (!url) return "";
|
return prefetchUrl(url);
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user