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 sitemap from "@quasibit/eleventy-plugin-sitemap"; 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 registerUnfurlShortcode, { getCachedCard, prefetchUrl } from "./lib/unfurl-shortcode.js"; import matter from "gray-matter"; import { createHash } from "crypto"; import { execFileSync } from "child_process"; import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from "fs"; import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const siteUrl = process.env.SITE_URL || "https://example.com"; 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/**"); // 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], }); 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); // 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. eleventyConfig.addPlugin(embedEverything, { use: ["youtube", "vimeo", "twitter", "mastodon", "bluesky", "spotify", "soundcloud"], youtube: { options: { lite: false, 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 embeds eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) { if (!outputPath || !outputPath.endsWith(".html")) { 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 standard YouTube iframe with exact oEmbed parameters return `

`; }); // Clean up empty

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

\s*<\/p>/g, ''); return content; }); // Image optimization - transforms tags automatically eleventyConfig.addPlugin(eleventyImageTransformPlugin, { extensions: "html", formats: ["webp", "jpeg"], widths: ["auto"], failOnError: false, 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; }); // 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/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 = existsSync(resolve(__dirname, ".cache", "og", `${ogSlug}.png`)); 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( /( { if (!dateObj) return ""; const date = new Date(dateObj); return date.toLocaleDateString("en-GB", { year: "numeric", month: "long", day: "numeric", }); }); // ISO date filter eleventyConfig.addFilter("isoDate", (dateObj) => { if (!dateObj) return ""; return new Date(dateObj).toISOString(); }); // 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]}

`; } 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, ""); }); 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); } return deduped; }); 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) 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]++; } } const max = Math.max(...counts, 1); const w = 200; const h = 30; 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(" "); return ``; }); // Helper: exclude drafts from collections const isPublished = (item) => !item.data.draft; // 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("notes", function (collectionApi) { return collectionApi .getFilteredByGlob("content/notes/**/*.md") .filter(isPublished) .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(); // slug -> original name (first seen) const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""); 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()) { const slug = slugify(cat.trim()); if (slug && !categoryMap.has(slug)) { categoryMap.set(slug, cat.trim()); } } }); } }); return [...categoryMap.values()].sort(); }); // Category feeds — pre-grouped posts for per-category RSS/JSON feeds eleventyConfig.addCollection("categoryFeeds", function (collectionApi) { const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""); 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); }); // 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 // Runs on every build (including watcher rebuilds) — manifest caching makes it fast // for incremental: only new posts without an OG image get generated (~200ms each) eleventyConfig.on("eleventy.before", () => { const contentDir = resolve(__dirname, "content"); const cacheDir = resolve(__dirname, ".cache"); const siteName = process.env.SITE_NAME || "My IndieWeb Blog"; try { execFileSync(process.execPath, [ "--max-old-space-size=768", resolve(__dirname, "lib", "og-cli.js"), contentDir, cacheDir, siteName, ], { stdio: "inherit", env: { ...process.env, NODE_OPTIONS: "" }, }); } 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; console.log(`[unfurl] Pre-fetching ${urls.size} interaction URLs...`); await Promise.all([...urls].map((url) => prefetchUrl(url))); 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); } } // 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); } } }); return { dir: { input: ".", output: "_site", includes: "_includes", data: "_data", }, markdownTemplateEngine: false, // Disable to avoid Nunjucks interpreting {{ in content htmlTemplateEngine: "njk", }; }