import pluginWebmentions from "@chrisburnell/eleventy-cache-webmentions"; import pluginRss from "@11ty/eleventy-plugin-rss"; import pluginMermaid from "@kevingimbel/eleventy-plugin-mermaid"; 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 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 } 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("/"); }; 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/**"); // 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); // 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); }); // 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 tags where href contains youtube.com/watch or youtu.be // Link text can be: URL, www.youtube..., youtube..., or youtube-related text const youtubePattern = /]+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 `

`; }); // Clean up empty

tags created by the replacement content = content.replace(/

\s*<\/p>/g, ''); return content; }); // Image optimization - transforms 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: "", }, }); // Sitemap generation eleventyConfig.addPlugin(sitemap, { sitemap: { hostname: siteUrl, }, }); // Wrap elements in for responsive tables eleventyConfig.addTransform("table-saw-wrap", function (content, outputPath) { if (outputPath && outputPath.endsWith(".html")) { return content.replace(/)/g, "/g, "
"); } 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. 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/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( /( tags that are the primary content of a

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

tags whose content is short text + a single external as the last element // Pattern:

optional short text ...

const linkParagraphRe = /

([^<]{0,80})?]*>[^<]*<\/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,

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 const children = (node.content || []).flatMap(child => { if (child.tag === "p") { // Strip outer

, keep inner content (excluding backref ) 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