Files
indiekit-blog/eleventy.config.js

401 lines
14 KiB
JavaScript

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 { minify } from "html-minifier-terser";
import { createHash } from "crypto";
import { readFileSync } 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("node_modules");
eleventyConfig.ignores.add("node_modules/**");
// 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,
});
eleventyConfig.setLibrary("md", md);
// 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);
});
// 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: "mstdn.social",
},
},
});
// 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 <a> tags where href contains youtube.com/watch or youtu.be
// Link text can be: URL, www.youtube..., youtube..., or youtube-related text
const youtubePattern = /<a[^>]+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 `</p><div class="video-embed"><iframe width="560" height="315" src="https://www.youtube.com/embed/${videoId}?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen title="YouTube video"></iframe></div><p>`;
});
// Clean up empty <p></p> tags created by the replacement
content = content.replace(/<p>\s*<\/p>/g, '');
return content;
});
// Image optimization - transforms <img> 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,
},
});
// HTML minification for production builds
eleventyConfig.addTransform("htmlmin", async function (content, outputPath) {
if (outputPath && outputPath.endsWith(".html")) {
return await minify(content, {
collapseWhitespace: true,
removeComments: true,
html5: true,
decodeEntities: true,
minifyCSS: true,
minifyJS: true,
});
}
return content;
});
// Copy static assets to output
eleventyConfig.addPassthroughCopy("css");
eleventyConfig.addPassthroughCopy("images");
eleventyConfig.addPassthroughCopy("js");
eleventyConfig.addPassthroughCopy("favicon.ico");
// 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://webmention.io/api/mentions.jf2?domain=${wmDomain}&token=${process.env.WEBMENTION_IO_TOKEN}`,
key: "children",
});
// Date formatting filter
eleventyConfig.addFilter("dateDisplay", (dateObj) => {
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();
});
// 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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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 <img> tags and extract src attribute
const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i);
if (imgMatch && imgMatch[1]) {
let src = imgMatch[1];
// Skip data URIs and external placeholder images
if (src.startsWith('data:')) return null;
// Return the src (will be made absolute in template)
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, "");
});
// 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);
}
});
// 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
eleventyConfig.addFilter("webmentionsForUrl", function (webmentions, url, urlAliases) {
if (!webmentions || !url) return [];
// 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(/\/$/, ""));
}
}
// Filter webmentions matching any of our URLs
return webmentions.filter((wm) => urlsToCheck.has(wm["wm-target"]));
});
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);
});
// 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")
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("notes", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/notes/**/*.md")
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("articles", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/articles/**/*.md")
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("bookmarks", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/bookmarks/**/*.md")
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("photos", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/photos/**/*.md")
.sort((a, b) => b.date - a.date);
});
eleventyConfig.addCollection("likes", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/likes/**/*.md")
.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) => 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) => 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 => !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")
.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().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();
});
// Recent posts for sidebar
eleventyConfig.addCollection("recentPosts", function (collectionApi) {
return collectionApi
.getFilteredByGlob("content/posts/**/*.md")
.sort((a, b) => b.date - a.date)
.slice(0, 5);
});
return {
dir: {
input: ".",
output: "_site",
includes: "_includes",
data: "_data",
},
markdownTemplateEngine: false, // Disable to avoid Nunjucks interpreting {{ in content
htmlTemplateEngine: "njk",
};
}