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 markdownItTaskLists = require('markdown-it-task-lists'); const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight"); const fs = require("fs"); const crypto = require("crypto"); const path = require("path"); const { DateTime } = require("luxon"); const siteConfig = require("./_data/site.js"); 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 expandHierarchicalTags = (tags) => { const expanded = new Set(); tags.forEach(tag => { expanded.add(tag); const parts = tag.split('/'); for (let i = 1; i < parts.length; i++) { const parentTag = parts.slice(0, i).join('/'); expanded.add(parentTag); } }); return [...expanded]; }; const getPostTags = (post, tagMdInstance) => { const filePath = post.inputPath; try { const content = fs.readFileSync(filePath, 'utf-8'); const tags = extractTags(content, tagMdInstance); return tags.map(tag => tag.toLowerCase()); } 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)}`; }; }; // Plugin: Strip trailing hashtag-only paragraphs from rendered output // Must be applied AFTER footnote plugin since footnotes are moved to the end const markdownItStripTrailingHashtags = (md) => { md.core.ruler.push('strip_trailing_hashtags', function(state) { const tokens = state.tokens; const isHashtagOnlyParagraph = (inlineToken) => inlineToken?.type === 'inline' && inlineToken.children?.every(child => child.type === 'hashtag' || (child.type === 'text' && child.content.trim() === '') || child.type === 'softbreak' ) && inlineToken.children?.some(child => child.type === 'hashtag'); const isHashtagParagraphAt = (idx) => tokens[idx]?.type === 'paragraph_open' && tokens[idx + 1]?.type === 'inline' && tokens[idx + 2]?.type === 'paragraph_close' && isHashtagOnlyParagraph(tokens[idx + 1]); const footnoteIdx = tokens.findIndex(t => t.type === 'footnote_block_open'); const footnoteSectionStart = footnoteIdx === -1 ? tokens.length : footnoteIdx; const hashtagSectionStart = Array.from( { length: Math.floor(footnoteSectionStart / 3) }, (_, i) => footnoteSectionStart - 3 * (i + 1) ).reduce( (start, idx) => isHashtagParagraphAt(idx) ? idx : start, footnoteSectionStart ); state.tokens = tokens.filter((_, idx) => idx < hashtagSectionStart || idx >= footnoteSectionStart ); return true; }); }; const markdownItDetails = (md) => { md.use(markdownItContainer, 'details', { validate: (params) => params.trim().match(/^(.*)$/), render: (tokens, idx) => { const m = tokens[idx].info.trim().match(/^(.*)$/); if (tokens[idx].nesting === 1) { const title = md.utils.escapeHtml(m[1]); return `\\n'; } }); }; const sharedPlugins = [ markdownItFootnote, markdownItHashtag, markdownItMermaid, [markdownItTaskLists, { enabled: true, label: true, labelAfter: false }], markdownItDetails, ]; const applyPlugins = (md, plugins) => plugins.reduce((instance, plugin) => { const [pluginFn, options] = Array.isArray(plugin) ? plugin : [plugin]; return instance.use(pluginFn, options), instance; }, md); const createMarkdownInstance = (extraPlugins = []) => { const md = markdownIt({ html: true, breaks: true, linkify: true }); applyPlugins(md, [...sharedPlugins, ...extraPlugins]); return md; }; const md = createMarkdownInstance([markdownItStripTrailingHashtags]); // Customize footnote rendering to remove brackets md.renderer.rules.footnote_ref = (tokens, idx, options, env, slf) => { const id = slf.rules.footnote_anchor_name(tokens, idx, options, env, slf); const n = Number(tokens[idx].meta.id + 1).toString(); let refid = id; if (tokens[idx].meta.subId > 0) { refid += ':' + tokens[idx].meta.subId; } return '' + n + ''; }; const tagExtractorMd = createMarkdownInstance(); module.exports = (eleventyConfig) => { eleventyConfig.addPlugin(syntaxHighlight); eleventyConfig.addFilter("extractTags", (content) => extractTags(content, tagExtractorMd)); eleventyConfig.addFilter("extractTagsFromFile", (filePath) => { try { const content = fs.readFileSync(filePath, 'utf-8'); return extractTags(content, tagExtractorMd); } catch (e) { return []; } }); eleventyConfig.setLibrary("md", md); eleventyConfig.addFilter("dateTimeFormat", (date) => { const dt = date instanceof Date ? DateTime.fromJSDate(date, { zone: 'utc' }) : DateTime.fromISO(date, { zone: 'utc' }); // Convert to site timezone for display const displayDt = dt.setZone(siteConfig.timezone); return displayDt.toFormat('MMMM d, yyyy h:mm a ZZZZ'); }); eleventyConfig.addFilter("isoDateTime", (date) => { const dt = date instanceof Date ? DateTime.fromJSDate(date, { zone: 'utc' }) : DateTime.fromISO(date, { zone: 'utc' }); return dt.toISO(); }); eleventyConfig.addFilter("isoDate", (date) => { const dt = date instanceof Date ? DateTime.fromJSDate(date, { zone: 'utc' }) : DateTime.fromISO(date, { zone: 'utc' }); 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, { zone: 'utc' }); return DateTime.fromISO(d, { zone: 'utc' }); }; 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 rawTags = getPostTags(post, tagExtractorMd); const tags = expandHierarchicalTags(rawTags); tags.forEach((tag) => { tagMap[tag] = { name: tag, posts: [post, ...(tagMap[tag]?.posts ?? [])], recipes: tagMap[tag]?.recipes ?? [], } }) }); Object.values(tagMap).forEach(tagData => { tagData.posts = [...new Set(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, tagExtractorMd); 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, tagExtractorMd)); const recipeTags = recipes.flatMap(recipe => getRecipeTags(recipe)); const allTags = expandHierarchicalTags([...postTags, ...recipeTags]); return [...new Set(allTags)].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; }; const postTagMap = posts.reduce((acc, post) => { const rawTags = getPostTags(post, tagExtractorMd); const tags = expandHierarchicalTags(rawTags); return tags.reduce((innerAcc, tag) => ({ ...innerAcc, [tag]: { name: tag, posts: [...(innerAcc[tag]?.posts || []), post], recipes: innerAcc[tag]?.recipes || [], }, }), acc); }, {}); const tagMap = recipes.reduce((acc, recipe) => { const rawTags = getRecipeTags(recipe); const tags = expandHierarchicalTags(rawTags); return tags.reduce((innerAcc, tag) => ({ ...innerAcc, [tag]: { name: tag, posts: innerAcc[tag]?.posts || [], recipes: [...(innerAcc[tag]?.recipes || []), recipe], }, }), acc); }, postTagMap); return Object.entries(tagMap).reduce((acc, [tag, tagData]) => ({ ...acc, [tag]: { ...tagData, posts: [...new Set(tagData.posts)].sort(sortByDate), recipes: [...new Set(tagData.recipes)], }, }), {}); }); // 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.addPassthroughCopy("simulations"); eleventyConfig.ignores.add("README.md"); return { dir: { includes: "_includes", output: "_site" }, markdownTemplateEngine: "njk", htmlTemplateEngine: "njk" }; };