diff --git a/src/lib/js/recipeJsonLd.ts b/src/lib/js/recipeJsonLd.ts index 1600866..67a3814 100644 --- a/src/lib/js/recipeJsonLd.ts +++ b/src/lib/js/recipeJsonLd.ts @@ -55,7 +55,14 @@ interface RecipeJsonLd { [key: string]: unknown; } -export function generateRecipeJsonLd(data: RecipeModelType) { +type ReferencedNutrition = { + shortName: string; + name: string; + nutrition: Record; + baseMultiplier: number; +}; + +export function generateRecipeJsonLd(data: RecipeModelType, referencedNutrition?: ReferencedNutrition[]) { const jsonLd: RecipeJsonLd = { "@context": "https://schema.org", "@type": "Recipe", @@ -145,7 +152,7 @@ export function generateRecipeJsonLd(data: RecipeModelType) { } // Add nutrition information from stored mappings - const nutritionInfo = computeNutritionInfo(data.ingredients || [], data.nutritionMappings, data.portions); + const nutritionInfo = computeNutritionInfo(data.ingredients || [], data.nutritionMappings, data.portions, referencedNutrition); if (nutritionInfo) { jsonLd.nutrition = nutritionInfo; } @@ -181,8 +188,9 @@ function computeNutritionInfo( ingredients: any[], mappings: NutritionMapping[] | undefined, portions: string | undefined, + referencedNutrition?: ReferencedNutrition[], ): Record | null { - if (!mappings || mappings.length === 0) return null; + if ((!mappings || mappings.length === 0) && (!referencedNutrition || referencedNutrition.length === 0)) return null; const index = new Map( mappings.map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m]) @@ -227,6 +235,16 @@ function computeNutritionInfo( } } + // Add nutrition from referenced recipes (base refs + anchor-tag refs) + if (referencedNutrition) { + for (const ref of referencedNutrition) { + const scale = ref.baseMultiplier; + for (const key of Object.keys(totals) as (keyof typeof totals)[]) { + totals[key] += (ref.nutrition[key] || 0) * scale; + } + } + } + if (totals.calories === 0) return null; // Parse portion count for per-serving values diff --git a/src/lib/server/nutritionMatcher.ts b/src/lib/server/nutritionMatcher.ts index 475c0b2..c25a308 100644 --- a/src/lib/server/nutritionMatcher.ts +++ b/src/lib/server/nutritionMatcher.ts @@ -132,7 +132,7 @@ async function getBlsEmbeddingIndex() { /** Normalize an ingredient name for matching (English) */ export function normalizeIngredientName(name: string): string { - let normalized = name.toLowerCase().trim(); + let normalized = name.replace(/<[^>]*>/g, '').toLowerCase().trim(); normalized = normalized.replace(/\(.*?\)/g, '').trim(); for (const mod of STRIP_MODIFIERS) { normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim(); @@ -143,7 +143,7 @@ export function normalizeIngredientName(name: string): string { /** Normalize a German ingredient name for matching */ export function normalizeIngredientNameDe(name: string): string { - let normalized = name.toLowerCase().trim(); + let normalized = name.replace(/<[^>]*>/g, '').toLowerCase().trim(); normalized = normalized.replace(/\(.*?\)/g, '').trim(); for (const mod of STRIP_MODIFIERS_DE) { normalized = normalized.replace(new RegExp(`\\b${mod}\\b,?\\s*`, 'gi'), '').trim(); @@ -280,7 +280,8 @@ function substringMatchScore( const pos = nameLower.indexOf(form); if (pos >= 0 && pos < 15) hasEarlyMatch = true; // Word-boundary match - const wordBoundary = new RegExp(`(^|[\\s,/])${form}([\\s,/]|$)`); + const escaped = form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const wordBoundary = new RegExp(`(^|[\\s,/])${escaped}([\\s,/]|$)`); if (wordBoundary.test(nameLower)) hasWordBoundaryMatch = true; } @@ -813,3 +814,72 @@ export function computeRecipeNutritionTotals( function stripHtml(html: string): string { return html.replace(/<[^>]*>/g, ''); } + +/** Parse anchor href from ingredient name, return recipe short_name or null */ +export function parseAnchorRecipeRef(ingredientName: string): string | null { + const match = ingredientName.match(/]+)["']?[^>]*>/i); + if (!match) return null; + let href = match[1].trim(); + href = href.split('?')[0]; + if (href.startsWith('http') || href.includes('://')) return null; + href = href.replace(/^(\.?\/?rezepte\/|\.\/|\/)/, ''); + if (href.includes('.')) return null; + return href || null; +} + +export type ReferencedNutritionResult = { + shortName: string; + name: string; + nutrition: Record; + baseMultiplier: number; +}; + +/** + * Build nutrition totals for referenced recipes: + * 1. Base recipe references (type='reference' with populated baseRecipeRef) + * 2. Anchor-tag references in ingredient names () + */ +export async function resolveReferencedNutrition( + ingredients: any[], +): Promise { + const { Recipe } = await import('$models/Recipe'); + const results: ReferencedNutritionResult[] = []; + const processedSlugs = new Set(); + + for (const section of ingredients) { + // Type 1: Base recipe references + if (section.type === 'reference' && section.baseRecipeRef) { + const ref = section.baseRecipeRef; + const slug = ref.short_name; + if (processedSlugs.has(slug)) continue; + processedSlugs.add(slug); + + if (ref.nutritionMappings?.length > 0) { + const mult = section.baseMultiplier || 1; + const nutrition = computeRecipeNutritionTotals(ref.ingredients || [], ref.nutritionMappings, 1); + results.push({ shortName: slug, name: ref.name, nutrition, baseMultiplier: mult }); + } + } + + // Type 2: Anchor-tag references in ingredient names + if (section.list) { + for (const item of section.list) { + const refSlug = parseAnchorRecipeRef(item.name || ''); + if (!refSlug || processedSlugs.has(refSlug)) continue; + processedSlugs.add(refSlug); + + const refRecipe = await Recipe.findOne({ short_name: refSlug }) + .select('short_name name ingredients nutritionMappings portions') + .lean(); + if (!refRecipe?.nutritionMappings?.length) continue; + + const nutrition = computeRecipeNutritionTotals( + refRecipe.ingredients || [], refRecipe.nutritionMappings, 1 + ); + results.push({ shortName: refSlug, name: refRecipe.name, nutrition, baseMultiplier: 1 }); + } + } + } + + return results; +} diff --git a/src/routes/api/[recipeLang=recipeLang]/items/[name]/+server.ts b/src/routes/api/[recipeLang=recipeLang]/items/[name]/+server.ts index 7d1d3aa..b6ad046 100644 --- a/src/routes/api/[recipeLang=recipeLang]/items/[name]/+server.ts +++ b/src/routes/api/[recipeLang=recipeLang]/items/[name]/+server.ts @@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db'; import { error } from '@sveltejs/kit'; import type { RecipeModelType, IngredientItem, InstructionItem } from '$types/types'; import { isEnglish } from '$lib/server/recipeHelpers'; -import { getNutritionEntryByFdcId, getBlsEntryByCode, computeRecipeNutritionTotals } from '$lib/server/nutritionMatcher'; +import { getNutritionEntryByFdcId, getBlsEntryByCode, resolveReferencedNutrition } from '$lib/server/nutritionMatcher'; /** Recursively map populated baseRecipeRef to resolvedRecipe field */ function mapBaseRecipeRefs(items: any[]): any[] { @@ -42,72 +42,6 @@ function resolveNutritionData(mappings: any[]): any[] { }); } -/** Parse anchor href from ingredient name, return short_name or null */ -function parseAnchorRecipeRef(ingredientName: string): string | null { - const match = ingredientName.match(/]+)["']?[^>]*>/i); - if (!match) return null; - let href = match[1].trim(); - // Strip query params (e.g., ?multiplier={{multiplier}}) - href = href.split('?')[0]; - // Skip external links - if (href.startsWith('http') || href.includes('://')) return null; - // Strip leading path components like /rezepte/ or ./ - href = href.replace(/^(\.?\/?rezepte\/|\.\/|\/)/, ''); - // Skip if contains a dot (file extensions, external domains) - if (href.includes('.')) return null; - return href || null; -} - -/** - * Build nutrition totals for referenced recipes: - * 1. Base recipe references (type='reference' with populated baseRecipeRef) - * 2. Anchor-tag references in ingredient names () - */ -async function resolveReferencedNutrition( - ingredients: any[], -): Promise<{ shortName: string; name: string; nutrition: Record; baseMultiplier: number }[]> { - const results: { shortName: string; name: string; nutrition: Record; baseMultiplier: number }[] = []; - const processedSlugs = new Set(); - - for (const section of ingredients) { - // Type 1: Base recipe references - if (section.type === 'reference' && section.baseRecipeRef) { - const ref = section.baseRecipeRef; - const slug = ref.short_name; - if (processedSlugs.has(slug)) continue; - processedSlugs.add(slug); - - if (ref.nutritionMappings?.length > 0) { - const mult = section.baseMultiplier || 1; - const nutrition = computeRecipeNutritionTotals(ref.ingredients || [], ref.nutritionMappings, 1); - results.push({ shortName: slug, name: ref.name, nutrition, baseMultiplier: mult }); - } - } - - // Type 2: Anchor-tag references in ingredient names - if (section.list) { - for (const item of section.list) { - const refSlug = parseAnchorRecipeRef(item.name || ''); - if (!refSlug || processedSlugs.has(refSlug)) continue; - processedSlugs.add(refSlug); - - // Look up the referenced recipe - const refRecipe = await Recipe.findOne({ short_name: refSlug }) - .select('short_name name ingredients nutritionMappings portions') - .lean(); - if (!refRecipe?.nutritionMappings?.length) continue; - - const nutrition = computeRecipeNutritionTotals( - refRecipe.ingredients || [], refRecipe.nutritionMappings, 1 - ); - results.push({ shortName: refSlug, name: refRecipe.name, nutrition, baseMultiplier: 1 }); - } - } - } - - return results; -} - export const GET: RequestHandler = async ({ params }) => { await dbConnect(); const en = isEnglish(params.recipeLang!); diff --git a/src/routes/api/[recipeLang=recipeLang]/json-ld/[name]/+server.ts b/src/routes/api/[recipeLang=recipeLang]/json-ld/[name]/+server.ts index 290e2ed..ceef0f7 100644 --- a/src/routes/api/[recipeLang=recipeLang]/json-ld/[name]/+server.ts +++ b/src/routes/api/[recipeLang=recipeLang]/json-ld/[name]/+server.ts @@ -2,19 +2,23 @@ import { json, type RequestHandler } from '@sveltejs/kit'; import { Recipe } from '$models/Recipe'; import { dbConnect } from '$utils/db'; import { generateRecipeJsonLd } from '$lib/js/recipeJsonLd'; +import { resolveReferencedNutrition } from '$lib/server/nutritionMatcher'; 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 unknown as RecipeModelType; + let recipe = (await Recipe.findOne({ short_name: params.name }) + .populate({ path: 'ingredients.baseRecipeRef', select: 'short_name name ingredients nutritionMappings' }) + .lean()) as unknown as RecipeModelType; recipe = JSON.parse(JSON.stringify(recipe)); if (recipe == null) { throw error(404, "Recipe not found"); } - const jsonLd = generateRecipeJsonLd(recipe); + const referencedNutrition = await resolveReferencedNutrition(recipe.ingredients || []); + const jsonLd = generateRecipeJsonLd(recipe, referencedNutrition); // Set appropriate headers for JSON-LD setHeaders({