import type { ParsedRecipe, RecipeSourceAdapter, RecipeSourceListParams, RecipeSourceListResult, } from "../lib/recipe-sources/recipe-source-adapter.js"; import { RecipeSourceFetchError, RecipeSourceParseError, } from "../lib/recipe-sources/recipe-source-errors.js"; const SOURCE_KEY = "theMealDb"; // TheMealDB documents "1" as a shared, public test key, free to use for // development (https://www.themealdb.com/api.php) — a deployment serving // real traffic is expected to use a supporter-tier key instead (paid, via // Patreon). Configurable here via an env var without touching anything // else in this adapter. const API_KEY = process.env.THE_MEAL_DB_API_KEY ?? "1"; const API_BASE = `https://www.themealdb.com/api/json/v1/${API_KEY}`; /** * TheMealDB's flat meal shape — ingredients/measures are 20 numbered * field pairs (`strIngredient1`/`strMeasure1` … `strIngredient20`/ * `strMeasure20`), not an array, hence the string index signature rather * than 20 explicit optional properties. */ export interface TheMealDbMeal { idMeal: string; strMeal: string | null; strMealThumb: string | null; strInstructions: string | null; [key: string]: string | null | undefined; } interface TheMealDbMealsResponse { meals: TheMealDbMeal[] | null; } async function fetchTheMealDb(path: string): Promise { try { let response: Response; try { response = await fetch(`${API_BASE}${path}`); } catch (cause) { throw new RecipeSourceFetchError(SOURCE_KEY, `Network error calling TheMealDB (${path})`, { cause, }); } if (!response.ok) { throw new RecipeSourceFetchError( SOURCE_KEY, `TheMealDB responded ${response.status} (${path})`, ); } return (await response.json()) as T; } catch (err) { // Rethrown as-is — this adapter's only caller (`sources.service.ts`) // already handles/logs failures centrally; this method just isn't // allowed a bare `await` per the repo's async/try-catch convention. throw err; } } function detailUrl(idMeal: string): string { return `https://www.themealdb.com/meal/${idMeal}`; } /** Category-list response shape (`/list.php?c=list`) — same `meals` envelope as everything else, but each row only carries `strCategory`. */ interface TheMealDbCategoryListResponse { meals: { strCategory: string | null }[] | null; } /** Narrows to meals that actually carry a name — a titleless row isn't worth surfacing in a browse list. */ function hasName(meal: TheMealDbMeal): meal is TheMealDbMeal & { strMeal: string } { return Boolean(meal.strMeal); } /** Maps one raw meal (from `/search.php` or `/filter.php`) into the generic list-item shape. */ function toListItem(meal: TheMealDbMeal & { strMeal: string }) { return { externalId: meal.idMeal, title: meal.strMeal, picture: meal.strMealThumb, url: detailUrl(meal.idMeal), }; } /** * Name search (`/search.php?s=`) — the only mode TheMealDB's free tier * offers for a non-empty query. One response holds every match, so there's * never a next page. */ async function searchByName(query: string): Promise { try { const data = await fetchTheMealDb( `/search.php?s=${encodeURIComponent(query)}`, ); return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor: null }; } catch (err) { throw err; // see fetchTheMealDb()'s catch comment above } } /** * "Browse everything" (an empty/omitted query). TheMealDB's free tier has * no catalog-wide listing endpoint — `/search.php?s=` only answers with a * fixed ~25-meal sample — so this walks the category list instead: * `/list.php?c=list` for the categories, then `/filter.php?c={category}` * for one category at a time, exposing **one category per cursor step** so * the infinite scroll keeps pulling real, distinct pages instead of * stalling on that sample (issue #38). The category list is re-fetched on * each step rather than cached in module state — it's a single small * request, it keeps this adapter stateless like the others, and the walk * is paced one step per scroll anyway. */ async function listByCategory(cursor: string | null | undefined): Promise { try { const parsed = Number.parseInt(cursor ?? "0", 10); const startAt = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed; const categoryList = await fetchTheMealDb("/list.php?c=list"); const categories = (categoryList.meals ?? []) .map((row) => row.strCategory) .filter((name): name is string => Boolean(name)); const category = categories[startAt]; if (category === undefined) { // Cursor has walked past the last category — the clean end of the list. return { items: [], nextCursor: null }; } const data = await fetchTheMealDb( `/filter.php?c=${encodeURIComponent(category)}`, ); const nextCursor = startAt + 1 < categories.length ? String(startAt + 1) : null; return { items: (data.meals ?? []).filter(hasName).map(toListItem), nextCursor }; } catch (err) { throw err; // see fetchTheMealDb()'s catch comment above } } /** * TheMealDB (themealdb.com) — a free, public recipe API (no scraping: the * publisher's own structured JSON, hence `official: true`). The first real * `RecipeSourceAdapter` implementation, proving the generic contract * (recipe-source-adapter.ts) end to end against a live source. * * `list()` has two modes (see the helpers above): a non-empty `query` is a * name search (`/search.php?s=`, single unpaginated response, * `nextCursor: null`); an empty/omitted `query` — this contract's "browse * everything" — walks the category list one category per cursor step, * since the free tier has no real catalog-wide endpoint. */ export const theMealDbAdapter: RecipeSourceAdapter = { key: SOURCE_KEY, name: "TheMealDB", official: true, iconUrl: "https://www.themealdb.com/images/logo.svg", // TheMealDB's content (names, ingredients, instructions) is English — // determines which locale translateRecipe (recipe-translation.ts) // resolves this source's recipes against when previewing/importing one. locale: "en", async list(params: RecipeSourceListParams): Promise { try { const query = (params.query ?? "").trim(); return query.length > 0 ? await searchByName(query) : await listByCategory(params.cursor); } catch (err) { throw err; // see fetchTheMealDb()'s catch comment above } }, async fetchDetail(externalId: string): Promise { try { const data = await fetchTheMealDb(`/lookup.php?i=${externalId}`); const meal = data.meals?.[0]; if (!meal) { throw new RecipeSourceFetchError(SOURCE_KEY, `No meal found for id "${externalId}"`); } return meal; } catch (err) { throw err; // see fetchTheMealDb()'s catch comment above } }, parse(meal: TheMealDbMeal): ParsedRecipe { if (!meal.strMeal) { throw new RecipeSourceParseError(SOURCE_KEY, "Meal is missing its name (strMeal)"); } const ingredients = []; for (let i = 1; i <= 20; i++) { const name = meal[`strIngredient${i}`]?.trim(); if (!name) continue; const measure = meal[`strMeasure${i}`]?.trim(); ingredients.push({ rawText: measure ? `${measure} ${name}` : name, quantity: null, unit: null, name, }); } // Free-text instructions, usually one step per line — splitting on // blank/newlines is the closest this source gets to discrete steps. // TheMealDB frequently numbers each step on its own line ahead of the // paragraph that follows (e.g. "…melted.\n\n2\n\nPreheat oven…") rather // than inline ("1. Preheat oven…") — the blank-line split above turns // that lone number into its own "line", which would otherwise become a // bogus step containing nothing but a digit. Drop those rather than // keep them as steps in their own right (see issue #52). const steps = (meal.strInstructions ?? "") .split(/\r?\n+/) .map((line) => line.trim()) .filter((line) => line.length > 0) .filter((line) => !/^\d+\.?$/.test(line)) .map((description) => ({ description, picture: null })); if (steps.length === 0) { throw new RecipeSourceParseError( SOURCE_KEY, `Meal "${meal.strMeal}" has no usable instructions`, ); } return { name: meal.strMeal, description: null, picture: meal.strMealThumb, // TheMealDB's free API doesn't state a serving size. portions: null, sourceUrl: detailUrl(meal.idMeal), ingredients, steps, }; }, };