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; export type Step = z.infer; export type Recipe = z.infer; 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, }; }