Files
homepage/src/lib/js/recipeJsonLd.ts
Alexander Bocken ccf3fd7ea2 implement content-hash based image cache invalidation
Add content-based hashing to recipe images for proper cache invalidation
while maintaining graceful degradation through dual file storage.

Changes:
- Add imageHash utility with SHA-256 content hashing (8-char)
- Update Recipe model to store hashed filenames in images[0].mediapath
- Modify image upload endpoint to save both hashed and unhashed versions
- Update frontend components to use images[0].mediapath with fallback
- Add migration endpoint to hash existing images (production-only)
- Update image delete/rename endpoints to handle both file versions

Images are now stored as:
  - recipe.a1b2c3d4.webp (hashed, cached forever)
  - recipe.webp (unhashed, graceful degradation fallback)

Database stores hashed filename for cache busting, while unhashed
version remains on disk for backward compatibility and manual uploads.
2026-01-02 12:06:56 +01:00

126 lines
3.8 KiB
TypeScript

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.images?.[0]?.mediapath || `${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;
}