Files
blog-eleventy-indiekit/eleventy.config.js
Ricardo 1cdc4b89a7 fix: handle non-string outputPath in html-transformer override
outputPath can be `false` for pages without output. Optional chaining
(?.) only guards null/undefined, not booleans — use typeof check instead.

Confab-Link: http://localhost:8080/sessions/0b241cd6-aff2-4fec-853c-2b5a61e61946
2026-03-10 16:19:59 +01:00

1514 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import pluginWebmentions from "@chrisburnell/eleventy-cache-webmentions";
import pluginRss from "@11ty/eleventy-plugin-rss";
import embedEverything from "eleventy-plugin-embed-everything";
import { eleventyImageTransformPlugin } from "@11ty/eleventy-img";
import sitemap from "@quasibit/eleventy-plugin-sitemap";
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 { minify as minifyJS } from "terser";
import registerUnfurlShortcode, { getCachedCard, prefetchUrl } from "./lib/unfurl-shortcode.js";
import matter from "gray-matter";
import { createHash, createHmac } from "crypto";
import { createRequire } from "module";
import { execFileSync } from "child_process";
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, copyFileSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const esmRequire = createRequire(import.meta.url);
const postGraph = esmRequire("@rknightuk/eleventy-plugin-post-graph");
const __dirname = dirname(fileURLToPath(import.meta.url));
const siteUrl = process.env.SITE_URL || "https://example.com";
export default function (eleventyConfig) {
// Don't use .gitignore for determining what to process
// (content/ is in .gitignore because it's a symlink, but we need to process it)
eleventyConfig.setUseGitIgnore(false);
// Ignore output directory (prevents re-processing generated files via symlink)
eleventyConfig.ignores.add("_site");
eleventyConfig.ignores.add("_site/**");
eleventyConfig.ignores.add("/app/data/site");
eleventyConfig.ignores.add("/app/data/site/**");
eleventyConfig.ignores.add("node_modules");
eleventyConfig.ignores.add("node_modules/**");
eleventyConfig.ignores.add("CLAUDE.md");
eleventyConfig.ignores.add("README.md");
// Ignore Pagefind output directory
eleventyConfig.ignores.add("pagefind");
eleventyConfig.ignores.add("pagefind/**");
// Ignore interactive assets (served via passthrough copy, not processed as templates)
eleventyConfig.ignores.add("interactive");
eleventyConfig.ignores.add("interactive/**");
// Configure watch targets to exclude output directory
eleventyConfig.watchIgnores.add("_site");
eleventyConfig.watchIgnores.add("_site/**");
eleventyConfig.watchIgnores.add("/app/data/site");
eleventyConfig.watchIgnores.add("/app/data/site/**");
eleventyConfig.watchIgnores.add("pagefind");
eleventyConfig.watchIgnores.add("pagefind/**");
eleventyConfig.watchIgnores.add(".cache/og");
eleventyConfig.watchIgnores.add(".cache/og/**");
eleventyConfig.watchIgnores.add(".cache/unfurl");
eleventyConfig.watchIgnores.add(".cache/unfurl/**");
// Watcher tuning: handle rapid successive file changes
// When a post is created via Micropub, the file is written twice in quick
// succession: first the initial content, then ~2s later a Micropub update
// adds syndication URLs. awaitWriteFinish delays the watcher event until
// the file is stable (no writes for 2s), so both changes are captured in
// one build. The throttle adds a 3s build-level debounce on top.
eleventyConfig.setChokidarConfig({
awaitWriteFinish: {
stabilityThreshold: 2000,
pollInterval: 100,
},
});
eleventyConfig.setWatchThrottleWaitTime(3000);
// Configure markdown-it with linkify enabled (auto-convert URLs to links)
const md = markdownIt({
html: true,
linkify: true, // Auto-convert URLs to clickable links
typographer: true,
});
md.use(markdownItAnchor, {
permalink: markdownItAnchor.permalink.headerLink(),
slugify: (s) => s.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""),
level: [2, 3, 4],
});
// Hashtag plugin: converts #tag to category links on-site
// Syndication targets (Bluesky, Mastodon) handle raw #tag natively via facet detection
md.inline.ruler.push("hashtag", (state, silent) => {
const pos = state.pos;
if (state.src.charCodeAt(pos) !== 0x23 /* # */) return false;
// Must be at start of string or preceded by whitespace/punctuation (not part of a URL fragment or hex color)
if (pos > 0) {
const prevChar = state.src.charAt(pos - 1);
if (!/[\s()\[\]{},;:!?"'«»""'']/.test(prevChar)) return false;
}
// Match hashtag: # followed by letter/underscore, then word chars (letters, digits, underscores)
const tail = state.src.slice(pos + 1);
const match = tail.match(/^([a-zA-Z_]\w*)/);
if (!match) return false;
const tag = match[1];
// Skip pure hex color codes (3, 4, 6, or 8 hex digits with nothing else)
if (/^[0-9a-fA-F]{3,8}$/.test(tag)) return false;
if (!silent) {
const slug = tag.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
const tokenOpen = state.push("link_open", "a", 1);
tokenOpen.attrSet("href", `/categories/${slug}/`);
tokenOpen.attrSet("class", "p-category hashtag");
const tokenText = state.push("text", "", 0);
tokenText.content = `#${tag}`;
state.push("link_close", "a", -1);
}
state.pos = pos + 1 + tag.length;
return true;
});
eleventyConfig.setLibrary("md", md);
// Syntax highlighting for fenced code blocks (```lang)
eleventyConfig.addPlugin(syntaxHighlight);
// RSS plugin for feed filters (dateToRfc822, absoluteUrl, etc.)
// Custom feed templates in feed.njk and feed-json.njk use these filters
eleventyConfig.addPlugin(pluginRss);
// Post graph — GitHub-style contribution grid for posting frequency
eleventyConfig.addPlugin(postGraph, {
sort: "desc",
limit: 2,
dayBoxTitle: true,
selectorLight: ":root",
selectorDark: ".dark",
boxColorLight: "#e7e5e4", // surface-200 (warm stone)
highlightColorLight: "#d97706", // amber-600 (accent)
textColorLight: "#1c1917", // surface-900
boxColorDark: "#292524", // surface-800
highlightColorDark: "#fbbf24", // amber-400
textColorDark: "#fafaf9", // surface-50
});
// JSON encode filter for JSON feed
eleventyConfig.addFilter("jsonEncode", (value) => {
return JSON.stringify(value);
});
// Guess MIME type from URL extension
function guessMimeType(url, category) {
const lower = (typeof url === "string" ? url : "").toLowerCase();
if (category === "photo") {
if (lower.includes(".png")) return "image/png";
if (lower.includes(".gif")) return "image/gif";
if (lower.includes(".webp")) return "image/webp";
if (lower.includes(".svg")) return "image/svg+xml";
return "image/jpeg";
}
if (category === "audio") {
if (lower.includes(".ogg") || lower.includes(".opus")) return "audio/ogg";
if (lower.includes(".flac")) return "audio/flac";
if (lower.includes(".wav")) return "audio/wav";
return "audio/mpeg";
}
if (category === "video") {
if (lower.includes(".webm")) return "video/webm";
if (lower.includes(".mov")) return "video/quicktime";
return "video/mp4";
}
return "application/octet-stream";
}
// Extract URL string from value that may be a string or {url, alt} object
function resolveMediaUrl(value) {
if (typeof value === "string") return value;
if (value && typeof value === "object" && value.url) return value.url;
return null;
}
// Feed attachments filter — builds JSON Feed attachments array from post data
eleventyConfig.addFilter("feedAttachments", (postData) => {
const attachments = [];
const processMedia = (items, category) => {
const list = Array.isArray(items) ? items : [items];
for (const item of list) {
const rawUrl = resolveMediaUrl(item);
if (!rawUrl) continue;
const url = rawUrl.startsWith("http") ? rawUrl : `${siteUrl}${rawUrl}`;
attachments.push({ url, mime_type: guessMimeType(rawUrl, category) });
}
};
if (postData.photo) processMedia(postData.photo, "photo");
if (postData.audio) processMedia(postData.audio, "audio");
if (postData.video) processMedia(postData.video, "video");
return attachments;
});
// Textcasting support filter — builds clean support object excluding null values
eleventyConfig.addFilter("textcastingSupport", (support) => {
if (!support) return {};
const obj = {};
if (support.url) obj.url = support.url;
if (support.stripe) obj.stripe = support.stripe;
if (support.lightning) obj.lightning = support.lightning;
if (support.paymentPointer) obj.payment_pointer = support.paymentPointer;
return obj;
});
// Protocol type filter — classifies a URL by its origin protocol/network
eleventyConfig.addFilter("protocolType", (url) => {
if (!url || typeof url !== "string") return "web";
const lower = url.toLowerCase();
if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere";
// Match Fediverse instances by known domain patterns (avoid overly broad "social")
if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") ||
lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.") ||
lower.includes("fediverse")) return "fediverse";
return "web";
});
// Email obfuscation filter - converts email to HTML entities
// Blocks ~95% of spam harvesters while remaining valid for microformat parsers
// Usage: {{ email | obfuscateEmail }} or {{ email | obfuscateEmail("href") }}
eleventyConfig.addFilter("obfuscateEmail", (email, mode = "display") => {
if (!email) return "";
// Convert each character to HTML decimal entity
const encoded = [...email].map(char => `&#${char.charCodeAt(0)};`).join("");
if (mode === "href") {
// For mailto: links, also encode the "mailto:" prefix
const mailto = [...("mailto:")].map(char => `&#${char.charCodeAt(0)};`).join("");
return mailto + encoded;
}
return encoded;
});
// Alias dateToRfc822 (plugin provides dateToRfc2822)
eleventyConfig.addFilter("dateToRfc822", (date) => {
return pluginRss.dateToRfc2822(date);
});
// Embed Everything - auto-embed YouTube, Vimeo, Bluesky, Mastodon, etc.
// YouTube uses lite-yt-embed facade: shows thumbnail + play button,
// only loads full iframe on click (~800 KiB savings).
// CSS/JS disabled here — already loaded in base.njk.
eleventyConfig.addPlugin(embedEverything, {
use: ["youtube", "vimeo", "twitter", "mastodon", "bluesky", "spotify", "soundcloud"],
youtube: {
options: {
lite: {
css: { enabled: false },
js: { enabled: false },
responsive: true,
},
recommendSelfOnly: true,
},
},
mastodon: {
options: {
server: "indieweb.social",
},
},
});
// Unfurl shortcode — renders any URL as a rich card (OpenGraph/Twitter Card metadata)
// 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 lite-youtube embeds
// Catches bare YouTube links in Markdown that the embed plugin misses
eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
if (!outputPath || !outputPath.endsWith(".html")) {
return content;
}
// Fast substring check — skip regex on pages without YouTube links (vast majority)
if (!content.includes("youtube.com/watch") && !content.includes("youtu.be/")) {
return content;
}
// Match <a> tags where href contains youtube.com/watch or youtu.be
// Link text can be: URL, www.youtube..., youtube..., or youtube-related text
const youtubePattern = /<a[^>]+href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)[^"]*"[^>]*>(?:https?:\/\/)?(?:www\.)?[^<]*(?:youtube|youtu\.be)[^<]*<\/a>/gi;
content = content.replace(youtubePattern, (match, videoId) => {
// Use lite-youtube facade — loads full iframe only on click
return `</p><div class="video-embed eleventy-plugin-youtube-embed"><lite-youtube videoid="${videoId}" style="background-image: url('https://i.ytimg.com/vi/${videoId}/hqdefault.jpg');"><div class="lty-playbtn"></div></lite-youtube></div><p>`;
});
// Clean up empty <p></p> tags created by the replacement
content = content.replace(/<p>\s*<\/p>/g, '');
return content;
});
// Image optimization - transforms <img> tags automatically
// PROCESS_REMOTE_IMAGES: set to "true" to let Sharp download and re-encode remote images.
// Default "false" — skips remote URLs (adds eleventy:ignore) to avoid OOM from Sharp's
// native memory usage when processing hundreds of external images (bookmarks, webmentions).
const processRemoteImages = process.env.PROCESS_REMOTE_IMAGES === "true";
if (!processRemoteImages) {
eleventyConfig.htmlTransformer.addPosthtmlPlugin("html", () => {
return (tree) => {
tree.match({ tag: "img" }, (node) => {
if (node.attrs?.src && /^https?:\/\//.test(node.attrs.src)) {
node.attrs["eleventy:ignore"] = "";
}
return node;
});
return tree;
};
}, { priority: 1 }); // priority > 0 runs before image plugin (priority -1)
}
eleventyConfig.addPlugin(eleventyImageTransformPlugin, {
extensions: "html",
formats: ["webp", "jpeg"],
widths: ["auto"],
failOnError: false,
cacheOptions: {
duration: process.env.ELEVENTY_RUN_MODE === "build" ? "1d" : "30d",
},
concurrency: 1,
defaultAttributes: {
loading: "lazy",
decoding: "async",
sizes: "auto",
alt: "",
},
});
// Performance: skip PostHTML parsing for pages without <img> tags.
// Both registered PostHTML plugins (remote-image-marker, eleventy-img) only
// target <img> elements — no point parsing+serializing HTML without them.
// Overrides the default @11ty/eleventy/html-transformer transform (same name
// overwrites via addTransform) with a pre-check that avoids the full PostHTML
// parse/serialize cycle (~3ms/page) for image-free pages.
eleventyConfig.addTransform("@11ty/eleventy/html-transformer", async function(content) {
if (typeof this.outputPath === "string" && this.outputPath.endsWith(".html") && !content.includes("<img")) {
// Safety: if URL transform callbacks exist (they modify <a>, <link>, etc.)
// we must still run the full pipeline even without images.
const hasUrlCallbacks = eleventyConfig.htmlTransformer.getCallbacks("html", this).length > 0;
if (!hasUrlCallbacks) return content;
}
return eleventyConfig.htmlTransformer.transformContent(this.outputPath, content, this);
});
// Sitemap generation
eleventyConfig.addPlugin(sitemap, {
sitemap: {
hostname: siteUrl,
},
});
// Wrap <table> elements in <table-saw> for responsive tables
eleventyConfig.addTransform("table-saw-wrap", function (content, outputPath) {
if (outputPath && outputPath.endsWith(".html")) {
return content.replace(/<table(\s|>)/g, "<table-saw><table$1").replace(/<\/table>/g, "</table></table-saw>");
}
return content;
});
// Fix OG image meta tags post-rendering — bypasses Eleventy 3.x race condition (#3183).
// page.url is unreliable during parallel rendering, but outputPath IS correct
// since files are written to the correct location. Derives the OG slug from
// outputPath and replaces placeholders emitted by base.njk.
// Cache: directory listing built once per build instead of 3,426 existsSync calls
let _ogFileSet = null;
eleventyConfig.on("eleventy.before", () => { _ogFileSet = null; });
function hasOgImage(ogSlug) {
if (!_ogFileSet) {
const ogDir = resolve(__dirname, ".cache", "og");
try {
_ogFileSet = new Set(readdirSync(ogDir));
} catch {
_ogFileSet = new Set();
}
}
return _ogFileSet.has(`${ogSlug}.png`);
}
eleventyConfig.addTransform("og-fix", function (content, outputPath) {
if (!outputPath || !outputPath.endsWith(".html")) return content;
// Derive correct page URL and OG slug from outputPath (immune to race condition)
// Content pages match: .../type/yyyy/MM/dd/slug/index.html
const dateMatch = outputPath.match(
/\/([\w-]+)\/(\d{4})\/(\d{2})\/(\d{2})\/([\w-]+)\/index\.html$/
);
if (dateMatch) {
const [, type, year, month, day, slug] = dateMatch;
const pageUrlPath = `/${type}/${year}/${month}/${day}/${slug}/`;
const correctFullUrl = `${siteUrl}${pageUrlPath}`;
const ogSlug = `${year}-${month}-${day}-${slug}`;
const hasOg = hasOgImage(ogSlug);
const ogImageUrl = hasOg
? `${siteUrl}/og/${ogSlug}.png`
: `${siteUrl}/images/og-default.png`;
const twitterCard = hasOg ? "summary_large_image" : "summary";
// Fix og:url and canonical (also affected by race condition)
content = content.replace(
/(<meta property="og:url" content=")[^"]*(")/,
`$1${correctFullUrl}$2`
);
content = content.replace(
/(<link rel="canonical" href=")[^"]*(")/,
`$1${correctFullUrl}$2`
);
// Replace OG image and twitter card placeholders
content = content.replace(/__OG_IMAGE_PLACEHOLDER__/g, ogImageUrl);
content = content.replace(/__TWITTER_CARD_PLACEHOLDER__/g, twitterCard);
} else {
// Non-date pages (homepage, about, etc.): use defaults
content = content.replace(
/__OG_IMAGE_PLACEHOLDER__/g,
`${siteUrl}/images/og-default.png`
);
content = content.replace(/__TWITTER_CARD_PLACEHOLDER__/g, "summary");
}
return content;
});
// Auto-unfurl standalone external links in note content
// Finds <a> tags that are the primary content of a <p> tag and injects OG preview cards
eleventyConfig.addTransform("auto-unfurl-notes", async function (content, outputPath) {
if (!outputPath || !outputPath.endsWith(".html")) return content;
// Only process note pages (individual + listing)
if (!outputPath.includes("/notes/")) return content;
// Match <p> tags whose content is short text + a single external <a> as the last element
// Pattern: <p>optional short text <a href="https://external.example">...</a></p>
const linkParagraphRe = /<p>([^<]{0,80})?<a\s+href="(https?:\/\/[^"]+)"[^>]*>[^<]*<\/a>\s*<\/p>/g;
const siteHost = new URL(siteUrl).hostname;
const matches = [];
let match;
while ((match = linkParagraphRe.exec(content)) !== null) {
const url = match[2];
try {
const linkHost = new URL(url).hostname;
// Skip same-domain links and common non-content URLs
if (linkHost === siteHost || linkHost.endsWith("." + siteHost)) continue;
matches.push({ fullMatch: match[0], url, index: match.index });
} catch {
continue;
}
}
if (matches.length === 0) return content;
// Unfurl all matched URLs in parallel (uses cache, throttles network)
const cards = await Promise.all(matches.map(m => prefetchUrl(m.url)));
// Replace in reverse order to preserve indices
let result = content;
for (let i = matches.length - 1; i >= 0; i--) {
const m = matches[i];
const card = cards[i];
// Skip if unfurl returned just a fallback link (no OG data)
if (!card || !card.includes("unfurl-card")) continue;
// Insert the unfurl card after the paragraph
const insertPos = m.index + m.fullMatch.length;
result = result.slice(0, insertPos) + "\n" + card + "\n" + result.slice(insertPos);
}
return result;
});
// HTML minification — only during initial build, skip during watch rebuilds
eleventyConfig.addTransform("htmlmin", async function (content, outputPath) {
if (outputPath && outputPath.endsWith(".html") && process.env.ELEVENTY_RUN_MODE === "build") {
try {
return await minify(content, {
collapseWhitespace: true,
removeComments: true,
html5: true,
decodeEntities: true,
minifyCSS: false,
minifyJS: false,
});
} catch {
console.warn(`[htmlmin] Parse error in ${outputPath}, skipping minification`);
return content;
}
}
return content;
});
// Copy static assets to output
eleventyConfig.addPassthroughCopy("css");
eleventyConfig.addPassthroughCopy("images");
eleventyConfig.addPassthroughCopy("js");
eleventyConfig.addPassthroughCopy("favicon.ico");
eleventyConfig.addPassthroughCopy("interactive");
eleventyConfig.addPassthroughCopy({ ".cache/og": "og" });
// Copy vendor web components from node_modules
eleventyConfig.addPassthroughCopy({
"node_modules/@zachleat/table-saw/table-saw.js": "js/table-saw.js",
"node_modules/@11ty/is-land/is-land.js": "js/is-land.js",
"node_modules/@zachleat/filter-container/filter-container.js": "js/filter-container.js",
});
// Copy Inter font files (latin + latin-ext subsets, woff2 only for modern browsers)
eleventyConfig.addPassthroughCopy({
"node_modules/@fontsource/inter/files/inter-latin-*-normal.woff2": "fonts",
"node_modules/@fontsource/inter/files/inter-latin-ext-*-normal.woff2": "fonts",
});
// Watch for content changes
eleventyConfig.addWatchTarget("./content/");
eleventyConfig.addWatchTarget("./css/");
// Webmentions plugin configuration
const wmDomain = siteUrl.replace("https://", "").replace("http://", "");
eleventyConfig.addPlugin(pluginWebmentions, {
domain: siteUrl,
feed: `http://127.0.0.1:8080/webmentions/api/mentions?per-page=10000`,
key: "children",
});
// Date formatting filter
eleventyConfig.addFilter("dateDisplay", (dateObj) => {
if (!dateObj) return "";
const date = new Date(dateObj);
return date.toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
});
});
// ISO date filter
eleventyConfig.addFilter("isoDate", (dateObj) => {
if (!dateObj) return "";
return new Date(dateObj).toISOString();
});
// Digest-to-HTML filter for RSS feed descriptions
eleventyConfig.addFilter("digestToHtml", (digest, siteUrl) => {
const typeLabels = {
articles: "Articles",
notes: "Notes",
photos: "Photos",
bookmarks: "Bookmarks",
likes: "Likes",
reposts: "Reposts",
};
const typeOrder = ["articles", "notes", "photos", "bookmarks", "likes", "reposts"];
let html = "";
for (const type of typeOrder) {
const posts = digest.byType[type];
if (!posts || !posts.length) continue;
html += `<h3>${typeLabels[type]}</h3><ul>`;
for (const post of posts) {
const postUrl = siteUrl + post.url;
let label;
if (type === "likes") {
const target = post.data.likeOf || post.data.like_of;
label = `Liked: ${target}`;
} else if (type === "bookmarks") {
const target = post.data.bookmarkOf || post.data.bookmark_of;
label = post.data.title || `Bookmarked: ${target}`;
} else if (type === "reposts") {
const target = post.data.repostOf || post.data.repost_of;
label = `Reposted: ${target}`;
} else if (post.data.title) {
label = post.data.title;
} else {
const content = post.templateContent || "";
label = content.replace(/<[^>]*>/g, "").slice(0, 120).trim() || "Untitled";
}
html += `<li><a href="${postUrl}">${label}</a></li>`;
}
html += `</ul>`;
}
return html;
});
// Truncate filter
eleventyConfig.addFilter("truncate", (str, len = 200) => {
if (!str) return "";
if (str.length <= len) return str;
return str.slice(0, len).trim() + "...";
});
// Clean excerpt for OpenGraph - strips HTML, decodes entities, removes extra whitespace
eleventyConfig.addFilter("ogDescription", (content, len = 200) => {
if (!content) return "";
// Strip HTML tags
let text = content.replace(/<[^>]+>/g, ' ');
// Decode common HTML entities
text = text.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
// Remove extra whitespace
text = text.replace(/\s+/g, ' ').trim();
// Truncate
if (text.length > len) {
text = text.slice(0, len).trim() + "...";
}
return text;
});
// Extract first image from content for OpenGraph fallback
eleventyConfig.addFilter("extractFirstImage", (content) => {
if (!content) return null;
// Match all <img> tags, skip hidden ones and data URIs
const imgRegex = /<img[^>]*?\ssrc=["']([^"']+)["'][^>]*>/gi;
let match;
while ((match = imgRegex.exec(content)) !== null) {
const fullTag = match[0];
const src = match[1];
if (src.startsWith("data:")) continue;
if (/\bhidden\b/.test(fullTag)) continue;
return src;
}
return null;
});
// Head filter for arrays
eleventyConfig.addFilter("head", (array, n) => {
if (!Array.isArray(array) || n < 1) return array;
return array.slice(0, n);
});
// Slugify filter
eleventyConfig.addFilter("slugify", (str) => {
if (!str) return "";
return str
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
});
eleventyConfig.addFilter("stripTrailingSlash", (url) => {
if (!url || typeof url !== "string") return url || "";
return url.endsWith("/") ? url.slice(0, -1) : url;
});
// Hash filter for cache busting - generates MD5 hash of file content
// Cache: same 16 static files are hashed once per build instead of once per page
// (16 files × 3,426 pages = 55,332 readFileSync calls without cache)
const _hashCache = new Map();
eleventyConfig.addFilter("hash", (filePath) => {
const cached = _hashCache.get(filePath);
if (cached) return cached;
try {
const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
const content = readFileSync(fullPath);
const hash = createHash("md5").update(content).digest("hex").slice(0, 8);
_hashCache.set(filePath, hash);
return hash;
} catch {
// Return timestamp as fallback if file not found
return Date.now().toString(36);
}
});
// Clear hash cache on rebuild so changed files get new hashes
eleventyConfig.on("eleventy.before", () => { _hashCache.clear(); });
// Derive OG slug from page.url (reliable) instead of page.fileSlug
// (which suffers from Nunjucks race conditions in Eleventy 3.x parallel rendering).
// OG images are named with the full date prefix to match URL segments exactly.
eleventyConfig.addFilter("ogSlug", (url) => {
if (!url) return "";
const segments = url.split("/").filter(Boolean);
// Date-based URL: /type/yyyy/MM/dd/slug/ → 5 segments → "yyyy-MM-dd-slug"
if (segments.length === 5) {
const [, year, month, day, slug] = segments;
return `${year}-${month}-${day}-${slug}`;
}
// Fallback: last segment (for pages, legacy URLs)
return segments[segments.length - 1] || "";
});
// Check if a generated OG image exists for this slug
eleventyConfig.addFilter("hasOgImage", (slug) => {
if (!slug) return false;
const ogPath = resolve(__dirname, ".cache", "og", `${slug}.png`);
return existsSync(ogPath);
});
// Inline file contents (for critical CSS inlining)
eleventyConfig.addFilter("inlineFile", (filePath) => {
try {
const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
return readFileSync(fullPath, "utf-8");
} catch {
return "";
}
});
// Extract raw Markdown body from a source file (strips front matter)
eleventyConfig.addFilter("rawMarkdownBody", (inputPath) => {
try {
const src = readFileSync(inputPath, "utf-8");
const { content } = matter(src);
return content.trim();
} catch {
return "";
}
});
// Current timestamp filter (for client-side JS buildtime)
eleventyConfig.addFilter("timestamp", () => Date.now());
// Date filter (for sidebar dates)
eleventyConfig.addFilter("date", (dateObj, format) => {
if (!dateObj) return "";
const date = new Date(dateObj);
const options = {};
if (format.includes("MMM")) options.month = "short";
if (format.includes("d")) options.day = "numeric";
if (format.includes("yyyy")) options.year = "numeric";
return date.toLocaleDateString("en-US", options);
});
// Webmention filters - with legacy URL support
// This filter checks both current URL and any legacy URLs from redirects
// Merges webmentions + conversations with deduplication (conversations first)
eleventyConfig.addFilter("webmentionsForUrl", function (webmentions, url, urlAliases, conversationMentions = []) {
if (!url) return [];
// Merge conversations + webmentions with deduplication
const seen = new Set();
const merged = [];
// Add conversations first (richer metadata)
for (const item of conversationMentions) {
const key = item['wm-id'] || item.url;
if (key && !seen.has(key)) {
seen.add(key);
merged.push(item);
}
}
// Add webmentions (skip duplicates)
if (webmentions) {
for (const item of webmentions) {
const key = item['wm-id'];
if (!key || seen.has(key)) continue;
if (item.url && seen.has(item.url)) continue;
seen.add(key);
merged.push(item);
}
}
// Build list of all URLs to check (current + legacy)
const urlsToCheck = new Set();
// Add current URL variations
const absoluteUrl = url.startsWith("http") ? url : `${siteUrl}${url}`;
urlsToCheck.add(absoluteUrl);
urlsToCheck.add(absoluteUrl.replace(/\/$/, ""));
urlsToCheck.add(absoluteUrl.endsWith("/") ? absoluteUrl : `${absoluteUrl}/`);
// Add legacy URLs from aliases (if provided)
if (urlAliases?.aliases) {
const normalizedUrl = url.replace(/\/$/, "");
const oldUrls = urlAliases.aliases[normalizedUrl] || [];
for (const oldUrl of oldUrls) {
urlsToCheck.add(`${siteUrl}${oldUrl}`);
urlsToCheck.add(`${siteUrl}${oldUrl}/`);
urlsToCheck.add(`${siteUrl}${oldUrl}`.replace(/\/$/, ""));
}
}
// Compute legacy /content/ URL from current URL for old webmention.io targets
// Pattern: /type/yyyy/MM/dd/slug/ → /content/type/yyyy-MM-dd-slug/
const pathSegments = url.replace(/\/$/, "").split("/").filter(Boolean);
if (pathSegments.length === 5) {
const [type, year, month, day, slug] = pathSegments;
const contentUrl = `/content/${type}/${year}-${month}-${day}-${slug}/`;
urlsToCheck.add(`${siteUrl}${contentUrl}`);
urlsToCheck.add(`${siteUrl}${contentUrl}`.replace(/\/$/, ""));
}
// Filter merged data matching any of our URLs
const matched = merged.filter((wm) => urlsToCheck.has(wm["wm-target"]));
// Deduplicate cross-source: same author + same interaction type = same mention
// (webmention.io and conversations API may both report the same like/reply)
const deduped = [];
const authorActions = new Set();
for (const wm of matched) {
const authorUrl = wm.author?.url || wm.url || "";
const action = wm["wm-property"] || "mention";
const key = `${authorUrl}::${action}`;
if (authorActions.has(key)) continue;
authorActions.add(key);
deduped.push(wm);
}
return deduped;
});
eleventyConfig.addFilter("webmentionsByType", function (mentions, type) {
if (!mentions) return [];
const typeMap = {
likes: "like-of",
reposts: "repost-of",
bookmarks: "bookmark-of",
replies: "in-reply-to",
mentions: "mention-of",
};
const wmProperty = typeMap[type] || type;
return mentions.filter((m) => m["wm-property"] === wmProperty);
});
// Post navigation — find previous/next post in a collection
// (Nunjucks {% set %} inside {% for %} doesn't propagate, so we need filters)
eleventyConfig.addFilter("previousInCollection", function (collection, page) {
if (!collection || !page) return null;
const index = collection.findIndex((p) => p.url === page.url);
return index > 0 ? collection[index - 1] : null;
});
eleventyConfig.addFilter("nextInCollection", function (collection, page) {
if (!collection || !page) return null;
const index = collection.findIndex((p) => p.url === page.url);
return index >= 0 && index < collection.length - 1
? collection[index + 1]
: null;
});
// Posting frequency — compute posts-per-month for last 12 months (for sparkline).
// Returns an inline SVG that uses currentColor for stroke and a semi-transparent
// gradient fill. Wrap in a colored span to set the domain color via Tailwind.
eleventyConfig.addFilter("postingFrequency", (posts) => {
if (!Array.isArray(posts) || posts.length === 0) return "";
const now = new Date();
const counts = new Array(12).fill(0);
for (const post of posts) {
const postDate = new Date(post.date || post.data?.date);
if (isNaN(postDate.getTime())) continue;
const monthsAgo = (now.getFullYear() - postDate.getFullYear()) * 12 + (now.getMonth() - postDate.getMonth());
if (monthsAgo >= 0 && monthsAgo < 12) {
counts[11 - monthsAgo]++;
}
}
// Extrapolate the current (partial) month to avoid false downward trend.
// e.g. 51 posts in 5 days of a 31-day month projects to ~316.
const dayOfMonth = now.getDate();
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
if (dayOfMonth < daysInMonth && counts[11] > 0) {
counts[11] = Math.round(counts[11] / dayOfMonth * daysInMonth);
}
const max = Math.max(...counts, 1);
const w = 200;
const h = 32;
const pad = 2;
const step = w / (counts.length - 1);
const points = counts.map((v, i) => {
const x = i * step;
const y = h - pad - ((v / max) * (h - pad * 2));
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
// Closed polygon for gradient fill (line path + bottom corners)
const fillPoints = `${points} ${w},${h} 0,${h}`;
return [
`<svg viewBox="0 0 ${w} ${h}" width="100%" height="100%" preserveAspectRatio="none" class="sparkline" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Posting frequency over the last 12 months">`,
`<defs><linearGradient id="spk-fill" x1="0" y1="0" x2="0" y2="1">`,
`<stop offset="0%" stop-color="currentColor" stop-opacity="0.25"/>`,
`<stop offset="100%" stop-color="currentColor" stop-opacity="0.02"/>`,
`</linearGradient></defs>`,
`<polygon fill="url(#spk-fill)" points="${fillPoints}"/>`,
`<polyline fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="${points}"/>`,
`</svg>`,
].join("");
});
// Filter AI-involved posts (ai-text-level > "0" or aiTextLevel > "0")
// Memoized: same collections.posts input produces same output — compute once per build
// (694 calls × 2,350 posts = 1.6M iterations without cache)
let _aiPostsCache = null;
let _aiStatsCache = null;
eleventyConfig.on("eleventy.before", () => { _aiPostsCache = null; _aiStatsCache = null; });
eleventyConfig.addFilter("aiPosts", (posts) => {
if (!Array.isArray(posts)) return [];
if (_aiPostsCache) return _aiPostsCache;
_aiPostsCache = posts.filter((post) => {
const level = post.data?.aiTextLevel || post.data?.["ai-text-level"] || "0";
return level !== "0" && level !== 0;
});
return _aiPostsCache;
});
// AI stats — returns { total, aiCount, percentage, byLevel }
eleventyConfig.addFilter("aiStats", (posts) => {
if (!Array.isArray(posts)) return { total: 0, aiCount: 0, percentage: 0, byLevel: {} };
if (_aiStatsCache) return _aiStatsCache;
const total = posts.length;
const byLevel = { 0: 0, 1: 0, 2: 0, 3: 0 };
for (const post of posts) {
const level = parseInt(post.data?.aiTextLevel || post.data?.["ai-text-level"] || "0", 10);
byLevel[level] = (byLevel[level] || 0) + 1;
}
const aiCount = total - byLevel[0];
_aiStatsCache = {
total,
aiCount,
percentage: total > 0 ? ((aiCount / total) * 100).toFixed(1) : "0",
byLevel,
};
return _aiStatsCache;
});
// Helper: exclude drafts from collections
const isPublished = (item) => !item.data.draft;
// Collections for different post types
// Note: content path is content/ due to symlink structure
// "posts" shows ALL content types combined
eleventyConfig.addCollection("posts", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("notes", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/notes/**/*.md")
.filter(isPublished)
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("articles", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/articles/**/*.md")
.filter(isPublished)
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("bookmarks", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/bookmarks/**/*.md")
.filter(isPublished)
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("photos", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/photos/**/*.md")
.filter(isPublished)
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("likes", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/likes/**/*.md")
.filter(isPublished)
.sort((a, b) => b.date - a.date);
});
// Replies collection - posts with inReplyTo/in_reply_to property
// Supports both camelCase (Indiekit Eleventy preset) and underscore (legacy) names
eleventyConfig.addCollection("replies", function (collectionApi) {
return collectionApi
.getAll()
.filter((item) => isPublished(item) && (item.data.inReplyTo || item.data.in_reply_to))
.sort((a, b) => b.date - a.date);
});
// Reposts collection - posts with repostOf/repost_of property
// Supports both camelCase (Indiekit Eleventy preset) and underscore (legacy) names
eleventyConfig.addCollection("reposts", function (collectionApi) {
return collectionApi
.getAll()
.filter((item) => isPublished(item) && (item.data.repostOf || item.data.repost_of))
.sort((a, b) => b.date - a.date);
});
// Pages collection - root-level slash pages (about, now, uses, etc.)
// Includes both content/*.md (legacy) and content/pages/*.md (new post-type-page)
// Created via Indiekit's page post type
eleventyConfig.addCollection("pages", function (collectionApi) {
const rootPages = collectionApi.getFilteredByGlob("content/*.md");
const pagesDir = collectionApi.getFilteredByGlob("content/pages/*.md");
return [...rootPages, ...pagesDir]
.filter(page => isPublished(page) && !page.inputPath.includes('content.json') && !page.inputPath.includes('pages.json'))
.sort((a, b) => (a.data.title || a.data.name || "").localeCompare(b.data.title || b.data.name || ""));
});
// All content combined for homepage feed
eleventyConfig.addCollection("feed", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.sort((a, b) => b.date - a.date)
.slice(0, 20);
});
// Categories collection - deduplicated by slug to avoid duplicate permalinks
eleventyConfig.addCollection("categories", function (collectionApi) {
const categoryMap = new Map(); // slug -> original name (first seen)
const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
collectionApi.getAll().filter(isPublished).forEach((item) => {
if (item.data.category) {
const cats = Array.isArray(item.data.category) ? item.data.category : [item.data.category];
cats.forEach((cat) => {
if (cat && typeof cat === 'string' && cat.trim()) {
const slug = slugify(cat.trim());
if (slug && !categoryMap.has(slug)) {
categoryMap.set(slug, cat.trim());
}
}
});
}
});
return [...categoryMap.values()].sort();
});
// Category feeds — pre-grouped posts for per-category RSS/JSON feeds
eleventyConfig.addCollection("categoryFeeds", function (collectionApi) {
const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
const grouped = new Map(); // slug -> { name, slug, posts[] }
collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.sort((a, b) => b.date - a.date)
.forEach((item) => {
if (!item.data.category) return;
const cats = Array.isArray(item.data.category) ? item.data.category : [item.data.category];
for (const cat of cats) {
if (!cat || typeof cat !== "string" || !cat.trim()) continue;
const slug = slugify(cat.trim());
if (!slug) continue;
if (!grouped.has(slug)) {
grouped.set(slug, { name: cat.trim(), slug, posts: [] });
}
const entry = grouped.get(slug);
if (entry.posts.length < 50) {
entry.posts.push(item);
}
}
});
return [...grouped.values()].sort((a, b) => a.name.localeCompare(b.name));
});
// Recent posts for sidebar
eleventyConfig.addCollection("recentPosts", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.sort((a, b) => b.date - a.date)
.slice(0, 5);
});
// Featured posts — curated selection via `pinned: true` frontmatter
// Property named "pinned" to avoid conflict with "featured" (hero image) in MF2/Micropub
eleventyConfig.addCollection("featuredPosts", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.filter((item) => item.data.pinned === true || item.data.pinned === "true")
.sort((a, b) => b.date - a.date);
});
// Weekly digests — posts grouped by ISO week for digest pages and RSS feed
eleventyConfig.addCollection("weeklyDigests", function (collectionApi) {
const allPosts = collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.filter((item) => {
// Exclude replies
return !(item.data.inReplyTo || item.data.in_reply_to);
})
.sort((a, b) => b.date - a.date);
// ISO week helpers
const getISOWeek = (date) => {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
};
const getISOYear = (date) => {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
return d.getUTCFullYear();
};
// Group by ISO week
const weekMap = new Map();
for (const post of allPosts) {
const d = new Date(post.date);
const week = getISOWeek(d);
const year = getISOYear(d);
const key = `${year}-W${String(week).padStart(2, "0")}`;
if (!weekMap.has(key)) {
// Calculate Monday (start) and Sunday (end) of ISO week
const jan4 = new Date(Date.UTC(year, 0, 4));
const dayOfWeek = jan4.getUTCDay() || 7;
const monday = new Date(jan4);
monday.setUTCDate(jan4.getUTCDate() - dayOfWeek + 1 + (week - 1) * 7);
const sunday = new Date(monday);
sunday.setUTCDate(monday.getUTCDate() + 6);
weekMap.set(key, {
year,
week,
slug: `${year}/W${String(week).padStart(2, "0")}`,
label: `Week ${week}, ${year}`,
startDate: monday.toISOString().slice(0, 10),
endDate: sunday.toISOString().slice(0, 10),
posts: [],
});
}
weekMap.get(key).posts.push(post);
}
// Post type detection (matches blog.njk logic)
const typeDetect = (post) => {
if (post.data.likeOf || post.data.like_of) return "likes";
if (post.data.bookmarkOf || post.data.bookmark_of) return "bookmarks";
if (post.data.repostOf || post.data.repost_of) return "reposts";
if (post.data.photo && post.data.photo.length) return "photos";
if (post.data.title) return "articles";
return "notes";
};
// Build byType for each week and convert to array
const digests = [...weekMap.values()].map((entry) => {
const byType = {};
for (const post of entry.posts) {
const type = typeDetect(post);
if (!byType[type]) byType[type] = [];
byType[type].push(post);
}
return { ...entry, byType };
});
// Sort newest-week-first
digests.sort((a, b) => {
if (a.year !== b.year) return b.year - a.year;
return b.week - a.week;
});
return digests;
});
// Generate OpenGraph images for posts without photos.
// Uses batch spawning: each invocation generates up to BATCH_SIZE images then exits,
// fully releasing WASM native memory (Satori Yoga + Resvg Rust) between batches.
// Exit code 2 = batch complete, more work remains → re-spawn.
// Manifest caching makes incremental builds fast (only new posts get generated).
eleventyConfig.on("eleventy.before", () => {
const contentDir = resolve(__dirname, "content");
const cacheDir = resolve(__dirname, ".cache");
const siteName = process.env.SITE_NAME || "My IndieWeb Blog";
const BATCH_SIZE = 100;
let totalGenerated = 0;
let batch = 0;
try {
// eslint-disable-next-line no-constant-condition
while (true) {
batch++;
try {
execFileSync(process.execPath, [
"--max-old-space-size=512",
"--expose-gc",
resolve(__dirname, "lib", "og-cli.js"),
contentDir,
cacheDir,
siteName,
String(BATCH_SIZE),
], {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: "" },
});
// Exit code 0 = all done
break;
} catch (err) {
if (err.status === 2) {
// Exit code 2 = batch complete, more images remain
totalGenerated += BATCH_SIZE;
continue;
}
throw err;
}
}
// Sync new OG images to output directory.
// During incremental builds, .cache/og is in watchIgnores so Eleventy's
// passthrough copy won't pick up newly generated images. Copy them manually.
const ogCacheDir = resolve(cacheDir, "og");
const ogOutputDir = resolve(__dirname, "_site", "og");
if (existsSync(ogCacheDir) && existsSync(resolve(__dirname, "_site"))) {
mkdirSync(ogOutputDir, { recursive: true });
let synced = 0;
for (const file of readdirSync(ogCacheDir)) {
if (file.endsWith(".png") && !existsSync(resolve(ogOutputDir, file))) {
copyFileSync(resolve(ogCacheDir, file), resolve(ogOutputDir, file));
synced++;
}
}
if (synced > 0) {
console.log(`[og] Synced ${synced} new image(s) to output`);
}
}
} catch (err) {
console.error("[og] Image generation failed:", err.message);
}
});
// 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);
// Free parsed markdown content before starting network-heavy prefetch
if (typeof global.gc === "function") global.gc();
if (urls.size === 0) return;
const urlArray = [...urls];
const UNFURL_BATCH = 50;
const totalBatches = Math.ceil(urlArray.length / UNFURL_BATCH);
console.log(`[unfurl] Pre-fetching ${urlArray.length} interaction URLs (${totalBatches} batches of ${UNFURL_BATCH})...`);
let fetched = 0;
for (let i = 0; i < urlArray.length; i += UNFURL_BATCH) {
const batch = urlArray.slice(i, i + UNFURL_BATCH);
const batchNum = Math.floor(i / UNFURL_BATCH) + 1;
await Promise.all(batch.map((url) => prefetchUrl(url)));
fetched += batch.length;
if (typeof global.gc === "function") global.gc();
if (batchNum === 1 || batchNum % 5 === 0 || batchNum === totalBatches) {
const rss = (process.memoryUsage.rss() / 1024 / 1024).toFixed(0);
console.log(`[unfurl] Batch ${batchNum}/${totalBatches} (${fetched}/${urlArray.length}) | RSS: ${rss} MB`);
}
}
console.log(`[unfurl] Pre-fetch complete.`);
});
// Post-build hook: pagefind indexing + WebSub notification
// Pagefind runs once on the first build (initial or watcher's first full build), then never again.
// WebSub runs on every non-incremental build.
// Note: --incremental CLI flag sets incremental=true even for the watcher's first full build,
// so we cannot use the incremental flag to guard pagefind. Use a one-shot flag instead.
let pagefindDone = false;
eleventyConfig.on("eleventy.after", async ({ dir, directories, runMode, incremental }) => {
// Markdown for Agents — generate index.md alongside index.html for articles
const mdEnabled = (process.env.MARKDOWN_AGENTS_ENABLED || "true").toLowerCase() === "true";
if (mdEnabled && !incremental) {
const outputDir = directories?.output || dir.output;
const contentDir = resolve(__dirname, "content/articles");
const aiTrain = process.env.MARKDOWN_AGENTS_AI_TRAIN || "yes";
const search = process.env.MARKDOWN_AGENTS_SEARCH || "yes";
const aiInput = process.env.MARKDOWN_AGENTS_AI_INPUT || "yes";
const authorName = process.env.AUTHOR_NAME || "Blog Author";
let mdCount = 0;
try {
const files = readdirSync(contentDir).filter(f => f.endsWith(".md"));
for (const file of files) {
const src = readFileSync(resolve(contentDir, file), "utf-8");
const { data: fm, content: body } = matter(src);
if (!fm || fm.draft) continue;
// Derive the output path from the article's permalink or url
const articleUrl = fm.permalink || fm.url;
if (!articleUrl || !articleUrl.startsWith("/articles/")) continue;
const mdDir = resolve(outputDir, articleUrl.replace(/^\//, "").replace(/\/$/, ""));
const mdPath = resolve(mdDir, "index.md");
const trimmedBody = body.trim();
const tokens = Math.ceil(trimmedBody.length / 4);
const title = (fm.title || "").replace(/"/g, '\\"');
const date = fm.date ? new Date(fm.date).toISOString() : fm.published || "";
let frontLines = [
"---",
`title: "${title}"`,
`date: ${date}`,
`author: ${authorName}`,
`url: ${siteUrl}${articleUrl}`,
];
if (fm.category && Array.isArray(fm.category) && fm.category.length > 0) {
frontLines.push("categories:");
for (const cat of fm.category) {
frontLines.push(` - ${cat}`);
}
}
if (fm.description) {
frontLines.push(`description: "${fm.description.replace(/"/g, '\\"')}"`);
}
frontLines.push(`tokens: ${tokens}`);
frontLines.push(`content_signal: ai-train=${aiTrain}, search=${search}, ai-input=${aiInput}`);
frontLines.push("---");
mkdirSync(mdDir, { recursive: true });
writeFileSync(mdPath, frontLines.join("\n") + "\n\n# " + (fm.title || "") + "\n\n" + trimmedBody + "\n");
mdCount++;
}
console.log(`[markdown-agents] Generated ${mdCount} article .md files`);
} catch (err) {
console.error("[markdown-agents] Error generating .md files:", err.message);
}
}
// Pagefind indexing — run exactly once per process lifetime
if (!pagefindDone) {
pagefindDone = true;
const outputDir = directories?.output || dir.output;
try {
console.log(`[pagefind] Indexing ${outputDir} (${runMode})...`);
execFileSync("npx", ["pagefind", "--site", outputDir, "--output-subdir", "pagefind", "--glob", "**/*.html"], {
stdio: "inherit",
timeout: 120000,
});
console.log("[pagefind] Indexing complete");
} catch (err) {
console.error("[pagefind] Indexing failed:", err.message);
}
}
// JS minification — minify source JS files in output (skip vendor, already-minified)
if (runMode === "build" && !incremental) {
const jsOutputDir = directories?.output || dir.output;
const jsDir = resolve(jsOutputDir, "js");
if (existsSync(jsDir)) {
let jsMinified = 0;
let jsSaved = 0;
for (const file of readdirSync(jsDir).filter(f => f.endsWith(".js") && !f.endsWith(".min.js"))) {
const filePath = resolve(jsDir, file);
try {
const src = readFileSync(filePath, "utf-8");
const result = await minifyJS(src, { compress: true, mangle: true });
if (result.code) {
const saved = src.length - result.code.length;
if (saved > 0) {
writeFileSync(filePath, result.code);
jsSaved += saved;
jsMinified++;
}
}
} catch (err) {
console.error(`[js-minify] Failed to minify ${file}:`, err.message);
}
}
if (jsMinified > 0) {
console.log(`[js-minify] Minified ${jsMinified} JS files, saved ${(jsSaved / 1024).toFixed(1)} KiB`);
}
}
}
// Syndication webhook — trigger after incremental rebuilds (new posts are now live)
// Cuts syndication latency from ~2 min (poller) to ~5 sec (immediate trigger)
if (incremental) {
const syndicateUrl = process.env.SYNDICATE_WEBHOOK_URL;
if (syndicateUrl) {
try {
const secretFile = process.env.SYNDICATE_SECRET_FILE || "/app/data/config/.secret";
const secret = readFileSync(secretFile, "utf-8").trim();
// Build a minimal HS256 JWT using built-in crypto (no jsonwebtoken dependency)
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const payload = Buffer.from(JSON.stringify({
me: siteUrl,
scope: "update",
iat: now,
exp: now + 300, // 5 minutes
})).toString("base64url");
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
const token = `${header}.${payload}.${signature}`;
const res = await fetch(`${syndicateUrl}?token=${token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(30000),
});
console.log(`[syndicate-hook] Triggered syndication: ${res.status}`);
} catch (err) {
console.error(`[syndicate-hook] Failed:`, err.message);
}
}
}
// Force garbage collection after post-build work completes.
// V8 doesn't return freed heap pages to the OS without GC pressure.
// In watch mode the watcher sits idle after its initial full build,
// so without this, ~2 GB of build-time allocations stay resident.
// Requires --expose-gc in NODE_OPTIONS (set in start.sh for the watcher).
if (typeof global.gc === "function") {
const before = process.memoryUsage();
global.gc();
const after = process.memoryUsage();
const freed = ((before.heapUsed - after.heapUsed) / 1024 / 1024).toFixed(0);
console.log(`[gc] Post-build GC freed ${freed} MB (heap: ${(after.heapUsed / 1024 / 1024).toFixed(0)} MB)`);
// Log V8 heap space breakdown for memory investigation
try {
const v8 = await import("node:v8");
const spaces = v8.getHeapSpaceStatistics();
console.log(`[gc] Heap spaces after GC:`);
for (const s of spaces) {
const usedMB = (s.space_used_size / 1024 / 1024).toFixed(1);
if (s.space_used_size > 1024 * 1024) {
console.log(`[gc] ${s.space_name}: ${usedMB} MB`);
}
}
// Write heap snapshot to /tmp if HEAP_SNAPSHOT=1 is set
if (process.env.HEAP_SNAPSHOT === "1") {
const filename = `/tmp/eleventy-heap-${Date.now()}.heapsnapshot`;
console.log(`[gc] Writing heap snapshot to ${filename}...`);
v8.writeHeapSnapshot(filename);
console.log(`[gc] Snapshot written`);
// Only take one snapshot, then unset
delete process.env.HEAP_SNAPSHOT;
}
} catch (e) {
console.log(`[gc] Heap stats unavailable: ${e.message}`);
}
}
// WebSub hub notification — skip on incremental rebuilds
if (incremental) return;
const hubUrl = "https://websubhub.com/hub";
const feedUrls = [
`${siteUrl}/`,
`${siteUrl}/feed.xml`,
`${siteUrl}/feed.json`,
];
// Discover category feed URLs from build output
const outputDir = directories?.output || dir.output;
const categoriesDir = resolve(outputDir, "categories");
try {
for (const entry of readdirSync(categoriesDir, { withFileTypes: true })) {
if (entry.isDirectory() && existsSync(resolve(categoriesDir, entry.name, "feed.xml"))) {
feedUrls.push(`${siteUrl}/categories/${entry.name}/feed.xml`);
feedUrls.push(`${siteUrl}/categories/${entry.name}/feed.json`);
}
}
} catch {
// categoriesDir may not exist on first build — ignore
}
console.log(`[websub] Notifying hub for ${feedUrls.length} URLs...`);
for (const feedUrl of feedUrls) {
try {
const res = await fetch(hubUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `hub.mode=publish&hub.url=${encodeURIComponent(feedUrl)}`,
});
console.log(`[websub] Notified hub for ${feedUrl}: ${res.status}`);
} catch (err) {
console.error(`[websub] Hub notification failed for ${feedUrl}:`, err.message);
}
}
});
return {
dir: {
input: ".",
output: "_site",
includes: "_includes",
data: "_data",
},
markdownTemplateEngine: false, // Disable to avoid Nunjucks interpreting {{ in content
htmlTemplateEngine: "njk",
};
}