add: harden thumbnail generation and validate image assets
All checks were successful
Deploy on push / deploy (push) Has been skipped
All checks were successful
Deploy on push / deploy (push) Has been skipped
This commit is contained in:
@@ -34,6 +34,12 @@ If you only need to regenerate thumbnails, run:
|
|||||||
npm run build:thumbs
|
npm run build:thumbs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To force a full thumbnail rebuild, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build:thumbs:force
|
||||||
|
```
|
||||||
|
|
||||||
## Image Conventions
|
## Image Conventions
|
||||||
|
|
||||||
- Full-size images and thumbnails share the same numeric ID
|
- Full-size images and thumbnails share the same numeric ID
|
||||||
@@ -45,6 +51,7 @@ npm run build:thumbs
|
|||||||
## Thumbnail Focus
|
## Thumbnail Focus
|
||||||
|
|
||||||
Thumbnails are generated from `images/fulls` with `sharp` at `240x320`.
|
Thumbnails are generated from `images/fulls` with `sharp` at `240x320`.
|
||||||
|
The generator auto-rotates images using EXIF orientation, skips unchanged files by default, and removes stale thumbnail `.jpg` files that no longer map to a meal entry.
|
||||||
|
|
||||||
For images that should crop away from the center, add optional thumbnail focus metadata to the meal entry:
|
For images that should crop away from the center, add optional thumbnail focus metadata to the meal entry:
|
||||||
|
|
||||||
|
|||||||
308
images/thumbs/.thumbs-manifest.json
Normal file
308
images/thumbs/.thumbs-manifest.json
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
{
|
||||||
|
"meal:01": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500129000,
|
||||||
|
"size": 1052830,
|
||||||
|
"focus": {
|
||||||
|
"x": 0.35,
|
||||||
|
"y": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meal:02": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 835360,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:03": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1034158,
|
||||||
|
"focus": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.35
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meal:04": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1090215,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:05": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1122236,
|
||||||
|
"focus": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.35
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meal:06": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770444049000,
|
||||||
|
"size": 676787,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:07": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 872024,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:08": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 618276,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:09": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500127000,
|
||||||
|
"size": 1349804,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:10": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1071870,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:11": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 764329,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:12": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1172905,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:13": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500129000,
|
||||||
|
"size": 1099540,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:14": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1052362,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:15": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1227608,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:16": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500127000,
|
||||||
|
"size": 840466,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:17": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1136990,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:18": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500127000,
|
||||||
|
"size": 1261294,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:19": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1119498,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:20": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 868085,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:21": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1057896,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:22": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1088795,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:23": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500129000,
|
||||||
|
"size": 852307,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:24": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500129000,
|
||||||
|
"size": 1149955,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:25": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500129000,
|
||||||
|
"size": 1242099,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:26": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1414024,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:27": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1022877,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:28": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500129000,
|
||||||
|
"size": 1018868,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:29": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500128000,
|
||||||
|
"size": 1233602,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:30": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1770500129000,
|
||||||
|
"size": 739786,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:31": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1771126436000,
|
||||||
|
"size": 1069693,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:32": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1771226059000,
|
||||||
|
"size": 995282,
|
||||||
|
"focus": null
|
||||||
|
},
|
||||||
|
"meal:33": {
|
||||||
|
"version": 1,
|
||||||
|
"width": 240,
|
||||||
|
"height": 320,
|
||||||
|
"quality": 82,
|
||||||
|
"mtimeMs": 1771226144000,
|
||||||
|
"size": 729224,
|
||||||
|
"focus": null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:thumbs && npm run build:pages",
|
"build": "npm run build:thumbs && npm run build:pages",
|
||||||
"build:pages": "node scripts/build.js",
|
"build:pages": "node scripts/build.js",
|
||||||
"build:thumbs": "node scripts/generate-thumbnails.js"
|
"build:thumbs": "node scripts/generate-thumbnails.js",
|
||||||
|
"build:thumbs:force": "node scripts/generate-thumbnails.js --force"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ const { loadMeals, repoRoot } = require("./lib/meals");
|
|||||||
|
|
||||||
const fullsDir = path.join(repoRoot, "images", "fulls");
|
const fullsDir = path.join(repoRoot, "images", "fulls");
|
||||||
const thumbsDir = path.join(repoRoot, "images", "thumbs");
|
const thumbsDir = path.join(repoRoot, "images", "thumbs");
|
||||||
|
const manifestPath = path.join(thumbsDir, ".thumbs-manifest.json");
|
||||||
|
|
||||||
|
const THUMB_VERSION = 1;
|
||||||
const THUMB_WIDTH = 240;
|
const THUMB_WIDTH = 240;
|
||||||
const THUMB_HEIGHT = 320;
|
const THUMB_HEIGHT = 320;
|
||||||
const JPEG_QUALITY = 82;
|
const JPEG_QUALITY = 82;
|
||||||
@@ -15,6 +17,16 @@ function clamp(value, min, max) {
|
|||||||
return Math.min(Math.max(value, min), max);
|
return Math.min(Math.max(value, min), max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
return {
|
||||||
|
force: argv.includes("--force"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManifestKey(mealId) {
|
||||||
|
return `meal:${mealId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function getThumbnailPaths(meal) {
|
function getThumbnailPaths(meal) {
|
||||||
return {
|
return {
|
||||||
fullPath: path.join(fullsDir, `${meal.id}.jpg`),
|
fullPath: path.join(fullsDir, `${meal.id}.jpg`),
|
||||||
@@ -22,6 +34,24 @@ function getThumbnailPaths(meal) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOrientedDimensions(metadata) {
|
||||||
|
if (!metadata.width || !metadata.height) {
|
||||||
|
throw new Error("Could not determine image dimensions");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([5, 6, 7, 8].includes(metadata.orientation)) {
|
||||||
|
return {
|
||||||
|
width: metadata.height,
|
||||||
|
height: metadata.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: metadata.width,
|
||||||
|
height: metadata.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getCropArea(width, height, focus) {
|
function getCropArea(width, height, focus) {
|
||||||
const targetRatio = THUMB_WIDTH / THUMB_HEIGHT;
|
const targetRatio = THUMB_WIDTH / THUMB_HEIGHT;
|
||||||
const sourceRatio = width / height;
|
const sourceRatio = width / height;
|
||||||
@@ -48,43 +78,137 @@ function getCropArea(width, height, focus) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateThumbnail(meal) {
|
function loadManifest() {
|
||||||
|
if (!fs.existsSync(manifestPath)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThumbSignature(meal, sourceStats) {
|
||||||
|
return {
|
||||||
|
version: THUMB_VERSION,
|
||||||
|
width: THUMB_WIDTH,
|
||||||
|
height: THUMB_HEIGHT,
|
||||||
|
quality: JPEG_QUALITY,
|
||||||
|
mtimeMs: sourceStats.mtimeMs,
|
||||||
|
size: sourceStats.size,
|
||||||
|
focus: meal.thumbnail?.focus ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestEntryMatches(currentEntry, nextEntry) {
|
||||||
|
return JSON.stringify(currentEntry) === JSON.stringify(nextEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeStaleThumbnails(expectedIds) {
|
||||||
|
if (!fs.existsSync(thumbsDir)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs.promises.readdir(thumbsDir, { withFileTypes: true });
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== ".jpg") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = path.basename(entry.name, path.extname(entry.name));
|
||||||
|
|
||||||
|
if (expectedIds.has(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.unlink(path.join(thumbsDir, entry.name));
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateThumbnail(meal, manifest, options) {
|
||||||
const { fullPath, thumbPath } = getThumbnailPaths(meal);
|
const { fullPath, thumbPath } = getThumbnailPaths(meal);
|
||||||
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
throw new Error(`Missing full-size image for meal ${meal.id}: ${fullPath}`);
|
throw new Error(`Missing full-size image for meal ${meal.id}: ${fullPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = sharp(fullPath);
|
const sourceStats = await fs.promises.stat(fullPath);
|
||||||
const metadata = await image.metadata();
|
const signature = getThumbSignature(meal, sourceStats);
|
||||||
|
const thumbExists = fs.existsSync(thumbPath);
|
||||||
|
|
||||||
if (!metadata.width || !metadata.height) {
|
if (
|
||||||
throw new Error(`Could not read image dimensions for ${fullPath}`);
|
!options.force &&
|
||||||
|
thumbExists &&
|
||||||
|
manifestEntryMatches(manifest[getManifestKey(meal.id)], signature)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
mealId: meal.id,
|
||||||
|
changed: false,
|
||||||
|
manifestEntry: signature,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cropArea = getCropArea(
|
const image = sharp(fullPath);
|
||||||
metadata.width,
|
const metadata = await image.metadata();
|
||||||
metadata.height,
|
const { width, height } = getOrientedDimensions(metadata);
|
||||||
meal.thumbnail?.focus
|
|
||||||
);
|
const cropArea = getCropArea(width, height, meal.thumbnail?.focus);
|
||||||
|
|
||||||
await image
|
await image
|
||||||
|
.rotate()
|
||||||
.extract(cropArea)
|
.extract(cropArea)
|
||||||
.resize(THUMB_WIDTH, THUMB_HEIGHT)
|
.resize(THUMB_WIDTH, THUMB_HEIGHT)
|
||||||
.jpeg({ quality: JPEG_QUALITY, mozjpeg: true })
|
.jpeg({ quality: JPEG_QUALITY, mozjpeg: true })
|
||||||
.toFile(thumbPath);
|
.toFile(thumbPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mealId: meal.id,
|
||||||
|
changed: true,
|
||||||
|
manifestEntry: signature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeManifest(manifest) {
|
||||||
|
const sortedEntries = Object.entries(manifest).sort(([left], [right]) =>
|
||||||
|
left.localeCompare(right, undefined, { numeric: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedManifest = Object.fromEntries(sortedEntries);
|
||||||
|
fs.writeFileSync(manifestPath, `${JSON.stringify(orderedManifest, null, 2)}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
const meals = loadMeals();
|
const meals = loadMeals();
|
||||||
|
const manifest = loadManifest();
|
||||||
|
const nextManifest = {};
|
||||||
|
const expectedIds = new Set(meals.map((meal) => meal.id));
|
||||||
|
|
||||||
await fs.promises.mkdir(thumbsDir, { recursive: true });
|
await fs.promises.mkdir(thumbsDir, { recursive: true });
|
||||||
|
|
||||||
|
const removed = await removeStaleThumbnails(expectedIds);
|
||||||
|
let generated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
for (const meal of meals) {
|
for (const meal of meals) {
|
||||||
await generateThumbnail(meal);
|
const result = await generateThumbnail(meal, manifest, options);
|
||||||
|
nextManifest[getManifestKey(meal.id)] = result.manifestEntry;
|
||||||
|
|
||||||
|
if (result.changed) {
|
||||||
|
generated += 1;
|
||||||
|
} else {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Generated ${meals.length} thumbnails in images/thumbs`);
|
writeManifest(nextManifest);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Thumbnail build complete: ${generated} generated, ${skipped} skipped, ${removed} removed`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ function validateMeals(meals) {
|
|||||||
throw new Error("data/meals.json must contain an array");
|
throw new Error("data/meals.json must contain an array");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ids = new Set();
|
||||||
|
|
||||||
for (const [index, meal] of meals.entries()) {
|
for (const [index, meal] of meals.entries()) {
|
||||||
if (!meal || typeof meal !== "object") {
|
if (!meal || typeof meal !== "object") {
|
||||||
throw new Error(`Meal at index ${index} must be an object`);
|
throw new Error(`Meal at index ${index} must be an object`);
|
||||||
@@ -62,6 +64,12 @@ function validateMeals(meals) {
|
|||||||
throw new Error(`Meal ${index} has a non-string "position" value`);
|
throw new Error(`Meal ${index} has a non-string "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);
|
validateThumbnail(meal, index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user