- Create recipeJsonLd.ts function with Schema.org compliant Recipe markup - Add API endpoint at /api/rezepte/json-ld/[name] for on-demand generation - Include proper ISO 8601 time parsing for German formats - Add rel="alternate" link in recipe pages for discoverability - Set author to Alexander Bocken with proper Person type - Include caching headers for performance optimization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
		
							
								
								
									
										126
									
								
								src/lib/js/recipeJsonLd.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								src/lib/js/recipeJsonLd.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										32
									
								
								src/routes/api/rezepte/json-ld/[name]/+server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/routes/api/rezepte/json-ld/[name]/+server.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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'
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
@@ -13,6 +13,7 @@
 | 
				
			|||||||
	import RecipeNote from '$lib/components/RecipeNote.svelte';
 | 
						import RecipeNote from '$lib/components/RecipeNote.svelte';
 | 
				
			||||||
	import {stripHtmlTags} from '$lib/js/stripHtmlTags';
 | 
						import {stripHtmlTags} from '$lib/js/stripHtmlTags';
 | 
				
			||||||
	import FavoriteButton from '$lib/components/FavoriteButton.svelte';
 | 
						import FavoriteButton from '$lib/components/FavoriteButton.svelte';
 | 
				
			||||||
 | 
						import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    	export let data: PageData;
 | 
					    	export let data: PageData;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -277,6 +278,7 @@ h4{
 | 
				
			|||||||
	<meta property="og:image:secure_url" content="https://bocken.org/static/rezepte/thumb/{data.short_name}.webp" />
 | 
						<meta property="og:image:secure_url" content="https://bocken.org/static/rezepte/thumb/{data.short_name}.webp" />
 | 
				
			||||||
	<meta property="og:image:type" content="image/webp" />
 | 
						<meta property="og:image:type" content="image/webp" />
 | 
				
			||||||
	<meta property="og:image:alt" content="{stripHtmlTags(data.name)}" />
 | 
						<meta property="og:image:alt" content="{stripHtmlTags(data.name)}" />
 | 
				
			||||||
 | 
						<link rel="alternate" type="application/ld+json" href="/api/rezepte/json-ld/{data.short_name}" />
 | 
				
			||||||
</svelte:head>
 | 
					</svelte:head>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<TitleImgParallax src={hero_img_src} {placeholder_src}>
 | 
					<TitleImgParallax src={hero_img_src} {placeholder_src}>
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user