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