volpe/eleventy.config.js

498 lines
No EOL
16 KiB
JavaScript

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 `<a href="/tags/${slug}/" class="inline-tag">#${md.utils.escapeHtml(tagName)}</a>`;
};
};
// 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 `<details class="expandable">\\n<summary>${title}</summary>\\n`;
}
return '</details>\\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 '<sup class="footnote-ref"><a href="#fn' + id + '" id="fnref' + refid + '">' + n + '</a></sup>';
};
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"
};
};