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;
});
// HTML minification — only during initial build, skip during watch rebuilds
eleventyConfig.addTransform("htmlmin", async function (content, outputPath) {
if (outputPath && outputPath.endsWith(".html") && process.env.ELEVENTY_RUN_MODE === "build") {
try {
return await minify(content, {
collapseWhitespace: true,
removeComments: true,
html5: true,
decodeEntities: true,
minifyCSS: false,
minifyJS: false,
});
} catch {
console.warn(`[htmlmin] Parse error in ${outputPath}, skipping minification`);
return content;
}
}
return content;
});
// Copy static assets to output
eleventyConfig.addPassthroughCopy("css");
eleventyConfig.addPassthroughCopy("images");
eleventyConfig.addPassthroughCopy("js");
eleventyConfig.addPassthroughCopy("favicon.ico");
eleventyConfig.addPassthroughCopy("interactive");
eleventyConfig.addPassthroughCopy({ ".cache/og": "og" });
// Copy vendor web components from node_modules
eleventyConfig.addPassthroughCopy({
"node_modules/@zachleat/table-saw/table-saw.js": "js/table-saw.js",
"node_modules/@11ty/is-land/is-land.js": "js/is-land.js",
"node_modules/@zachleat/filter-container/filter-container.js": "js/filter-container.js",
});
// Copy Inter font files (latin + latin-ext subsets, woff2 only for modern browsers)
eleventyConfig.addPassthroughCopy({
"node_modules/@fontsource/inter/files/inter-latin-*-normal.woff2": "fonts",
"node_modules/@fontsource/inter/files/inter-latin-ext-*-normal.woff2": "fonts",
});
// Watch for content changes
eleventyConfig.addWatchTarget("./content/");
eleventyConfig.addWatchTarget("./css/");
// Webmentions plugin configuration
const wmDomain = siteUrl.replace("https://", "").replace("http://", "");
eleventyConfig.addPlugin(pluginWebmentions, {
domain: siteUrl,
feed: `https://blog.giersig.eu/webmentions/api/mentions?per-page=10000`,
key: "children",
});
// Date formatting filter — memoized (same dates repeat across pages in sidebars/pagination)
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 += `${typeLabels[type]} `;
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 += `${label} `;
}
html += ` `;
}
return html;
});
// Truncate filter
eleventyConfig.addFilter("truncate", (str, len = 200) => {
if (!str) return "";
if (str.length <= len) return str;
return str.slice(0, len).trim() + "...";
});
// Clean excerpt for OpenGraph - strips HTML, decodes entities, removes extra whitespace
eleventyConfig.addFilter("ogDescription", (content, len = 200) => {
if (!content) return "";
// Strip HTML tags
let text = content.replace(/<[^>]+>/g, ' ');
// Decode common HTML entities
text = text.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/ /g, ' ');
// Remove extra whitespace
text = text.replace(/\s+/g, ' ').trim();
// Truncate
if (text.length > len) {
text = text.slice(0, len).trim() + "...";
}
return text;
});
// Extract first image from content for OpenGraph fallback
eleventyConfig.addFilter("extractFirstImage", (content) => {
if (!content) return null;
// Match all tags, skip hidden ones and data URIs
const imgRegex = / ]*?\ssrc=["']([^"']+)["'][^>]*>/gi;
let match;
while ((match = imgRegex.exec(content)) !== null) {
const fullTag = match[0];
const src = match[1];
if (src.startsWith("data:")) continue;
if (/\bhidden\b/.test(fullTag)) continue;
return src;
}
return null;
});
// Head filter for arrays
eleventyConfig.addFilter("head", (array, n) => {
if (!Array.isArray(array) || n < 1) return array;
return array.slice(0, n);
});
// Slugify filter
eleventyConfig.addFilter("slugify", (str) => {
if (!str) return "";
return str
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
});
// 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("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
eleventyConfig.addFilter("hash", (filePath) => {
try {
const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
const content = readFileSync(fullPath);
return createHash("md5").update(content).digest("hex").slice(0, 8);
} catch {
// Return timestamp as fallback if file not found
return Date.now().toString(36);
}
});
// Derive OG slug from page.url (reliable) instead of page.fileSlug
// (which suffers from Nunjucks race conditions in Eleventy 3.x parallel rendering).
// OG images are named with the full date prefix to match URL segments exactly.
eleventyConfig.addFilter("ogSlug", (url) => {
if (!url) return "";
const segments = url.split("/").filter(Boolean);
// Date-based URL: /type/yyyy/MM/dd/slug/ → 5 segments → "yyyy-MM-dd-slug"
if (segments.length === 5) {
const [, year, month, day, slug] = segments;
return `${year}-${month}-${day}-${slug}`;
}
// Fallback: last segment (for pages, legacy URLs)
return segments[segments.length - 1] || "";
});
// Check if a generated OG image exists for this slug
eleventyConfig.addFilter("hasOgImage", (slug) => {
if (!slug) return false;
const ogPath = resolve(__dirname, ".cache", "og", `${slug}.png`);
return existsSync(ogPath);
});
// Inline file contents (for critical CSS inlining)
eleventyConfig.addFilter("inlineFile", (filePath) => {
try {
const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
return readFileSync(fullPath, "utf-8");
} catch {
return "";
}
});
// Extract raw Markdown body from a source file (strips front matter)
eleventyConfig.addFilter("rawMarkdownBody", (inputPath) => {
try {
const src = readFileSync(inputPath, "utf-8");
const { content } = matter(src);
return content.trim();
} catch {
return "";
}
});
// Current timestamp filter (for client-side JS buildtime)
eleventyConfig.addFilter("timestamp", () => Date.now());
// Date filter (for sidebar dates)
eleventyConfig.addFilter("date", (dateObj, format) => {
if (!dateObj) return "";
const date = new Date(dateObj);
const options = {};
if (format.includes("MMM")) options.month = "short";
if (format.includes("d")) options.day = "numeric";
if (format.includes("yyyy")) options.year = "numeric";
return date.toLocaleDateString("en-US", options);
});
// Webmention filters - with legacy URL support
// This filter checks both current URL and any legacy URLs from redirects
// Merges webmentions + conversations with deduplication (conversations first)
eleventyConfig.addFilter("webmentionsForUrl", function (webmentions, url, urlAliases, conversationMentions = []) {
if (!url) return [];
// Merge conversations + webmentions with deduplication
const seen = new Set();
const merged = [];
// Add conversations first (richer metadata)
for (const item of conversationMentions) {
const key = item['wm-id'] || item.url;
if (key && !seen.has(key)) {
seen.add(key);
merged.push(item);
}
}
// Add webmentions (skip duplicates)
if (webmentions) {
for (const item of webmentions) {
const key = item['wm-id'];
if (!key || seen.has(key)) continue;
if (item.url && seen.has(item.url)) continue;
seen.add(key);
merged.push(item);
}
}
// Build list of all URLs to check (current + legacy)
const urlsToCheck = new Set();
// Add current URL variations
const absoluteUrl = url.startsWith("http") ? url : `${siteUrl}${url}`;
urlsToCheck.add(absoluteUrl);
urlsToCheck.add(absoluteUrl.replace(/\/$/, ""));
urlsToCheck.add(absoluteUrl.endsWith("/") ? absoluteUrl : `${absoluteUrl}/`);
// Add legacy URLs from aliases (if provided)
if (urlAliases?.aliases) {
const normalizedUrl = url.replace(/\/$/, "");
const oldUrls = urlAliases.aliases[normalizedUrl] || [];
for (const oldUrl of oldUrls) {
urlsToCheck.add(`${siteUrl}${oldUrl}`);
urlsToCheck.add(`${siteUrl}${oldUrl}/`);
urlsToCheck.add(`${siteUrl}${oldUrl}`.replace(/\/$/, ""));
}
}
// Compute legacy /content/ URL from current URL for old webmention.io targets
// Pattern: /type/yyyy/MM/dd/slug/ → /content/type/yyyy-MM-dd-slug/
const pathSegments = url.replace(/\/$/, "").split("/").filter(Boolean);
if (pathSegments.length === 5) {
const [type, year, month, day, slug] = pathSegments;
const contentUrl = `/content/${type}/${year}-${month}-${day}-${slug}/`;
urlsToCheck.add(`${siteUrl}${contentUrl}`);
urlsToCheck.add(`${siteUrl}${contentUrl}`.replace(/\/$/, ""));
}
// Filter merged data matching any of our URLs
const matched = merged.filter((wm) => urlsToCheck.has(wm["wm-target"]));
// Deduplicate cross-source: same author + same interaction type = same mention
// (webmention.io and conversations API may both report the same like/reply)
const deduped = [];
const authorActions = new Set();
for (const wm of matched) {
const authorUrl = wm.author?.url || wm.url || "";
const action = wm["wm-property"] || "mention";
const key = `${authorUrl}::${action}`;
if (authorActions.has(key)) continue;
authorActions.add(key);
deduped.push(wm);
}
// 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 [
``,
``,
` `,
` `,
` `,
` `,
` `,
` `,
].join("");
});
// Filter AI-involved posts (aiTextLevel > "0")
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 };
};
eleventyConfig.addFilter("aiPosts", (posts) => {
if (!Array.isArray(posts)) return [];
return posts.filter((post) => {
const { textLevel: level } = getAiMetadata(post.data || {});
return level !== "0" && level !== 0;
});
});
// AI stats — returns { total, aiCount, percentage, byLevel }
eleventyConfig.addFilter("aiStats", (posts) => {
if (!Array.isArray(posts)) return { total: 0, aiCount: 0, percentage: 0, byLevel: {} };
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];
return {
total,
aiCount,
percentage: total > 0 ? ((aiCount / total) * 100).toFixed(1) : "0",
byLevel,
};
});
// Helper: exclude drafts from collections
const isPublished = (item) => !item.data.draft;
// 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;
});
// 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);
});
// 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);
});
// Weekly digests — posts grouped by ISO week for digest pages and RSS feed
eleventyConfig.addCollection("weeklyDigests", function (collectionApi) {
const allPosts = collectionApi
.getFilteredByGlob("content/**/*.md")
.filter(isPublished)
.filter((item) => {
// Exclude replies
return !(item.data.inReplyTo || item.data.in_reply_to);
})
.sort((a, b) => b.date - a.date);
// ISO week helpers
const getISOWeek = (date) => {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);
};
const getISOYear = (date) => {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
return d.getUTCFullYear();
};
// Group by ISO week
const weekMap = new Map();
for (const post of allPosts) {
const d = new Date(post.date);
const week = getISOWeek(d);
const year = getISOYear(d);
const key = `${year}-W${String(week).padStart(2, "0")}`;
if (!weekMap.has(key)) {
// Calculate Monday (start) and Sunday (end) of ISO week
const jan4 = new Date(Date.UTC(year, 0, 4));
const dayOfWeek = jan4.getUTCDay() || 7;
const monday = new Date(jan4);
monday.setUTCDate(jan4.getUTCDate() - dayOfWeek + 1 + (week - 1) * 7);
const sunday = new Date(monday);
sunday.setUTCDate(monday.getUTCDate() + 6);
weekMap.set(key, {
year,
week,
slug: `${year}/W${String(week).padStart(2, "0")}`,
label: `Week ${week}, ${year}`,
startDate: monday.toISOString().slice(0, 10),
endDate: sunday.toISOString().slice(0, 10),
posts: [],
});
}
weekMap.get(key).posts.push(post);
}
// Post type detection (matches blog.njk logic)
const typeDetect = (post) => {
if (post.data.likeOf || post.data.like_of) return "likes";
if (post.data.bookmarkOf || post.data.bookmark_of) return "bookmarks";
if (post.data.repostOf || post.data.repost_of) return "reposts";
if (post.data.photo && post.data.photo.length) return "photos";
if (post.data.title) return "articles";
return "notes";
};
// Build byType for each week and convert to array
const digests = [...weekMap.values()].map((entry) => {
const byType = {};
for (const post of entry.posts) {
const type = typeDetect(post);
if (!byType[type]) byType[type] = [];
byType[type].push(post);
}
return { ...entry, byType };
});
// Sort newest-week-first
digests.sort((a, b) => {
if (a.year !== b.year) return b.year - a.year;
return b.week - a.week;
});
return digests;
});
// Generate OpenGraph images for posts without photos.
// Uses batch spawning: each invocation generates up to BATCH_SIZE images then exits,
// fully releasing WASM native memory (Satori Yoga + Resvg Rust) between batches.
// Exit code 2 = batch complete, more work remains → re-spawn.
// Manifest caching makes incremental builds fast (only new posts get generated).
eleventyConfig.on("eleventy.before", () => {
const contentDir = resolve(__dirname, "content");
const cacheDir = resolve(__dirname, ".cache");
const siteName = process.env.SITE_NAME || "My IndieWeb Blog";
const BATCH_SIZE = 100;
try {
// eslint-disable-next-line no-constant-condition
while (true) {
try {
execFileSync(process.execPath, [
"--max-old-space-size=512",
"--expose-gc",
resolve(__dirname, "lib", "og-cli.js"),
contentDir,
cacheDir,
siteName,
String(BATCH_SIZE),
], {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: "" },
});
// Exit code 0 = all done
break;
} catch (err) {
if (err.status === 2) {
// Exit code 2 = batch complete, more images remain
continue;
}
throw err;
}
}
// Sync new OG images to output directory.
// During incremental builds, .cache/og is in watchIgnores so Eleventy's
// passthrough copy won't pick up newly generated images. Copy them manually.
const ogCacheDir = resolve(cacheDir, "og");
const ogOutputDir = resolve(__dirname, "_site", "og");
if (existsSync(ogCacheDir) && existsSync(resolve(__dirname, "_site"))) {
mkdirSync(ogOutputDir, { recursive: true });
let synced = 0;
for (const file of readdirSync(ogCacheDir)) {
if (file.endsWith(".png") && !existsSync(resolve(ogOutputDir, file))) {
copyFileSync(resolve(ogCacheDir, file), resolve(ogOutputDir, file));
synced++;
}
}
if (synced > 0) {
console.log(`[og] Synced ${synced} new image(s) to output`);
}
}
} catch (err) {
console.error("[og] Image generation failed:", err.message);
}
});
// Pre-fetch unfurl metadata for all interaction URLs in content files.
// Populates the disk cache BEFORE templates render, so the synchronous
// unfurlCard filter (used in nested includes like recent-posts) has data.
eleventyConfig.on("eleventy.before", async () => {
const contentDir = resolve(__dirname, "content");
if (!existsSync(contentDir)) return;
const urls = new Set();
const interactionProps = [
"likeOf", "like_of", "bookmarkOf", "bookmark_of",
"repostOf", "repost_of", "inReplyTo", "in_reply_to",
];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = resolve(dir, entry.name);
if (entry.isDirectory()) { walk(full); continue; }
if (!entry.name.endsWith(".md")) continue;
try {
const { data } = matter(readFileSync(full, "utf-8"));
for (const prop of interactionProps) {
if (data[prop]) urls.add(data[prop]);
}
} catch { /* skip unparseable files */ }
}
};
walk(contentDir);
if (urls.size === 0) return;
// Free parsed markdown content before starting network-heavy prefetch
if (typeof global.gc === "function") global.gc();
const urlArray = [...urls];
const UNFURL_BATCH = 50;
const totalBatches = Math.ceil(urlArray.length / UNFURL_BATCH);
console.log(`[unfurl] Pre-fetching ${urlArray.length} interaction URLs (${totalBatches} batches of ${UNFURL_BATCH})...`);
let fetched = 0;
for (let i = 0; i < urlArray.length; i += UNFURL_BATCH) {
const batch = urlArray.slice(i, i + UNFURL_BATCH);
const batchNum = Math.floor(i / UNFURL_BATCH) + 1;
await Promise.all(batch.map((url) => prefetchUrl(url)));
fetched += batch.length;
if (typeof global.gc === "function") global.gc();
if (batchNum === 1 || batchNum % 5 === 0 || batchNum === totalBatches) {
const rss = (process.memoryUsage.rss() / 1024 / 1024).toFixed(0);
console.log(`[unfurl] Batch ${batchNum}/${totalBatches} (${fetched}/${urlArray.length}) | RSS: ${rss} MB`);
}
}
console.log(`[unfurl] Pre-fetch complete.`);
});
// Post-build hook: pagefind indexing + WebSub notification
// Pagefind runs once on the first build (initial or watcher's first full build), then never again.
// WebSub runs on every non-incremental build.
// Note: --incremental CLI flag sets incremental=true even for the watcher's first full build,
// so we cannot use the incremental flag to guard pagefind. Use a one-shot flag instead.
let pagefindDone = false;
eleventyConfig.on("eleventy.after", async ({ dir, directories, runMode, incremental }) => {
// Markdown for Agents — generate index.md alongside index.html for articles
const mdEnabled = (process.env.MARKDOWN_AGENTS_ENABLED || "true").toLowerCase() === "true";
if (mdEnabled && !incremental) {
const outputDir = directories?.output || dir.output;
const contentDir = resolve(__dirname, "content/articles");
const aiTrain = process.env.MARKDOWN_AGENTS_AI_TRAIN || "yes";
const search = process.env.MARKDOWN_AGENTS_SEARCH || "yes";
const aiInput = process.env.MARKDOWN_AGENTS_AI_INPUT || "yes";
const authorName = process.env.AUTHOR_NAME || "Blog Author";
let mdCount = 0;
try {
const files = readdirSync(contentDir).filter(f => f.endsWith(".md"));
for (const file of files) {
const src = readFileSync(resolve(contentDir, file), "utf-8");
const { data: fm, content: body } = matter(src);
if (!fm || fm.draft) continue;
// Derive the output path from the article's permalink or url
const articleUrl = fm.permalink || fm.url;
if (!articleUrl || !articleUrl.startsWith("/articles/")) continue;
const mdDir = resolve(outputDir, articleUrl.replace(/^\//, "").replace(/\/$/, ""));
const mdPath = resolve(mdDir, "index.md");
const trimmedBody = body.trim();
const tokens = Math.ceil(trimmedBody.length / 4);
const title = (fm.title || "").replace(/"/g, '\\"');
const date = fm.date ? new Date(fm.date).toISOString() : fm.published || "";
let frontLines = [
"---",
`title: "${title}"`,
`date: ${date}`,
`author: ${authorName}`,
`url: ${siteUrl}${articleUrl}`,
];
if (fm.category && Array.isArray(fm.category) && fm.category.length > 0) {
frontLines.push("categories:");
for (const cat of fm.category) {
frontLines.push(` - ${cat}`);
}
}
if (fm.description) {
frontLines.push(`description: "${fm.description.replace(/"/g, '\\"')}"`);
}
frontLines.push(`tokens: ${tokens}`);
frontLines.push(`content_signal: ai-train=${aiTrain}, search=${search}, ai-input=${aiInput}`);
frontLines.push("---");
mkdirSync(mdDir, { recursive: true });
writeFileSync(mdPath, frontLines.join("\n") + "\n\n# " + (fm.title || "") + "\n\n" + trimmedBody + "\n");
mdCount++;
}
console.log(`[markdown-agents] Generated ${mdCount} article .md files`);
} catch (err) {
console.error("[markdown-agents] Error generating .md files:", err.message);
}
}
// Pagefind indexing — run exactly once per process lifetime
if (!pagefindDone) {
pagefindDone = true;
const outputDir = directories?.output || dir.output;
try {
console.log(`[pagefind] Indexing ${outputDir} (${runMode})...`);
execFileSync("npx", ["pagefind", "--site", outputDir, "--output-subdir", "pagefind", "--glob", "**/*.html"], {
stdio: "inherit",
timeout: 120000,
});
console.log("[pagefind] Indexing complete");
} catch (err) {
console.error("[pagefind] Indexing failed:", err.message);
}
}
// JS minification — minify source JS files in output (skip vendor, already-minified)
if (runMode === "build" && !incremental) {
const jsOutputDir = directories?.output || dir.output;
const jsDir = resolve(jsOutputDir, "js");
if (existsSync(jsDir)) {
let jsMinified = 0;
let jsSaved = 0;
for (const file of readdirSync(jsDir).filter(f => f.endsWith(".js") && !f.endsWith(".min.js"))) {
const filePath = resolve(jsDir, file);
try {
const src = readFileSync(filePath, "utf-8");
const result = await minifyJS(src, { compress: true, mangle: true });
if (result.code) {
const saved = src.length - result.code.length;
if (saved > 0) {
writeFileSync(filePath, result.code);
jsSaved += saved;
jsMinified++;
}
}
} catch (err) {
console.error(`[js-minify] Failed to minify ${file}:`, err.message);
}
}
if (jsMinified > 0) {
console.log(`[js-minify] Minified ${jsMinified} JS files, saved ${(jsSaved / 1024).toFixed(1)} KiB`);
}
}
}
// Syndication webhook — trigger after incremental rebuilds (new posts are now live)
// Cuts syndication latency from ~2 min (poller) to ~5 sec (immediate trigger)
if (incremental) {
const syndicateUrl = process.env.SYNDICATE_WEBHOOK_URL;
if (syndicateUrl) {
try {
const secretFile = process.env.SYNDICATE_SECRET_FILE || "/app/data/config/.secret";
const secret = readFileSync(secretFile, "utf-8").trim();
// Build a minimal HS256 JWT using built-in crypto (no jsonwebtoken dependency)
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const payload = Buffer.from(JSON.stringify({
me: siteUrl,
scope: "update",
iat: now,
exp: now + 300, // 5 minutes
})).toString("base64url");
const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`)
.digest("base64url");
const token = `${header}.${payload}.${signature}`;
const res = await fetch(`${syndicateUrl}?token=${token}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(30000),
});
console.log(`[syndicate-hook] Triggered syndication: ${res.status}`);
} catch (err) {
console.error(`[syndicate-hook] Failed:`, err.message);
}
}
}
// WebSub hub notification — skip on incremental rebuilds
if (incremental) return;
const hubUrl = "https://websubhub.com/hub";
const feedUrls = [
`${siteUrl}/`,
`${siteUrl}/feed.xml`,
`${siteUrl}/feed.json`,
];
// Discover category feed URLs from build output
const outputDir = directories?.output || dir.output;
const categoriesDir = resolve(outputDir, "categories");
try {
for (const entry of readdirSync(categoriesDir, { withFileTypes: true })) {
if (entry.isDirectory() && existsSync(resolve(categoriesDir, entry.name, "feed.xml"))) {
feedUrls.push(`${siteUrl}/categories/${entry.name}/feed.xml`);
feedUrls.push(`${siteUrl}/categories/${entry.name}/feed.json`);
}
}
} catch {
// categoriesDir may not exist on first build — ignore
}
console.log(`[websub] Notifying hub for ${feedUrls.length} URLs...`);
for (const feedUrl of feedUrls) {
try {
const res = await fetch(hubUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `hub.mode=publish&hub.url=${encodeURIComponent(feedUrl)}`,
});
console.log(`[websub] Notified hub for ${feedUrl}: ${res.status}`);
} catch (err) {
console.error(`[websub] Hub notification failed for ${feedUrl}:`, err.message);
}
}
// 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",
};
}