diff --git a/src/lib/js/recipeJsonLd.ts b/src/lib/js/recipeJsonLd.ts
new file mode 100644
index 0000000..1075d15
--- /dev/null
+++ b/src/lib/js/recipeJsonLd.ts
@@ -0,0 +1,126 @@
+function parseTimeToISO8601(timeString: string): string | undefined {
+ if (!timeString) return undefined;
+
+ // Handle common German time formats
+ const cleanTime = timeString.toLowerCase().trim();
+
+ // Match patterns like "30 min", "2 h", "1.5 h", "90 min"
+ const minMatch = cleanTime.match(/(\d+(?:[.,]\d+)?)\s*(?:min|minuten?)/);
+ const hourMatch = cleanTime.match(/(\d+(?:[.,]\d+)?)\s*(?:h|stunden?|std)/);
+
+ if (minMatch) {
+ const minutes = Math.round(parseFloat(minMatch[1].replace(',', '.')));
+ return `PT${minutes}M`;
+ }
+
+ if (hourMatch) {
+ const hours = parseFloat(hourMatch[1].replace(',', '.'));
+ if (hours % 1 === 0) {
+ return `PT${Math.round(hours)}H`;
+ } else {
+ const totalMinutes = Math.round(hours * 60);
+ return `PT${totalMinutes}M`;
+ }
+ }
+
+ return undefined;
+}
+
+export function generateRecipeJsonLd(data: any) {
+ const jsonLd: any = {
+ "@context": "https://schema.org",
+ "@type": "Recipe",
+ "name": data.name?.replace(/<[^>]*>/g, ''), // Strip HTML tags
+ "description": data.description,
+ "author": {
+ "@type": "Person",
+ "name": "Alexander Bocken"
+ },
+ "datePublished": data.dateCreated ? new Date(data.dateCreated).toISOString() : undefined,
+ "dateModified": data.dateModified || data.updatedAt ? new Date(data.dateModified || data.updatedAt).toISOString() : undefined,
+ "recipeCategory": data.category,
+ "keywords": data.tags?.join(', '),
+ "image": {
+ "@type": "ImageObject",
+ "url": `https://bocken.org/static/rezepte/full/${data.short_name}.webp`,
+ "width": 1200,
+ "height": 800
+ },
+ "recipeIngredient": [] as string[],
+ "recipeInstructions": [] as any[],
+ "url": `https://bocken.org/rezepte/${data.short_name}`
+ };
+
+ // Add optional fields if they exist
+ if (data.portions) {
+ jsonLd.recipeYield = data.portions;
+ }
+
+ // Parse times properly for ISO 8601
+ const prepTime = parseTimeToISO8601(data.preparation);
+ if (prepTime) jsonLd.prepTime = prepTime;
+
+ const cookTime = parseTimeToISO8601(data.cooking);
+ if (cookTime) jsonLd.cookTime = cookTime;
+
+ const totalTime = parseTimeToISO8601(data.total_time);
+ if (totalTime) jsonLd.totalTime = totalTime;
+
+ // Extract ingredients
+ if (data.ingredients) {
+ for (const ingredientGroup of data.ingredients) {
+ if (ingredientGroup.list) {
+ for (const ingredient of ingredientGroup.list) {
+ if (ingredient.name) {
+ let ingredientText = ingredient.name;
+ if (ingredient.amount) {
+ ingredientText = `${ingredient.amount} ${ingredient.unit || ''} ${ingredient.name}`.trim();
+ }
+ jsonLd.recipeIngredient.push(ingredientText);
+ }
+ }
+ }
+ }
+ }
+
+ // Extract instructions
+ if (data.instructions) {
+ for (const instructionGroup of data.instructions) {
+ if (instructionGroup.steps) {
+ for (let i = 0; i < instructionGroup.steps.length; i++) {
+ jsonLd.recipeInstructions.push({
+ "@type": "HowToStep",
+ "name": `Schritt ${i + 1}`,
+ "text": instructionGroup.steps[i]
+ });
+ }
+ }
+ }
+ }
+
+ // Add baking instructions if available
+ if (data.baking?.temperature || data.baking?.length) {
+ const bakingText = [
+ data.baking.temperature ? `bei ${data.baking.temperature}` : '',
+ data.baking.length ? `für ${data.baking.length}` : '',
+ data.baking.mode || ''
+ ].filter(Boolean).join(' ');
+
+ if (bakingText) {
+ jsonLd.recipeInstructions.push({
+ "@type": "HowToStep",
+ "name": "Backen",
+ "text": `Backen ${bakingText}`
+ });
+ }
+ }
+
+ // Clean up undefined values
+ Object.keys(jsonLd).forEach(key => {
+ if (jsonLd[key] === undefined) {
+ delete jsonLd[key];
+ }
+ });
+
+ return jsonLd;
+}
\ No newline at end of file
diff --git a/src/routes/api/rezepte/json-ld/[name]/+server.ts b/src/routes/api/rezepte/json-ld/[name]/+server.ts
new file mode 100644
index 0000000..03fe60c
--- /dev/null
+++ b/src/routes/api/rezepte/json-ld/[name]/+server.ts
@@ -0,0 +1,32 @@
+import { json, type RequestHandler } from '@sveltejs/kit';
+import { Recipe } from '../../../../../models/Recipe';
+import { dbConnect, dbDisconnect } from '../../../../../utils/db';
+import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
+import type { RecipeModelType } from '../../../../../types/types';
+import { error } from '@sveltejs/kit';
+
+export const GET: RequestHandler = async ({ params, setHeaders }) => {
+ await dbConnect();
+ let recipe = (await Recipe.findOne({ short_name: params.name }).lean()) as RecipeModelType;
+ await dbDisconnect();
+
+ recipe = JSON.parse(JSON.stringify(recipe));
+ if (recipe == null) {
+ throw error(404, "Recipe not found");
+ }
+
+ const jsonLd = generateRecipeJsonLd(recipe);
+
+ // Set appropriate headers for JSON-LD
+ setHeaders({
+ 'Content-Type': 'application/ld+json',
+ 'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
+ });
+
+ return new Response(JSON.stringify(jsonLd, null, 2), {
+ headers: {
+ 'Content-Type': 'application/ld+json',
+ 'Cache-Control': 'public, max-age=3600'
+ }
+ });
+};
\ No newline at end of file
diff --git a/src/routes/rezepte/[name]/+page.svelte b/src/routes/rezepte/[name]/+page.svelte
index 417c419..f28e127 100644
--- a/src/routes/rezepte/[name]/+page.svelte
+++ b/src/routes/rezepte/[name]/+page.svelte
@@ -13,6 +13,7 @@
import RecipeNote from '$lib/components/RecipeNote.svelte';
import {stripHtmlTags} from '$lib/js/stripHtmlTags';
import FavoriteButton from '$lib/components/FavoriteButton.svelte';
+ import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
export let data: PageData;
@@ -277,6 +278,7 @@ h4{
+