From 4c4fa733cde841926c400d99717ca1dce9995470 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 3 Apr 2026 09:07:41 +0200 Subject: [PATCH] nutrition: include NutritionInformation in recipe JSON-LD Compute macro/micro totals from stored nutrition mappings and emit a schema.org NutritionInformation block in the JSON-LD output. Values are per-serving when portions are defined, otherwise recipe totals. --- src/lib/js/recipeJsonLd.ts | 104 ++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/src/lib/js/recipeJsonLd.ts b/src/lib/js/recipeJsonLd.ts index 1f68b2d..1600866 100644 --- a/src/lib/js/recipeJsonLd.ts +++ b/src/lib/js/recipeJsonLd.ts @@ -26,7 +26,7 @@ function parseTimeToISO8601(timeString: string | undefined): string | undefined return undefined; } -import type { RecipeModelType } from '$types/types'; +import type { RecipeModelType, NutritionMapping } from '$types/types'; interface HowToStep { "@type": "HowToStep"; @@ -144,6 +144,12 @@ export function generateRecipeJsonLd(data: RecipeModelType) { } } + // Add nutrition information from stored mappings + const nutritionInfo = computeNutritionInfo(data.ingredients || [], data.nutritionMappings, data.portions); + if (nutritionInfo) { + jsonLd.nutrition = nutritionInfo; + } + // Clean up undefined values Object.keys(jsonLd).forEach(key => { if (jsonLd[key] === undefined) { @@ -152,4 +158,100 @@ export function generateRecipeJsonLd(data: RecipeModelType) { }); return jsonLd; +} + +function parseAmount(amount: string): number { + if (!amount?.trim()) return 0; + const s = amount.trim().replace(',', '.'); + const rangeMatch = s.match(/^(\d+(?:\.\d+)?)\s*[-–]\s*(\d+(?:\.\d+)?)$/); + if (rangeMatch) return (parseFloat(rangeMatch[1]) + parseFloat(rangeMatch[2])) / 2; + const fractionMatch = s.match(/^(\d+)\s*\/\s*(\d+)$/); + if (fractionMatch) return parseInt(fractionMatch[1]) / parseInt(fractionMatch[2]); + const mixedMatch = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/); + if (mixedMatch) return parseInt(mixedMatch[1]) + parseInt(mixedMatch[2]) / parseInt(mixedMatch[3]); + const parsed = parseFloat(s); + return isNaN(parsed) ? 0 : parsed; +} + +function stripHtml(html: string): string { + return html.replace(/<[^>]*>/g, ''); +} + +function computeNutritionInfo( + ingredients: any[], + mappings: NutritionMapping[] | undefined, + portions: string | undefined, +): Record | null { + if (!mappings || mappings.length === 0) return null; + + const index = new Map( + mappings.map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m]) + ); + + const totals = { calories: 0, protein: 0, fat: 0, saturatedFat: 0, carbs: 0, fiber: 0, sugars: 0, sodium: 0, cholesterol: 0 }; + + // Collect section names for dedup + const sectionNames = new Set(); + for (const section of ingredients) { + if (section.name) sectionNames.add(stripHtml(section.name).toLowerCase().trim()); + } + + for (let si = 0; si < ingredients.length; si++) { + const section = ingredients[si]; + if (section.type === 'reference' || !section.list) continue; + const currentSectionName = section.name ? stripHtml(section.name).toLowerCase().trim() : ''; + + for (let ii = 0; ii < section.list.length; ii++) { + const item = section.list[ii]; + const rawName = item.name || ''; + const itemName = stripHtml(rawName).toLowerCase().trim(); + if (/ 0 ? portionCount : 1; + + const fmt = (val: number, unit: string) => `${Math.round(val / div)} ${unit}`; + + const info: Record = { + '@type': 'NutritionInformation', + calories: `${Math.round(totals.calories / div)} calories`, + proteinContent: fmt(totals.protein, 'g'), + fatContent: fmt(totals.fat, 'g'), + saturatedFatContent: fmt(totals.saturatedFat, 'g'), + carbohydrateContent: fmt(totals.carbs, 'g'), + fiberContent: fmt(totals.fiber, 'g'), + sugarContent: fmt(totals.sugars, 'g'), + sodiumContent: fmt(totals.sodium, 'mg'), + cholesterolContent: fmt(totals.cholesterol, 'mg'), + }; + + if (portionCount > 0) { + info.servingSize = `1 portion (${portionCount} total)`; + } + + return info; } \ No newline at end of file