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