Merge pull request #38 from kyuno053/feat/themealdb-source-icon

feat(recipes): première source concrète (TheMealDB) + icône de source
This commit is contained in:
kyuno053 2026-08-20 11:57:22 +02:00 committed by GitHub
commit 89937d7a6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 457 additions and 17 deletions

View file

@ -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;

View file

@ -237,6 +237,11 @@ model Source {
/// sources to enable (see `HouseSource`) so scraped content is never /// sources to enable (see `HouseSource`) so scraped content is never
/// mistaken for an official feed. /// mistaken for an official feed.
official Boolean 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[] recipes Recipe[]
enabledHouses HouseSource[] enabledHouses HouseSource[]

View file

@ -1,6 +1,7 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { seedReferenceData } from "../src/db/reference-seed-data.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 // 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 // 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 // `prisma migrate reset`. The actual data/logic lives in
// `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`. // `src/db/reference-seed-data.ts`, shared with `test-support/reset-db.ts`.
const prisma = new PrismaClient(); const prisma = new PrismaClient();
registerAllRecipeSources();
seedReferenceData(prisma) seedReferenceData(prisma)
.then(() => syncRecipeSources(prisma)) .then(() => syncRecipeSources(prisma))

View file

@ -18,16 +18,23 @@ import { listRecipeSources } from "../lib/recipe-source-registry.js";
* out from under it (see `onDelete: SetNull` on `Recipe.source` in * out from under it (see `onDelete: SetNull` on `Recipe.source` in
* schema.prisma, which is what *would* happen on an actual delete). * schema.prisma, which is what *would* happen on an actual delete).
* *
* Safe to call with an empty registry currently always the case, since * Safe to call with an empty registry (leaves the `sources` table
* no concrete adapter exists yet (see recipe-source-adapter.ts) leaves * untouched) the case whenever nothing has called `registerRecipeSource`
* the `sources` table untouched. * 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<void> { export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
for (const adapter of listRecipeSources()) { for (const adapter of listRecipeSources()) {
await prisma.source.upsert({ await prisma.source.upsert({
where: { key: adapter.key }, where: { key: adapter.key },
update: { 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 }, create: {
key: adapter.key,
name: adapter.name,
official: adapter.official,
iconUrl: adapter.iconUrl,
},
}); });
} }
} }

View file

@ -145,6 +145,7 @@ export interface ParsedRecipe {
* key: "someRecipeSite", * key: "someRecipeSite",
* name: "Some Recipe Site", * name: "Some Recipe Site",
* official: false, * official: false,
* iconUrl: "https://somerecipesite.example/favicon.svg",
* async list(params) { ... }, * async list(params) { ... },
* async fetchDetail(externalId) { ... }, * async fetchDetail(externalId) { ... },
* parse(raw) { ... }, * parse(raw) { ... },
@ -168,6 +169,8 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
* inheriting a guess. * inheriting a guess.
*/ */
official: boolean; 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<RecipeSourceListResult>; list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
fetchDetail(externalId: string): Promise<TRawDetail>; fetchDetail(externalId: string): Promise<TRawDetail>;
parse(raw: TRawDetail): ParsedRecipe; parse(raw: TRawDetail): ParsedRecipe;

View file

@ -78,7 +78,7 @@ export async function getSources(): Promise<SourceView[]> {
// `SourceView` yet, so it must not leak into the response the way a bare // `SourceView` yet, so it must not leak into the response the way a bare
// `findMany()` would let it. // `findMany()` would let it.
return prisma.source.findMany({ 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" }, orderBy: { name: "asc" },
}); });
} }

View file

@ -1,5 +1,11 @@
import { createServer } from "./app.js"; import { createServer } from "./app.js";
import { env } from "./config/env.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(); const server = createServer();

View file

@ -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);
}

View file

@ -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<T>(path: string): Promise<T> {
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<T>;
}
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<TheMealDbMeal> = {
key: SOURCE_KEY,
name: "TheMealDB",
official: true,
iconUrl: "https://www.themealdb.com/images/logo.svg",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
const query = params.query ?? "";
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/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<TheMealDbMeal> {
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/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,
};
},
};

View file

