Files
indiekit-blog/eleventy.config.js
svemagie 46507a7f1b
Build & Deploy / build-and-deploy (push) Successful in 1m58s
perf: ignore docs/, .superpowers/, .interface-design/ from Eleventy build
2026-05-11 09:42:45 +02:00

2228 lines
91 KiB
JavaScript
Raw Permalink 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 pluginMermaid from "@kevingimbel/eleventy-plugin-mermaid";
import pluginInterlinker from "@photogabble/eleventy-plugin-interlinker";
import embedEverything from "eleventy-plugin-embed-everything";
import { eleventyImageTransformPlugin } from "@11ty/eleventy-img";
import markdownIt from "markdown-it";
import markdownItAnchor from "markdown-it-anchor";
import markdownItFootnote from "markdown-it-footnote";
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
import { minify } from "html-minifier-terser";
import posthtml from "posthtml";
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, appendFileSync } 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";
// OG image cache — persistent across CI runs when OG_CACHE_DIR env var is set.
// In CI, point this outside the act runner workspace (e.g. /usr/local/git/.cache/og).
const OG_CACHE_DIR = process.env.OG_CACHE_DIR
? resolve(process.env.OG_CACHE_DIR)
: resolve(__dirname, ".cache", "og");
// Slugify each path segment, preserving "/" separators for nested tags (e.g. "tech/programming")
const nestedSlugify = (str) => {
if (!str) return "";
return str
.split("/")
.map((s) =>
s
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, ""),
)
.filter(Boolean)
.join("/");
};
// Memory profiler — logs RSS + V8 heap at key build phases
function logMemory(phase) {
const mem = process.memoryUsage();
const rss = (mem.rss / 1024 / 1024).toFixed(0);
const heapUsed = (mem.heapUsed / 1024 / 1024).toFixed(0);
const heapTotal = (mem.heapTotal / 1024 / 1024).toFixed(0);
const external = (mem.external / 1024 / 1024).toFixed(0);
const arrayBuffers = (mem.arrayBuffers / 1024 / 1024).toFixed(0);
console.log(`[mem] ${phase}: RSS=${rss}MB heap=${heapUsed}/${heapTotal}MB external=${external}MB buffers=${arrayBuffers}MB`);
}
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);
// Passthrough copy for OG images
eleventyConfig.addPassthroughCopy({ [OG_CACHE_DIR]: "images/og" });
// 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/**");
// Ignore theme/ subdirectory (contains theme source files, not site content)
eleventyConfig.ignores.add("theme");
eleventyConfig.ignores.add("theme/**");
// Ignore internal planning docs — not public site content
eleventyConfig.ignores.add("docs");
eleventyConfig.ignores.add("docs/**");
eleventyConfig.ignores.add(".superpowers");
eleventyConfig.ignores.add(".superpowers/**");
eleventyConfig.ignores.add(".interface-design");
eleventyConfig.ignores.add(".interface-design/**");
// 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(OG_CACHE_DIR);
eleventyConfig.watchIgnores.add(OG_CACHE_DIR + "/**");
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(markdownItFootnote);
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);
// Mermaid diagram support — renders ```mermaid code blocks as diagrams
eleventyConfig.addPlugin(pluginMermaid);
// Wikilink + backlinks support — adds [[Page Title]] syntax and populates
// page.data.backlinks from both wikilinks and internal HTML links.
eleventyConfig.addPlugin(pluginInterlinker, {
deadLinkReport: 'console',
});
// markdown-it-footnote handles standard [^1] Markdown footnote syntax
// Post graph — GitHub-style contribution grid for posting frequency
eleventyConfig.addPlugin(postGraph, {
sort: "desc",
limit: 2,
dayBoxTitle: true,
selectorLight: ":root",
selectorDark: ".dark",
boxColorLight: "#d5c4a1", // surface-200 (gruvbox)
highlightColorLight: "#076678", // gruvbox blue (accent)
textColorLight: "#282828", // surface-900
boxColorDark: "#3c3836", // surface-800
highlightColorDark: "#83a598", // gruvbox blue light
textColorDark: "#fbf1c7", // 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);
});
// Coerce a value to a Date object (handles ISO strings from frontmatter like `updated:`)
eleventyConfig.addFilter("toDate", (value) => {
if (!value) return null;
if (value instanceof Date) return value;
return new Date(value);
});
// 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 (typeof outputPath !== "string" || !outputPath.endsWith(".html")) {
return content;
}
// Single substring check — "youtu" covers both youtube.com/watch and youtu.be/
// Avoids scanning large HTML twice (was two includes() calls on 15-50KB per page)
if (!content.includes("youtu")) {
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
// Exclude links with a class attribute — those are intentional microformat/styled
// links (u-repost-of, u-like-of, unfurl cards, etc.) that must not become embeds.
const youtubePattern = /<a(?![^>]*\bclass=)[^>]+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.
// No URL transform callbacks are registered by any plugin, so the getCallbacks()
// check is omitted — it costs ~1ms × 639 pages for a result that is always false.
eleventyConfig.addTransform("@11ty/eleventy/html-transformer", async function(content) {
if (typeof this.outputPath === "string" && this.outputPath.endsWith(".html") && !content.includes("<img")) {
return content;
}
return eleventyConfig.htmlTransformer.transformContent(this.outputPath, content, this);
});
// 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;
});
// Cache: directory listing built once per build instead of existsSync calls per page
let _ogFileSet = null;
eleventyConfig.on("eleventy.before", () => { _ogFileSet = null; });
function hasOgImage(ogSlug) {
if (!_ogFileSet) {
try {
_ogFileSet = new Set(readdirSync(OG_CACHE_DIR));
} catch {
_ogFileSet = new Set();
}
}
return _ogFileSet.has(`${ogSlug}.png`);
}
// 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.
// Clear eleventy-img in-memory cache between builds to prevent native memory leak.
// The MemoryCache singleton holds Sharp ArrayBuffers (~200KB-1MB each) that never get
// freed in watch mode. After 20-30 incremental rebuilds, external memory grows from
// ~170MB to 500MB+, eventually causing OOM. Disk cache handles persistence.
const { memCache: imgMemCache } = esmRequire("@11ty/eleventy-img/src/caches.js");
eleventyConfig.on("eleventy.before", () => {
const size = imgMemCache.size();
if (size > 0) {
imgMemCache.cache = {};
console.log(`[eleventy-img] Cleared in-memory cache (${size} entries)`);
}
});
eleventyConfig.addTransform("og-fix", function (content, outputPath) {
if (!outputPath || !outputPath.endsWith(".html")) return content;
if (!content.includes("__OG_IMAGE_PLACEHOLDER__")) return content;
// Derive correct page URL and OG slug from outputPath (immune to race condition)
// Content pages match: .../type/slug/index.html
const postMatch = outputPath.match(
/\/([\w-]+)\/([\w-]+)\/index\.html$/
);
if (postMatch) {
const [, type, slug] = postMatch;
const pageUrlPath = `/${type}/${slug}/`;
const correctFullUrl = `${siteUrl}${pageUrlPath}`;
const ogSlug = 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-post pages (homepage, archives, 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;
});
// Sidenotes — convert markdown-it-footnote output into margin sidenotes.
// Wide screens (xl+): sidenotes float left. Narrow: footnote section at bottom.
eleventyConfig.addTransform("sidenotes", async function (content, outputPath) {
// Fast bail-outs
if (typeof outputPath !== "string" || !outputPath.endsWith(".html")) return content;
if (!content.includes('class="footnote-ref"')) return content;
const isPostPage = /\/(articles|notes|bookmarks|photos|replies|reposts|likes|pages)\/[^/]+\/index\.html$/.test(outputPath);
if (!isPostPage) return content;
const result = await posthtml([
(tree) => {
// 1. Build map: fnId → inline HTML (backref stripped, <p> wrappers stripped)
const fnMap = {};
tree.walk(node => {
if (
node.tag === "li" &&
node.attrs?.class?.includes("footnote-item") &&
node.attrs?.id
) {
const fnId = node.attrs.id;
// Collect children, skip <a class="footnote-backref">
const children = (node.content || []).flatMap(child => {
if (child.tag === "p") {
// Strip outer <p>, keep inner content (excluding backref <a>)
return (child.content || []).filter(c =>
!(c.tag === "a" && c.attrs?.class?.includes("footnote-backref"))
);
}
return [child];
});
fnMap[fnId] = children;
}
return node;
});
// 2. Track sidenotes and collect aside nodes for later insertion into .e-content.
// Putting <aside> (block) inside <span> (phrasing) is invalid HTML — browsers
// re-parent the aside, splitting the surrounding <p> and creating a visual gap.
// Instead: inline host gets only the superscript span; asides are appended
// as block children of .e-content where they're valid and don't break text flow.
let hasSidenotes = false;
const sidenoteAsides = [];
// 3. Replace each <sup class="footnote-ref"> with a plain inline sidenote-host
tree.walk(node => {
if (node.tag === "sup" && node.attrs?.class?.includes("footnote-ref")) {
// Find the child <a> to get href and id
const anchor = (node.content || []).find(c => c.tag === "a");
if (!anchor) return node;
const href = anchor.attrs?.href || ""; // e.g. "#fn1"
const fnId = href.replace(/^#/, ""); // e.g. "fn1"
const refId = anchor.attrs?.id || ""; // e.g. "fnref1"
// Extract numeric label from anchor text e.g. "[1]" → "1" or "[1:1]" → "1"
const rawLabel = (anchor.content || []).find(c => typeof c === "string") || "";
const label = rawLabel.replace(/[\[\]]/g, "").replace(/:.*$/, "").trim();
const noteContent = fnMap[fnId] || [];
if (!noteContent.length) return node; // Skip orphan refs with no definition
hasSidenotes = true;
// Collect aside for insertion into .e-content (not inline — avoids block-in-inline)
sidenoteAsides.push({
tag: "aside",
attrs: {
class: "sidenote",
"aria-label": `Sidenote ${label}`,
"data-fn-ref": refId,
},
content: [
{ tag: "span", attrs: { class: "sidenote-number" }, content: [label] },
" ",
...noteContent,
],
});
// Inline host: only the superscript number — no block child
return {
tag: "span",
attrs: { class: "sidenote-host" },
content: [
{
tag: "span",
attrs: { class: "footnote-ref-num", id: refId },
content: [label],
},
],
};
}
return node;
});
// 3b. Append collected asides as block children of .e-content
if (sidenoteAsides.length > 0) {
tree.walk(node => {
// Only match the article body .e-content (has prose-lg), not sidebar card divs
if (node.tag === "div" && node.attrs?.class?.includes("e-content") && node.attrs?.class?.includes("prose-lg")) {
node.content = [...(node.content || []), ...sidenoteAsides];
}
return node;
});
}
// 4. Add has-sidenotes class to <article> if any sidenotes were injected
if (hasSidenotes) {
tree.walk(node => {
if (node.tag === "article") {
const existing = node.attrs?.class || "";
node.attrs = { ...node.attrs, class: (existing + " has-sidenotes").trim() };
}
return node;
});
// 5. Inject positioning script before </body>.
// JS sets position:relative and overflow on .e-content/.main-content directly
// (more reliable than relying on CSS cascade/media-query ordering).
// getBoundingClientRect() is viewport-relative; subtracting eRect.top from
// hRect.top gives the distance of the host from .e-content top, which equals
// the CSS `top` value needed for an absolute child of .e-content.
// Browsers re-parent <aside> out of <span> (invalid block-in-inline nesting),
// so s.parentElement is .e-content, not .sidenote-host. We use data-fn-ref
// to find the corresponding .footnote-ref-num element by its id attribute.
const posScript = `(function(){var a,e,mc;function p(){if(window.innerWidth<1440){if(e){e.style.position='';e.style.overflowX='';}if(mc)mc.style.overflowX='';return;}if(!a)a=document.querySelector('article.has-sidenotes');if(!a)return;if(!e)e=a.querySelector('.e-content');if(!e)return;if(!mc)mc=a.closest('.main-content');e.style.position='relative';e.style.overflowX='visible';if(mc)mc.style.overflowX='visible';var er=e.getBoundingClientRect();var lb=0;a.querySelectorAll('.sidenote').forEach(function(s){var refId=s.getAttribute('data-fn-ref');var ref=refId?document.getElementById(refId):null;if(!ref)return;var hr=ref.getBoundingClientRect();var t=Math.max(hr.top-er.top,lb);s.style.top=t+'px';lb=t+s.offsetHeight+8;});}requestAnimationFrame(p);window.addEventListener('load',p);window.addEventListener('resize',p);})();`;
tree.walk(node => {
if (node.tag === "body") {
node.content = node.content || [];
node.content.push({ tag: "script", content: [posScript] });
}
return node;
});
}
},
]).process(content, { sync: false });
return result.html;
});
// 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,
conservativeCollapse: true,
removeComments: true,
html5: 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("robots.txt");
eleventyConfig.addPassthroughCopy("interactive");
eleventyConfig.addPassthroughCopy({ [OG_CACHE_DIR]: "og" });
// Funkwhale images are copied in eleventy.after (after data files download them)
// 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",
});
// Copy Lora font files (latin + latin-ext, weights 400/700, normal + italic)
eleventyConfig.addPassthroughCopy({
"node_modules/@fontsource/lora/files/lora-latin-400-*.woff2": "fonts",
"node_modules/@fontsource/lora/files/lora-latin-700-*.woff2": "fonts",
"node_modules/@fontsource/lora/files/lora-latin-ext-400-*.woff2": "fonts",
"node_modules/@fontsource/lora/files/lora-latin-ext-700-*.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: `${siteUrl}/webmentions/api/mentions?per-page=10000`,
key: "children",
});
// Date formatting filter
// Memoized: same dates repeat across pages (sidebars, pagination, feeds)
// 16,935 calls → unique dates are ~2,350 (one per post)
const _dateDisplayCache = new Map();
eleventyConfig.on("eleventy.before", () => { _dateDisplayCache.clear(); });
eleventyConfig.addFilter("dateDisplay", (dateObj) => {
if (!dateObj) return "";
const key = dateObj instanceof Date ? dateObj.getTime() : dateObj;
const cached = _dateDisplayCache.get(key);
if (cached !== undefined) return cached;
const result = new Date(dateObj).toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
});
_dateDisplayCache.set(key, result);
return result;
});
// ISO date filter — memoized
const _isoDateCache = new Map();
eleventyConfig.on("eleventy.before", () => { _isoDateCache.clear(); });
eleventyConfig.addFilter("isoDate", (dateObj) => {
if (!dateObj) return "";
const key = dateObj instanceof Date ? dateObj.getTime() : dateObj;
const cached = _isoDateCache.get(key);
if (cached !== undefined) return cached;
const result = new Date(dateObj).toISOString();
_isoDateCache.set(key, result);
return result;
});
// 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
// Memoized: same post content is processed once per post page + once per listing page card
const _ogDescCache = new Map();
eleventyConfig.on("eleventy.before", () => { _ogDescCache.clear(); });
eleventyConfig.addFilter("ogDescription", (content, len = 200) => {
if (!content) return "";
const key = `${len}:${content.length}:${content.slice(0, 64)}`;
const cached = _ogDescCache.get(key);
if (cached !== undefined) return cached;
let text = content.replace(/<[^>]+>/g, ' ');
text = text.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
text = text.replace(/\s+/g, ' ').trim();
if (text.length > len) {
text = text.slice(0, len).trim() + "...";
}
_ogDescCache.set(key, text);
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);
});
// Merge Funkwhale listenings and Last.fm scrobbles into a single sorted timeline
eleventyConfig.addFilter("mergeListens", (listenings, scrobbles) => {
const fw = (listenings || []).map((l) => ({
...l,
_source: "funkwhale",
_ts: new Date(l.listenedAt || l.creation_date || l.listened_at || 0).getTime(),
}));
const lfm = (scrobbles || []).map((s) => ({
...s,
_source: "lastfm",
_ts: new Date(s.scrobbledAt || 0).getTime(),
}));
return [...fw, ...lfm].sort((a, b) => b._ts - a._ts);
});
// Exclude post types from a collection by detecting type from frontmatter properties
// Usage: collections.posts | excludePostTypes(["reply", "like"])
// Supported types: reply, like, bookmark, repost, photo, article, note
eleventyConfig.addFilter("excludePostTypes", (posts, excludeTypes) => {
if (!Array.isArray(posts) || !Array.isArray(excludeTypes) || !excludeTypes.length) return posts;
return posts.filter((post) => {
const d = post.data || {};
let type;
if (d.inReplyTo || d.in_reply_to) type = "reply";
else if (d.likeOf || d.like_of) type = "like";
else if (d.bookmarkOf || d.bookmark_of) type = "bookmark";
else if (d.repostOf || d.repost_of) type = "repost";
else if (d.photo && d.photo.length) type = "photo";
else if (d.title) type = "article";
else type = "note";
return !excludeTypes.includes(type);
});
});
// Slugify filter
eleventyConfig.addFilter("slugify", (str) => {
if (!str) return "";
return str
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
});
// Nested tag filters (Obsidian-style hierarchical tags using "/" separator)
// Like slugify but preserves "/" so "tech/programming" → "tech/programming" (not "techprogramming")
eleventyConfig.addFilter("nestedSlugify", nestedSlugify);
// Returns true if postCategories (string or array) contains an exact or ancestor match for category.
// e.g. post tagged "tech/js" matches category page "tech" (ancestor) and "tech/js" (exact).
eleventyConfig.addFilter("categoryMatches", (postCategories, category) => {
if (!postCategories || !category) return false;
const cats = Array.isArray(postCategories) ? postCategories : [postCategories];
const target = String(category).replace(/^#/, "").trim();
return cats.some((cat) => {
const clean = String(cat).replace(/^#/, "").trim();
return clean === target || clean.startsWith(target + "/");
});
});
// Returns breadcrumb array for a nested category path.
// "tech/programming/js" → [{ label:"tech", path:"tech", isLast:false }, ...]
eleventyConfig.addFilter("categoryBreadcrumb", (category) => {
if (!category) return [];
const parts = String(category).split("/");
return parts.map((part, i) => ({
label: part,
path: parts.slice(0, i + 1).join("/"),
isLast: i === parts.length - 1,
}));
});
// Groups a flat sorted categories array by root for the index tree view.
// Returns [{ root, children: ["tech/js", "tech/python", ...] }, ...]
eleventyConfig.addFilter("categoryGroupByRoot", (categories) => {
if (!categories) return [];
const groups = new Map();
for (const cat of categories) {
const root = cat.split("/")[0];
if (!groups.has(root)) groups.set(root, { root, children: [] });
if (cat !== root) groups.get(root).children.push(cat);
}
return [...groups.values()].sort((a, b) => a.root.localeCompare(b.root));
});
// Returns direct children of a parent category from the full categories array.
// Parent "tech" + ["tech", "tech/js", "tech/python", "tech/js/react"] → ["tech/js", "tech/python"]
eleventyConfig.addFilter("categoryDirectChildren", (allCategories, parent) => {
if (!allCategories || !parent) return [];
const parentSlug = nestedSlugify(parent);
return allCategories.filter((cat) => {
const catSlug = nestedSlugify(cat);
if (!catSlug.startsWith(parentSlug + "/")) return false;
const remainder = catSlug.slice(parentSlug.length + 1);
return !remainder.includes("/");
});
});
eleventyConfig.addFilter("youtubeId", (url) => {
if (!url || typeof url !== "string") return null;
try {
const u = new URL(url);
if (u.hostname === "youtu.be") return u.pathname.slice(1).split("?")[0] || null;
if (u.hostname.endsWith("youtube.com")) return u.searchParams.get("v") || null;
} catch {}
return null;
});
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 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).
// URLs are plain (/type/slug/), so the slug is the last path segment.
eleventyConfig.addFilter("ogSlug", (url) => {
if (!url) return "";
const segments = url.split("/").filter(Boolean);
return segments[segments.length - 1] || "";
});
// Check if a generated OG image exists for this slug
// Delegates to the memoized hasOgImage() above (directory listed once per build → Set lookup)
eleventyConfig.addFilter("hasOgImage", (slug) => {
if (!slug) return false;
return hasOgImage(slug);
});
// 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)
// Memoized: same date+format combos repeat across pages
// 33,025 calls on initial build → unique combos ~2,350
const _dateFilterCache = new Map();
eleventyConfig.on("eleventy.before", () => { _dateFilterCache.clear(); });
eleventyConfig.addFilter("date", (dateObj, format) => {
if (!dateObj) return "";
const dateKey = dateObj instanceof Date ? dateObj.getTime() : dateObj;
const key = `${dateKey}|${format}`;
const cached = _dateFilterCache.get(key);
if (cached !== undefined) return cached;
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";
const result = date.toLocaleDateString("en-US", options);
_dateFilterCache.set(key, result);
return result;
});
// 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);
}
// Filter out self-interactions from own Bluesky account
const isSelfBsky = (wm) => {
const u = (wm.url || "").toLowerCase();
const a = (wm.author?.url || "").toLowerCase();
return u.includes("bsky.app/profile/svemagie.bsky.social") ||
u.includes("did:plc:g4utqyolpyb5zpwwodmm3hht") ||
a.includes("bsky.app/profile/svemagie.bsky.social") ||
a.includes("did:plc:g4utqyolpyb5zpwwodmm3hht");
};
return deduped.filter((wm) => !isSelfBsky(wm));
});
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 (aiTextLevel > "0")
// Memoized: same collections.posts input produces same output — compute once per build
// (694 calls × 2,350 posts = 1.6M iterations without cache)
const getAiMetadata = (data = {}) => {
const aiMeta = (data && typeof data.ai === "object" && !Array.isArray(data.ai))
? data.ai
: {};
const textLevel = String(
data.aiTextLevel
?? data.ai_text_level
?? aiMeta.textLevel
?? aiMeta.aiTextLevel
?? "0",
);
const codeLevel = String(
data.aiCodeLevel
?? data.ai_code_level
?? aiMeta.codeLevel
?? aiMeta.aiCodeLevel
?? "0",
);
const tools = data.aiTools ?? data.ai_tools ?? aiMeta.aiTools ?? aiMeta.tools;
const description =
data.aiDescription
?? data.ai_description
?? aiMeta.aiDescription
?? aiMeta.description;
return { textLevel, codeLevel, tools, description };
};
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 { textLevel: level } = getAiMetadata(post.data || {});
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 { textLevel } = getAiMetadata(post.data || {});
const level = parseInt(textLevel || "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 && !item.data.deleted;
// Helper: exclude unlisted/private visibility from public listing surfaces
const isListed = (item) => {
const data = item?.data || {};
const rawVisibility = data.visibility ?? data.properties?.visibility;
const visibility = String(Array.isArray(rawVisibility) ? rawVisibility[0] : (rawVisibility ?? "")).toLowerCase();
return visibility !== "unlisted" && visibility !== "private";
};
// Exclude unlisted/private posts from UI slices like homepage/sidebar recent-post lists.
eleventyConfig.addFilter("excludeUnlistedPosts", (posts) => {
if (!Array.isArray(posts)) return [];
return posts.filter(isListed);
});
// ── Digital Garden ───────────────────────────────────────────────────────
// Returns display metadata for a garden stage slug.
// Used by garden-badge.njk and garden.njk to render labels + emoji.
// Stages map to Obsidian's #garden/* tag convention:
// #garden/plant → gardenStage: plant (newly planted idea)
// #garden/cultivate → gardenStage: cultivate (actively developing)
// #garden/question → gardenStage: question (open exploration)
// #garden/repot → gardenStage: repot (being restructured)
// #garden/revitalize → gardenStage: revitalize (being refreshed)
// #garden/revisit → gardenStage: revisit (flagged for revisiting)
eleventyConfig.addFilter("gardenStageInfo", (stage) => {
const stages = {
plant: { label: "Seedling", emoji: "🌱", description: "Newly planted idea" },
cultivate: { label: "Growing", emoji: "🌿", description: "Being actively developed" },
evergreen: { label: "Evergreen", emoji: "🌳", description: "Mature and reasonably complete, still growing" },
question: { label: "Open Question", emoji: "❓", description: "Open for exploration" },
repot: { label: "Repotting", emoji: "🪴", description: "Being restructured" },
revitalize: { label: "Revitalizing", emoji: "✨", description: "Being refreshed and updated" },
revisit: { label: "Revisit", emoji: "🔄", description: "Flagged for revisiting" },
};
return stages[stage] || null;
});
// Merge plugin backlinks [{url, title}] with posts from the backlinksWith filter,
// returning a deduplicated [{url, title}] array. Plugin entries take priority.
eleventyConfig.addFilter("mergeBacklinks", function (pluginBacklinks, filterPosts) {
const seen = new Set();
const result = [];
for (const bl of (pluginBacklinks || [])) {
if (!seen.has(bl.url)) { seen.add(bl.url); result.push({ url: bl.url, title: bl.title || null }); }
}
for (const post of (filterPosts || [])) {
if (!seen.has(post.url)) { seen.add(post.url); result.push({ url: post.url, title: post.data?.title || null }); }
}
return result;
});
// Backlinks — find all published posts whose raw source links to the given URL path.
// Reads each file once per build and caches it to avoid repeated disk I/O.
{
const contentCache = new Map();
eleventyConfig.addFilter("backlinksWith", function (posts, currentUrl) {
const fullUrl = `${siteUrl}${currentUrl}`;
return (posts || []).filter((post) => {
if (post.url === currentUrl) return false;
let content = contentCache.get(post.inputPath);
if (content === undefined) {
try { content = readFileSync(post.inputPath, "utf-8"); }
catch { content = ""; }
contentCache.set(post.inputPath, content);
}
return content.includes(fullUrl);
});
});
}
// Look up a post by its absolute blog URL — used to show titles in related-links lists.
eleventyConfig.addFilter("postByUrl", function (posts, url) {
const path = url.replace(siteUrl, "");
return (posts || []).find((p) => p.url === path || p.url === `${path}/`);
});
// Collect all internal blog links from a post's raw markdown and merge with
// the explicit `related` frontmatter URLs. Returns a deduplicated list.
eleventyConfig.addFilter("seeAlsoLinks", function (inputPath, relatedUrls) {
const seen = new Set();
const result = [];
const add = (url) => {
const key = String(url).replace(/\/$/, "");
if (!seen.has(key)) { seen.add(key); result.push(String(url)); }
};
const relArray = !relatedUrls ? [] : Array.isArray(relatedUrls) ? relatedUrls : [relatedUrls];
for (const url of relArray) add(url);
try {
const content = readFileSync(inputPath, "utf-8");
const escaped = siteUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(?<!!)\\[[^\\]]*\\]\\((${escaped}/[^)\\s]+)\\)`, "g");
let m;
while ((m = re.exec(content)) !== null) add(m[1]);
// Also extract reference-style link definitions and bare footnote URLs:
// [^1]: https://blog.giersig.eu/... or [label]: https://blog.giersig.eu/...
const reRef = new RegExp(`^\\[[^\\]]+\\]:\\s+(${escaped}/[^\\s]+)`, "gm");
while ((m = reRef.exec(content)) !== null) add(m[1]);
} catch { /* ignore */ }
return result;
});
// Strip origin from a URL, returning just the path (and query/hash if present).
eleventyConfig.addFilter("pathOnly", function (url) {
try { return new URL(String(url)).pathname.replace(/\/$/, "") || "/"; }
catch { return String(url); }
});
// Find other published posts that share the same external target URL
// (repostOf, likeOf, bookmarkOf, inReplyTo). Used to cross-link related posts.
eleventyConfig.addFilter("sameTargetPosts", function (posts, targetUrl, currentUrl) {
if (!targetUrl) return [];
const norm = (u) => String(u).replace(/\/$/, "");
const t = norm(targetUrl);
return (posts || []).filter((p) => {
if (norm(p.url) === norm(currentUrl)) return false;
const d = p.data;
return [d.repostOf, d.repost_of, d.likeOf, d.like_of,
d.bookmarkOf, d.bookmark_of, d.inReplyTo, d.in_reply_to]
.some((v) => v && norm(v) === t);
});
});
// Strip garden/* tags from a category list so they don't render as
// plain category pills alongside the garden badge.
eleventyConfig.addFilter("withoutGardenTags", (categories) => {
if (!categories) return categories;
const arr = Array.isArray(categories) ? categories : [categories];
const filtered = arr.filter(
(c) => !String(c).replace(/^#/, "").startsWith("garden/"),
);
if (!Array.isArray(categories)) return filtered[0] ?? null;
return filtered;
});
// 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("listedPosts", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/**/*.md")
.filter((item) => isPublished(item) && isListed(item))
.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("listedNotes", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/notes/**/*.md")
.filter((item) => isPublished(item) && isListed(item))
.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);
});
// Recently edited posts (updated !== published) — for /updated.xml
// Note: getFilteredByGlob reuses Eleventy's cached template parse, no extra I/O
eleventyConfig.addCollection("recentlyUpdated", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.filter((item) => {
if (!item.data.updated) return false;
const published = new Date(item.date).getTime();
const updated = new Date(item.data.updated).getTime();
return updated > published;
})
.sort((a, b) => new Date(b.data.updated) - new Date(a.data.updated))
.slice(0, 20);
});
// Categories collection - deduplicated by slug to avoid duplicate permalinks
eleventyConfig.addCollection("categories", function (collectionApi) {
const categoryMap = new Map(); // nestedSlug -> display name (first seen)
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()) {
// Exclude garden/* tags — they're rendered as garden badges, not categories
if (cat.replace(/^#/, "").startsWith("garden/")) return;
const trimmed = cat.trim().replace(/^#/, "");
const slug = nestedSlugify(trimmed);
if (slug && !categoryMap.has(slug)) categoryMap.set(slug, trimmed);
// Auto-create ancestor pages for nested tags (e.g. "tech/js" → also register "tech")
const parts = trimmed.split("/");
for (let i = 1; i < parts.length; i++) {
const parentPath = parts.slice(0, i).join("/");
const parentSlug = nestedSlugify(parentPath);
if (parentSlug && !categoryMap.has(parentSlug)) categoryMap.set(parentSlug, parentPath);
}
}
});
}
});
return [...categoryMap.values()].sort((a, b) => a.localeCompare(b));
});
// Category feeds — pre-grouped posts for per-category RSS/JSON feeds
eleventyConfig.addCollection("categoryFeeds", function (collectionApi) {
const slugify = nestedSlugify;
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);
});
// Digital Garden — posts with a gardenStage frontmatter property
eleventyConfig.addCollection("gardenPosts", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.filter((item) => item.data.gardenStage)
.sort((a, b) => b.date - a.date);
});
// Posts that recently reached evergreen status (within the last 90 days).
// Requires evergreen-since frontmatter field, written by the Micropub plugin on first evergreen publish.
eleventyConfig.addCollection("recentEvergreens", function (collectionApi) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 90);
return collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.filter((item) => {
if (item.data.gardenStage !== "evergreen") return false;
if (!item.data["evergreen-since"]) return false;
const d = new Date(item.data["evergreen-since"]);
return !isNaN(d.getTime()) && d >= cutoff;
})
.sort(
(a, b) =>
new Date(b.data["evergreen-since"]) - new Date(a.data["evergreen-since"]),
);
});
// 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(isListed)
.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", () => {
logMemory("before-build (OG start)");
console.time("[og] image generation");
const contentDir = resolve(__dirname, "content");
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,
OG_CACHE_DIR,
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, OG_CACHE_DIR is in watchIgnores so Eleventy's
// passthrough copy won't pick up newly generated images. Copy them manually.
const ogOutputDir = resolve(__dirname, "_site", "og");
if (existsSync(OG_CACHE_DIR) && existsSync(resolve(__dirname, "_site"))) {
mkdirSync(ogOutputDir, { recursive: true });
let synced = 0;
for (const file of readdirSync(OG_CACHE_DIR)) {
if (file.endsWith(".png") && !existsSync(resolve(ogOutputDir, file))) {
copyFileSync(resolve(OG_CACHE_DIR, 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);
}
console.timeEnd("[og] image generation");
});
// 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 () => {
console.time("[unfurl] prefetch");
const contentDir = resolve(__dirname, "content");
if (!existsSync(contentDir)) {
console.timeEnd("[unfurl] prefetch");
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) {
console.timeEnd("[unfurl] prefetch");
return;
}
// Manifest skip: only prefetch URLs not seen in previous builds.
// prefetchUrl() already caches responses on disk; re-calling it for known URLs
// is pure overhead. Writing [...urls] (not [...seen, ...newUrls]) intentionally
// prunes URLs from soft-deleted posts, preventing unbounded manifest growth.
const manifestPath = process.env.UNFURL_CACHE_DIR
? resolve(process.env.UNFURL_CACHE_DIR, "manifest.json")
: resolve(__dirname, ".cache", "unfurl-manifest.json");
let seen = new Set();
try {
seen = new Set(JSON.parse(readFileSync(manifestPath, "utf-8")));
} catch { /* first build or corrupted manifest — full prefetch */ }
const newUrls = [...urls].filter(u => !seen.has(u));
if (newUrls.length === 0) {
console.log("[unfurl] No new URLs — skipping prefetch");
console.timeEnd("[unfurl] prefetch");
return;
}
const urlArray = newUrls;
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.`);
// Update manifest with full current URL set
mkdirSync(resolve(__dirname, ".cache"), { recursive: true });
writeFileSync(manifestPath, JSON.stringify([...urls]));
console.timeEnd("[unfurl] prefetch");
});
// 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 }) => {
logMemory("after-build (pre-hooks)");
// 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);
}
}
// Funkwhale cover images — copy from cache to output after data files have downloaded them
{
const fwSrc = resolve(__dirname, ".cache/funkwhale-images");
const fwDest = resolve(directories?.output || dir.output, "images/funkwhale-cache");
if (existsSync(fwSrc)) {
mkdirSync(fwDest, { recursive: true });
let copied = 0;
for (const file of readdirSync(fwSrc)) {
copyFileSync(resolve(fwSrc, file), resolve(fwDest, file));
copied++;
}
if (copied > 0) console.log(`[funkwhale-images] Copied ${copied} cover image(s) to output`);
}
}
// Sitemap generation — scan output HTML files, exclude URL patterns
// Runs on every build (including incremental) so new posts appear immediately
{
const sitemapOutputDir = directories?.output || dir.output;
const excludePatterns = [
/^\/replies\//,
/\/feed\.(xml|json)$/,
/^\/categories\//,
/^\/digest/,
/^\/webmention-debug\//,
/^\/404\.html$/,
/^\/dashboard/,
/^\/homepage/,
/^\/search\//,
/^\/graph\//,
/^\/sitemap\.xml$/,
/^\/\.interface-design\//,
];
try {
const walkHtml = (base, prefix = "") => {
const entries = [];
for (const entry of readdirSync(resolve(base, prefix), { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
entries.push(...walkHtml(base, rel));
} else if (entry.name === "index.html") {
const urlPath = prefix ? `/${prefix}/` : "/";
entries.push(urlPath);
}
}
return entries;
};
const allUrls = walkHtml(sitemapOutputDir)
.filter((url) => !excludePatterns.some((p) => p.test(url)))
.sort();
const xmlLines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
];
for (const url of allUrls) {
xmlLines.push(` <url><loc>${siteUrl}${url}</loc></url>`);
}
xmlLines.push("</urlset>");
writeFileSync(resolve(sitemapOutputDir, "sitemap.xml"), xmlLines.join("\n"));
console.log(`[sitemap] Generated sitemap.xml with ${allUrls.length} URLs`);
} catch (err) {
console.error("[sitemap] Generation failed:", 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);
}
}
}
// Signal readiness to Indiekit plugins — build is complete, system is stable.
// Plugins poll for this file via @rmdes/indiekit-startup-gate before starting
// heavy background tasks (feed polling, AP federation, sync jobs).
// Only fires once — subsequent incremental builds skip this.
const readyPath = "/app/data/.indiekit-ready";
if (!existsSync(readyPath)) {
try {
writeFileSync(readyPath, "");
console.log("[startup-gate] Readiness signal created — plugins will start deferred tasks");
} catch { /* not running in Cloudron container — skip */ }
}
// 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...`);
await Promise.all(feedUrls.map(async (feedUrl) => {
try {
const res = await fetch(hubUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `hub.mode=publish&hub.url=${encodeURIComponent(feedUrl)}`,
signal: AbortSignal.timeout(5000),
});
console.log(`[websub] Notified hub for ${feedUrl}: ${res.status}`);
} catch (err) {
console.error(`[websub] Hub notification failed for ${feedUrl}:`, 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)`);
}
});
return {
dir: {
input: ".",
output: "_site",
includes: "_includes",
data: "_data",
},
markdownTemplateEngine: false, // Disable to avoid Nunjucks interpreting {{ in content
htmlTemplateEngine: "njk",
};
}