planmymeal/app/lib/recipes/dish.ts
Cayden Schwechten c19b910c36 init
2026-10-31 20:44:29 -05:00

79 lines
3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Appliance, CookingStyle, Cuisine, MealKind, RecipeData, RecipeIngredient, RecipeUnit } from "@/app/types";
// ---------------------------------------------------------------------------
// Compact recipe format, used for the big recipe library in ./extra/.
//
// Instead of a 12-line object per recipe, each dish is one entry with its
// ingredients written like a shopping list:
//
// { title: "Beef Stroganoff", mins: 30, tools: ["stovetop"], styles: ["comfort"],
// about: "Creamy mushroom sauce over egg noodles.",
// items: "ground_beef 1 lb; egg_noodles 12 oz; mushrooms 8 oz; sour_cream 0.5 cup" }
//
// Each ingredient is "<catalog item id> <amount> <unit>". Bad units throw at
// module load, so typos fail loudly in dev instead of producing $0 groceries.
// ---------------------------------------------------------------------------
export interface DishInput {
title: string;
/** Defaults to a slug of the title. Set it explicitly to keep saved plans stable if you rename. */
id?: string;
about: string;
mins: number;
tools: Appliance[];
styles?: CookingStyle[];
/** Defaults to the file's cuisine/kind (see `dishes`). */
cuisine?: Cuisine;
kind?: MealKind;
/** People one batch feeds. Default 4. */
serves?: number;
/** "item_id amount unit; item_id amount unit; …" */
items: string;
/** Optional cook's tip shown under the steps. */
tip?: string;
}
const UNITS = new Set<RecipeUnit>(["g", "oz", "lb", "ml", "tsp", "tbsp", "cup", "each"]);
export const slugify = (text: string) =>
text
.normalize("NFD") // "Étouffée" → "Etouffee" + combining accents…
.replace(/[\u0300-\u036f]/g, "") // …which this strips
.replace(/ı/g, "i") // Turkish dotless i (doesn't decompose)
.toLowerCase()
.replace(/['’]/g, "") // "Shepherd's" → "shepherds", not "shepherd-s"
.replace(/&/g, "and")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
function parseItems(items: string, title: string): RecipeIngredient[] {
return items
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const [itemId, amountText, unit] = part.split(/\s+/);
const amount = Number(amountText);
if (!itemId || !Number.isFinite(amount) || amount <= 0 || !UNITS.has(unit as RecipeUnit)) {
throw new Error(`Recipe "${title}": can't parse ingredient "${part}" (expected "item_id amount unit")`);
}
return { itemId, amount, unit: unit as RecipeUnit };
});
}
/** Build a list of recipes that share a default cuisine and meal kind. */
export function dishes(defaults: { cuisine: Cuisine; kind?: MealKind }, list: DishInput[]): RecipeData[] {
return list.map((d) => ({
id: d.id ?? slugify(d.title),
title: d.title,
description: d.about,
cuisine: d.cuisine ?? defaults.cuisine,
styles: d.styles ?? [],
kind: d.kind ?? defaults.kind ?? "main",
prepTime: d.mins,
requiredAppliances: d.tools,
servings: d.serves ?? 4,
ingredients: parseItems(d.items, d.title),
...(d.tip ? { tip: d.tip } : {}),
}));
}