mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 16:44:56 +02:00
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 }}
|
||||
</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 }}
|
||||
|
||||
20
blog.njk
20
blog.njk
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user