const fs = require('fs') const path = require("path"); const { execSync } = require("child_process"); const { DateTime } = require("luxon"); const extractDietTags = (filePath) => { try { const content = fs.readFileSync(filePath, 'utf-8'); const dietTagRegex = /#diet\/(omnivore|vegetarian|plant_based)(?:\/optional)?/g; const matches = content.match(dietTagRegex) || []; return matches.map(tag => { const match = tag.match(/#diet\/(omnivore|vegetarian|plant_based)/); return match ? match[1] : null; }).filter(Boolean); } catch (e) { return []; } } const getPlantBased = (filePath) => { const dietTags = extractDietTags(filePath); if (dietTags.length === 0) { return null; } const hasOmnivore = dietTags.includes('omnivore'); const hasVegetarian = dietTags.includes('vegetarian'); const hasPlantBased = dietTags.includes('plant_based'); if (hasPlantBased) { return true; } // If has omnivore OR vegetarian without plant-based, it's not plant-based if (hasOmnivore || hasVegetarian) { return false; } // No recognized diet tags return null; } const slugify = (text) => { return text .toLowerCase() .replace(/\s+/g, '-') // Replace spaces with hyphens .replace(/[()]/g, '') // Remove parentheses .replace(/[^\w-]+/g, '') // Remove non-word chars except hyphens .replace(/--+/g, '-') // Replace multiple hyphens with single hyphen .replace(/^-+/, '') // Trim hyphens from start .replace(/-+$/, ''); // Trim hyphens from end } 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 slugify(path.basename(secondPart, '.md')); } // Otherwise it's a folder name (recipes/bar/v1.md -> bar) return slugify(secondPart); } return slugify(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 getFileCreatedDateTime = (filePath) => { const gitTime = getGitCreatedTime(filePath); if (gitTime) { return DateTime.fromMillis(gitTime, { zone: 'utc' }); } try { const stats = fs.statSync(filePath); const time = stats.birthtime ?? stats.mtime; return DateTime.fromJSDate(time, { zone: 'utc' }); } catch (e) { return DateTime.utc(); } } 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; }, plantBased: (data) => { return getPlantBased(data.page.inputPath); }, createdAt: (data) => { return getFileCreatedDateTime(data.page.inputPath); }, permalink: (data) => { const slug = getSlugFromPath(data.page.inputPath); const version = getVersion(data.page.inputPath); return `/recipe/${slug}/${version}/`; } } }