- 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