Compare commits

2 Commits

Author SHA1 Message Date
97969f8151 nutrition: extract shared ref resolution, fix HTML in ingredient names
All checks were successful
CI / update (push) Successful in 4m45s
- Move parseAnchorRecipeRef and resolveReferencedNutrition from the
  items endpoint into nutritionMatcher.ts for reuse
- JSON-LD endpoint now includes nutrition from referenced recipes
  (base recipe refs and anchor-tag ingredient refs)
- Strip HTML tags in normalizeIngredientName/De before matching to
  prevent regex crash on ingredients containing anchor tags
- Escape regex special chars in substringMatchScore word-boundary check
2026-04-03 11:15:55 +02:00
9b2325a0cb 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.
2026-04-03 11:15:49 +02:00
4 changed files with 101 additions and 75 deletions

View File

@@ -55,7 +55,14 @@ interface RecipeJsonLd {
[key: string]: unknown;
}
export function generateRecipeJsonLd(data: RecipeModelType) {
type ReferencedNutrition = {
shortName: string;
name: string;
nutrition: Record<string, number>;
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<string, string> | 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

View File

@@ -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(/<a\s+href=["']?([^"' >]+)["']?[^>]*>/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<string, number>;
baseMultiplier: number;
};
/**
* Build nutrition totals for referenced recipes:
* 1. Base recipe references (type='reference' with populated baseRecipeRef)
* 2. Anchor-tag references in ingredient names (<a href=...>)
*/
export async function resolveReferencedNutrition(
ingredients: any[],
): Promise<ReferencedNutritionResult[]> {
const { Recipe } = await import('$models/Recipe');
const results: ReferencedNutritionResult[] = [];
const processedSlugs = new Set<string>();
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;
}

View File

@@ -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(/<a\s+href=["']?([^"' >]+)["']?[^>]*>/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 (<a href=...>)
*/
async function resolveReferencedNutrition(
ingredients: any[],
): Promise<{ shortName: string; name: string; nutrition: Record<string, number>; baseMultiplier: number }[]> {
const results: { shortName: string; name: string; nutrition: Record<string, number>; baseMultiplier: number }[] = [];
const processedSlugs = new Set<string>();
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!);

View File

@@ -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({