diff --git a/_includes/recipe.njk b/_includes/recipe.njk
new file mode 100644
index 0000000..aab5197
--- /dev/null
+++ b/_includes/recipe.njk
@@ -0,0 +1,55 @@
+---
+layout: base.njk
+---
+
+
+
+
+ {{ content | safe }}
+
+
+ {% set recipeTags = page.inputPath | extractTagsFromFile %}
+
+ {% if recipeTags.length > 0 %}
+
+ {% endif %}
+
+ {% if not isDraft and isNewestVersion %}
+
+ Permalink: /recipe/{{ recipeSlug }}/
+
+ {% endif %}
+
+ {% set recipeData = collections.recipesBySlug[recipeSlug] %}
+ {% set nonDraftVersions = [] %}
+ {% for version in recipeData.versions %}
+ {% if not version.data.isDraft %}
+ {% set nonDraftVersions = (nonDraftVersions.push(version), nonDraftVersions) %}
+ {% endif %}
+ {% endfor %}
+
+ {% if nonDraftVersions.length > 1 %}
+
+ {% endif %}
+
\ No newline at end of file
diff --git a/eleventy.config.js b/eleventy.config.js
index cc45552..d24ef5c 100644
--- a/eleventy.config.js
+++ b/eleventy.config.js
@@ -208,12 +208,6 @@ module.exports = (eleventyConfig) => {
return grouped;
});
- eleventyConfig.addCollection("contentTags", (collectionApi) => {
- const posts = collectionApi.getFilteredByGlob("posts/**/*.md").filter(isReleased);
-
- return [...new Set(posts.flatMap(post => getPostTags(post, md)))].sort();
- });
-
eleventyConfig.addCollection("postsByTag", (collectionApi) => {
const posts = collectionApi.getFilteredByGlob("posts/**/*.md").filter(isReleased);
const tagMap = {};
@@ -224,6 +218,7 @@ module.exports = (eleventyConfig) => {
tagMap[tag] = {
name: tag,
posts: [post, ...(tagMap[tag]?.posts ?? [])],
+ recipes: tagMap[tag]?.recipes ?? [],
}
})
});
@@ -239,6 +234,109 @@ module.exports = (eleventyConfig) => {
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);
@@ -299,7 +397,6 @@ module.exports = (eleventyConfig) => {
return {
dir: {
- input: ".",
includes: "_includes",
output: "_site"
},
diff --git a/index.njk b/index.njk
index bc952c7..40a2ea4 100644
--- a/index.njk
+++ b/index.njk
@@ -8,6 +8,7 @@ description: Welcome to my website! I write about tech, politics, food, and hobb
{{ description }}
+{% if collections.posts.length > 0 %}
Blog Posts
@@ -23,7 +24,30 @@ description: Welcome to my website! I write about tech, politics, food, and hobb
{% endfor %}
+{% endif %}
-{% if collections.posts.length == 0 %}
-No blog posts yet.
+{% set hasRecipes = false %}
+{% for slug, recipeData in collections.recipesBySlug %}
+ {% if recipeData.newest %}
+ {% set hasRecipes = true %}
+ {% endif %}
+{% endfor %}
+
+{% if hasRecipes %}
+Recipes
+
+
{% endif %}
\ No newline at end of file
diff --git a/posts/posts.11tydata.js b/posts/posts.11tydata.js
index fcec690..4d8d3bd 100644
--- a/posts/posts.11tydata.js
+++ b/posts/posts.11tydata.js
@@ -42,6 +42,13 @@ const getTitleFromFilename = (filePath) => {
}
const getFileCreatedTime = (filePath) => {
+ // Try git first for accurate cross-system creation time
+ const gitTime = getGitCreatedTime(filePath);
+ if (gitTime) {
+ return gitTime;
+ }
+
+ // Fall back to filesystem for untracked files
try {
const stats = fs.statSync(filePath);
const time = stats.birthtime ?? stats.mtime;
diff --git a/recipe.njk b/recipe.njk
new file mode 100644
index 0000000..93ce1a2
--- /dev/null
+++ b/recipe.njk
@@ -0,0 +1,61 @@
+---
+pagination:
+ data: collections.recipesBySlug
+ size: 1
+ alias: slugData
+ resolve: keys
+permalink: /recipe/{{ slugData }}/
+layout: base.njk
+excludeFromSitemap: true
+eleventyComputed:
+ title: "{{ collections.recipesBySlug[slugData].newest.data.title }}"
+---
+
+{% set recipeData = collections.recipesBySlug[slugData] %}
+{% set recipe = recipeData.newest %}
+
+{% if recipe %}
+
+
+
+
+ {{ recipe.content | safe }}
+
+
+ {% set recipeTags = recipe.inputPath | extractTagsFromFile %}
+
+ {% if recipeTags.length > 0 %}
+
+ {% endif %}
+
+ {% set nonDraftVersions = [] %}
+ {% for version in recipeData.versions %}
+ {% if not version.data.isDraft %}
+ {% set nonDraftVersions = (nonDraftVersions.push(version), nonDraftVersions) %}
+ {% endif %}
+ {% endfor %}
+
+ {% if nonDraftVersions.length > 1 %}
+
+ {% endif %}
+
+{% else %}
+No published recipe found for this slug.
+{% endif %}
\ No newline at end of file
diff --git a/recipes/recipes.11tydata.js b/recipes/recipes.11tydata.js
new file mode 100644
index 0000000..9c6a9d2
--- /dev/null
+++ b/recipes/recipes.11tydata.js
@@ -0,0 +1,154 @@
+const fs = require('fs')
+const path = require("path");
+const { execSync } = require("child_process");
+
+const getSlugFromPath = (filePath) => {
+ // Normalize the path - remove leading ./ if present
+ const normalizedPath = filePath.startsWith('./') ? filePath.slice(2) : filePath;
+
+ // For top-level files: recipes/foo.md -> slug is "foo"
+ // For nested folders: recipes/bar/v1.md -> slug is "bar"
+ const parts = normalizedPath.split(path.sep);
+
+ // parts[0] should be 'recipes', parts[1] is the key part
+ if (parts.length >= 2 && parts[0] === 'recipes') {
+ const secondPart = parts[1];
+ // If it's a .md file at the top level (recipes/foo.md), strip the extension
+ if (secondPart.endsWith('.md')) {
+ return path.basename(secondPart, '.md');
+ }
+ // Otherwise it's a folder name (recipes/bar/v1.md -> bar)
+ return secondPart;
+ }
+ return path.basename(filePath, '.md');
+}
+
+const getTitleFromSlug = (slug) => {
+ return slug
+ .split('-')
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ');
+}
+
+const getGitCreatedTime = (filePath) => {
+ try {
+ const result = execSync(
+ `git log --diff-filter=A --follow --format=%aI -- "${filePath}" | tail -1`,
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
+ ).trim();
+ if (result) {
+ return new Date(result).getTime();
+ }
+ } catch (e) {
+ // Git command failed, fall through to filesystem
+ }
+ return null;
+}
+
+const getFileCreatedTime = (filePath) => {
+ // Try git first for accurate cross-system creation time
+ const gitTime = getGitCreatedTime(filePath);
+ if (gitTime) {
+ return gitTime;
+ }
+
+ // Fall back to filesystem for untracked files
+ try {
+ const stats = fs.statSync(filePath);
+ const time = stats.birthtime ?? stats.mtime;
+ return time.getTime();
+ } catch (e) {
+ return Date.now();
+ }
+}
+
+const getVersion = (filePath) => {
+ const dirName = path.dirname(filePath)
+
+ // Top-level files (directly in recipes/) always have version 0
+ if (dirName === 'recipes' || dirName === './recipes') {
+ return 0
+ }
+
+ const files = fs.readdirSync(dirName).filter(f => f.endsWith('.md'))
+
+ const filesWithDates = files
+ .map((file) => {
+ const fullPath = path.join(dirName, file);
+ return {
+ file: fullPath,
+ createdAt: getFileCreatedTime(fullPath),
+ }
+ })
+ .sort((a, b) => a.createdAt - b.createdAt) // oldest first (version 0)
+
+ const normalizedFilePath = filePath.startsWith('./') ? filePath.slice(2) : filePath;
+ const version = filesWithDates.findIndex(({ file }) => {
+ const normalizedFile = file.startsWith('./') ? file.slice(2) : file;
+ return normalizedFile === normalizedFilePath;
+ });
+
+ return version === -1 ? 0 : version;
+}
+
+const isNewestVersion = (filePath) => {
+ const dirName = path.dirname(filePath)
+
+ // Top-level files are always the "newest" (only version)
+ if (dirName === 'recipes' || dirName === './recipes') {
+ return true
+ }
+
+ const files = fs.readdirSync(dirName).filter(f => f.endsWith('.md'))
+
+ const filesWithDates = files
+ .map((file) => {
+ const fullPath = path.join(dirName, file);
+ return {
+ file: fullPath,
+ createdAt: getFileCreatedTime(fullPath),
+ }
+ })
+ .sort((a, b) => b.createdAt - a.createdAt) // newest first
+
+ const normalizedFilePath = filePath.startsWith('./') ? filePath.slice(2) : filePath;
+ const newestFile = filesWithDates[0]?.file;
+ const normalizedNewest = newestFile?.startsWith('./') ? newestFile.slice(2) : newestFile;
+
+ return normalizedFilePath === normalizedNewest;
+}
+
+module.exports = {
+ layout: "recipe.njk",
+ tags: ["recipe"],
+ eleventyComputed: {
+ recipeSlug: (data) => {
+ return getSlugFromPath(data.page.inputPath);
+ },
+ title: (data) => {
+ if (data.title && data.title !== data.page.fileSlug) {
+ return data.title;
+ }
+ const slug = getSlugFromPath(data.page.inputPath);
+ return getTitleFromSlug(slug);
+ },
+ recipeVersion: (data) => {
+ return getVersion(data.page.inputPath);
+ },
+ isNewestVersion: (data) => {
+ // Draft recipes are never considered "newest" for redirect purposes
+ if (data.draft === true) {
+ return false;
+ }
+ return isNewestVersion(data.page.inputPath);
+ },
+ isDraft: (data) => {
+ return data.draft === true;
+ },
+ permalink: (data) => {
+ const slug = getSlugFromPath(data.page.inputPath);
+ const version = getVersion(data.page.inputPath);
+ return `/recipe/${slug}/${version}/`;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tags.njk b/tags.njk
index e0a18b4..fad82f2 100644
--- a/tags.njk
+++ b/tags.njk
@@ -1,6 +1,6 @@
---
pagination:
- data: collections.postsByTag
+ data: collections.contentByTag
size: 1
alias: tag
addAllPagesToCollections: true
@@ -8,10 +8,14 @@ permalink: /tags/{{ tag }}/
layout: base.njk
---
-Posts tagged "#{{ collections.postsByTag[tag].name }}"
+Content tagged "#{{ collections.contentByTag[tag].name }}"
+{% set tagData = collections.contentByTag[tag] %}
+
+{% if tagData.posts.length > 0 %}
+Posts
- {% for post in collections.postsByTag[tag].posts %}
+ {% for post in tagData.posts %}
-
{{ post.data.title }}
@@ -23,5 +27,22 @@ layout: base.njk
{% endfor %}
+{% endif %}
-� All tags
\ No newline at end of file
+{% if tagData.recipes.length > 0 %}
+Recipes
+
+{% endif %}
+
+← All tags
\ No newline at end of file