planmymeal/app/lib/recipes/dish.ts

80 lines
3 KiB
TypeScript
Raw Normal View History

2026-10-31 20:44:29 -05:00
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 } : {}),
}));
}