@ -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 { function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
return { return {
key, key,
name, name,
official: false, official: false,
iconUrl: null,
async list() { async list() {
return { items: [], nextCursor: null }; return { items: [], nextCursor: null };
}, },

View file

@ -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 { function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
return { return {
key, key,
name, name,
official: false, official: false,
iconUrl: null,
async list() { async list() {
return { items: [], nextCursor: null }; return { items: [], nextCursor: null };
}, },

View file

@ -59,6 +59,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<FakeRawRecipe
key, key,
name: "Fake Source", name: "Fake Source",
official: false, official: false,
iconUrl: null,
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> { async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
const start = params.cursor ? Number(params.cursor) : 0; const start = params.cursor ? Number(params.cursor) : 0;
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE); const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);

View file

@ -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 { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.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 { function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
return { return {
key, key,
name, name,
official: false, official: false,
iconUrl: null,
async list() { async list() {
return { items: [], nextCursor: null }; return { items: [], nextCursor: null };
}, },

View file

@ -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 { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.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, official: boolean): RecipeSourceAdapter { function buildFakeAdapter(
key: string,
name: string,
official: boolean,
iconUrl: string | null = null,
): RecipeSourceAdapter {
return { return {
key, key,
name, name,
official, official,
iconUrl,
async list() { async list() {
return { items: [], nextCursor: null }; return { items: [], nextCursor: null };
}, },
@ -159,19 +165,28 @@ describe("Reference data", () => {
expect(res.body).to.deep.equal([]); 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("fakeSource", "Fake Source", false));
registerRecipeSource(buildFakeAdapter("officialSource", "Official Source", true)); registerRecipeSource(
buildFakeAdapter(
"officialSource",
"Official Source",
true,
"https://example.test/icon.svg",
),
);
await syncRecipeSources(prisma); await syncRecipeSources(prisma);
const res = await request(app).get("/reference/sources"); const res = await request(app).get("/reference/sources");
expect(res.status).to.equal(200); expect(res.status).to.equal(200);
expect(res.body).to.have.length(2); 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); const byKey = (key: string) => res.body.find((s: { key: string }) => s.key === key);
expect(byKey("fakeSource").official).to.equal(false); expect(byKey("fakeSource").official).to.equal(false);
expect(byKey("fakeSource").iconUrl).to.equal(null);
expect(byKey("officialSource").official).to.equal(true); 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 () => { it("orders sources alphabetically by name", async () => {

View file

@ -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");
});
});

View file

@ -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,
);
});
});
});

View file

@ -20,11 +20,13 @@ interface SourceSelectProps {
* (opt-in see `HouseSource` in schema.prisma), not an incomplete one. * (opt-in see `HouseSource` in schema.prisma), not an incomplete one.
* *
* Unlike `AllergySelect`, a source's display text is its own `name` * 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.<domain>.<key>` i18n nothing to translate. The * through `catalog.<domain>.<key>` i18n nothing to translate. The
* `official`/`unofficial` badge next to it is what *is* translated, so a * `official`/`unofficial` badge next to it is what *is* translated, so a
* household can tell an official API apart from a scraped site before * 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) { export function SourceSelect({ legend, sources, value, onChange }: SourceSelectProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -45,6 +47,7 @@ export function SourceSelect({ legend, sources, value, onChange }: SourceSelectP
onChange={() => toggle(source.id)} onChange={() => toggle(source.id)}
className="source-select__option" className="source-select__option"
> >
{source.iconUrl && <img src={source.iconUrl} alt="" className="source-select__icon" />}
{source.name} {source.name}
<span className={`source-select__badge ${source.official ? "is-official" : ""}`}> <span className={`source-select__badge ${source.official ? "is-official" : ""}`}>
{t(source.official ? "household.sources.official" : "household.sources.unofficial")} {t(source.official ? "household.sources.official" : "household.sources.unofficial")}

View file

@ -25,6 +25,15 @@
font-size: var(--font-size-base); 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 // Official/unofficial marker same "small pill" language as
// `settings-pages.scss`'s `.settings-page__member-badge`, neutral grey by // `settings-pages.scss`'s `.settings-page__member-badge`, neutral grey by
// default (unofficial/scraped) and tinted primary once official. // default (unofficial/scraped) and tinted primary once official.

View file

@ -219,18 +219,21 @@ export interface TechStepView {
* the API only a real adapter being registered in code adds a row). * the API only a real adapter being registered in code adds a row).
* *
* Unlike `DietView`/`TechStepView`, `name` is the actual display string * 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.<domain>.<key>` a source's name is a proper noun/brand, * `catalog.<domain>.<key>` a source's name is a proper noun/brand,
* nothing to translate. `official` distinguishes a source backed by an * nothing to translate. `official` distinguishes a source backed by an
* official API from one built by scraping HTML the site never committed to * 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 * a stable shape surfaced so households can make an informed choice when
* picking which sources to enable (see `HouseSource` in schema.prisma). * 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 { export interface SourceView {
id: number; id: number;
key: string; key: string;
name: string; name: string;
official: boolean; official: boolean;
iconUrl: string | null;
} }
/** /**