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{ +