143 lines
4.8 KiB
TypeScript
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,
|
|
};
|
|
}
|