Files
HamHam/src/lib/recipe.ts
T

143 lines
4.8 KiB
TypeScript

import { z } from 'zod';
const text = (max: number) =>
z
.string()
.trim()
.max(max)
.refine((v) => !/<\/?[a-z][^>]*>/i.test(v), 'HTML ist nicht erlaubt.');
const id = z.string().regex(/^[a-zA-Z0-9_-]{1,80}$/);
export const units = ['g', 'kg', 'ml', 'l', 'Stück', 'TL', 'EL', 'Prise', 'Bund', ''] as const;
export const imageSchema = z.union(
[
z.string().regex(/^\/(?:media\/[a-f0-9-]+\.webp|images\/[a-z0-9-]+\.(?:webp|jpg|svg))$/),
z
.url({ protocol: /^https$/ })
.max(2048)
.regex(/^https:\/\/[^@\s/\\]+(?:[/?#][^\s\\]*)?$/i),
],
{ error: 'Bitte einen lokalen Bildpfad oder eine vollständige HTTPS-Bildadresse verwenden.' },
);
export const ingredientSchema = z.object({
id,
name: text(160).min(1, 'Zutat benötigt einen Namen.'),
amount: z.number().positive().max(1000000).nullable().default(null),
unit: z.enum(units).default(''),
section: text(80).default(''),
note: text(200).default(''),
optional: z.boolean().default(false),
});
export const stepSchema = z.object({
id,
title: text(160).default(''),
text: text(6000).min(1, 'Schritt benötigt eine Anleitung.'),
ingredients: z.array(id).max(100).default([]),
images: z.array(imageSchema).max(10).default([]),
timers: z
.array(z.object({ id, label: text(100).min(1), seconds: z.number().int().min(1).max(172800) }))
.max(10)
.default([]),
});
export const recipeSchema = z
.object({
id: z.string().uuid().optional(),
title: text(180).min(2, 'Titel: mindestens 2 Zeichen.'),
subtitle: text(200).default(''),
description: text(4000).default(''),
image: imageSchema.or(z.literal('')).default(''),
images: z.array(imageSchema).max(20).default([]),
categories: z.array(text(60).min(1)).max(20).default([]),
tags: z.array(text(60).min(1)).max(30).default([]),
cuisine: text(80).default(''),
difficulty: z.enum(['Einfach', 'Mittel', 'Anspruchsvoll']).default('Einfach'),
prepMinutes: z.number().int().min(0).max(10080).default(0),
cookMinutes: z.number().int().min(0).max(10080).default(0),
restMinutes: z.number().int().min(0).max(10080).default(0),
servings: z.number().positive().max(10000),
ingredients: z.array(ingredientSchema).min(1).max(200),
steps: z.array(stepSchema).min(1).max(100),
tips: text(6000).default(''),
storage: text(3000).default(''),
nutrition: z
.object({
kcal: z.number().min(0),
protein: z.number().min(0),
carbs: z.number().min(0),
fat: z.number().min(0),
})
.nullable()
.default(null),
published: z.boolean().default(false),
createdAt: z.iso.datetime().optional(),
updatedAt: z.iso.datetime().optional(),
})
.superRefine((r, ctx) => {
const ids = new Set(r.ingredients.map((i) => i.id));
if (ids.size !== r.ingredients.length)
ctx.addIssue({
code: 'custom',
path: ['ingredients'],
message: 'Zutaten-IDs müssen eindeutig sein.',
});
const stepIds = new Set(r.steps.map((s) => s.id));
if (stepIds.size !== r.steps.length)
ctx.addIssue({
code: 'custom',
path: ['steps'],
message: 'Schritt-IDs müssen eindeutig sein.',
});
const timers = r.steps.flatMap((s) => s.timers.map((t) => t.id));
if (new Set(timers).size !== timers.length)
ctx.addIssue({
code: 'custom',
path: ['steps'],
message: 'Timer-IDs müssen eindeutig sein.',
});
r.steps.forEach((s, n) => {
const refs = [
...s.ingredients,
...Array.from(s.text.matchAll(/\{\{([^}]+)\}\}/g), (m) => m[1]),
];
if (refs.some((ref) => !ids.has(ref)))
ctx.addIssue({
code: 'custom',
path: ['steps', n],
message: 'Unbekannte Zutatenreferenz.',
});
});
});
export const collectionSchema = z.object({
version: z.literal(1),
recipes: z.array(recipeSchema).min(1).max(100),
});
export type Ingredient = z.infer<typeof ingredientSchema>;
export type Step = z.infer<typeof stepSchema>;
export type Recipe = z.infer<typeof recipeSchema>;
export type SavedRecipe = Recipe & { id: string; createdAt: string; updatedAt: string };
export const totalMinutes = (r: Recipe) => r.prepMinutes + r.cookMinutes + r.restMinutes;
export function emptyRecipe(): Recipe {
return {
title: '',
subtitle: '',
description: '',
image: '',
images: [],
categories: [],
tags: [],
cuisine: '',
difficulty: 'Einfach',
prepMinutes: 10,
cookMinutes: 20,
restMinutes: 0,
servings: 2,
ingredients: [
{ id: 'i1', name: '', amount: null, unit: 'g', section: '', note: '', optional: false },
],
steps: [{ id: 's1', title: '', text: '', ingredients: [], images: [], timers: [] }],
tips: '',
storage: '',
nutrition: null,
published: false,
};
}