import pluginWebmentions from "@chrisburnell/eleventy-cache-webmentions";
import pluginRss from "@11ty/eleventy-plugin-rss";
import embedEverything from "eleventy-plugin-embed-everything";
import { eleventyImageTransformPlugin } from "@11ty/eleventy-img";
import markdownIt from "markdown-it";
import markdownItAnchor from "markdown-it-anchor";
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
import { minify } from "html-minifier-terser";
import { minify as minifyJS } from "terser";
import registerUnfurlShortcode, { getCachedCard, prefetchUrl } from "./lib/unfurl-shortcode.js";
import matter from "gray-matter";
import { createHash, createHmac } from "crypto";
import { createRequire } from "module";
import { execFileSync } from "child_process";
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, copyFileSync, 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";
// Memory profiler — logs RSS + V8 heap at key build phases
// Writes to file (log buffer gets flushed by AP inbox traffic) AND stdout
const MEM_LOG = "/app/data/.eleventy-mem.log";
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);
const line = `[${new Date().toISOString()}] ${phase}: RSS=${rss}MB heap=${heapUsed}/${heapTotal}MB external=${external}MB buffers=${arrayBuffers}MB`;
console.log(`[mem] ${phase}: RSS=${rss}MB heap=${heapUsed}/${heapTotal}MB external=${external}MB buffers=${arrayBuffers}MB`);
try { appendFileSync(MEM_LOG, line + "\n"); } catch { /* not on Cloudron or not writable */ }
}
export default function (eleventyConfig) {
// Don't use .gitignore for determining what to process
// (content/ is in .gitignore because it's a symlink, but we need to process it)
eleventyConfig.setUseGitIgnore(false);
// Ignore output directory (prevents re-processing generated files via symlink)
eleventyConfig.ignores.add("_site");
eleventyConfig.ignores.add("_site/**");
eleventyConfig.ignores.add("/app/data/site");
eleventyConfig.ignores.add("/app/data/site/**");
eleventyConfig.ignores.add("node_modules");
eleventyConfig.ignores.add("node_modules/**");
eleventyConfig.ignores.add("CLAUDE.md");
eleventyConfig.ignores.add("README.md");
// Ignore Pagefind output directory
eleventyConfig.ignores.add("pagefind");
eleventyConfig.ignores.add("pagefind/**");
// Ignore interactive assets (served via passthrough copy, not processed as templates)
eleventyConfig.ignores.add("interactive");
eleventyConfig.ignores.add("interactive/**");
// Configure watch targets to exclude output directory
eleventyConfig.watchIgnores.add("_site");
eleventyConfig.watchIgnores.add("_site/**");
eleventyConfig.watchIgnores.add("/app/data/site");
eleventyConfig.watchIgnores.add("/app/data/site/**");
eleventyConfig.watchIgnores.add("pagefind");
eleventyConfig.watchIgnores.add("pagefind/**");
eleventyConfig.watchIgnores.add(".cache/og");
eleventyConfig.watchIgnores.add(".cache/og/**");
eleventyConfig.watchIgnores.add(".cache/unfurl");
eleventyConfig.watchIgnores.add(".cache/unfurl/**");
// Watcher tuning: handle rapid successive file changes
// When a post is created via Micropub, the file is written twice in quick
// succession: first the initial content, then ~2s later a Micropub update
// adds syndication URLs. awaitWriteFinish delays the watcher event until
// the file is stable (no writes for 2s), so both changes are captured in
// one build. The throttle adds a 3s build-level debounce on top.
eleventyConfig.setChokidarConfig({
awaitWriteFinish: {
stabilityThreshold: 2000,
pollInterval: 100,
},
});
eleventyConfig.setWatchThrottleWaitTime(3000);
// Configure markdown-it with linkify enabled (auto-convert URLs to links)
const md = markdownIt({
html: true,
linkify: true, // Auto-convert URLs to clickable links
typographer: true,
});
md.use(markdownItAnchor, {
permalink: markdownItAnchor.permalink.headerLink(),
slugify: (s) => s.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""),
level: [2, 3, 4],
});
// Hashtag plugin: converts #tag to category links on-site
// Syndication targets (Bluesky, Mastodon) handle raw #tag natively via facet detection
md.inline.ruler.push("hashtag", (state, silent) => {
const pos = state.pos;
if (state.src.charCodeAt(pos) !== 0x23 /* # */) return false;
// Must be at start of string or preceded by whitespace/punctuation (not part of a URL fragment or hex color)
if (pos > 0) {
const prevChar = state.src.charAt(pos - 1);
if (!/[\s()\[\]{},;:!?"'«»""'']/.test(prevChar)) return false;
}
// Match hashtag: # followed by letter/underscore, then word chars (letters, digits, underscores)
const tail = state.src.slice(pos + 1);
const match = tail.match(/^([a-zA-Z_]\w*)/);
if (!match) return false;
const tag = match[1];
// Skip pure hex color codes (3, 4, 6, or 8 hex digits with nothing else)
if (/^[0-9a-fA-F]{3,8}$/.test(tag)) return false;
if (!silent) {
const slug = tag.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
const tokenOpen = state.push("link_open", "a", 1);
tokenOpen.attrSet("href", `/categories/${slug}/`);
tokenOpen.attrSet("class", "p-category hashtag");
const tokenText = state.push("text", "", 0);
tokenText.content = `#${tag}`;
state.push("link_close", "a", -1);
}
state.pos = pos + 1 + tag.length;
return true;
});
eleventyConfig.setLibrary("md", md);
// Syntax highlighting for fenced code blocks (```lang)
eleventyConfig.addPlugin(syntaxHighlight);
// RSS plugin for feed filters (dateToRfc822, absoluteUrl, etc.)
// Custom feed templates in feed.njk and feed-json.njk use these filters
eleventyConfig.addPlugin(pluginRss);
// Post graph — GitHub-style contribution grid for posting frequency
eleventyConfig.addPlugin(postGraph, {
sort: "desc",
limit: 2,
dayBoxTitle: true,
selectorLight: ":root",
selectorDark: ".dark",
boxColorLight: "#e7e5e4", // surface-200 (warm stone)
highlightColorLight: "#d97706", // amber-600 (accent)
textColorLight: "#1c1917", // surface-900
boxColorDark: "#292524", // surface-800
highlightColorDark: "#fbbf24", // amber-400
textColorDark: "#fafaf9", // surface-50
});
// JSON encode filter for JSON feed
eleventyConfig.addFilter("jsonEncode", (value) => {
return JSON.stringify(value);
});
// Guess MIME type from URL extension
function guessMimeType(url, category) {
const lower = (typeof url === "string" ? url : "").toLowerCase();
if (category === "photo") {
if (lower.includes(".png")) return "image/png";
if (lower.includes(".gif")) return "image/gif";
if (lower.includes(".webp")) return "image/webp";
if (lower.includes(".svg")) return "image/svg+xml";
return "image/jpeg";
}
if (category === "audio") {
if (lower.includes(".ogg") || lower.includes(".opus")) return "audio/ogg";
if (lower.includes(".flac")) return "audio/flac";
if (lower.includes(".wav")) return "audio/wav";
return "audio/mpeg";
}
if (category === "video") {
if (lower.includes(".webm")) return "video/webm";
if (lower.includes(".mov")) return "video/quicktime";
return "video/mp4";
}
return "application/octet-stream";
}
// Extract URL string from value that may be a string or {url, alt} object
function resolveMediaUrl(value) {
if (typeof value === "string") return value;
if (value && typeof value === "object" && value.url) return value.url;
return null;
}
// Feed attachments filter — builds JSON Feed attachments array from post data
eleventyConfig.addFilter("feedAttachments", (postData) => {
const attachments = [];
const processMedia = (items, category) => {
const list = Array.isArray(items) ? items : [items];
for (const item of list) {
const rawUrl = resolveMediaUrl(item);
if (!rawUrl) continue;
const url = rawUrl.startsWith("http") ? rawUrl : `${siteUrl}${rawUrl}`;
attachments.push({ url, mime_type: guessMimeType(rawUrl, category) });
}
};
if (postData.photo) processMedia(postData.photo, "photo");
if (postData.audio) processMedia(postData.audio, "audio");
if (postData.video) processMedia(postData.video, "video");
return attachments;
});
// Textcasting support filter — builds clean support object excluding null values
eleventyConfig.addFilter("textcastingSupport", (support) => {
if (!support) return {};
const obj = {};
if (support.url) obj.url = support.url;
if (support.stripe) obj.stripe = support.stripe;
if (support.lightning) obj.lightning = support.lightning;
if (support.paymentPointer) obj.payment_pointer = support.paymentPointer;
return obj;
});
// Protocol type filter — classifies a URL by its origin protocol/network
eleventyConfig.addFilter("protocolType", (url) => {
if (!url || typeof url !== "string") return "web";
const lower = url.toLowerCase();
if (lower.includes("bsky.app") || lower.includes("bluesky")) return "atmosphere";
// Match Fediverse instances by known domain patterns (avoid overly broad "social")
if (lower.includes("mastodon.") || lower.includes("mstdn.") || lower.includes("fosstodon.") ||
lower.includes("pleroma.") || lower.includes("misskey.") || lower.includes("pixelfed.") ||
lower.includes("fediverse")) return "fediverse";
return "web";
});
// Email obfuscation filter - converts email to HTML entities
// Blocks ~95% of spam harvesters while remaining valid for microformat parsers
// Usage: {{ email | obfuscateEmail }} or {{ email | obfuscateEmail("href") }}
eleventyConfig.addFilter("obfuscateEmail", (email, mode = "display") => {
if (!email) return "";
// Convert each character to HTML decimal entity
const encoded = [...email].map(char => `${char.charCodeAt(0)};`).join("");
if (mode === "href") {
// For mailto: links, also encode the "mailto:" prefix
const mailto = [...("mailto:")].map(char => `${char.charCodeAt(0)};`).join("");
return mailto + encoded;
}
return encoded;
});
// Alias dateToRfc822 (plugin provides dateToRfc2822)
eleventyConfig.addFilter("dateToRfc822", (date) => {
return pluginRss.dateToRfc2822(date);
});
// Embed Everything - auto-embed YouTube, Vimeo, Bluesky, Mastodon, etc.
// YouTube uses lite-yt-embed facade: shows thumbnail + play button,
// only loads full iframe on click (~800 KiB savings).
// CSS/JS disabled here — already loaded in base.njk.
eleventyConfig.addPlugin(embedEverything, {
use: ["youtube", "vimeo", "twitter", "mastodon", "bluesky", "spotify", "soundcloud"],
youtube: {
options: {
lite: {
css: { enabled: false },
js: { enabled: false },
responsive: true,
},
recommendSelfOnly: true,
},
},
mastodon: {
options: {
server: "indieweb.social",
},
},
});
// Unfurl shortcode — renders any URL as a rich card (OpenGraph/Twitter Card metadata)
// Usage in templates: {% unfurl "https://example.com/article" %}
registerUnfurlShortcode(eleventyConfig);
// Synchronous unfurl filter — reads from pre-populated disk cache.
// Safe for deeply nested includes where async shortcodes fail silently.
// Usage: {{ url | unfurlCard | safe }}
eleventyConfig.addFilter("unfurlCard", getCachedCard);
// Custom transform to convert YouTube links to lite-youtube embeds
// Catches bare YouTube links in Markdown that the embed plugin misses
eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
if (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: "",
},
});
// Performance: skip PostHTML parsing for pages without tags.
// Both registered PostHTML plugins (remote-image-marker, eleventy-img) only
// target elements — no point parsing+serializing HTML without them.
// Overrides the default @11ty/eleventy/html-transformer transform (same name
// overwrites via addTransform) with a pre-check that avoids the full PostHTML
// parse/serialize cycle (~3ms/page) for image-free pages.
eleventyConfig.addTransform("@11ty/eleventy/html-transformer", async function(content) {
if (typeof this.outputPath === "string" && this.outputPath.endsWith(".html") && !content.includes(", , etc.)
// we must still run the full pipeline even without images.
const hasUrlCallbacks = eleventyConfig.htmlTransformer.getCallbacks("html", this).length > 0;
if (!hasUrlCallbacks) return content;
}
return eleventyConfig.htmlTransformer.transformContent(this.outputPath, content, this);
});
// 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;
});
// Fix OG image meta tags post-rendering — bypasses Eleventy 3.x race condition (#3183).
// page.url is unreliable during parallel rendering, but outputPath IS correct
// since files are written to the correct location. Derives the OG slug from
// outputPath and replaces placeholders emitted by base.njk.
// Cache: directory listing built once per build instead of 3,426 existsSync calls
let _ogFileSet = null;
eleventyConfig.on("eleventy.before", () => { _ogFileSet = null; });
function hasOgImage(ogSlug) {
if (!_ogFileSet) {
const ogDir = resolve(__dirname, ".cache", "og");
try {
_ogFileSet = new Set(readdirSync(ogDir));
} catch {
_ogFileSet = new Set();
}
}
return _ogFileSet.has(`${ogSlug}.png`);
}
eleventyConfig.addTransform("og-fix", function (content, outputPath) {
if (!outputPath || !outputPath.endsWith(".html")) return content;
// Derive correct page URL and OG slug from outputPath (immune to race condition)
// Content pages match: .../type/yyyy/MM/dd/slug/index.html
const dateMatch = outputPath.match(
/\/([\w-]+)\/(\d{4})\/(\d{2})\/(\d{2})\/([\w-]+)\/index\.html$/
);
if (dateMatch) {
const [, type, year, month, day, slug] = dateMatch;
const pageUrlPath = `/${type}/${year}/${month}/${day}/${slug}/`;
const correctFullUrl = `${siteUrl}${pageUrlPath}`;
const ogSlug = `${year}-${month}-${day}-${slug}`;
const hasOg = hasOgImage(ogSlug);
const ogImageUrl = hasOg
? `${siteUrl}/og/${ogSlug}.png`
: `${siteUrl}/images/og-default.png`;
const twitterCard = hasOg ? "summary_large_image" : "summary";
// Fix og:url and canonical (also affected by race condition)
content = content.replace(
/( 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