79 lines
3 KiB
TypeScript
79 lines
3 KiB
TypeScript
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 } : {}),
|
||
}));
|
||
}
|