Merge pull request #40 from kyuno053/feat/jsonld-source-adapter
feat(recipes): source générique JSON-LD (schema.org/Recipe)
This commit is contained in:
commit
7fc74541f6
4 changed files with 537 additions and 2 deletions
|
|
@ -1,11 +1,12 @@
|
||||||
import { registerRecipeSource } from "../lib/recipe-source-registry.js";
|
import { registerRecipeSource } from "../lib/recipe-source-registry.js";
|
||||||
|
import { jsonLdRecipeAdapter } from "./json-ld-recipe.js";
|
||||||
import { theMealDbAdapter } from "./the-meal-db.js";
|
import { theMealDbAdapter } from "./the-meal-db.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers every concrete `RecipeSourceAdapter` this app ships with into
|
* Registers every concrete `RecipeSourceAdapter` this app ships with into
|
||||||
* the shared in-memory registry (`recipe-source-registry.ts`) — currently
|
* the shared in-memory registry (`recipe-source-registry.ts`) — currently
|
||||||
* just `theMealDbAdapter`. Called once, explicitly, by the two real entry
|
* `theMealDbAdapter` and `jsonLdRecipeAdapter`. Called once, explicitly, by
|
||||||
* points that need the registry populated:
|
* the two real entry points that need the registry populated:
|
||||||
*
|
*
|
||||||
* - `server.ts` — the running API process, before it starts listening.
|
* - `server.ts` — the running API process, before it starts listening.
|
||||||
* - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into
|
* - `prisma/seed.ts` — so `syncRecipeSources` has something to mirror into
|
||||||
|
|
@ -21,4 +22,5 @@ import { theMealDbAdapter } from "./the-meal-db.js";
|
||||||
*/
|
*/
|
||||||
export function registerAllRecipeSources(): void {
|
export function registerAllRecipeSources(): void {
|
||||||
registerRecipeSource(theMealDbAdapter);
|
registerRecipeSource(theMealDbAdapter);
|
||||||
|
registerRecipeSource(jsonLdRecipeAdapter);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
226
apps/api/src/sources/json-ld-recipe.ts
Normal file
226
apps/api/src/sources/json-ld-recipe.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import type {
|
||||||
|
ParsedRecipe,
|
||||||
|
ParsedRecipeIngredient,
|
||||||
|
RecipeSourceAdapter,
|
||||||
|
} from "../lib/recipe-source-adapter.js";
|
||||||
|
import { RecipeSourceFetchError, RecipeSourceParseError } from "../lib/recipe-source-errors.js";
|
||||||
|
|
||||||
|
const SOURCE_KEY = "jsonLdRecipe";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches every `<script type="application/ld+json">…</script>` block in a
|
||||||
|
* page — a plain regex rather than a full HTML parser (no such dependency
|
||||||
|
* exists in this codebase yet): a `<script>` tag's content is never nested
|
||||||
|
* HTML, so a non-greedy match reliably captures each block whole without
|
||||||
|
* needing real DOM parsing.
|
||||||
|
*/
|
||||||
|
const JSON_LD_SCRIPT_PATTERN =
|
||||||
|
/<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
||||||
|
|
||||||
|
/** schema.org's `image` property — a bare URL, one `ImageObject`, or an array of either. */
|
||||||
|
type SchemaOrgImage = string | { url?: string } | Array<string | { url?: string }>;
|
||||||
|
|
||||||
|
/** One `HowToStep`, or a `HowToSection` grouping several of them under `itemListElement` — schema.org's two shapes for `recipeInstructions` array entries. */
|
||||||
|
interface SchemaOrgHowToStep {
|
||||||
|
"@type"?: string;
|
||||||
|
text?: string;
|
||||||
|
name?: string;
|
||||||
|
itemListElement?: SchemaOrgHowToStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subset of schema.org's `Recipe` type this adapter reads — every field is optional per the spec, sites vary in how much they fill in. */
|
||||||
|
interface SchemaOrgRecipe {
|
||||||
|
"@type"?: string | string[];
|
||||||
|
"@graph"?: unknown[];
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: SchemaOrgImage;
|
||||||
|
recipeYield?: string | number | Array<string | number>;
|
||||||
|
recipeIngredient?: string[];
|
||||||
|
recipeInstructions?: string | Array<string | SchemaOrgHowToStep>;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts and JSON-parses every JSON-LD block on the page — a block that fails to parse is skipped rather than failing the whole page over one malformed script tag (some sites ship more than one JSON-LD block, e.g. `BreadcrumbList` alongside `Recipe`). */
|
||||||
|
function extractJsonLdBlocks(html: string): unknown[] {
|
||||||
|
const blocks: unknown[] = [];
|
||||||
|
for (const match of html.matchAll(JSON_LD_SCRIPT_PATTERN)) {
|
||||||
|
try {
|
||||||
|
blocks.push(JSON.parse((match[1] ?? "").trim()));
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON-LD — not our problem to fix, just skip it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRecipeType(type: SchemaOrgRecipe["@type"]): boolean {
|
||||||
|
return type === "Recipe" || (Array.isArray(type) && type.includes("Recipe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the first `Recipe`-typed node within one parsed JSON-LD block —
|
||||||
|
* handles a bare `Recipe` object, an array of mixed-type nodes (a page
|
||||||
|
* commonly ships `Recipe` alongside `BreadcrumbList`/`WebPage`/…), and the
|
||||||
|
* `@graph` wrapper some sites nest everything under.
|
||||||
|
*/
|
||||||
|
function findRecipeNode(node: unknown): SchemaOrgRecipe | null {
|
||||||
|
if (node === null || typeof node !== "object") return null;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const item of node) {
|
||||||
|
const found = findRecipeNode(item);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const obj = node as SchemaOrgRecipe;
|
||||||
|
if (hasRecipeType(obj["@type"])) return obj;
|
||||||
|
if (Array.isArray(obj["@graph"])) return findRecipeNode(obj["@graph"]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `image` can be a bare URL, an `ImageObject`, or an array of either (usually several resolutions of the same picture) — this just wants one usable URL, the first it finds. */
|
||||||
|
function extractImageUrl(image: SchemaOrgImage | undefined): string | null {
|
||||||
|
if (!image) return null;
|
||||||
|
if (typeof image === "string") return image;
|
||||||
|
if (Array.isArray(image)) return image.length > 0 ? extractImageUrl(image[0]) : null;
|
||||||
|
return image.url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `recipeYield` is meant to be a serving count but schema.org also allows a free-text string (`"4 servings"`) or an array of either — pulls the first integer out of whatever's there, `null` if none can be found. */
|
||||||
|
function extractPortions(recipeYield: SchemaOrgRecipe["recipeYield"]): number | null {
|
||||||
|
const raw = Array.isArray(recipeYield) ? recipeYield[0] : recipeYield;
|
||||||
|
if (raw === undefined || raw === null) return null;
|
||||||
|
if (typeof raw === "number") return Number.isFinite(raw) ? Math.trunc(raw) : null;
|
||||||
|
const match = raw.match(/\d+/);
|
||||||
|
return match ? Number(match[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `recipeIngredient` is already a flat array of free-text lines — the
|
||||||
|
* closest schema.org gets to `ParsedRecipeIngredient`. Unlike TheMealDB's
|
||||||
|
* separate name/measure fields, there's nothing here to cleanly split a
|
||||||
|
* quantity/unit away from the ingredient name, so `name` just repeats the
|
||||||
|
* whole line (same fallback every other free-text-only field in this
|
||||||
|
* codebase uses when it can't do better).
|
||||||
|
*/
|
||||||
|
function extractIngredients(lines: string[] | undefined): ParsedRecipeIngredient[] {
|
||||||
|
return (lines ?? [])
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.map((line) => ({ rawText: line, quantity: null, unit: null, name: line }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `recipeInstructions` is the most inconsistent field across real sites:
|
||||||
|
* one unbroken string, an array of plain strings, an array of `HowToStep`
|
||||||
|
* objects (`text`, sometimes `name` instead), or `HowToStep`s grouped into
|
||||||
|
* named `HowToSection`s via a nested `itemListElement` — this flattens
|
||||||
|
* every shape into a plain ordered list of step descriptions.
|
||||||
|
*/
|
||||||
|
function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]): string[] {
|
||||||
|
if (!instructions) return [];
|
||||||
|
if (typeof instructions === "string") {
|
||||||
|
// A single field crammed with every step — the closest split available
|
||||||
|
// is by line, same approach TheMealDB's own free-text instructions use.
|
||||||
|
return instructions
|
||||||
|
.split(/\r?\n+/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
}
|
||||||
|
const steps: string[] = [];
|
||||||
|
for (const item of instructions) {
|
||||||
|
if (typeof item === "string") {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
if (trimmed.length > 0) steps.push(trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(item.itemListElement)) {
|
||||||
|
steps.push(...flattenInstructions(item.itemListElement));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = item.text ?? item.name;
|
||||||
|
if (text && text.trim().length > 0) steps.push(text.trim());
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic recipe source: given any recipe page's URL, fetches it and reads
|
||||||
|
* the schema.org `Recipe` structured data most recipe sites embed as
|
||||||
|
* JSON-LD for search engines (Google Rich Results, etc.) — one adapter
|
||||||
|
* covering a large share of recipe sites, rather than one hand-built
|
||||||
|
* adapter per site.
|
||||||
|
*
|
||||||
|
* `official: false` — even though JSON-LD is the site's own published
|
||||||
|
* structured data (not markup we're inferring meaning from), this adapter
|
||||||
|
* has no dedicated relationship with any one site: it fetches an arbitrary
|
||||||
|
* page and reads embedded metadata, not a maintained API endpoint a
|
||||||
|
* publisher operates and supports. That's closer to "unofficial" than
|
||||||
|
* "official" under this app's own distinction (see `official`'s doc
|
||||||
|
* comment on `RecipeSourceAdapter`).
|
||||||
|
*
|
||||||
|
* Has no catalog of its own to browse — `list()` always returns nothing;
|
||||||
|
* `fetchDetail`'s `externalId` is simply the target URL itself, not an id
|
||||||
|
* from a prior `list()` call. A future "import from URL" flow would call
|
||||||
|
* `fetchDetail(pastedUrl)` directly.
|
||||||
|
*/
|
||||||
|
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
|
||||||
|
key: SOURCE_KEY,
|
||||||
|
name: "Import générique (JSON-LD)",
|
||||||
|
official: false,
|
||||||
|
iconUrl: null,
|
||||||
|
|
||||||
|
async list() {
|
||||||
|
return { items: [], nextCursor: null };
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchDetail(url: string): Promise<{ html: string; url: string }> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error fetching ${url}`, { cause });
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new RecipeSourceFetchError(SOURCE_KEY, `${url} responded ${response.status}`);
|
||||||
|
}
|
||||||
|
const html = await response.text();
|
||||||
|
return { html, url };
|
||||||
|
},
|
||||||
|
|
||||||
|
parse({ html, url }): ParsedRecipe {
|
||||||
|
let recipe: SchemaOrgRecipe | null = null;
|
||||||
|
for (const block of extractJsonLdBlocks(html)) {
|
||||||
|
recipe = findRecipeNode(block);
|
||||||
|
if (recipe) break;
|
||||||
|
}
|
||||||
|
if (!recipe) {
|
||||||
|
throw new RecipeSourceParseError(SOURCE_KEY, `No JSON-LD Recipe found at ${url}`);
|
||||||
|
}
|
||||||
|
if (!recipe.name) {
|
||||||
|
throw new RecipeSourceParseError(SOURCE_KEY, `JSON-LD Recipe at ${url} has no name`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = flattenInstructions(recipe.recipeInstructions).map((description) => ({
|
||||||
|
description,
|
||||||
|
picture: null,
|
||||||
|
}));
|
||||||
|
if (steps.length === 0) {
|
||||||
|
throw new RecipeSourceParseError(
|
||||||
|
SOURCE_KEY,
|
||||||
|
`JSON-LD Recipe "${recipe.name}" at ${url} has no usable instructions`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: recipe.name,
|
||||||
|
description: recipe.description ?? null,
|
||||||
|
picture: extractImageUrl(recipe.image),
|
||||||
|
portions: extractPortions(recipe.recipeYield),
|
||||||
|
sourceUrl: recipe.url ?? url,
|
||||||
|
ingredients: extractIngredients(recipe.recipeIngredient),
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
298
apps/api/test/json-ld-recipe.test.ts
Normal file
298
apps/api/test/json-ld-recipe.test.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
import { expect } from "chai";
|
||||||
|
import { RecipeSourceFetchError, RecipeSourceParseError } from "../src/lib/recipe-source-errors.js";
|
||||||
|
import { jsonLdRecipeAdapter } from "../src/sources/json-ld-recipe.js";
|
||||||
|
|
||||||
|
/** Stubs `globalThis.fetch` to return `html` as the response body — same reasoning/pattern as `the-meal-db.test.ts`'s `stubFetch`, just returning text instead of JSON. */
|
||||||
|
function stubFetchHtml(html: string, status = 200) {
|
||||||
|
globalThis.fetch = (async () => new Response(html, { status })) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps a JSON-LD payload (already an object/array, not yet stringified) in a minimal HTML page carrying it as one `<script type="application/ld+json">` block, optionally alongside `extraBlocks` (e.g. an unrelated `BreadcrumbList`, or deliberately malformed JSON). */
|
||||||
|
function htmlWithJsonLd(payload: unknown, ...extraBlocks: string[]): string {
|
||||||
|
const scripts = [JSON.stringify(payload), ...extraBlocks]
|
||||||
|
.map((json) => `<script type="application/ld+json">${json}</script>`)
|
||||||
|
.join("\n");
|
||||||
|
return `<!doctype html><html><head>${scripts}</head><body></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECIPE_URL = "https://example.test/recipes/apple-pie";
|
||||||
|
|
||||||
|
const baseRecipe = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Recipe",
|
||||||
|
name: "Apple Pie",
|
||||||
|
description: "A classic apple pie.",
|
||||||
|
image: "https://example.test/apple-pie.jpg",
|
||||||
|
recipeYield: 8,
|
||||||
|
recipeIngredient: ["6 apples, peeled", "200g flour", " "],
|
||||||
|
recipeInstructions: ["Peel the apples.", "Bake at 180°C for 40 minutes."],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("jsonLdRecipeAdapter", () => {
|
||||||
|
let originalFetch: typeof fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalFetch = globalThis.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declares itself as an unofficial, iconless, browse-less source", () => {
|
||||||
|
expect(jsonLdRecipeAdapter.key).to.equal("jsonLdRecipe");
|
||||||
|
expect(jsonLdRecipeAdapter.official).to.equal(false);
|
||||||
|
expect(jsonLdRecipeAdapter.iconUrl).to.be.null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("list", () => {
|
||||||
|
it("always returns an empty page — this source has no catalog of its own", async () => {
|
||||||
|
const result = await jsonLdRecipeAdapter.list({ query: "anything" });
|
||||||
|
expect(result).to.deep.equal({ items: [], nextCursor: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchDetail", () => {
|
||||||
|
it("fetches the given URL and returns its html alongside the url", async () => {
|
||||||
|
stubFetchHtml(htmlWithJsonLd(baseRecipe));
|
||||||
|
|
||||||
|
const result = await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
|
||||||
|
|
||||||
|
expect(result.url).to.equal(RECIPE_URL);
|
||||||
|
expect(result.html).to.include("Apple Pie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws RecipeSourceFetchError on a non-2xx response", async () => {
|
||||||
|
stubFetchHtml("", 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
|
||||||
|
expect.fail("expected fetchDetail 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 jsonLdRecipeAdapter.fetchDetail(RECIPE_URL);
|
||||||
|
expect.fail("expected fetchDetail to throw");
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).to.be.instanceOf(RecipeSourceFetchError);
|
||||||
|
expect((err as RecipeSourceFetchError).cause).to.be.instanceOf(Error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parse", () => {
|
||||||
|
function parse(html: string, url = RECIPE_URL) {
|
||||||
|
return jsonLdRecipeAdapter.parse({ html, url });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("maps a straightforward JSON-LD Recipe end to end", () => {
|
||||||
|
const parsed = parse(htmlWithJsonLd(baseRecipe));
|
||||||
|
|
||||||
|
expect(parsed.name).to.equal("Apple Pie");
|
||||||
|
expect(parsed.description).to.equal("A classic apple pie.");
|
||||||
|
expect(parsed.picture).to.equal("https://example.test/apple-pie.jpg");
|
||||||
|
expect(parsed.portions).to.equal(8);
|
||||||
|
expect(parsed.sourceUrl).to.equal(RECIPE_URL);
|
||||||
|
expect(parsed.steps).to.deep.equal([
|
||||||
|
{ description: "Peel the apples.", picture: null },
|
||||||
|
{ description: "Bake at 180°C for 40 minutes.", picture: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out blank ingredient lines, keeping the full line as both rawText and name", () => {
|
||||||
|
const parsed = parse(htmlWithJsonLd(baseRecipe));
|
||||||
|
|
||||||
|
expect(parsed.ingredients).to.deep.equal([
|
||||||
|
{ rawText: "6 apples, peeled", quantity: null, unit: null, name: "6 apples, peeled" },
|
||||||
|
{ rawText: "200g flour", quantity: null, unit: null, name: "200g flour" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the JSON-LD's own url over the fetched url, when present", () => {
|
||||||
|
const parsed = parse(
|
||||||
|
htmlWithJsonLd({ ...baseRecipe, url: "https://example.test/canonical" }),
|
||||||
|
);
|
||||||
|
expect(parsed.sourceUrl).to.equal("https://example.test/canonical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts @type as an array containing Recipe", () => {
|
||||||
|
const parsed = parse(htmlWithJsonLd({ ...baseRecipe, "@type": ["Recipe", "NewsArticle"] }));
|
||||||
|
expect(parsed.name).to.equal("Apple Pie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the Recipe nested under @graph", () => {
|
||||||
|
const graphPayload = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [{ "@type": "BreadcrumbList", itemListElement: [] }, baseRecipe],
|
||||||
|
};
|
||||||
|
const parsed = parse(htmlWithJsonLd(graphPayload));
|
||||||
|
expect(parsed.name).to.equal("Apple Pie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the Recipe among several top-level JSON-LD script blocks, skipping malformed ones", () => {
|
||||||
|
const html = htmlWithJsonLd(
|
||||||
|
{ "@type": "BreadcrumbList", itemListElement: [] },
|
||||||
|
"{ this is not valid json",
|
||||||
|
JSON.stringify(baseRecipe),
|
||||||
|
);
|
||||||
|
const parsed = parse(html);
|
||||||
|
expect(parsed.name).to.equal("Apple Pie");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recipeInstructions shapes", () => {
|
||||||
|
it("splits a single free-text string on line breaks", () => {
|
||||||
|
const parsed = parse(
|
||||||
|
htmlWithJsonLd({
|
||||||
|
...baseRecipe,
|
||||||
|
recipeInstructions: "Step one.\nStep two.\n\nStep three.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.steps).to.deep.equal([
|
||||||
|
{ description: "Step one.", picture: null },
|
||||||
|
{ description: "Step two.", picture: null },
|
||||||
|
{ description: "Step three.", picture: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads HowToStep objects' text field", () => {
|
||||||
|
const parsed = parse(
|
||||||
|
htmlWithJsonLd({
|
||||||
|
...baseRecipe,
|
||||||
|
recipeInstructions: [
|
||||||
|
{ "@type": "HowToStep", text: "Peel the apples." },
|
||||||
|
{ "@type": "HowToStep", text: "Bake." },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.steps).to.deep.equal([
|
||||||
|
{ description: "Peel the apples.", picture: null },
|
||||||
|
{ description: "Bake.", picture: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a HowToStep's name when it has no text", () => {
|
||||||
|
const parsed = parse(
|
||||||
|
htmlWithJsonLd({
|
||||||
|
...baseRecipe,
|
||||||
|
recipeInstructions: [{ "@type": "HowToStep", name: "Peel the apples." }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.steps).to.deep.equal([{ description: "Peel the apples.", picture: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattens HowToSections into their nested steps, dropping the section name", () => {
|
||||||
|
const parsed = parse(
|
||||||
|
htmlWithJsonLd({
|
||||||
|
...baseRecipe,
|
||||||
|
recipeInstructions: [
|
||||||
|
{
|
||||||
|
"@type": "HowToSection",
|
||||||
|
name: "Filling",
|
||||||
|
itemListElement: [
|
||||||
|
{ "@type": "HowToStep", text: "Peel the apples." },
|
||||||
|
{ "@type": "HowToStep", text: "Slice them." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "HowToSection",
|
||||||
|
name: "Baking",
|
||||||
|
itemListElement: [{ "@type": "HowToStep", text: "Bake at 180°C." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.steps).to.deep.equal([
|
||||||
|
{ description: "Peel the apples.", picture: null },
|
||||||
|
{ description: "Slice them.", picture: null },
|
||||||
|
{ description: "Bake at 180°C.", picture: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws RecipeSourceParseError when there are no usable instructions", () => {
|
||||||
|
expect(() => parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: [] }))).to.throw(
|
||||||
|
RecipeSourceParseError,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
parse(htmlWithJsonLd({ ...baseRecipe, recipeInstructions: undefined })),
|
||||||
|
).to.throw(RecipeSourceParseError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("image shapes", () => {
|
||||||
|
it("reads a bare string image", () => {
|
||||||
|
const parsed = parse(
|
||||||
|
htmlWithJsonLd({ ...baseRecipe, image: "https://example.test/a.jpg" }),
|
||||||
|
);
|
||||||
|
expect(parsed.picture).to.equal("https://example.test/a.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the first entry of an array of ImageObjects", () => {
|
||||||
|
const parsed = parse(
|
||||||
|
htmlWithJsonLd({
|
||||||
|
...baseRecipe,
|
||||||
|
image: [
|
||||||
|
{ "@type": "ImageObject", url: "https://example.test/large.jpg" },
|
||||||
|
{ "@type": "ImageObject", url: "https://example.test/small.jpg" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.picture).to.equal("https://example.test/large.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null when there's no image at all", () => {
|
||||||
|
const parsed = parse(htmlWithJsonLd({ ...baseRecipe, image: undefined }));
|
||||||
|
expect(parsed.picture).to.be.null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recipeYield shapes", () => {
|
||||||
|
it("accepts a plain number", () => {
|
||||||
|
expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: 4 })).portions).to.equal(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts the leading integer from a free-text string", () => {
|
||||||
|
expect(
|
||||||
|
parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "4 servings" })).portions,
|
||||||
|
).to.equal(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the first entry of an array", () => {
|
||||||
|
expect(
|
||||||
|
parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: ["6", "6 servings"] })).portions,
|
||||||
|
).to.equal(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null when absent or unparseable", () => {
|
||||||
|
expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: undefined })).portions).to.be
|
||||||
|
.null;
|
||||||
|
expect(parse(htmlWithJsonLd({ ...baseRecipe, recipeYield: "plenty" })).portions).to.be.null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("failure cases", () => {
|
||||||
|
it("throws RecipeSourceParseError when the page has no JSON-LD at all", () => {
|
||||||
|
expect(() => parse("<html><body>No structured data here</body></html>")).to.throw(
|
||||||
|
RecipeSourceParseError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws RecipeSourceParseError when JSON-LD exists but none of it is a Recipe", () => {
|
||||||
|
const html = htmlWithJsonLd({ "@type": "BreadcrumbList", itemListElement: [] });
|
||||||
|
expect(() => parse(html)).to.throw(RecipeSourceParseError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws RecipeSourceParseError when the Recipe has no name", () => {
|
||||||
|
const html = htmlWithJsonLd({ ...baseRecipe, name: undefined });
|
||||||
|
expect(() => parse(html)).to.throw(RecipeSourceParseError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -24,4 +24,13 @@ describe("registerAllRecipeSources", () => {
|
||||||
expect(theMealDb?.name).to.equal("TheMealDB");
|
expect(theMealDb?.name).to.equal("TheMealDB");
|
||||||
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb");
|
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("theMealDb");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers the generic JSON-LD source into the shared registry", () => {
|
||||||
|
registerAllRecipeSources();
|
||||||
|
|
||||||
|
const jsonLd = getRecipeSource("jsonLdRecipe");
|
||||||
|
expect(jsonLd).to.not.be.undefined;
|
||||||
|
expect(jsonLd?.official).to.equal(false);
|
||||||
|
expect(listRecipeSources().map((adapter) => adapter.key)).to.include("jsonLdRecipe");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue