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 +--- +
+
+

{{ title }}

+ {% if isDraft %} + Draft + {% endif %} +

Version {{ recipeVersion }}

+
+ +
+ {{ content | safe }} +
+ + {% set recipeTags = page.inputPath | extractTagsFromFile %} + + {% if recipeTags.length > 0 %} +
+

Tags

+ +
+ {% endif %} + + {% if not isDraft and isNewestVersion %} + + {% 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

+{% 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.data.title }}

+
+ +
+ {{ recipe.content | safe }} +
+ + {% set recipeTags = recipe.inputPath | extractTagsFromFile %} + + {% if recipeTags.length > 0 %} +
+

Tags

+ +
+ {% 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

+{% endif %} -

� All tags

\ No newline at end of file +{% if tagData.recipes.length > 0 %} +

Recipes

+ +{% endif %} + +

← All tags

\ No newline at end of file