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