const markdownIt = require("markdown-it"); const markdownItContainer = require("markdown-it-container"); const markdownItFootnote = require("markdown-it-footnote"); const markdownItMermaid = require('markdown-it-mermaid').default const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight"); const fs = require("fs"); const crypto = require("crypto"); const path = require("path"); const { DateTime } = require("luxon"); const fileHashCache = {}; const getFileHash = (file, dir = "css") => { const cacheKey = `${dir}/${file}`; if (fileHashCache[cacheKey]) return fileHashCache[cacheKey]; const filePath = path.join(__dirname, dir, file); try { const content = fs.readFileSync(filePath, "utf-8"); const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8); fileHashCache[cacheKey] = hash; return hash; } catch (e) { console.warn(`Could not hash file: ${file} in ${dir}`); return null; } }; const extractTags = (content, mdInstance) => { if (!content) return []; const collectHashtags = (tokens) => tokens.flatMap(token => [ ...(token.type === 'hashtag' ? [token.content] : []), ...(token.children ? collectHashtags(token.children) : []) ]); const tokens = mdInstance.parse(content, {}); const tags = collectHashtags(tokens); return [...new Set(tags)]; } const getPostTags = (post, mdInstance) => { const filePath = post.inputPath; try { const content = fs.readFileSync(filePath, 'utf-8'); const tags = extractTags(content, mdInstance); return tags.map(tag => { const normalizedTag = tag.toLowerCase(); return normalizedTag }); } catch (e) { // Skip if file can't be read return [] } } const isReleased = (post) => { return post.data.released !== false; } const markdownItHashtag = (md) => { const hashtagRegex = /^#([a-zA-Z][a-zA-Z0-9_]*)(?![a-zA-Z0-9_-])/; const HASH_CODE = '#'.charCodeAt(0); const SPACE_CODE = ' '.charCodeAt(0); const TAB_CODE = '\t'.charCodeAt(0); const NEWLINE_CODE = '\n'.charCodeAt(0); const CARRIAGE_RETURN_CODE = '\r'.charCodeAt(0); md.inline.ruler.push('hashtag', function(state, silent) { const pos = state.pos; const ch = state.src.charCodeAt(pos); if (ch !== HASH_CODE) return false; if (pos > 0) { const prevCh = state.src.charCodeAt(pos - 1); if (prevCh !== SPACE_CODE && prevCh !== TAB_CODE && prevCh !== NEWLINE_CODE && prevCh !== CARRIAGE_RETURN_CODE) { return false; } } const match = state.src.slice(pos).match(hashtagRegex); if (!match) return false; if (!silent) { const token = state.push('hashtag', 'a', 0); token.content = match[1]; token.markup = '#'; } state.pos += match[0].length; return true; }); md.renderer.rules.hashtag = function(tokens, idx) { const tagName = tokens[idx].content; const slug = tagName.toLowerCase(); return `#${md.utils.escapeHtml(tagName)}`; }; } const md = markdownIt({ html: true, breaks: true, linkify: true }); md.use(markdownItContainer, 'details', { validate: function (params) { return params.trim().match(/^(.*)$/); }, render: function (tokens, idx) { const m = tokens[idx].info.trim().match(/^(.*)$/); if (tokens[idx].nesting === 1) { const title = md.utils.escapeHtml(m[1]); return `\n'; } } }); md.use(markdownItFootnote); md.use(markdownItHashtag); md.use(markdownItMermaid); module.exports = (eleventyConfig) => { eleventyConfig.addPlugin(syntaxHighlight); eleventyConfig.addFilter("extractTags", (content) => extractTags(content, md)); eleventyConfig.addFilter("extractTagsFromFile", (filePath) => { try { const content = fs.readFileSync(filePath, 'utf-8'); return extractTags(content, md); } catch (e) { return []; } }); eleventyConfig.setLibrary("md", md); eleventyConfig.addFilter("dateTimeFormat", (date) => { const dt = date instanceof Date ? DateTime.fromJSDate(date) : DateTime.fromISO(date); return dt.toFormat('MMMM d, yyyy h:mm a'); }); eleventyConfig.addFilter("isoDate", (date) => { const dt = date instanceof Date ? DateTime.fromJSDate(date) : DateTime.fromISO(date); return dt.toISODate(); }); eleventyConfig.addFilter("isMoreThanHourAfter", (date1, date2) => { if (!date1 || !date2) return false; const toDateTime = (d) => { if (DateTime.isDateTime(d)) return d; if (d instanceof Date) return DateTime.fromJSDate(d); return DateTime.fromISO(d); }; const dt1 = toDateTime(date1); const dt2 = toDateTime(date2); const diff = dt1.diff(dt2, 'hours').hours; return Math.abs(diff) > 1; }); eleventyConfig.addCollection("posts", (collectionApi) => { return collectionApi.getFilteredByGlob("posts/**/*.md") .filter(isReleased) .sort((a, b) => { const aDate = a.data.createdAt || a.date; const bDate = b.data.createdAt || b.date; return aDate - bDate; }); }); eleventyConfig.addCollection("allPosts", (collectionApi) => { return collectionApi.getFilteredByGlob("posts/**/*.md").sort((a, b) => { const aDate = a.data.createdAt || a.date; const bDate = b.data.createdAt || b.date; return aDate - bDate; }); }); eleventyConfig.addCollection("postsBySlug", (collectionApi) => { const posts = collectionApi.getFilteredByGlob("posts/**/*.md").filter(isReleased); const slugify = eleventyConfig.getFilter("slugify"); const grouped = {}; posts.forEach(post => { const slug = slugify(post.data.title); if (!grouped[slug]) { grouped[slug] = []; } grouped[slug].push(post); }); Object.values(grouped).forEach(postList => { postList.sort((a, b) => { const aDate = a.data.createdAt || a.date; const bDate = b.data.createdAt || b.date; return aDate - bDate; }); }); return grouped; }); eleventyConfig.addCollection("postsByTag", (collectionApi) => { const posts = collectionApi.getFilteredByGlob("posts/**/*.md").filter(isReleased); const tagMap = {}; posts.forEach(post => { const tags = getPostTags(post, md) tags.forEach((tag) => { tagMap[tag] = { name: tag, posts: [post, ...(tagMap[tag]?.posts ?? [])], recipes: tagMap[tag]?.recipes ?? [], } }) }); Object.values(tagMap).forEach(tagData => { tagData.posts.sort((a, b) => { const aDate = a.data.createdAt || a.date; const bDate = b.data.createdAt || b.date; return aDate - bDate; }); }); return tagMap; }); // Recipe collections eleventyConfig.addCollection("recipes", (collectionApi) => { return collectionApi.getFilteredByGlob("recipes/**/*.md") .filter(recipe => recipe.data.draft !== true); }); eleventyConfig.addCollection("allRecipes", (collectionApi) => { return collectionApi.getFilteredByGlob("recipes/**/*.md"); }); eleventyConfig.addCollection("recipesBySlug", (collectionApi) => { const recipes = collectionApi.getFilteredByGlob("recipes/**/*.md"); // Group recipes by slug using reduce const grouped = recipes.reduce((acc, recipe) => { const slug = recipe.data.recipeSlug; return { ...acc, [slug]: [...(acc[slug] || []), recipe], }; }, {}); // Transform grouped recipes into final structure with sorted versions and newest non-draft return Object.entries(grouped).reduce((acc, [slug, recipeList]) => { const versions = [...recipeList].sort((a, b) => a.data.recipeVersion - b.data.recipeVersion); const newest = [...versions].reverse().find(r => r.data.draft !== true) || null; return { ...acc, [slug]: { versions, newest }, }; }, {}); }); // Get tags from recipes (only from newest non-draft versions) const getRecipeTags = (recipe) => { const filePath = recipe.inputPath; try { const content = fs.readFileSync(filePath, 'utf-8'); const tags = extractTags(content, md); return tags.map(tag => tag.toLowerCase()); } catch (e) { return []; } }; eleventyConfig.addCollection("contentTags", (collectionApi) => { const posts = collectionApi.getFilteredByGlob("posts/**/*.md").filter(isReleased); const recipes = collectionApi.getFilteredByGlob("recipes/**/*.md") .filter(r => r.data.isNewestVersion && r.data.draft !== true); const postTags = posts.flatMap(post => getPostTags(post, md)); const recipeTags = recipes.flatMap(recipe => getRecipeTags(recipe)); return [...new Set([...postTags, ...recipeTags])].sort(); }); eleventyConfig.addCollection("contentByTag", (collectionApi) => { const posts = collectionApi.getFilteredByGlob("posts/**/*.md").filter(isReleased); const recipes = collectionApi.getFilteredByGlob("recipes/**/*.md") .filter(r => r.data.isNewestVersion && r.data.draft !== true); const sortByDate = (a, b) => { const aDate = a.data.createdAt || a.date; const bDate = b.data.createdAt || b.date; return aDate - bDate; }; // Build tag map from posts const postTagMap = posts.reduce((acc, post) => { const tags = getPostTags(post, md); return tags.reduce((innerAcc, tag) => ({ ...innerAcc, [tag]: { name: tag, posts: [...(innerAcc[tag]?.posts || []), post], recipes: innerAcc[tag]?.recipes || [], }, }), acc); }, {}); // Merge recipe tags into the tag map const tagMap = recipes.reduce((acc, recipe) => { const tags = getRecipeTags(recipe); return tags.reduce((innerAcc, tag) => ({ ...innerAcc, [tag]: { name: tag, posts: innerAcc[tag]?.posts || [], recipes: [...(innerAcc[tag]?.recipes || []), recipe], }, }), acc); }, postTagMap); // Return with sorted posts return Object.entries(tagMap).reduce((acc, [tag, tagData]) => ({ ...acc, [tag]: { ...tagData, posts: [...tagData.posts].sort(sortByDate), }, }), {}); }); // Cache busting filter: returns hashed filename eleventyConfig.addFilter("fileHash", (file, dir = "css") => { const hash = getFileHash(file, dir); const ext = path.extname(file); const base = path.basename(file, ext); return `/${dir}/${base}.${hash}${ext}`; }); eleventyConfig.on("eleventy.before", async () => { // Copy CSS files with hashes const cssDir = path.join(__dirname, "css"); const outputCssDir = path.join(__dirname, "_site", "css"); if (!fs.existsSync(outputCssDir)) { fs.mkdirSync(outputCssDir, { recursive: true }); } const cssFiles = fs.readdirSync(cssDir).filter(f => f.endsWith(".css")); for (const cssFile of cssFiles) { const hash = getFileHash(cssFile, "css"); const ext = path.extname(cssFile); const base = path.basename(cssFile, ext); const hashedName = `${base}${hash == null ? '' : `.${hash}`}${ext}`; fs.copyFileSync( path.join(cssDir, cssFile), path.join(outputCssDir, hashedName) ); } // Copy assets files with hashes const assetsDir = path.join(__dirname, "assets"); const outputAssetsDir = path.join(__dirname, "_site", "assets"); if (fs.existsSync(assetsDir)) { if (!fs.existsSync(outputAssetsDir)) { fs.mkdirSync(outputAssetsDir, { recursive: true }); } const assetFiles = fs.readdirSync(assetsDir); for (const assetFile of assetFiles) { const hash = getFileHash(assetFile, "assets"); const ext = path.extname(assetFile); const base = path.basename(assetFile, ext); const hashedName = `${base}${hash == null ? '' : `.${hash}`}${ext}`; fs.copyFileSync( path.join(assetsDir, assetFile), path.join(outputAssetsDir, hashedName) ); } } }); eleventyConfig.addPassthroughCopy("robots.txt"); eleventyConfig.ignores.add("README.md"); return { dir: { includes: "_includes", output: "_site" }, markdownTemplateEngine: "njk", htmlTemplateEngine: "njk" }; };