263 lines
No EOL
7.2 KiB
JavaScript
263 lines
No EOL
7.2 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()
|
|
.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 && 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);
|
|
},
|
|
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}/`;
|
|
}
|
|
}
|
|
} |