diff --git a/apps/api/prisma/migrations/20260820130000_source_icon_url/migration.sql b/apps/api/prisma/migrations/20260820130000_source_icon_url/migration.sql new file mode 100644 index 0000000..2590cfd --- /dev/null +++ b/apps/api/prisma/migrations/20260820130000_source_icon_url/migration.sql @@ -0,0 +1,6 @@ +-- Adds `Source.icon_url` — the source's own logo/favicon, shown next to +-- its name in the `SourceSelect` picker (apps/web). Nullable, no backfill +-- needed for existing rows (none had one to begin with). + +-- AlterTable +ALTER TABLE "sources" ADD COLUMN "icon_url" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index cf35cfb..5514042 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -237,6 +237,11 @@ model Source { /// sources to enable (see `HouseSource`) so scraped content is never /// mistaken for an official feed. official Boolean + /// The source's own logo/favicon URL, shown next to its name in + /// `SourceSelect` (apps/web) — mirrors `RecipeSourceAdapter.iconUrl`, + /// synced the same way as `name`/`official`. `null` if the source has + /// none worth showing. + iconUrl String? @map("icon_url") recipes Recipe[] enabledHouses HouseSource[] diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 6bc8ea6..1db9bd9 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,6 +1,7 @@ import { PrismaClient } from "@prisma/client"; import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { seedReferenceData } from "../src/db/reference-seed-data.js"; +import { registerAllRecipeSources } from "../src/sources/index.js"; // Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in // the rest of the app's config/env plumbing this doesn't need), run via @@ -9,6 +10,7 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js"; // `prisma migrate reset`. The actual data/logic lives in // `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`. const prisma = new PrismaClient(); +registerAllRecipeSources(); seedReferenceData(prisma) .then(() => syncRecipeSources(prisma)) diff --git a/apps/api/src/db/recipe-source-sync.ts b/apps/api/src/db/recipe-source-sync.ts index 1d8f57a..f6ae4aa 100644 --- a/apps/api/src/db/recipe-source-sync.ts +++ b/apps/api/src/db/recipe-source-sync.ts @@ -18,16 +18,23 @@ import { listRecipeSources } from "../lib/recipe-source-registry.js"; * out from under it (see `onDelete: SetNull` on `Recipe.source` in * schema.prisma, which is what *would* happen on an actual delete). * - * Safe to call with an empty registry — currently always the case, since - * no concrete adapter exists yet (see recipe-source-adapter.ts) — leaves - * the `sources` table untouched. + * Safe to call with an empty registry (leaves the `sources` table + * untouched) — the case whenever nothing has called `registerRecipeSource` + * yet, e.g. most test files (see `apps/api/src/sources/index.ts` for where + * the app's own concrete adapters — currently just TheMealDB — register + * themselves at startup). */ export async function syncRecipeSources(prisma: PrismaClient): Promise { for (const adapter of listRecipeSources()) { await prisma.source.upsert({ where: { key: adapter.key }, - update: { name: adapter.name, official: adapter.official }, - create: { key: adapter.key, name: adapter.name, official: adapter.official }, + update: { name: adapter.name, official: adapter.official, iconUrl: adapter.iconUrl }, + create: { + key: adapter.key, + name: adapter.name, + official: adapter.official, + iconUrl: adapter.iconUrl, + }, }); } } diff --git a/apps/api/src/lib/recipe-source-adapter.ts b/apps/api/src/lib/recipe-source-adapter.ts index d903fa9..2db6816 100644 --- a/apps/api/src/lib/recipe-source-adapter.ts +++ b/apps/api/src/lib/recipe-source-adapter.ts @@ -145,6 +145,7 @@ export interface ParsedRecipe { * key: "someRecipeSite", * name: "Some Recipe Site", * official: false, + * iconUrl: "https://somerecipesite.example/favicon.svg", * async list(params) { ... }, * async fetchDetail(externalId) { ... }, * parse(raw) { ... }, @@ -168,6 +169,8 @@ export interface RecipeSourceAdapter { * inheriting a guess. */ official: boolean; + /** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */ + iconUrl: string | null; list(params: RecipeSourceListParams): Promise; fetchDetail(externalId: string): Promise; parse(raw: TRawDetail): ParsedRecipe; diff --git a/apps/api/src/modules/reference/reference.service.ts b/apps/api/src/modules/reference/reference.service.ts index 2a933af..01baf80 100644 --- a/apps/api/src/modules/reference/reference.service.ts +++ b/apps/api/src/modules/reference/reference.service.ts @@ -78,7 +78,7 @@ export async function getSources(): Promise { // `SourceView` yet, so it must not leak into the response the way a bare // `findMany()` would let it. return prisma.source.findMany({ - select: { id: true, key: true, name: true, official: true }, + select: { id: true, key: true, name: true, official: true, iconUrl: true }, orderBy: { name: "asc" }, }); } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index f7753cc..388c3a8 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,5 +1,11 @@ import { createServer } from "./app.js"; import { env } from "./config/env.js"; +import { registerAllRecipeSources } from "./sources/index.js"; + +// Populates the recipe-source registry (recipe-source-registry.ts) before +// the app starts — see registerAllRecipeSources' doc comment for why this +// doesn't happen inside app.ts/createServer() itself. +registerAllRecipeSources(); const server = createServer(); diff --git a/apps/api/src/sources/index.ts b/apps/api/src/sources/index.ts new file mode 100644 index 0000000..2457d1a --- /dev/null +++ b/apps/api/src/sources/index.ts @@ -0,0 +1,24 @@ +import { registerRecipeSource } from "../lib/recipe-source-registry.js"; +import { theMealDbAdapter } from "./the-meal-db.js"; + +/** + * Registers every concrete `RecipeSourceAdapter` this app ships with into + * the shared in-memory registry (`recipe-source-registry.ts`) — currently + * just `theMealDbAdapter`. Called once, explicitly, by the two real entry + * points that need the registry populated: + * + * - `server.ts` — the running API process, before it starts listening. + * - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into + * the `sources` table. + * + * Deliberately **not** imported by `app.ts`: `createApp()` is what every + * test file gets via supertest, and registering a real adapter there would + * make its presence in the registry depend on test *order* (once + * registered at module load, nothing re-registers it after a test's + * `clearRecipeSources()` clears it out) instead of each test's own + * explicit setup. Tests that need a source in the registry register their + * own throwaway fake instead (see e.g. `test/recipe-source-sync.test.ts`). + */ +export function registerAllRecipeSources(): void { + registerRecipeSource(theMealDbAdapter); +} diff --git a/apps/api/src/sources/the-meal-db.ts b/apps/api/src/sources/the-meal-db.ts new file mode 100644 index 0000000..b43d6d6 --- /dev/null +++ b/apps/api/src/sources/the-meal-db.ts @@ -0,0 +1,151 @@ +import type { + ParsedRecipe, + RecipeSourceAdapter, + RecipeSourceListParams, + RecipeSourceListResult, +} from "../lib/recipe-source-adapter.js"; +import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/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 { + 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 response.json() as Promise; +} + +function detailUrl(idMeal: string): string { + return `https://www.themealdb.com/meal/${idMeal}`; +} + +/** + * 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()` is search-only — TheMealDB has no dedicated "browse everything" + * endpoint on its free tier. An omitted `query` searches for an empty + * string, which TheMealDB happens to answer with a small default sample + * (~25 meals) rather than nothing — close enough to this contract's + * "omitted `query` means browse everything" convention + * (`RecipeSourceListParams.query`) to lean on as-is, though it's a fixed + * sample, not the whole catalog. Search isn't paginated either — one + * response holds every match, so `nextCursor` is always `null`. + */ +export const theMealDbAdapter: RecipeSourceAdapter = { + key: SOURCE_KEY, + name: "TheMealDB", + official: true, + iconUrl: "https://www.themealdb.com/images/logo.svg", + + async list(params: RecipeSourceListParams): Promise { + const query = params.query ?? ""; + const data = await fetchTheMealDb( + `/search.php?s=${encodeURIComponent(query)}`, + ); + const meals = data.meals ?? []; + return { + items: meals + .filter((meal): meal is TheMealDbMeal & { strMeal: string } => Boolean(meal.strMeal)) + .map((meal) => ({ + externalId: meal.idMeal, + title: meal.strMeal, + picture: meal.strMealThumb, + url: detailUrl(meal.idMeal), + })), + nextCursor: null, + }; + }, + + async fetchDetail(externalId: string): Promise { + 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; + }, + + 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. + const steps = (meal.strInstructions ?? "") + .split(/\r?\n+/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .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, + }; + }, +}; diff --git a/apps/api/test/house.test.ts b/apps/api/test/house.test.ts index 8c2c4f5..f25fa60 100644 --- a/apps/api/test/house.test.ts +++ b/apps/api/test/house.test.ts @@ -21,12 +21,13 @@ function buildSignupPayload(): SignupInput { }; } -/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */ +/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { return { key, name, official: false, + iconUrl: null, async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/recipe-source-sync.test.ts b/apps/api/test/recipe-source-sync.test.ts index 7b863a8..05f9c4e 100644 --- a/apps/api/test/recipe-source-sync.test.ts +++ b/apps/api/test/recipe-source-sync.test.ts @@ -21,12 +21,13 @@ function buildSignupPayload(): SignupInput { }; } -/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name`/`official` matter for exercising `syncRecipeSources`. */ +/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name`/`official`/`iconUrl` matter for exercising `syncRecipeSources`. */ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { return { key, name, official: false, + iconUrl: null, async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/recipe-source.test.ts b/apps/api/test/recipe-source.test.ts index 1b91a33..45d0c36 100644 --- a/apps/api/test/recipe-source.test.ts +++ b/apps/api/test/recipe-source.test.ts @@ -59,6 +59,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter { const start = params.cursor ? Number(params.cursor) : 0; const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE); diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 1f2cb8a..d3740ba 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -10,12 +10,13 @@ import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js"; import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js"; import { resetDatabase } from "../test-support/reset-db.js"; -/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */ +/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { return { key, name, official: false, + iconUrl: null, async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index bdf3d09..245f508 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -8,12 +8,18 @@ import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js"; import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js"; import { resetDatabase } from "../test-support/reset-db.js"; -/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */ -function buildFakeAdapter(key: string, name: string, official: boolean): RecipeSourceAdapter { +/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official`/`iconUrl` matter for `syncRecipeSources` fixtures here. */ +function buildFakeAdapter( + key: string, + name: string, + official: boolean, + iconUrl: string | null = null, +): RecipeSourceAdapter { return { key, name, official, + iconUrl, async list() { return { items: [], nextCursor: null }; }, @@ -159,19 +165,28 @@ describe("Reference data", () => { expect(res.body).to.deep.equal([]); }); - it("returns every synced adapter, official flag included, no session required", async () => { + it("returns every synced adapter, official flag and icon included, no session required", async () => { registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source", false)); - registerRecipeSource(buildFakeAdapter("officialSource", "Official Source", true)); + registerRecipeSource( + buildFakeAdapter( + "officialSource", + "Official Source", + true, + "https://example.test/icon.svg", + ), + ); await syncRecipeSources(prisma); const res = await request(app).get("/reference/sources"); expect(res.status).to.equal(200); expect(res.body).to.have.length(2); - expect(res.body[0]).to.have.keys(["id", "key", "name", "official"]); + expect(res.body[0]).to.have.keys(["id", "key", "name", "official", "iconUrl"]); const byKey = (key: string) => res.body.find((s: { key: string }) => s.key === key); expect(byKey("fakeSource").official).to.equal(false); + expect(byKey("fakeSource").iconUrl).to.equal(null); expect(byKey("officialSource").official).to.equal(true); + expect(byKey("officialSource").iconUrl).to.equal("https://example.test/icon.svg"); }); it("orders sources alphabetically by name", async () => { diff --git a/apps/api/test/sources-index.test.ts b/apps/api/test/sources-index.test.ts new file mode 100644 index 0000000..a9600f2 --- /dev/null +++ b/apps/api/test/sources-index.test.ts @@ -0,0 +1,27 @@ +import { expect } from "chai"; +import { + clearRecipeSources, + getRecipeSource, + listRecipeSources, +} from "../src/lib/recipe-source-registry.js"; +import { registerAllRecipeSources } from "../src/sources/index.js"; + +// Not exercised by any other test file — `registerAllRecipeSources` is +// deliberately never imported by `app.ts` (see its own doc comment), so +// nothing else in the suite triggers it. Registers/clears explicitly here +// rather than relying on module-load order, so this test's outcome doesn't +// depend on which other test file Mocha happens to load first. +describe("registerAllRecipeSources", () => { + afterEach(() => { + clearRecipeSources(); + }); + + it("registers TheMealDB into the shared registry", () => { + registerAllRecipeSources(); + + const theMealDb = getRecipeSource("theMealDb"); + expect(theMealDb).to.not.be.undefined; + expect(theMealDb?.name).to.equal("TheMealDB"); + expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb"); + }); +}); diff --git a/apps/api/test/the-meal-db.test.ts b/apps/api/test/the-meal-db.test.ts new file mode 100644 index 0000000..7427146 --- /dev/null +++ b/apps/api/test/the-meal-db.test.ts @@ -0,0 +1,175 @@ +import { expect } from "chai"; +import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js"; +import { type TheMealDbMeal, theMealDbAdapter } from "../src/sources/the-meal-db.js"; + +/** + * Stubs `globalThis.fetch` for one test — no HTTP-mocking library exists + * in this codebase yet (this is the first module that talks to a real + * external network), and a single reassignable global covers the handful + * of call shapes this adapter needs without adding a new dependency. + * Restored by the `afterEach` below regardless of which test used it. + */ +function stubFetch(body: unknown, status = 200) { + globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as typeof fetch; +} + +const baseMeal: TheMealDbMeal = { + idMeal: "52795", + strMeal: "Chicken Handi", + strMealThumb: "https://www.themealdb.com/images/media/meals/wyxwsp1486979827.jpg", + strInstructions: "Step one.\r\nStep two.\r\n\r\nStep three.", + strIngredient1: "Chicken", + strMeasure1: "1 kg", + strIngredient2: " ", + strMeasure2: "2 tbsp", + strIngredient3: "Onion", + strMeasure3: "", +}; + +describe("theMealDbAdapter", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("declares itself as an official source, with a key/name/icon", () => { + expect(theMealDbAdapter.key).to.equal("theMealDb"); + expect(theMealDbAdapter.name).to.equal("TheMealDB"); + expect(theMealDbAdapter.official).to.equal(true); + expect(theMealDbAdapter.iconUrl).to.be.a("string"); + }); + + describe("list", () => { + it("maps search results into RecipeSourceListItems", async () => { + stubFetch({ + meals: [ + { idMeal: "1", strMeal: "Test Meal", strMealThumb: "https://example.test/thumb.jpg" }, + ], + }); + + const result = await theMealDbAdapter.list({ query: "test" }); + + expect(result.items).to.deep.equal([ + { + externalId: "1", + title: "Test Meal", + picture: "https://example.test/thumb.jpg", + url: "https://www.themealdb.com/meal/1", + }, + ]); + expect(result.nextCursor).to.be.null; + }); + + it("returns an empty list when the API responds with meals: null", async () => { + stubFetch({ meals: null }); + + const result = await theMealDbAdapter.list({ query: "doesnotexist" }); + + expect(result.items).to.deep.equal([]); + expect(result.nextCursor).to.be.null; + }); + + it("skips a meal with no name rather than surfacing a titleless item", async () => { + stubFetch({ meals: [{ idMeal: "1", strMeal: null, strMealThumb: null }] }); + + const result = await theMealDbAdapter.list({ query: "x" }); + + expect(result.items).to.deep.equal([]); + }); + + it("throws RecipeSourceFetchError on a non-2xx response", async () => { + stubFetch({}, 500); + + try { + await theMealDbAdapter.list({ query: "x" }); + expect.fail("expected list to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + } + }); + + it("throws RecipeSourceFetchError when the network request itself fails", async () => { + globalThis.fetch = (async () => { + throw new Error("network down"); + }) as typeof fetch; + + try { + await theMealDbAdapter.list({ query: "x" }); + expect.fail("expected list to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error); + } + }); + }); + + describe("fetchDetail", () => { + it("returns the first meal from the lookup response", async () => { + stubFetch({ meals: [baseMeal] }); + + const result = await theMealDbAdapter.fetchDetail("52795"); + + expect(result).to.deep.equal(baseMeal); + }); + + it("throws RecipeSourceFetchError when no meal matches the id", async () => { + stubFetch({ meals: null }); + + try { + await theMealDbAdapter.fetchDetail("999999"); + expect.fail("expected fetchDetail to throw"); + } catch (err) { + expect(err).to.be.instanceOf(RecipeSourceFetchError); + } + }); + }); + + describe("parse", () => { + it("maps name/picture/sourceUrl and splits instructions into steps", () => { + const parsed = theMealDbAdapter.parse(baseMeal); + + expect(parsed.name).to.equal("Chicken Handi"); + expect(parsed.description).to.be.null; + expect(parsed.picture).to.equal(baseMeal.strMealThumb); + expect(parsed.portions).to.be.null; + expect(parsed.sourceUrl).to.equal("https://www.themealdb.com/meal/52795"); + expect(parsed.steps).to.deep.equal([ + { description: "Step one.", picture: null }, + { description: "Step two.", picture: null }, + { description: "Step three.", picture: null }, + ]); + }); + + it("skips blank ingredient slots and keeps the measure alongside the name in rawText", () => { + const parsed = theMealDbAdapter.parse(baseMeal); + + expect(parsed.ingredients).to.deep.equal([ + { rawText: "1 kg Chicken", quantity: null, unit: null, name: "Chicken" }, + { rawText: "Onion", quantity: null, unit: null, name: "Onion" }, + ]); + }); + + it("throws RecipeSourceParseError when the meal has no name", () => { + expect(() => theMealDbAdapter.parse({ ...baseMeal, strMeal: null })).to.throw( + RecipeSourceParseError, + ); + }); + + it("throws RecipeSourceParseError when there are no usable instructions", () => { + expect(() => + theMealDbAdapter.parse({ ...baseMeal, strInstructions: " \r\n\r\n " }), + ).to.throw(RecipeSourceParseError); + }); + + it("throws RecipeSourceParseError when instructions are null", () => { + expect(() => theMealDbAdapter.parse({ ...baseMeal, strInstructions: null })).to.throw( + RecipeSourceParseError, + ); + }); + }); +}); diff --git a/apps/web/src/features/house/SourceSelect.tsx b/apps/web/src/features/house/SourceSelect.tsx index 65a4f52..976bb41 100644 --- a/apps/web/src/features/house/SourceSelect.tsx +++ b/apps/web/src/features/house/SourceSelect.tsx @@ -20,11 +20,13 @@ interface SourceSelectProps { * (opt-in — see `HouseSource` in schema.prisma), not an incomplete one. * * Unlike `AllergySelect`, a source's display text is its own `name` - * (`SourceView.name` — a proper noun like "Marmiton"), not resolved + * (`SourceView.name` — a proper noun like "TheMealDB"), not resolved * through `catalog..` i18n — nothing to translate. The * `official`/`unofficial` badge next to it is what *is* translated, so a * household can tell an official API apart from a scraped site before - * deciding whether to trust it. + * deciding whether to trust it. `iconUrl`, when the source has one, is + * shown as a small logo before the name — purely decorative (`alt=""`), + * the name text already carries the information. */ export function SourceSelect({ legend, sources, value, onChange }: SourceSelectProps) { const { t } = useTranslation(); @@ -45,6 +47,7 @@ export function SourceSelect({ legend, sources, value, onChange }: SourceSelectP onChange={() => toggle(source.id)} className="source-select__option" > + {source.iconUrl && } {source.name} {t(source.official ? "household.sources.official" : "household.sources.unofficial")} diff --git a/apps/web/src/features/house/house-forms.scss b/apps/web/src/features/house/house-forms.scss index 7425232..c9af5be 100644 --- a/apps/web/src/features/house/house-forms.scss +++ b/apps/web/src/features/house/house-forms.scss @@ -25,6 +25,15 @@ font-size: var(--font-size-base); } + // The source's own logo — small and square, never bigger than the text + // line it sits next to regardless of the source image's real dimensions. + &__icon { + width: 1.1em; + height: 1.1em; + object-fit: contain; + flex-shrink: 0; + } + // Official/unofficial marker — same "small pill" language as // `settings-pages.scss`'s `.settings-page__member-badge`, neutral grey by // default (unofficial/scraped) and tinted primary once official. diff --git a/packages/shared/src/types/reference.ts b/packages/shared/src/types/reference.ts index 6dff5fd..1653b58 100644 --- a/packages/shared/src/types/reference.ts +++ b/packages/shared/src/types/reference.ts @@ -219,18 +219,21 @@ export interface TechStepView { * the API — only a real adapter being registered in code adds a row). * * Unlike `DietView`/`TechStepView`, `name` is the actual display string - * (e.g. `"Marmiton"`) rather than a `key` resolved through + * (e.g. `"TheMealDB"`) rather than a `key` resolved through * `catalog..` — a source's name is a proper noun/brand, * nothing to translate. `official` distinguishes a source backed by an * official API from one built by scraping HTML the site never committed to * a stable shape — surfaced so households can make an informed choice when * picking which sources to enable (see `HouseSource` in schema.prisma). + * `iconUrl` is the source's own logo/favicon, `null` if it has none worth + * showing — `SourceSelect` (apps/web) renders it next to `name`. */ export interface SourceView { id: number; key: string; name: string; official: boolean; + iconUrl: string | null; } /**