const fs = require("fs"); const path = require("path"); const repoRoot = path.resolve(__dirname, "..", ".."); const mealsPath = path.join(repoRoot, "data", "meals.json"); const fullsDir = path.join(repoRoot, "images", "fulls"); const thumbsDir = path.join(repoRoot, "images", "thumbs"); function isNonEmptyString(value) { return typeof value === "string" && value.trim().length > 0; } function validateThumbnail(meal, index) { if (meal.thumbnail === undefined) { return; } if ( !meal.thumbnail || typeof meal.thumbnail !== "object" || Array.isArray(meal.thumbnail) ) { throw new Error(`Meal ${index} has an invalid "thumbnail" object`); } if (meal.thumbnail.focus === undefined) { return; } const { focus } = meal.thumbnail; if (!focus || typeof focus !== "object" || Array.isArray(focus)) { throw new Error(`Meal ${index} has an invalid "thumbnail.focus" object`); } for (const axis of ["x", "y"]) { if (typeof focus[axis] !== "number" || !Number.isFinite(focus[axis])) { throw new Error( `Meal ${index} has a non-numeric thumbnail focus value for "${axis}"` ); } if (focus[axis] < 0 || focus[axis] > 1) { throw new Error( `Meal ${index} thumbnail focus "${axis}" must be between 0 and 1` ); } } } function validateMeals(meals) { if (!Array.isArray(meals)) { throw new Error("data/meals.json must contain an array"); } const ids = new Set(); for (const [index, meal] of meals.entries()) { if (!meal || typeof meal !== "object") { throw new Error(`Meal at index ${index} must be an object`); } for (const field of ["id", "title", "description"]) { if (!isNonEmptyString(meal[field])) { throw new Error(`Meal ${index} is missing required string field "${field}"`); } } if (!/^\d+$/.test(meal.id)) { throw new Error(`Meal ${index} has a non-numeric id "${meal.id}"`); } if (meal.position !== undefined && !isNonEmptyString(meal.position)) { throw new Error(`Meal ${index} has an invalid "position" value`); } if (ids.has(meal.id)) { throw new Error(`Duplicate meal id "${meal.id}" found in data/meals.json`); } ids.add(meal.id); validateThumbnail(meal, index); } } function loadMeals() { const meals = JSON.parse(fs.readFileSync(mealsPath, "utf8")); validateMeals(meals); return meals; } function saveMeals(meals) { validateMeals(meals); fs.writeFileSync(mealsPath, `${JSON.stringify(meals, null, 2)}\n`); } function getMealImagePaths(mealOrId) { const mealId = typeof mealOrId === "string" ? mealOrId : mealOrId && typeof mealOrId.id === "string" ? mealOrId.id : null; if (!mealId) { throw new Error("Expected a meal object or meal id string"); } return { fullPath: path.join(fullsDir, `${mealId}.jpg`), thumbPath: path.join(thumbsDir, `${mealId}.jpg`), }; } function validateMealAssets(meals, options = {}) { const settings = { requireFull: true, requireThumb: true, ...options, }; const missingAssets = []; for (const meal of meals) { const { fullPath, thumbPath } = getMealImagePaths(meal); if (settings.requireFull && !fs.existsSync(fullPath)) { missingAssets.push( `Meal ${meal.id} is missing full-size image: ${path.relative(repoRoot, fullPath)}` ); } if (settings.requireThumb && !fs.existsSync(thumbPath)) { missingAssets.push( `Meal ${meal.id} is missing thumbnail image: ${path.relative(repoRoot, thumbPath)}` ); } } if (missingAssets.length > 0) { throw new Error(`Missing image assets:\n${missingAssets.join("\n")}`); } } function getNextMealId(meals) { if (meals.length === 0) { return "01"; } const nextNumber = Math.max(...meals.map((meal) => Number.parseInt(meal.id, 10))) + 1; const idWidth = Math.max( 2, ...meals.map((meal) => meal.id.length), String(nextNumber).length ); return String(nextNumber).padStart(idWidth, "0"); } module.exports = { fullsDir, getNextMealId, getMealImagePaths, loadMeals, mealsPath, repoRoot, saveMeals, thumbsDir, validateMealAssets, };