feat: created recipes structure

This commit is contained in:
Leyla Becker 2026-02-12 14:57:57 -06:00
parent a035c08249
commit f93207b4e3
7 changed files with 432 additions and 13 deletions

55
_includes/recipe.njk Normal file
View file

@ -0,0 +1,55 @@
---
layout: base.njk
---
<article class="recipe">
<header class="recipe-header">
<h1>{{ title }}</h1>
{% if isDraft %}
<span class="draft-badge">Draft</span>
{% endif %}
<p class="recipe-version">Version {{ recipeVersion }}</p>
</header>
<div class="recipe-content">
{{ content | safe }}
</div>
{% set recipeTags = page.inputPath | extractTagsFromFile %}
{% if recipeTags.length > 0 %}
<section class="recipe-tags">
<h2>Tags</h2>
<ul class="tag-list">
{% for tag in recipeTags %}
<li><a href="/tags/{{ tag | lower }}/">#{{ tag }}</a></li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if not isDraft and isNewestVersion %}
<p class="recipe-permalink">
Permalink: <a href="/recipe/{{ recipeSlug }}/">/recipe/{{ recipeSlug }}/</a>
</p>
{% 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 %}
<aside class="recipe-other-versions">
<p>Other versions:
{% for version in nonDraftVersions %}
{% if version.url != page.url %}
<a href="{{ version.url }}">v{{ version.data.recipeVersion }}</a>{% if not loop.last %}, {% endif %}
{% endif %}
{% endfor %}
</p>
</aside>
{% endif %}
</article>

View file

@ -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"
},

View file

@ -8,6 +8,7 @@ description: Welcome to my website! I write about tech, politics, food, and hobb
<p>{{ description }}</p>
</section>
{% if collections.posts.length > 0 %}
<h1>Blog Posts</h1>
<ul class="post-list">
@ -23,7 +24,30 @@ description: Welcome to my website! I write about tech, politics, food, and hobb
</li>
{% endfor %}
</ul>
{% if collections.posts.length == 0 %}
<p>No blog posts yet.</p>
{% endif %}
{% set hasRecipes = false %}
{% for slug, recipeData in collections.recipesBySlug %}
{% if recipeData.newest %}
{% set hasRecipes = true %}
{% endif %}
{% endfor %}
{% if hasRecipes %}
<h1>Recipes</h1>
<ul class="post-list">
{% for slug, recipeData in collections.recipesBySlug %}
{% if recipeData.newest %}
<li>
<a href="/recipe/{{ slug }}/" class="post-card">
<h2>{{ recipeData.newest.data.title }}</h2>
{% if recipeData.newest.data.description %}
<p>{{ recipeData.newest.data.description }}</p>
{% endif %}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}

View file

@ -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;

61
recipe.njk Normal file
View file

@ -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 %}
<article class="recipe">
<header class="recipe-header">
<h1>{{ recipe.data.title }}</h1>
</header>
<div class="recipe-content">
{{ recipe.content | safe }}
</div>
{% set recipeTags = recipe.inputPath | extractTagsFromFile %}
{% if recipeTags.length > 0 %}
<section class="recipe-tags">
<h2>Tags</h2>
<ul class="tag-list">
{% for tag in recipeTags %}
<li><a href="/tags/{{ tag | lower }}/">#{{ tag }}</a></li>
{% endfor %}
</ul>
</section>
{% 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 %}
<aside class="recipe-other-versions">
<p>Other versions:
{% for version in nonDraftVersions %}
{% if not version.data.isNewestVersion %}
<a href="{{ version.url }}">v{{ version.data.recipeVersion }}</a>{% if not loop.last %}, {% endif %}
{% endif %}
{% endfor %}
</p>
</aside>
{% endif %}
</article>
{% else %}
<p>No published recipe found for this slug.</p>
{% endif %}

154
recipes/recipes.11tydata.js Normal file
View file

@ -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}/`;
}
}
}

View file

@ -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
---
<h1>Posts tagged "#{{ collections.postsByTag[tag].name }}"</h1>
<h1>Content tagged "#{{ collections.contentByTag[tag].name }}"</h1>
{% set tagData = collections.contentByTag[tag] %}
{% if tagData.posts.length > 0 %}
<h2>Posts</h2>
<ul class="post-list">
{% for post in collections.postsByTag[tag].posts %}
{% for post in tagData.posts %}
<li>
<a href="{{ post.url }}" class="post-card">
<h2>{{ post.data.title }}</h2>
@ -23,5 +27,22 @@ layout: base.njk
</li>
{% endfor %}
</ul>
{% endif %}
<p><a href="/tags/"><3E> All tags</a></p>
{% if tagData.recipes.length > 0 %}
<h2>Recipes</h2>
<ul class="post-list">
{% for recipe in tagData.recipes %}
<li>
<a href="/recipe/{{ recipe.data.recipeSlug }}/" class="post-card">
<h2>{{ recipe.data.title }}</h2>
{% if recipe.data.description %}
<p>{{ recipe.data.description }}</p>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<p><a href="/tags/">← All tags</a></p>