From 63bd438e45af1dec7d3cf07ecd720d3085fb1e45 Mon Sep 17 00:00:00 2001 From: Leyla Becker Date: Sun, 22 Feb 2026 16:20:00 -0600 Subject: [PATCH 1/2] refactor: moved plugins to own folders --- eleventy.config.js | 231 +------------------------- lib/cache-busting/plugin.js | 68 ++++++++ lib/details/plugin.js | 17 ++ lib/hashtags/plugin.js | 43 +++++ lib/strip-trailing-hashtags/plugin.js | 41 +++++ lib/tags/plugin.js | 58 +++++++ 6 files changed, 235 insertions(+), 223 deletions(-) create mode 100644 lib/cache-busting/plugin.js create mode 100644 lib/details/plugin.js create mode 100644 lib/hashtags/plugin.js create mode 100644 lib/strip-trailing-hashtags/plugin.js create mode 100644 lib/tags/plugin.js diff --git a/eleventy.config.js b/eleventy.config.js index 2ea01ae..ea1f998 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -1,172 +1,22 @@ 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 markdownItMeasurements = require('./lib/measurements/plugin'); +const markdownItHashtag = require('./lib/hashtags/plugin'); +const markdownItStripTrailingHashtags = require('./lib/strip-trailing-hashtags/plugin'); +const markdownItDetails = require('./lib/details/plugin'); +const { extractTags, expandHierarchicalTags, getPostTags, getRecipeTags } = require('./lib/tags/plugin'); +const { cacheBustingPlugin } = require('./lib/cache-busting/plugin'); 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, @@ -207,6 +57,7 @@ const tagExtractorMd = createMarkdownInstance(); module.exports = (eleventyConfig) => { eleventyConfig.addPlugin(syntaxHighlight); + eleventyConfig.addPlugin(cacheBustingPlugin, { rootDir: __dirname }); eleventyConfig.addFilter("extractTags", (content) => extractTags(content, tagExtractorMd)); eleventyConfig.addFilter("extractTagsFromFile", (filePath) => { @@ -358,25 +209,13 @@ module.exports = (eleventyConfig) => { }, {}); }); - // 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 recipeTags = recipes.flatMap(recipe => getRecipeTags(recipe, tagExtractorMd)); const allTags = expandHierarchicalTags([...postTags, ...recipeTags]); @@ -408,7 +247,7 @@ module.exports = (eleventyConfig) => { }, {}); const tagMap = recipes.reduce((acc, recipe) => { - const rawTags = getRecipeTags(recipe); + const rawTags = getRecipeTags(recipe, tagExtractorMd); const tags = expandHierarchicalTags(rawTags); return tags.reduce((innerAcc, tag) => ({ ...innerAcc, @@ -430,60 +269,6 @@ module.exports = (eleventyConfig) => { }), {}); }); - // 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.addPassthroughCopy("js"); diff --git a/lib/cache-busting/plugin.js b/lib/cache-busting/plugin.js new file mode 100644 index 0000000..e954daa --- /dev/null +++ b/lib/cache-busting/plugin.js @@ -0,0 +1,68 @@ +const fs = require("fs"); +const crypto = require("crypto"); +const path = require("path"); + +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 copyHashedFiles = (sourceDir, outputDir) => { + if (!fs.existsSync(sourceDir)) return; + + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const dirName = path.basename(sourceDir); + const files = fs.readdirSync(sourceDir); + for (const file of files) { + const hash = getFileHash(file, dirName); + const ext = path.extname(file); + const base = path.basename(file, ext); + const hashedName = `${base}${hash == null ? '' : `.${hash}`}${ext}`; + + fs.copyFileSync( + path.join(sourceDir, file), + path.join(outputDir, hashedName) + ); + } +}; + +const cacheBustingPlugin = (eleventyConfig, { rootDir }) => { + 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(rootDir, "css"); + const outputCssDir = path.join(rootDir, "_site", "css"); + copyHashedFiles(cssDir, outputCssDir); + + // Copy assets files with hashes + const assetsDir = path.join(rootDir, "assets"); + const outputAssetsDir = path.join(rootDir, "_site", "assets"); + copyHashedFiles(assetsDir, outputAssetsDir); + }); +}; + +module.exports = { + cacheBustingPlugin, + getFileHash, +}; \ No newline at end of file diff --git a/lib/details/plugin.js b/lib/details/plugin.js new file mode 100644 index 0000000..e268098 --- /dev/null +++ b/lib/details/plugin.js @@ -0,0 +1,17 @@ +const markdownItContainer = require("markdown-it-container"); + +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'; + } + }); +}; + +module.exports = markdownItDetails; \ No newline at end of file diff --git a/lib/hashtags/plugin.js b/lib/hashtags/plugin.js new file mode 100644 index 0000000..19b6a6c --- /dev/null +++ b/lib/hashtags/plugin.js @@ -0,0 +1,43 @@ +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); + +const markdownItHashtag = (md) => { + 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)}`; + }; +}; + +module.exports = markdownItHashtag; \ No newline at end of file diff --git a/lib/strip-trailing-hashtags/plugin.js b/lib/strip-trailing-hashtags/plugin.js new file mode 100644 index 0000000..ff8c98d --- /dev/null +++ b/lib/strip-trailing-hashtags/plugin.js @@ -0,0 +1,41 @@ +// 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; + }); +}; + +module.exports = markdownItStripTrailingHashtags; \ No newline at end of file diff --git a/lib/tags/plugin.js b/lib/tags/plugin.js new file mode 100644 index 0000000..2a5953a --- /dev/null +++ b/lib/tags/plugin.js @@ -0,0 +1,58 @@ +const fs = require("fs"); + +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 getRecipeTags = (recipe, tagMdInstance) => { + const filePath = recipe.inputPath; + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const tags = extractTags(content, tagMdInstance); + return tags.map(tag => tag.toLowerCase()); + } catch (e) { + return []; + } +}; + +module.exports = { + extractTags, + expandHierarchicalTags, + getPostTags, + getRecipeTags, +}; \ No newline at end of file From 2cbd931e99a3699d541b8065447e4678cf2d5a17 Mon Sep 17 00:00:00 2001 From: Leyla Becker Date: Sun, 22 Feb 2026 16:29:46 -0600 Subject: [PATCH 2/2] feat: made default markdown not use the measurements plugin --- _includes/recipe.njk | 2 +- eleventy.config.js | 24 ++++++++---------------- lib/footnotes/plugin.js | 20 ++++++++++++++++++++ recipe.njk | 2 +- recipes/recipes.11tydata.js | 1 + 5 files changed, 31 insertions(+), 18 deletions(-) create mode 100644 lib/footnotes/plugin.js diff --git a/_includes/recipe.njk b/_includes/recipe.njk index 7c80d1e..e4c379f 100644 --- a/_includes/recipe.njk +++ b/_includes/recipe.njk @@ -30,7 +30,7 @@ pageType: recipe
- {{ content | safe }} + {{ content | renderRecipeMarkdown | safe }}
{% set recipeTags = page.inputPath | extractTagsFromFile %} diff --git a/eleventy.config.js b/eleventy.config.js index ea1f998..65f10ce 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -1,5 +1,5 @@ const markdownIt = require("markdown-it"); -const markdownItFootnote = require("markdown-it-footnote"); +const markdownItFootnoteCustom = require('./lib/footnotes/plugin'); const markdownItMermaid = require('markdown-it-mermaid').default const markdownItTaskLists = require('markdown-it-task-lists'); const markdownItMeasurements = require('./lib/measurements/plugin'); @@ -18,12 +18,11 @@ const isReleased = (post) => { } const sharedPlugins = [ - markdownItFootnote, + markdownItFootnoteCustom, markdownItHashtag, markdownItMermaid, [markdownItTaskLists, { enabled: true, label: true, labelAfter: false }], markdownItDetails, - markdownItMeasurements, ]; const applyPlugins = (md, plugins) => @@ -39,19 +38,7 @@ const createMarkdownInstance = (extraPlugins = []) => { }; 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 recipeMd = createMarkdownInstance([markdownItStripTrailingHashtags, markdownItMeasurements]); const tagExtractorMd = createMarkdownInstance(); @@ -69,6 +56,11 @@ module.exports = (eleventyConfig) => { } }); + eleventyConfig.addFilter("renderRecipeMarkdown", (content) => { + if (!content) return ''; + return recipeMd.render(content); + }); + eleventyConfig.setLibrary("md", md); eleventyConfig.addFilter("dateTimeFormat", (date) => { diff --git a/lib/footnotes/plugin.js b/lib/footnotes/plugin.js new file mode 100644 index 0000000..15e078f --- /dev/null +++ b/lib/footnotes/plugin.js @@ -0,0 +1,20 @@ +const markdownItFootnote = require("markdown-it-footnote"); + +// Wraps markdown-it-footnote and customizes rendering to remove brackets +const markdownItFootnoteCustom = (md) => { + md.use(markdownItFootnote); + + 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 + ''; + }; +}; + +module.exports = markdownItFootnoteCustom; \ No newline at end of file diff --git a/recipe.njk b/recipe.njk index 003b165..85d12eb 100644 --- a/recipe.njk +++ b/recipe.njk @@ -29,7 +29,7 @@ eleventyComputed:
- {{ recipe.content | safe }} + {{ recipe.content | renderRecipeMarkdown | safe }}
{% set recipeTags = recipe.inputPath | extractTagsFromFile %} diff --git a/recipes/recipes.11tydata.js b/recipes/recipes.11tydata.js index d0c658d..c82203f 100644 --- a/recipes/recipes.11tydata.js +++ b/recipes/recipes.11tydata.js @@ -218,6 +218,7 @@ const isNewestVersion = (filePath) => { module.exports = { layout: "recipe.njk", tags: ["recipe"], + templateEngineOverride: "njk", eleventyComputed: { recipeSlug: (data) => { return getSlugFromPath(data.page.inputPath);