volpe/recipes/recipes.11tydata.js

265 lines
No EOL
7.3 KiB
JavaScript

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()
.normalize('NFD') // Decompose accented characters
.replace(/[\u0300-\u036f]/g, '') // Remove combining diacritical marks
.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 getGitModifiedTime = (filePath) => {
try {
const result = execSync(
`git log -1 --format=%aI -- "${filePath}"`,
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
).trim();
if (result) {
return DateTime.fromISO(result).toUTC();
}
} catch (e) {
// Git command failed, return null
}
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 getFileModifiedTime = (filePath) => {
try {
const stats = fs.statSync(filePath);
return DateTime.fromJSDate(stats.mtime, { zone: 'utc' });
} catch (e) {
return null;
}
}
const parseDate = (value) => {
if (!value) return null;
if (DateTime.isDateTime(value)) return value;
if (value instanceof Date) return DateTime.fromJSDate(value, { zone: 'utc' });
const parsed = DateTime.fromISO(value, { zone: 'utc' });
return parsed.isValid ? parsed : null;
}
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"],
templateEngineOverride: "njk",
eleventyComputed: {
recipeSlug: (data) => {
return getSlugFromPath(data.page.inputPath);
},
title: (data) => {
if (data.title) {
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);
},
updatedAt: (data) => {
return parseDate(data.updatedAt) ??
getGitModifiedTime(data.page.inputPath) ??
getFileModifiedTime(data.page.inputPath);
},
permalink: (data) => {
const slug = getSlugFromPath(data.page.inputPath);
const version = getVersion(data.page.inputPath);
return `/recipe/${slug}/${version}/`;
}
}
}