mcp-recipes 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/config.ts","../../src/version.ts","../../src/errors.ts","../../src/sources/ids.ts","../../src/sources/registry.ts","../../src/sources/adapter.ts","../../src/sources/cookbook.ts","../../src/sources/marmiton.ts","../../src/sources/wordings.ts","../../src/sources/client.ts"],"sourcesContent":["/**\n * Settings, read from the environment.\n *\n * A value that cannot be read warns and falls back rather than stopping the\n * server: a typo in one variable should not take away every tool. Warnings go\n * to stderr, because stdout carries the protocol and anything written there\n * corrupts the session.\n *\n * One setting governs every source. Each is read through its own client, and\n * each of those keeps a pacing floor of its own that nothing here can lower, so\n * a value set here can only make this server more patient than it already is.\n */\n\nimport process from \"node:process\";\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport const LOG_LEVELS = [\"silent\", \"error\", \"info\", \"debug\"] as const;\nexport type LogLevel = (typeof LOG_LEVELS)[number];\n\n/**\n * Spacing between two requests to the same site.\n *\n * No source publishes a ceiling for a client like this one, so the number is\n * self-imposed politeness: a second between requests is far below what a person\n * browsing one of these sites generates, and the answers are cached, so a\n * conversation that circles back to a recipe costs no traffic at all.\n */\nexport const DEFAULT_INTERVAL_MS = 1000;\n/**\n * Configuration can slow the server down. It cannot take the spacing below this\n * floor, whether the setting arrives through the environment or through a\n * configuration object handed to the published client entry point.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n/** Beyond this a request would look hung rather than paced. */\nexport const MAX_ALLOWED_INTERVAL_MS = 60_000;\n\nexport interface Config {\n userAgent: string;\n minIntervalMs: number;\n timeoutMs: number;\n maxRetries: number;\n cacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\nexport const DEFAULT_USER_AGENT = `mcp-recipes/${PKG_VERSION} (+${REPO_URL})`;\n\nexport interface Logger {\n debug: (message: string) => void;\n info: (message: string) => void;\n warn: (message: string) => void;\n error: (message: string) => void;\n}\n\nexport function createLogger(level: LogLevel): Logger {\n const rank = LOG_LEVELS.indexOf(level);\n const write = (at: LogLevel, message: string) => {\n if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) {\n return;\n }\n process.stderr.write(`[mcp-recipes] ${at}: ${message}\\n`);\n };\n return {\n debug: (m) => write(\"debug\", m),\n info: (m) => write(\"info\", m),\n // A warning goes out at the error level so it survives the default\n // setting: a caller has to know that a site was left out of an answer.\n warn: (m) => write(\"error\", m),\n error: (m) => write(\"error\", m),\n };\n}\n\nfunction readInteger(\n env: NodeJS.ProcessEnv,\n name: string,\n fallback: number,\n min: number,\n max: number,\n): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") {\n return fallback;\n }\n\n const value = Number(raw);\n if (!(Number.isFinite(value) && Number.isInteger(value))) {\n process.stderr.write(\n `[mcp-recipes] error: ${name}=\"${raw}\" is not a whole number; using ${fallback}.\\n`,\n );\n return fallback;\n }\n if (value < min || value > max) {\n // Clamping silently would let a caller believe a setting took effect when\n // the opposite is true, so the refusal is stated and the default stands.\n process.stderr.write(\n `[mcp-recipes] error: ${name}=${value} is outside ${min}..${max}; using ${fallback}.\\n`,\n );\n return fallback;\n }\n return value;\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const level = env.RECIPES_LOG_LEVEL as LogLevel | undefined;\n const logLevel = level && LOG_LEVELS.includes(level) ? level : \"error\";\n if (level && !LOG_LEVELS.includes(level)) {\n process.stderr.write(\n `[mcp-recipes] error: RECIPES_LOG_LEVEL=\"${level}\" is not one of ${LOG_LEVELS.join(\", \")}; using error.\\n`,\n );\n }\n\n const custom = env.RECIPES_USER_AGENT?.trim();\n\n return {\n // A caller who wants to be recognised may say who they are, and the\n // contact address stays attached: a site has to be able to reach a human\n // about traffic it did not expect.\n userAgent: custom ? `${custom} ${DEFAULT_USER_AGENT}` : DEFAULT_USER_AGENT,\n minIntervalMs: readInteger(\n env,\n \"RECIPES_MIN_INTERVAL_MS\",\n DEFAULT_INTERVAL_MS,\n MIN_ALLOWED_INTERVAL_MS,\n MAX_ALLOWED_INTERVAL_MS,\n ),\n timeoutMs: readInteger(env, \"RECIPES_TIMEOUT_MS\", 20_000, 1000, 120_000),\n maxRetries: readInteger(env, \"RECIPES_MAX_RETRIES\", 3, 0, 8),\n cacheTtlMs: readInteger(env, \"RECIPES_CACHE_TTL_MS\", 900_000, 0, 86_400_000),\n cacheMaxEntries: readInteger(env, \"RECIPES_CACHE_MAX_ENTRIES\", 200, 1, 5000),\n logLevel,\n };\n}\n","/**\n * Kept in step with the version in package.json. The User-Agent embeds it, so\n * the sites this server reads can tell which build is calling from their own\n * logs.\n */\nexport const PKG_VERSION = \"2.0.0\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-recipes\";\nexport const ISSUES_URL = \"https://github.com/smeet666/mcp-recipes/issues\";\n","/**\n * One error type, carrying a code the caller can branch on.\n *\n * The distinction that matters most is between \"no site holds this\" and \"the\n * question could not be asked\". Collapsing the two lets a model report an\n * absence it never established, which is a false statement about the world\n * rather than a missing feature.\n */\n\nimport { ISSUES_URL } from \"./version.js\";\n\nexport type ErrorCode =\n /** A site answered, and holds no such recipe. */\n | \"not_found\"\n /** The arguments cannot produce a request. */\n | \"invalid_input\"\n /** A site asked this client to slow down. */\n | \"rate_limited\"\n /** A response arrived in a shape this server cannot read. */\n | \"parse_failure\"\n /** The request could not be completed. */\n | \"network_error\"\n /** The request was abandoned before an answer arrived. */\n | \"timeout\";\n\nexport const ERROR_CODES: readonly ErrorCode[] = [\n \"not_found\",\n \"invalid_input\",\n \"rate_limited\",\n \"parse_failure\",\n \"network_error\",\n \"timeout\",\n];\n\nexport interface ErrorDetails {\n /** What the caller can do about it, when there is something. */\n hint?: string;\n /** The address that produced the failure, for a bug report. */\n url?: string;\n status?: number;\n /** What was raised underneath, kept for the bug report the hint asks for. */\n cause?: unknown;\n}\n\nexport class RecipesError extends Error {\n readonly code: ErrorCode;\n readonly details: ErrorDetails;\n\n constructor(code: ErrorCode, message: string, details: ErrorDetails = {}) {\n super(message, details.cause === undefined ? undefined : { cause: details.cause });\n this.name = \"RecipesError\";\n this.code = code;\n this.details = details;\n }\n}\n\nexport const notFound = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"not_found\", message, details ?? {});\n\nexport const invalidInput = (message: string, hint?: string, cause?: unknown) =>\n new RecipesError(\"invalid_input\", message, { ...(hint ? { hint } : {}), cause });\n\nexport const rateLimited = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"rate_limited\", message, {\n hint: \"Wait a moment and ask again. This says nothing about whether the recipe exists.\",\n ...details,\n });\n\nexport const parseFailure = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"parse_failure\", message, {\n hint: `A site may have changed how it answers. Please report this at ${ISSUES_URL} with the arguments you used.`,\n ...details,\n });\n\nexport const networkError = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"network_error\", message, details ?? {});\n\nexport const timeout = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"timeout\", message, details ?? {});\n\n/**\n * Read the code off a failure raised by the library that reads a source.\n *\n * Those libraries throw their own error classes carrying the six codes above,\n * and the classes are not part of the interface this server imports. Reading\n * the field keeps the taxonomy intact without depending on a class identity a\n * library is free to change, and anything unrecognisable is reported as a\n * network failure, which is the reading that claims least.\n */\nexport function toRecipesError(error: unknown): RecipesError {\n if (error instanceof RecipesError) {\n return error;\n }\n\n const message = error instanceof Error ? error.message : String(error);\n const raw = error as { code?: unknown; details?: unknown } | null;\n const code = typeof raw?.code === \"string\" ? raw.code : \"\";\n const known = ERROR_CODES.find((candidate) => candidate === code);\n\n const details =\n raw && typeof raw.details === \"object\" && raw.details !== null\n ? (raw.details as ErrorDetails)\n : {};\n\n return new RecipesError(known ?? \"network_error\", message, details);\n}\n\n/**\n * Adopt a failure raised while reading a source, in this server's own words.\n *\n * The code is kept, since that is the taxonomy every layer branches on. The\n * sentence is not: what arrives with such a failure is written by whoever the\n * reader was talking to, in their vocabulary and about their internals, and\n * serving it as this answer's own sentence states as this server's finding\n * something it never established. It is quoted and credited to the source\n * instead, on one line, so the diagnostic survives without borrowing a voice\n * or a shape it has no claim to. The advice that came with it goes, because\n * advice addressed to somebody else's reader sends a caller somewhere this\n * server cannot answer for.\n */\nexport function fromSource(error: unknown, sourceName: string): RecipesError {\n if (error instanceof RecipesError) {\n return error;\n }\n\n const known = toRecipesError(error);\n const reported = known.message.replace(/\\s+/g, \" \").trim();\n const opening =\n known.code === \"not_found\"\n ? `${sourceName} holds nothing under this name.`\n : `${sourceName} could not be read.`;\n const message = reported === \"\" ? opening : `${opening} It reports: \"${reported}\"`;\n const details: ErrorDetails = {};\n if (known.details.url !== undefined) {\n details.url = known.details.url;\n }\n if (known.details.status !== undefined) {\n details.status = known.details.status;\n }\n\n switch (known.code) {\n case \"not_found\":\n return notFound(message, details);\n case \"invalid_input\":\n return invalidInput(message);\n case \"rate_limited\":\n return rateLimited(message, details);\n case \"parse_failure\":\n return parseFailure(message, details);\n case \"timeout\":\n return timeout(message, details);\n default:\n return networkError(message, details);\n }\n}\n","/**\n * Identifiers that name the source they came from.\n *\n * Every source hands back an opaque string, and a caller holding a merged list\n * has no way to tell one source's string from another's. Every identifier this\n * server returns therefore carries a prefix, and `get_recipe` routes on it\n * rather than trying each source in turn.\n */\n\nimport { invalidInput } from \"../errors.js\";\nimport type { SourceId } from \"../types.js\";\nimport type { SourceAdapter } from \"./adapter.js\";\n\nconst SEPARATOR = \":\";\n\nexport interface ResolvedId {\n source: SourceAdapter;\n /** The string that source's own reader takes. */\n reference: string;\n /** Set when the shape was read rather than stated, so the answer can say so. */\n inferred: string | null;\n}\n\n/**\n * Work out which source an identifier belongs to.\n *\n * A prefixed identifier is routed on what it says. A raw one is offered to each\n * source in turn, and a source claims only the shapes it mints: digits for one,\n * a namespaced page key for another. Exactly one claim routes the read and the\n * answer says which reading it used.\n *\n * More than one claim is refused rather than resolved, because picking a winner\n * would send the read somewhere a caller did not intend and answer with a\n * confident recipe for the wrong dish. No claim at all is refused too: a bare\n * dish name is a search, not an identifier, and guessing a page name from it\n * returns whatever that guess happens to hit.\n */\nexport function resolveId(rawId: string, sources: readonly SourceAdapter[]): ResolvedId {\n const trimmed = rawId.trim();\n if (trimmed === \"\") {\n throw invalidInput(\n \"A recipe identifier is required.\",\n `Use one of the ids search_recipes returned, such as ${example(sources)}.`,\n );\n }\n\n for (const source of sources) {\n const prefix = `${source.id}${SEPARATOR}`;\n if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {\n continue;\n }\n const reference = trimmed.slice(prefix.length).trim();\n if (reference === \"\") {\n throw invalidInput(\n `\"${trimmed}\" names a source and no recipe.`,\n \"Put the source's own identifier after the colon.\",\n );\n }\n return { source, reference, inferred: null };\n }\n\n const claimed = sources\n .map((source) => ({ source, claim: source.claims(trimmed) }))\n .filter((entry): entry is { source: SourceAdapter; claim: NonNullable<typeof entry.claim> } =>\n Boolean(entry.claim),\n );\n\n if (claimed.length > 1) {\n throw invalidInput(\n `\"${trimmed}\" could be an identifier on ${names(claimed.map((entry) => entry.source))}, so it names no one recipe.`,\n `Spell it with its source, as in ${claimed.map((entry) => `${entry.source.id}:${trimmed}`).join(\" or \")}.`,\n );\n }\n\n const only = claimed[0];\n if (!only) {\n throw invalidInput(\n `\"${trimmed}\" is not an identifier any of the sources this server reads would mint.`,\n `Call search_recipes with \"${trimmed}\" as the query and pass an id from a row, ` +\n `which reads like ${example(sources)}.`,\n );\n }\n\n return {\n source: only.source,\n reference: only.claim.reference,\n inferred: only.claim.guess\n ? `${only.claim.why}, which is a guess: check the recipe that comes back is the one you meant`\n : only.claim.why,\n };\n}\n\n/** Sources named the way a sentence names them. */\nfunction names(sources: readonly { name: string }[]): string {\n const all = sources.map((source) => source.name);\n if (all.length <= 1) {\n return all.join(\"\");\n }\n return `${all.slice(0, -1).join(\", \")} and ${all.at(-1)}`;\n}\n\n/** An identifier of the shape this server hands out, for a hint. */\nfunction example(sources: readonly SourceAdapter[]): string {\n const first = sources[0];\n return first ? `${first.id}${SEPARATOR}<the source's own id>` : \"<source>:<id>\";\n}\n\n/** Which source an identifier names, without resolving what it points at. */\nexport function sourceOf(rawId: string, sources: readonly SourceAdapter[]): SourceId | null {\n const prefix = rawId.split(SEPARATOR)[0]?.toLowerCase();\n return sources.find((source) => source.id.toLowerCase() === prefix)?.id ?? null;\n}\n","/**\n * The sources this server reads.\n *\n * One list, and everything above it is written for however long the list is.\n * Adding a corpus means writing an adapter and adding it here; no tool, no\n * merge and no error path has a branch per source.\n */\n\nimport { MarmitonClient } from \"mcp-marmiton/client\";\nimport type { MarmitonClientOptions } from \"mcp-marmiton/client\";\nimport { CookbookClient } from \"mcp-wikibooks-cookbook/client\";\nimport type { ClientOptions as CookbookClientOptions } from \"mcp-wikibooks-cookbook/client\";\n\nimport type { Config } from \"../config.js\";\nimport { invalidInput } from \"../errors.js\";\nimport type { SourceId } from \"../types.js\";\nimport type { SourceAdapter } from \"./adapter.js\";\nimport { COOKBOOK_PROFILE, cookbookAdapter } from \"./cookbook.js\";\nimport type { CookbookReader } from \"./cookbook.js\";\nimport { MARMITON_PROFILE, marmitonAdapter } from \"./marmiton.js\";\nimport type { MarmitonReader } from \"./marmiton.js\";\n\n/**\n * A reader may be supplied in place of the one that talks to a site, so a\n * program embedding this server can put its own cache in front of a corpus, and\n * a test can drive it from fixed answers.\n */\nexport interface Readers {\n marmiton?: MarmitonReader;\n cookbook?: CookbookReader;\n}\n\n/** The sources a caller can name, in the order an answer takes them. */\nexport const SOURCE_IDS: readonly SourceId[] = [MARMITON_PROFILE.id, COOKBOOK_PROFILE.id];\n\nexport function buildSources(config: Config, readers: Readers = {}): SourceAdapter[] {\n const shared = {\n userAgent: config.userAgent,\n minIntervalMs: config.minIntervalMs,\n timeoutMs: config.timeoutMs,\n maxRetries: config.maxRetries,\n cacheTtlMs: config.cacheTtlMs,\n cacheMaxEntries: config.cacheMaxEntries,\n logLevel: config.logLevel,\n };\n\n const marmitonOptions: MarmitonClientOptions = { config: shared };\n const cookbookOptions: CookbookClientOptions = { config: shared };\n\n return [\n marmitonAdapter(readers.marmiton ?? new MarmitonClient(marmitonOptions)),\n cookbookAdapter(readers.cookbook ?? new CookbookClient(cookbookOptions)),\n ];\n}\n\n/** The sources a caller asked for, in the registry's own order. */\nexport function selectSources(\n sources: SourceAdapter[],\n wanted: readonly SourceId[] | undefined,\n): SourceAdapter[] {\n if (!wanted) {\n return sources;\n }\n\n const unknown = wanted.filter((id) => !sources.some((source) => source.id === id));\n if (unknown.length > 0) {\n throw invalidInput(\n `This server reads no source called ${unknown.map((id) => `\"${id}\"`).join(\", \")}.`,\n `It reads ${sources.map((source) => source.id).join(\", \")}.`,\n );\n }\n\n const chosen = sources.filter((source) => wanted.includes(source.id));\n if (chosen.length === 0) {\n throw invalidInput(\n \"A search needs at least one source.\",\n `Name one of ${sources.map((source) => source.id).join(\", \")}, or leave the argument out to ask them all.`,\n );\n }\n return chosen;\n}\n","/**\n * What a source has to provide to be one.\n *\n * Everything above this file is written for however many sources the registry\n * holds. A source knows four things nothing else does: what it is called, how\n * to recognise one of its own identifiers, how to search it, and how to read one\n * recipe off it. Adding a corpus is writing those four and registering it.\n *\n * The helpers below are the ones every adapter needs, because no source answers\n * with a contract: a field can arrive missing, null, or holding something other\n * than the type it usually holds.\n */\n\nimport { parseFailure } from \"../errors.js\";\nimport type { Language } from \"../recipe/language.js\";\nimport type { RecipeDetail, RecipeRow, SourceId, SourceProfile } from \"../types.js\";\n\nconst NUMBER_AND_REST = /^\\s*(\\d+(?:[.,]\\d+)?)\\s*(.*)$/;\nconst RANGE_AND_REST =\n /^\\s*(\\d+(?:[.,]\\d+)?)\\s*(?:à|a|to|-|–|—|ou|or)\\s*(\\d+(?:[.,]\\d+)?)\\s*(.*)$/i;\n\n/** Rows that could be read, and how many could not. */\nexport interface ReadRows {\n rows: RecipeRow[];\n /** Rows the source sent in a shape this server could not read. */\n skipped: number;\n /** Rows the source said it saw, in the terms that source counts in. */\n reportedTotal: number | null;\n /** What `reportedTotal` counts on this source, in words. Null when it states none. */\n reportedTotalMeans: string | null;\n cached: boolean;\n}\n\nexport interface ReadRecipe {\n recipe: RecipeDetail;\n cached: boolean;\n}\n\n/** How a source recognised a raw identifier as one of its own. */\nexport interface Claim {\n /** The string this source's own reader takes. */\n reference: string;\n /** Why the shape was read this way, for an answer that has to say so. */\n why: string;\n /**\n * Whether reading it this way is a guess. A shape only this source mints is\n * not; a shape it would merely accept is, and the answer says so.\n */\n guess: boolean;\n}\n\nexport interface SourceAdapter extends SourceProfile {\n /**\n * Recognise a raw identifier, or decline it.\n *\n * A source claims a shape it mints and declines everything else, so a string\n * no source claims is refused, instead of being sent somewhere that would\n * answer it with a confident absence.\n */\n claims: (raw: string) => Claim | null;\n search: (query: string, limit: number) => Promise<ReadRows>;\n getRecipe: (reference: string) => Promise<ReadRecipe>;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Reading a value a source sent, without trusting its shape */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The distinction these draw is between a row and a record. One row this server\n * cannot read is dropped and counted, because the rest of the list is still a\n * good answer. A record it cannot read is `parse_failure`, because there is\n * nothing left to return and a caller told \"network_error\" would retry a source\n * that has actually changed.\n */\nexport function text(value: unknown): string | null {\n return typeof value === \"string\" && value.trim() !== \"\" ? value : null;\n}\n\nexport function textList(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((entry): entry is string => typeof entry === \"string\")\n : [];\n}\n\nexport function count(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nexport function required(value: unknown, field: string, source: SourceProfile): string {\n const found = text(value);\n if (found === null) {\n throw parseFailure(\n `${source.name} answered without a readable \"${field}\", so there is no recipe to return.`,\n );\n }\n return found;\n}\n\nexport function rowsOf<T>(value: unknown, source: SourceProfile): T[] {\n if (!Array.isArray(value)) {\n throw parseFailure(\n `${source.name} answered in a shape this server could not read: the list of results was missing.`,\n );\n }\n return value as T[];\n}\n\n/** Build the identifier this server hands out for a row from a source. */\nexport function namespacedId(source: SourceId, reference: string): string {\n return `${source}:${reference}`;\n}\n\n/**\n * Keep the entries a page actually published.\n *\n * A nutrition panel arrives with a slot for every figure the source knows how\n * to print, and the slots a page left empty are null. Passing those through\n * would put \"protein: null\" in front of a reader as though the page had said\n * something about protein.\n *\n * The panel is walked rather than typed, because each source names its slots\n * differently and a figure is repeated as the page wrote it either way.\n */\nexport function publishedFigures(panel: unknown): Record<string, string> | null {\n if (typeof panel !== \"object\" || panel === null) {\n return null;\n }\n const kept: Record<string, string> = {};\n for (const [key, value] of Object.entries(panel)) {\n if (typeof value === \"string\" && value.trim() !== \"\") {\n kept[key] = value.trim();\n }\n }\n return Object.keys(kept).length > 0 ? kept : null;\n}\n\n/**\n * Read a yield that names a span rather than a number.\n *\n * \"4 à 6 personnes\" and \"4 personnes\" are different claims, and a source that\n * reports the first as a plain count of four hands this server a factor that is\n * too large for anyone at the upper end. The span is read back off the published\n * wording so the answer can say which end it scaled from.\n */\nexport function readYieldSpan(published: string | null): {\n count: number | null;\n max: number | null;\n unit: string | null;\n} {\n if (published === null) {\n return { count: null, max: null, unit: null };\n }\n\n const span = RANGE_AND_REST.exec(published);\n if (span) {\n const [lowText = \"\", highText = \"\"] = span.slice(1);\n const low = Number(lowText.replace(\",\", \".\"));\n const high = Number(highText.replace(\",\", \".\"));\n if (Number.isFinite(low) && Number.isFinite(high) && high > low) {\n return { count: low, max: high, unit: text(span[3]) };\n }\n }\n\n const single = NUMBER_AND_REST.exec(published);\n if (single) {\n const [figure = \"\"] = single.slice(1);\n const value = Number(figure.replace(\",\", \".\"));\n if (Number.isFinite(value)) {\n return { count: value, max: null, unit: text(single[2]) };\n }\n }\n\n return { count: null, max: null, unit: null };\n}\n\n/** Make the language a source publishes in available to whatever reads its lines. */\nexport function detailBase(source: SourceProfile): {\n source: SourceId;\n sourceName: string;\n language: Language;\n attribution: string;\n} {\n return {\n source: source.id,\n sourceName: source.name,\n language: source.language,\n attribution: source.attribution,\n };\n}\n","/**\n * The Cookbook on the English Wikibooks, as a source.\n *\n * A wiki written by whoever edits it, published under a licence that requires\n * attribution, with no author and no reader rating by nature. Its identifiers\n * are page keys carrying the namespace every Cookbook page lives in.\n */\n\nimport { invalidInput } from \"../errors.js\";\nimport type { RecipeDetail, RecipeRow } from \"../types.js\";\nimport type { Claim, ReadRecipe, ReadRows, SourceAdapter } from \"./adapter.js\";\nimport {\n count,\n detailBase,\n namespacedId,\n publishedFigures,\n readYieldSpan,\n required,\n rowsOf,\n text,\n textList,\n} from \"./adapter.js\";\n\n/** A search row, as the Cookbook's own reader publishes one. */\nexport interface CookbookSummary {\n key: string;\n title: string;\n description: string | null;\n excerpt: string | null;\n imageUrl: string | null;\n sourceUrl: string;\n}\n\n/** A page, as the Cookbook's own reader publishes one. */\nexport interface CookbookRecipe {\n key: string;\n title: string;\n sourceUrl: string;\n license: { title: string; url: string } | null;\n description: string | null;\n category: string | null;\n servings: number | null;\n yieldText: string | null;\n yieldUnit: string | null;\n totalMinutes: number | null;\n ingredients: string[];\n equipment: string[];\n steps: string[];\n tips: string[];\n nutrition: unknown;\n sectionTitles: string[];\n}\n\n/** The part of the Cookbook's client this server uses. */\nexport interface CookbookReader {\n search: (\n query: string,\n limit: number,\n ) => Promise<{ data: { results: CookbookSummary[] }; cached: boolean }>;\n getRecipe: (reference: string) => Promise<{ data: CookbookRecipe; cached: boolean }>;\n}\n\nexport const COOKBOOK_PROFILE = {\n id: \"cookbook\",\n name: \"Wikibooks Cookbook\",\n homeUrl: \"https://en.wikibooks.org/wiki/Cookbook:Table_of_Contents\",\n language: \"en\" as const,\n attribution: \"Source: Wikibooks Cookbook\",\n // The namespace holds pages about salt and about a dish made with it alike.\n mixesReferencePages: true,\n};\n\nconst SITE_URL = /^https?:\\/\\/en\\.wikibooks\\.org\\/wiki\\//i;\n/** The namespace every page in the Cookbook lives under. */\nconst NAMESPACE = /^cookbook\\s*:/i;\n\n/**\n * Every page in the Cookbook is filed under one namespace, so its title arrives\n * as \"Cookbook:Spaghetti alla Carbonara\". The prefix is where the page lives on\n * the wiki rather than what the dish is called, and repeating it on every row of\n * a list says nothing and crowds out the names. The key keeps it, because that\n * is the string the site resolves.\n */\nfunction dishName(title: string): string {\n return title.replace(NAMESPACE, \"\").trim() || title;\n}\n\nexport function cookbookAdapter(reader: CookbookReader): SourceAdapter {\n return {\n ...COOKBOOK_PROFILE,\n\n claims(raw: string): Claim | null {\n if (SITE_URL.test(raw)) {\n let key: string;\n try {\n key = decodeURIComponent(raw.replace(SITE_URL, \"\")).replace(/_/g, \" \");\n } catch (cause) {\n // A percent sign that opens no escape is common in a wiki title, and\n // it is the caller's string rather than a site that failed.\n throw invalidInput(\n `\"${raw}\" carries a percent sign that is not a valid escape, so the page name cannot be read.`,\n \"Pass the id search_recipes returned instead of the address.\",\n cause,\n );\n }\n return {\n reference: key,\n why: \"the address is a page on the English Wikibooks\",\n guess: false,\n };\n }\n\n if (NAMESPACE.test(raw)) {\n return {\n reference: raw,\n why: \"the name opens with the namespace every Cookbook page lives under\",\n guess: false,\n };\n }\n\n return null;\n },\n\n async search(query: string, limit: number): Promise<ReadRows> {\n const outcome = await reader.search(query, limit);\n const list = rowsOf<Partial<CookbookSummary>>(\n (outcome.data as { results?: unknown })?.results,\n COOKBOOK_PROFILE,\n );\n const rows: RecipeRow[] = [];\n let skipped = 0;\n\n for (const summary of list) {\n const key = text(summary?.key);\n const title = text(summary?.title) ?? key;\n const url = text(summary?.sourceUrl);\n if (!(key && title && url)) {\n skipped += 1;\n continue;\n }\n rows.push({\n id: namespacedId(COOKBOOK_PROFILE.id, key),\n source: COOKBOOK_PROFILE.id,\n sourceName: COOKBOOK_PROFILE.name,\n title: dishName(title),\n url,\n imageUrl: text(summary?.imageUrl),\n excerpt: text(summary?.excerpt) ?? text(summary?.description),\n });\n }\n\n return {\n rows,\n skipped,\n // The gateway states no total and offers no second page, so there is no\n // number to report. Counting the rows returned and calling it a total\n // would invent one.\n reportedTotal: null,\n reportedTotalMeans: null,\n cached: outcome.cached,\n };\n },\n\n async getRecipe(reference: string): Promise<ReadRecipe> {\n const outcome = await reader.getRecipe(reference);\n return { recipe: cookbookDetail(outcome.data), cached: outcome.cached };\n },\n };\n}\n\nexport function cookbookDetail(payload: unknown): RecipeDetail {\n const recipe = (payload ?? {}) as Partial<CookbookRecipe>;\n const license = recipe.license as Partial<NonNullable<CookbookRecipe[\"license\"]>> | null;\n const licenseTitle = text(license?.title);\n const licenseUrl = text(license?.url);\n\n const published = text(recipe.yieldText);\n const span = readYieldSpan(published);\n\n return {\n ...detailBase(COOKBOOK_PROFILE),\n id: namespacedId(COOKBOOK_PROFILE.id, required(recipe.key, \"key\", COOKBOOK_PROFILE)),\n title: dishName(required(recipe.title ?? recipe.key, \"title\", COOKBOOK_PROFILE)),\n url: required(recipe.sourceUrl, \"url\", COOKBOOK_PROFILE),\n // The page source carries no picture this server can address.\n imageUrl: null,\n yieldCount: count(recipe.servings) ?? span.count,\n yieldMax: span.max,\n yieldText: published,\n yieldUnit: text(recipe.yieldUnit) ?? span.unit,\n ingredients: textList(recipe.ingredients),\n steps: textList(recipe.steps),\n // The page's own headings, which the reader reports. An answer holding no\n // ingredient line uses them to say whether the page announced a list.\n publishedSections: Array.isArray(recipe.sectionTitles) ? textList(recipe.sectionTitles) : null,\n // The Cookbook publishes one time for a recipe rather than splitting it.\n prepMinutes: null,\n cookMinutes: null,\n totalMinutes: count(recipe.totalMinutes),\n category: text(recipe.category),\n // The page is written by everyone who edited it, so it credits nobody in\n // particular and carries no reader rating.\n author: null,\n rating: null,\n nutrition: publishedFigures(recipe.nutrition),\n equipment: textList(recipe.equipment),\n tips: textList(recipe.tips),\n // A licence is only stated when both halves of it arrived: a name with no\n // address, or an address with no name, is not terms anyone can follow.\n license: licenseTitle && licenseUrl ? { title: licenseTitle, url: licenseUrl } : null,\n };\n}\n","/**\n * Marmiton, as a source.\n *\n * A French recipe site with reader ratings, whose identifiers are digits and\n * whose search serves a single page it disallows paging past.\n */\n\nimport type { RecipeDetail, RecipeRow } from \"../types.js\";\nimport type { Claim, ReadRecipe, ReadRows, SourceAdapter } from \"./adapter.js\";\nimport {\n count,\n detailBase,\n namespacedId,\n publishedFigures,\n readYieldSpan,\n required,\n rowsOf,\n text,\n textList,\n} from \"./adapter.js\";\n\nconst ABSOLUTE_URL = /^https?:\\/\\//i;\n\n/** A search row, as Marmiton's own reader publishes one. */\nexport interface MarmitonSummary {\n id: string;\n title: string;\n url: string;\n imageUrl: string | null;\n}\n\n/** A recipe, as Marmiton's own reader publishes one. */\nexport interface MarmitonRecipe {\n id: string;\n title: string;\n url: string;\n imageUrl: string | null;\n ingredients: string[];\n steps: string[];\n recipeYield: { count: number | null; unit: string | null; text: string };\n prepMinutes: number | null;\n cookMinutes: number | null;\n totalMinutes: number | null;\n category: string | null;\n author: string | null;\n rating: { value: number; count: number | null; best: number | null } | null;\n nutrition: unknown;\n}\n\n/** The part of Marmiton's client this server uses. */\nexport interface MarmitonReader {\n search: (query: string) => Promise<{ data: MarmitonSummary[]; cached: boolean }>;\n getRecipe: (ref: {\n id?: string;\n url?: string;\n }) => Promise<{ data: MarmitonRecipe; cached: boolean }>;\n}\n\nexport const MARMITON_PROFILE = {\n id: \"marmiton\",\n name: \"Marmiton\",\n homeUrl: \"https://www.marmiton.org\",\n language: \"fr\" as const,\n attribution: \"Source: Marmiton\",\n // Every page the search returns is a recipe.\n mixesReferencePages: false,\n};\n\n/** Recipe pages, whose address ends in the numeric identifier. */\nconst SITE_URL = /^https?:\\/\\/(?:www\\.)?marmiton\\.org\\//i;\n/** The shape Marmiton mints: digits and nothing else. */\nconst REFERENCE = /^\\d+$/;\n\n/**\n * Ask Marmiton, and read the answer it gives to a wording it matches nothing\n * for.\n *\n * Marmiton serves a 404 on the results page when no recipe matches, which is\n * the site stating an absence. Handed on as a failure it would put \"Marmiton\n * did not answer\" in front of a reader for a search Marmiton answered, and\n * leave the corpus unspoken for. Every other failure stays a failure, so a site\n * that is unreachable is never read as a site that holds nothing.\n */\nasync function readSearch(\n reader: MarmitonReader,\n query: string,\n): Promise<{ data: MarmitonSummary[]; cached: boolean }> {\n try {\n return await reader.search(query);\n } catch (error) {\n const code = (error as { code?: string } | null)?.code;\n if (code === \"not_found\") {\n return { data: [], cached: false };\n }\n throw error;\n }\n}\n\nexport function marmitonAdapter(reader: MarmitonReader): SourceAdapter {\n return {\n ...MARMITON_PROFILE,\n\n claims(raw: string): Claim | null {\n if (SITE_URL.test(raw)) {\n return { reference: raw, why: \"the address is a Marmiton page\", guess: false };\n }\n if (REFERENCE.test(raw)) {\n return {\n reference: raw,\n why: \"a bare number is the shape Marmiton mints\",\n guess: false,\n };\n }\n return null;\n },\n\n async search(query: string): Promise<ReadRows> {\n const outcome = await readSearch(reader, query);\n const list = rowsOf<Partial<MarmitonSummary>>(outcome.data, MARMITON_PROFILE);\n const rows: RecipeRow[] = [];\n let skipped = 0;\n\n for (const summary of list) {\n // A row needs something to call it, somewhere to send a reader, and an\n // identifier that reads back. A row missing any of them is dropped\n // rather than returned with a hole in it.\n const id = text(summary?.id);\n const title = text(summary?.title);\n const url = text(summary?.url);\n if (!(id && title && url)) {\n skipped += 1;\n continue;\n }\n rows.push({\n id: namespacedId(MARMITON_PROFILE.id, id),\n source: MARMITON_PROFILE.id,\n sourceName: MARMITON_PROFILE.name,\n title,\n url,\n imageUrl: text(summary?.imageUrl),\n // The result list carries a title and a picture and no prose, so\n // there is nothing to quote here.\n excerpt: null,\n });\n }\n\n return {\n rows,\n skipped,\n // Rows on the page, including the ones this server could not read: the\n // site saw them, and reporting only the readable ones would understate\n // what it holds.\n reportedTotal: rows.length + skipped,\n reportedTotalMeans:\n \"rows on the single page of results Marmiton serves; the site disallows paging, so this is not a catalogue count\",\n cached: outcome.cached,\n };\n },\n\n async getRecipe(reference: string): Promise<ReadRecipe> {\n // The reader takes an identifier or an address and tells them apart.\n const ref = ABSOLUTE_URL.test(reference) ? { url: reference } : { id: reference };\n const outcome = await reader.getRecipe(ref);\n return { recipe: marmitonDetail(outcome.data), cached: outcome.cached };\n },\n };\n}\n\nexport function marmitonDetail(payload: unknown): RecipeDetail {\n const recipe = (payload ?? {}) as Partial<MarmitonRecipe>;\n const yields = (recipe.recipeYield ?? {}) as Partial<MarmitonRecipe[\"recipeYield\"]>;\n const rating = recipe.rating as Partial<NonNullable<MarmitonRecipe[\"rating\"]>> | null | undefined;\n const value = count(rating?.value);\n\n // The reader splits the published yield at the first number, which leaves the\n // rest of a span sitting where the word being counted belongs: \"4 à 6\n // personnes\" arrives as a count of four and a unit of \"à 6 personnes\". The\n // wording is read again here so both ends of the span are known.\n const published = text(yields.text);\n const span = readYieldSpan(published);\n\n return {\n ...detailBase(MARMITON_PROFILE),\n id: namespacedId(MARMITON_PROFILE.id, required(recipe.id, \"id\", MARMITON_PROFILE)),\n title: required(recipe.title, \"title\", MARMITON_PROFILE),\n url: required(recipe.url, \"url\", MARMITON_PROFILE),\n imageUrl: text(recipe.imageUrl),\n yieldCount: span.count ?? count(yields.count),\n yieldMax: span.max,\n yieldText: published,\n yieldUnit: span.unit ?? text(yields.unit),\n ingredients: textList(recipe.ingredients),\n steps: textList(recipe.steps),\n // The reader hands over the parts of a page it recognised and not the\n // headings the page carries, so there is nothing here to say how it is\n // laid out.\n publishedSections: null,\n prepMinutes: count(recipe.prepMinutes),\n cookMinutes: count(recipe.cookMinutes),\n totalMinutes: count(recipe.totalMinutes),\n category: text(recipe.category),\n author: text(recipe.author),\n rating:\n value === null ? null : { value, count: count(rating?.count), max: count(rating?.best) },\n nutrition: publishedFigures(recipe.nutrition),\n equipment: [],\n tips: [],\n // Marmiton states no licence on a recipe page, and silence is not a grant.\n license: null,\n };\n}\n","/**\n * Several wordings from one question.\n *\n * A person asks for a recipe in a sentence, and a recipe index reads none of\n * it. One of the indexes behind this server requires every word given to appear\n * on the same page, so a sentence returns nothing where the corpus holds several\n * of the dish. The other ranks pages on the words it was handed, so the words\n * framing the question pull up whatever happens to share them and push the dish\n * down the list or out of it. Both come back to a reader looking like a\n * statement about a corpus when they are statements about a wording. Deriving\n * the shorter wordings and asking for the union is what keeps the two apart.\n *\n * Every wording is built from the words the question carried, with no\n * dictionary, no corpus statistics and no translation. That has two\n * consequences worth stating. A derived wording is one a reader can retype by\n * hand, so an answer built from several of them stays checkable. And the\n * derivations that would need a lexicon are not made: a question asked in one\n * language is never turned into the other, because a source that publishes in\n * the other language and holds nothing under the words written is an honest\n * absence, while a guessed translation is a word nobody wrote.\n *\n * The order runs from the wording closest to what was asked to the widest.\n *\n * A question also states what the recipe must not hold, and that part is read\n * by the role its words play rather than by the turn of phrase they are written\n * in: a negation, a sentence about how a food affects the person, or the name\n * of a diet. All three put a food out of the dish, so all three are set aside\n * from the derived wordings and named back to the caller. Reading them off a\n * list of phrasings is what lets a declared allergen become a word to search\n * for, and there is no phrasing whose absence from such a list is safe.\n */\n\nconst DIGITS_ONLY = /^\\d+$/;\nconst WORD_SEPARATORS = /[^\\p{L}\\p{N}-]+/u;\n\n/** One wording, and how it was arrived at. */\nexport interface Wording {\n query: string;\n /** How this wording was derived from the question, in words. */\n derivation: string;\n}\n\n/**\n * The most searches one source receives for one question.\n *\n * A ceiling per source rather than a budget shared between them, because the\n * sources are asked in parallel and each is paced on its own, so the worst case\n * is three of one source's intervals rather than six. Three is also the whole\n * ladder: the question as asked, the dish words, and the word naming the dish.\n */\nexport const MAX_WORDINGS_PER_SOURCE = 3;\n\n/* -------------------------------------------------------------------------- */\n/* The words a question is built out of, as opposed to the words naming a dish */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Words that frame a question rather than name what is cooked.\n *\n * Both languages are applied to every question. Telling them apart would need a\n * detector, and a detector that guesses wrong strips the wrong words; the two\n * lists barely overlap, and where they do the word frames a question in both.\n *\n * A word is left out of these lists wherever it is also something a person\n * eats. French \"son\" is a possessive and is also bran, so it stays. The same\n * caution governs anything added here: the cost of keeping a framing word is a\n * diluted ranking, and the cost of dropping an ingredient is the dish.\n */\nconst FRENCH_FRAME = new Set([\n \"a\",\n \"ai\",\n \"aimerais\",\n \"au\",\n \"aurais\",\n \"auriez\",\n \"aux\",\n \"avec\",\n \"avons\",\n \"bcp\",\n \"beaucoup\",\n \"bien\",\n \"bonjour\",\n \"bonne\",\n \"bonnes\",\n \"cherche\",\n \"chercher\",\n \"cherchez\",\n \"connais\",\n \"connaissez\",\n \"coucou\",\n \"ce\",\n \"ces\",\n \"cette\",\n \"comment\",\n \"d\",\n \"dans\",\n \"de\",\n \"des\",\n \"du\",\n \"en\",\n \"est\",\n \"et\",\n \"facile\",\n \"faire\",\n \"fais\",\n \"fait\",\n \"idee\",\n \"idees\",\n \"idée\",\n \"idées\",\n \"j\",\n \"je\",\n \"l\",\n \"la\",\n \"le\",\n \"les\",\n \"ma\",\n \"meilleur\",\n \"meilleure\",\n \"merci\",\n \"mes\",\n \"mon\",\n \"ou\",\n \"par\",\n \"peux\",\n \"plait\",\n \"plaît\",\n \"plus\",\n \"pour\",\n \"qu\",\n \"que\",\n \"quel\",\n \"quelle\",\n \"quelles\",\n \"quels\",\n \"qui\",\n \"rapide\",\n \"recette\",\n \"recettes\",\n \"recherche\",\n \"s\",\n \"salut\",\n \"se\",\n \"si\",\n \"simple\",\n \"sommes\",\n \"souhaite\",\n \"stp\",\n \"suis\",\n \"sur\",\n \"svp\",\n \"tres\",\n \"très\",\n \"un\",\n \"une\",\n \"veux\",\n \"voudrais\",\n \"vous\",\n \"à\",\n]);\n\nconst ENGLISH_FRAME = new Set([\n \"a\",\n \"about\",\n \"am\",\n \"an\",\n \"and\",\n \"any\",\n \"anything\",\n \"are\",\n \"at\",\n \"be\",\n \"best\",\n \"by\",\n \"can\",\n \"could\",\n \"do\",\n \"does\",\n \"easy\",\n \"find\",\n \"for\",\n \"from\",\n \"get\",\n \"good\",\n \"hello\",\n \"hi\",\n \"how\",\n \"i\",\n \"in\",\n \"into\",\n \"is\",\n \"it\",\n \"like\",\n \"looking\",\n \"make\",\n \"making\",\n \"me\",\n \"my\",\n \"need\",\n \"of\",\n \"on\",\n \"or\",\n \"please\",\n \"quick\",\n \"recipe\",\n \"recipes\",\n \"should\",\n \"simple\",\n \"some\",\n \"something\",\n \"suggest\",\n \"thank\",\n \"thanks\",\n \"that\",\n \"the\",\n \"less\",\n \"over\",\n \"than\",\n \"to\",\n \"under\",\n \"want\",\n \"what\",\n \"which\",\n \"will\",\n \"with\",\n \"would\",\n \"you\",\n \"your\",\n]);\n\n/**\n * What a condition is measured in.\n *\n * \"under 300 calories\" and \"in 20 minutes\" set a bound, and the word naming the\n * dimension is left in the sentence once the number has gone. No page is named\n * for it, so carrying it into a derived wording ranks pages that merely mention\n * counting calories.\n */\nconst CONDITION_UNITS = new Set([\n \"calorie\",\n \"calories\",\n \"couvert\",\n \"couverts\",\n \"heure\",\n \"heures\",\n \"kcal\",\n \"min\",\n \"mins\",\n \"minute\",\n \"minutes\",\n \"part\",\n \"parts\",\n \"people\",\n \"person\",\n \"persons\",\n \"personne\",\n \"personnes\",\n \"portion\",\n \"portions\",\n \"serving\",\n \"servings\",\n]);\n\n/**\n * Words whose whole job is to take something out of the dish.\n *\n * A negation is a function word, and function words are a closed class: the\n * ones a language has are the ones it has, and covering the class covers every\n * phrasing built on it, whether the question writes \"sans beurre\", \"without\n * butter\", \"no butter\" or \"pas de beurre\". That is what makes this a rule\n * rather than a collection of turns of phrase, and it is why the verbs that do\n * the same work, \"avoid\" and \"éviter\", sit here beside them.\n *\n * The price is a question such as \"no bake cookies\", where the negation is part\n * of a dish's name. It costs a caveat the reader can discard. Missing an\n * exclusion costs the reader the thing they wrote the question to avoid, so the\n * two errors are not weighed the same.\n */\nconst NEGATORS = new Set([\n \"aucun\",\n \"aucune\",\n \"avoid\",\n \"avoiding\",\n \"eviter\",\n \"evite\",\n \"evitez\",\n \"exclude\",\n \"excluding\",\n \"exclure\",\n \"except\",\n \"ni\",\n \"no\",\n \"non\",\n \"not\",\n \"pas\",\n \"sans\",\n \"sauf\",\n \"without\",\n \"zero\",\n]);\n\n/**\n * Words saying that a food does something to the person rather than to the\n * dish.\n *\n * A sentence about the eater names what the recipe must not hold just as\n * plainly as a negation does, and it is the one condition whose failure is a\n * matter of health, so it is read as its own kind and reported as one.\n */\nconst HEALTH_MARKERS = new Set([\n \"allergene\",\n \"allergenes\",\n \"allergen\",\n \"allergens\",\n \"allergic\",\n \"allergie\",\n \"allergies\",\n \"allergique\",\n \"allergiques\",\n \"allergy\",\n \"celiac\",\n \"coeliaque\",\n \"intolerance\",\n \"intolerances\",\n \"intolerant\",\n \"intolerante\",\n \"intolerantes\",\n \"intolerants\",\n]);\n\n/**\n * Diets, each of which names a class of recipes by what it leaves out.\n *\n * Naming one states an exclusion without writing a negation anywhere, which is\n * why a reading built on negations alone lets it through and hands the word\n * back to the index as though it named a dish.\n */\nconst DIETS = new Set([\n \"casher\",\n \"cetogene\",\n \"halal\",\n \"kascher\",\n \"keto\",\n \"ketogenic\",\n \"kosher\",\n \"paleo\",\n \"pescatarian\",\n \"pescetarien\",\n \"pescetarienne\",\n \"vegan\",\n \"vegane\",\n \"veganes\",\n \"vegans\",\n \"vegetalien\",\n \"vegetalienne\",\n \"vegetaliennes\",\n \"vegetaliens\",\n \"vegetarian\",\n \"vegetarians\",\n \"vegetarien\",\n \"vegetarienne\",\n \"vegetariennes\",\n \"vegetariens\",\n \"veggie\",\n]);\n\n/**\n * Where a condition ends.\n *\n * \"sans farine de blé et sans oeufs\" names two things to leave out, and the\n * conjunction is where the first one stops.\n */\nconst CONDITION_ENDS = new Set([\n \"et\",\n \"and\",\n \"ou\",\n \"or\",\n \"pour\",\n \"for\",\n \"avec\",\n \"with\",\n \"mais\",\n \"but\",\n \"dans\",\n \"sur\",\n]);\n\n/**\n * Words joining a marker to what it names, which name nothing themselves.\n *\n * \"pas de beurre\" and \"allergique aux noix\" put one of these between the word\n * carrying the condition and the food it is about. One of them standing right\n * after a marker is what says the food comes after it, which is the only sign\n * in the sentence of which side the marker reaches towards.\n */\nconst CONDITION_JOINERS = new Set([\n \"a\",\n \"au\",\n \"aux\",\n \"d\",\n \"de\",\n \"des\",\n \"du\",\n \"from\",\n \"l\",\n \"la\",\n \"le\",\n \"les\",\n \"my\",\n \"of\",\n \"the\",\n \"to\",\n]);\n\n/**\n * Words that carry a food's name on into a second noun.\n *\n * \"farine de blé\" and \"noix de coco\" are one food written in three words, and\n * the middle one is what says so. A food's name stops wherever no such word\n * carries it further, which is what keeps \"sans gluten gateau au chocolat\" from\n * reading as a condition on chocolate cake.\n */\nconst FOOD_LINKERS = new Set([\"d\", \"de\", \"des\", \"du\", \"of\"]);\n\n/**\n * How many words a condition names before the sentence resumes.\n *\n * What a recipe has to leave out is a food, so it is a short noun phrase:\n * \"beurre\", \"farine de blé\", \"noix de coco\". Past that the words belong to the\n * question again, and a scope with no end turns a negation used inside a dish's\n * name into a condition swallowing the rest of the sentence.\n */\nconst CONDITION_WORDS = 3;\n\n/* -------------------------------------------------------------------------- */\n/* Conditions */\n/* -------------------------------------------------------------------------- */\n\n/** How a question put a condition, which decides how it is reported back. */\nexport type ConditionKind =\n /** The question wrote the recipe must not hold this. */\n | \"excluded\"\n /** The question said the person reacts to this food. */\n | \"allergy\"\n /** The question named a diet, which is a class defined by what it leaves out. */\n | \"diet\";\n\n/** One thing a question asks the recipe not to hold. */\nexport interface Condition {\n /**\n * The words the question used for the food, or null where the question states\n * a condition and the sentence does not say plainly which food it covers.\n *\n * A word named here is a word the recipe must not hold and a word no wording\n * looks for, so naming the wrong one both hides a dish and leaves the food\n * being avoided in the search. Where the reading is not certain, the caller is\n * told a condition was stated and that its food was not read, which is a\n * smaller claim than either.\n */\n named: string | null;\n kind: ConditionKind;\n}\n\n/** What a question asks the dish to avoid, and how many it has to serve. */\nexport interface Conditions {\n /** What the dish must not hold, in the words the question used. */\n conditions: Condition[];\n /** How many the dish has to serve, when the question said. */\n servings: number | null;\n}\n\n/** How many the question asks the dish to serve, written either way round. */\nconst SERVING_PHRASES = [\n /\\bpour\\s+(\\d{1,3})\\s*(?:personnes?|parts?|couverts?)\\b/iu,\n /\\bfor\\s+(\\d{1,3})\\s*(?:people|persons?|servings?|portions?)\\b/iu,\n /\\bserves?\\s+(\\d{1,3})\\b/iu,\n /\\b(\\d{1,3})\\s*(?:personnes?|people|servings?)\\b/iu,\n];\n\n/** A food and the marker written as a single word: \"gluten-free\", \"sugarfree\". */\nconst FREE_OF = /\\b([\\p{L}]{3,})[\\s-]?free\\b/iu;\n\n/** What one reading of a question found in it. */\ninterface ReadQuestion {\n /** The words that name what is cooked, in the order the question wrote them. */\n dish: string[];\n conditions: Condition[];\n}\n\n/** Lowercase and strip diacritics, so \"végétarien\" and \"vegetarien\" read alike. */\nfunction foldWord(word: string): string {\n return word\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(/\\p{M}+/gu, \"\")\n .replace(/œ/gu, \"oe\");\n}\n\n/**\n * Whether a word left over from a question names the dish.\n *\n * The framing of a question is not the dish it asks for, a number counts eaters\n * or minutes rather than naming anything, and what an apostrophe leaves behind\n * once it has split an elision is not a word at all.\n */\nfunction namesTheDish(word: string, key: string): boolean {\n if (FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key)) {\n return false;\n }\n if (FRENCH_FRAME.has(word) || ENGLISH_FRAME.has(word)) {\n return false;\n }\n if (DIGITS_ONLY.test(word)) {\n return false;\n }\n if (word.length < 2) {\n return false;\n }\n return !CONDITION_UNITS.has(word);\n}\n\n/**\n * Read a question once, deciding for every word whether it names the dish or\n * states a condition on it.\n *\n * One pass rather than two, because the two answers have to agree: a word read\n * as a condition here and as a dish word there is a condition reported to the\n * caller and searched for at the same time, which is the shape of the worst\n * thing this file can do. A single reading cannot disagree with itself.\n */\nfunction readQuestion(question: string): ReadQuestion {\n const words = tokenise(question);\n const conditions: Condition[] = [];\n const dish: string[] = [];\n\n const state = (named: string | null, kind: ConditionKind): void => {\n const trimmed = named === null ? null : named.trim();\n if (trimmed === \"\") {\n return;\n }\n if (conditions.some((held) => held.named === trimmed && held.kind === kind)) {\n return;\n }\n conditions.push({ named: trimmed, kind });\n };\n\n for (let index = 0; index < words.length; index += 1) {\n const word = words[index] ?? \"\";\n const key = foldWord(word);\n\n // A hyphen joins everywhere else, so \"sans-gluten\" and \"no-sugar\" arrive as\n // one word with the negation inside it.\n const hyphen = word.indexOf(\"-\");\n if (hyphen > 0 && NEGATORS.has(foldWord(word.slice(0, hyphen)))) {\n state(word.slice(hyphen + 1), \"excluded\");\n continue;\n }\n\n // \"gluten-free\" writes the food and the marker as one word.\n const free = FREE_OF.exec(word);\n if (free) {\n state(free[1] ?? \"\", \"excluded\");\n continue;\n }\n\n // \"free\" is also a price and a way of keeping hens, so it states a\n // condition only where the sentence puts a food on one side of it.\n if (key === \"free\") {\n const scope = scopeOf(words, index, dish);\n if (scope.named !== null) {\n state(scope.named, \"excluded\");\n index = scope.next - 1;\n }\n continue;\n }\n\n // A negation stands before what it negates, in both languages: \"sans\n // beurre\", \"without butter\", \"pas de beurre\".\n if (NEGATORS.has(key)) {\n const forward = takeFood(words, index + 1);\n state(forward.named === \"\" ? null : forward.named, \"excluded\");\n // The marker itself never names a dish, so it leaves whether or not it\n // turned out to scope over anything.\n index = forward.next - 1;\n continue;\n }\n\n if (HEALTH_MARKERS.has(key)) {\n const scope = scopeOf(words, index, dish);\n state(scope.named, \"allergy\");\n index = scope.next - 1;\n continue;\n }\n\n if (DIETS.has(key)) {\n state(word, \"diet\");\n continue;\n }\n\n if (namesTheDish(word, key) && !dish.includes(word)) {\n dish.push(word);\n }\n }\n\n return { dish, conditions };\n}\n\n/**\n * The food a marker covers, on whichever side of it the sentence put one.\n *\n * A joining word straight after the marker is the sentence saying its food\n * follows: \"allergique aux noix\", \"allergic to peanuts\", \"free of dairy\". With\n * no such word the food is the one the marker was written onto the end of:\n * \"peanut allergy\", \"lactose intolerant\", \"gluten free\". The two orders belong\n * to the two languages these sources publish in, so reading only one of them\n * leaves every question written in the other unread.\n *\n * Read the wrong way round, a marker takes a word out of the dish and leaves\n * the food being avoided in the search, which is both halves of the harm at\n * once. So a marker with a food on neither side covers none, and says so.\n *\n * A food found before the marker was already read as a word of the dish, and\n * leaves it here: the same word cannot be a thing to avoid and a thing to look\n * for.\n */\nfunction scopeOf(\n words: string[],\n marker: number,\n dish: string[],\n): { named: string | null; next: number } {\n if (CONDITION_JOINERS.has(foldWord(words[marker + 1] ?? \"\"))) {\n const forward = takeFood(words, marker + 1);\n return { named: forward.named === \"\" ? null : forward.named, next: forward.next };\n }\n\n const before = words[marker - 1];\n if (before !== undefined) {\n const held = dish.lastIndexOf(before);\n if (held !== -1) {\n dish.splice(held, 1);\n return { named: before, next: marker + 1 };\n }\n }\n\n return { named: null, next: marker + 1 };\n}\n\n/**\n * The food named from a point in a sentence onwards, and where the sentence\n * picks up again.\n *\n * A food is a short noun phrase, and it ends where nothing carries it further.\n * Taking a fixed number of words instead lets a condition run on into the dish,\n * so \"sans gluten gateau au chocolat\" comes back as a condition on the cake.\n */\nfunction takeFood(words: string[], from: number): { named: string; next: number } {\n let index = from;\n // An article or a quantifier can stand between the marker and the food:\n // \"pas de beurre\", \"without any butter\".\n while (index < words.length && isFiller(words[index] ?? \"\")) {\n index += 1;\n }\n\n const named: string[] = [];\n while (index < words.length && named.length < CONDITION_WORDS) {\n const word = words[index] ?? \"\";\n if (!namesFood(word)) {\n break;\n }\n named.push(word);\n index += 1;\n\n const beyond = words[index + 1];\n if (\n named.length + 2 <= CONDITION_WORDS &&\n FOOD_LINKERS.has(foldWord(words[index] ?? \"\")) &&\n beyond !== undefined &&\n namesFood(beyond)\n ) {\n named.push(words[index] ?? \"\");\n index += 1;\n continue;\n }\n break;\n }\n\n return { named: named.join(\" \"), next: index };\n}\n\n/** A word standing between a marker and its food, naming no food itself. */\nfunction isFiller(word: string): boolean {\n const key = foldWord(word);\n return CONDITION_JOINERS.has(key) || FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key);\n}\n\n/** Whether a word can be part of the name of something a recipe holds. */\nfunction namesFood(word: string): boolean {\n const key = foldWord(word);\n if (key.length < 2) {\n return false;\n }\n if (DIGITS_ONLY.test(key)) {\n return false;\n }\n if (key === \"free\") {\n return false;\n }\n if (CONDITION_ENDS.has(key)) {\n return false;\n }\n if (NEGATORS.has(key) || HEALTH_MARKERS.has(key)) {\n return false;\n }\n if (CONDITION_UNITS.has(key)) {\n return false;\n }\n return !(FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key));\n}\n\n/**\n * Read what the question asks the recipe to avoid or to serve.\n *\n * These are the parts of a question no recipe index answers. A page is not\n * named for what it leaves out, and the number of people at a table is not\n * printed in a title at all, so both are read here and handled as what they\n * are rather than as words to look for.\n */\nexport function readConditions(question: string): Conditions {\n const { conditions } = readQuestion(question);\n\n let servings: number | null = null;\n for (const phrase of SERVING_PHRASES) {\n const found = phrase.exec(question);\n if (found) {\n const value = Number(found[1]);\n if (Number.isFinite(value) && value > 0) {\n servings = value;\n break;\n }\n }\n }\n\n return { conditions, servings };\n}\n\n/* -------------------------------------------------------------------------- */\n/* The ladder */\n/* -------------------------------------------------------------------------- */\n\nexport function deriveWordings(question: string): Wording[] {\n const asked = tidy(question);\n const wordings: Wording[] = [];\n const seen = new Set<string>();\n\n const offer = (candidate: string, derivation: string): void => {\n const clean = tidy(candidate);\n if (clean === \"\") {\n return;\n }\n // Two wordings differing only in case or spacing are one wording to these\n // indexes, and sending both spends an interval to learn nothing.\n const key = clean.toLowerCase();\n if (seen.has(key)) {\n return;\n }\n seen.add(key);\n wordings.push({ query: clean, derivation });\n };\n\n offer(asked, \"the words as asked, condition words included\");\n\n const dish = dishWords(asked);\n offer(\n dish.join(\" \"),\n \"the words naming the dish, with the words framing the question and the conditions set aside, because a page is named for what it holds\",\n );\n\n offer(\n dish[0] ?? \"\",\n \"the leading dish word on its own, because an index that requires every word given returns nothing for a phrase and a great deal for the word naming the dish\",\n );\n\n // The ladder is three rungs by construction; the ceiling holds whatever a\n // future rung would add to it.\n return wordings.slice(0, MAX_WORDINGS_PER_SOURCE);\n}\n\n/**\n * Whether a row's title carries a word naming the dish that was asked for.\n *\n * This decides whether a wording answered, and nothing else: no row is dropped\n * and no row is moved for failing it. A source that ranks on the words it was\n * handed answers a sentence with a page of recipes that share its framing\n * words, and counting those as an answer would stop the search on the page that\n * proves it has not found the dish.\n *\n * Diacritics and punctuation fold away, so \"crêpes\" reads the same as \"crepes\"\n * and a namespace in front of a page name settles nothing. A question that\n * names no dish has nothing to check against, and every row passes.\n */\nexport function namesDish(title: string, question: string): boolean {\n const words = namingWords(question);\n if (words.length === 0) {\n return true;\n }\n return dishWordsMissing(title, question).length < words.length;\n}\n\n/**\n * The words naming the dish that a title does not carry.\n *\n * Sharing one word is what makes a row worth offering, and it is not what makes\n * a row the dish. \"Biscuits and gravy\" and a tin of Christmas biscuits share\n * the biscuit and nothing else, so a caller weighing one against the other has\n * to be told which of the words asked for went unanswered.\n *\n * Empty for a title carrying all of them, and empty for a question that names\n * no dish, since there is then nothing to go unanswered.\n */\nexport function dishWordsMissing(title: string, question: string): string[] {\n const haystack = ` ${fold(title)} `;\n return namingWords(question).filter((word) => !haystack.includes(` ${fold(word)}`));\n}\n\n/** The words of a question long enough to name what is cooked. */\nfunction namingWords(question: string): string[] {\n return dishWords(question).filter((word) => word.length > 2);\n}\n\nfunction fold(value: string): string {\n return value\n .normalize(\"NFD\")\n .replace(/\\p{M}+/gu, \"\")\n .replace(/œ/gu, \"oe\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \" \");\n}\n\n/**\n * The words of a question that name what is cooked.\n *\n * The framing words go because they rank pages that merely share them. The\n * condition words go for a different reason, and it is worth stating: a search\n * for \"sans gluten\" ranks pages about gluten, and a search for \"6 personnes\"\n * ranks nothing at all, so carrying them into a derived wording buries the dish\n * without excluding anything. Dropping them silently would be the worse move,\n * since the recipe found may hold exactly what the person was avoiding, and\n * that is why the question as asked is sent first and every condition set aside\n * is named back to the caller.\n */\nfunction dishWords(question: string): string[] {\n return readQuestion(question).dish;\n}\n\n/**\n * The words of a question, lowercased.\n *\n * An apostrophe separates: \"l'origan\" is the article and the herb, and the herb\n * is the word an index holds. A hyphen joins, because \"pot-au-feu\" and\n * \"gluten-free\" are each one word wherever they are written.\n */\nfunction tokenise(value: string): string[] {\n return value\n .toLowerCase()\n .replace(/['’]/gu, \" \")\n .split(WORD_SEPARATORS)\n .map((word) => word.replace(/^-+|-+$/g, \"\"))\n .filter((word) => word !== \"\");\n}\n\nfunction tidy(value: string): string {\n return value.replace(/\\s+/g, \" \").trim();\n}\n","/**\n * The one place that talks to the sources.\n *\n * It asks them all at once and merges what comes back, so a question is\n * answered by whichever of them holds something. It imports nothing from the\n * MCP layer and is published on its own, so the same code serves a plain\n * script.\n *\n * A source that fails is reported as a source that failed. It is never folded\n * into the answer as an absence: \"Marmiton was unreachable\" and \"Marmiton holds\n * no such recipe\" are different statements about the world, and a caller that\n * cannot tell them apart will say the second when only the first is true.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport {\n MAX_ALLOWED_INTERVAL_MS,\n MIN_ALLOWED_INTERVAL_MS,\n createLogger,\n loadConfig,\n} from \"../config.js\";\nimport { fromSource, invalidInput, timeout as timedOut } from \"../errors.js\";\nimport type {\n MergedSearch,\n RecipeDetail,\n RecipeRow,\n SourceId,\n SourceReport,\n WordingAttempt,\n} from \"../types.js\";\nimport type { SourceAdapter } from \"./adapter.js\";\nimport { resolveId } from \"./ids.js\";\nimport type { ResolvedId } from \"./ids.js\";\nimport { buildSources, selectSources } from \"./registry.js\";\nimport type { Readers } from \"./registry.js\";\nimport { MAX_WORDINGS_PER_SOURCE, deriveWordings, namesDish } from \"./wordings.js\";\nimport type { Wording } from \"./wordings.js\";\n\nexport type { SourceAdapter, ReadRecipe, ReadRows } from \"./adapter.js\";\nexport type { CookbookReader } from \"./cookbook.js\";\nexport type { MarmitonReader } from \"./marmiton.js\";\nexport type { Readers } from \"./registry.js\";\nexport type {\n MergedSearch,\n RecipeDetail,\n RecipeRow,\n SourceId,\n SourceProfile,\n SourceReport,\n WordingAttempt,\n} from \"../types.js\";\n\nconst LETTER_OR_DIGIT = /[\\p{L}\\p{N}]/u;\n\nexport interface RecipesClientOptions {\n config?: Partial<Config>;\n logger?: Logger;\n /** Stands in for one or more of the site readers. */\n readers?: Readers;\n /** Replaces the registry outright, for a program bringing its own corpora. */\n sources?: SourceAdapter[];\n}\n\n/** The largest number of rows any one source is asked for in a single call. */\nexport const MAX_LIMIT_PER_SOURCE = 25;\n\nexport interface SearchOptions {\n /**\n * Whether a question may be sent in wordings derived from it as well as in\n * the words it was written in.\n *\n * On unless a caller turns it off. A recipe index answers the words it is\n * handed, so a question written as a sentence comes back empty from a corpus\n * holding several of the dish, and an empty answer is read as a corpus that\n * holds nothing. Off, a caller gets exactly the wording they typed and the\n * answer names the wordings that were not sent.\n */\n fanOut?: boolean;\n}\n\n/**\n * The pacing this server owes each source, applied to whatever it is handed.\n *\n * A configuration object assembled by a caller has not been through\n * `loadConfig`, so it can carry a missing value, a value of the wrong shape, or\n * a User-Agent that names somebody else. Every setting is held to the same range\n * the environment parser enforces: `timeoutMs: 0` is the usual way of writing\n * \"no deadline\", and a retry count of a hundred thousand aimed at a site paced\n * in milliseconds is hours of traffic from a single call.\n */\nfunction withGuarantees(config: Config): Config {\n const defaults = loadConfig({});\n\n const bounded = (value: unknown, fallback: number, min: number, max: number): number => {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) {\n return fallback;\n }\n return Math.min(max, Math.max(min, parsed));\n };\n\n const claimed = typeof config.userAgent === \"string\" ? config.userAgent.trim() : \"\";\n const identifier = defaults.userAgent;\n\n return {\n ...config,\n userAgent:\n claimed === \"\" || claimed.includes(identifier) ? identifier : `${claimed} ${identifier}`,\n minIntervalMs: bounded(\n config.minIntervalMs,\n defaults.minIntervalMs,\n MIN_ALLOWED_INTERVAL_MS,\n MAX_ALLOWED_INTERVAL_MS,\n ),\n timeoutMs: bounded(config.timeoutMs, defaults.timeoutMs, 1000, 120_000),\n maxRetries: bounded(config.maxRetries, defaults.maxRetries, 0, 8),\n cacheTtlMs: bounded(config.cacheTtlMs, defaults.cacheTtlMs, 0, 86_400_000),\n cacheMaxEntries: bounded(config.cacheMaxEntries, defaults.cacheMaxEntries, 1, 5000),\n logLevel: config.logLevel ?? defaults.logLevel,\n };\n}\n\n/**\n * A wall clock over one source, as a backstop under the deadline that source's\n * own reader keeps.\n *\n * That reader times out its own requests, so this only matters when it cannot:\n * a socket that stalls without erroring, a stand-in reader, a bug in a\n * dependency free to change under a caret range. Without it, one source that\n * never answers holds the whole call open and the rows the others had ready in\n * milliseconds are never returned.\n *\n * The allowance covers every attempt a reader is entitled to make, plus the\n * pacing between them, so this never fires before the reader's own deadline has.\n */\nfunction withDeadline<T>(work: Promise<T>, ms: number, source: SourceAdapter): Promise<T> {\n let timer: ReturnType<typeof setTimeout>;\n const alarm = new Promise<never>((_resolve, reject) => {\n timer = setTimeout(\n () =>\n reject(\n timedOut(`${source.name} did not answer within ${ms}ms and was left out of this answer.`),\n ),\n ms,\n );\n // A pending timer must not hold the process open once the answer is out.\n timer.unref?.();\n });\n return Promise.race([work, alarm]).finally(() => clearTimeout(timer));\n}\n\n/** What one source produced for one question, whether or not it produced anything. */\ninterface Attempt {\n source: SourceAdapter;\n rows: RecipeRow[];\n /** Whether rows naming the dish were put in front of rows that do not. */\n preferredByName: boolean;\n cached: boolean;\n reportedTotal: number | null;\n reportedTotalMeans: string | null;\n skipped: number;\n error: { code: string; message: string; hint?: string } | null;\n wordings: WordingAttempt[];\n}\n\nfunction reportOf(attempt: Attempt, count: number): SourceReport {\n return {\n source: attempt.source.id,\n name: attempt.source.name,\n status: attempt.error ? \"failed\" : \"answered\",\n count,\n reportedTotal: attempt.reportedTotal,\n reportedTotalMeans: attempt.reportedTotalMeans,\n skipped: attempt.skipped,\n mixesReferencePages: attempt.source.mixesReferencePages,\n cached: attempt.cached,\n error: attempt.error,\n wordings: attempt.wordings,\n preferredByName: attempt.preferredByName,\n };\n}\n\n/** A wording that was never sent, and the reason it was not. */\nfunction heldBack(wording: Wording, because: string): WordingAttempt {\n return {\n query: wording.query,\n derivation: wording.derivation,\n ran: false,\n count: null,\n added: null,\n notRunBecause: because,\n error: null,\n };\n}\n\n/**\n * Keep the rows a wording brought back that no earlier wording already did, and\n * count how many of them name the dish that was asked for.\n *\n * The identifier names its source, so two sources minting the same reference\n * stay two rows while one recipe reached twice stays one.\n */\nfunction keepNewRows<T extends { id: string; title: string }>(\n read: { rows: readonly T[] },\n seen: Set<string>,\n rows: T[],\n question: string,\n): { added: number; onTopic: number } {\n let added = 0;\n let onTopic = 0;\n\n for (const row of read.rows) {\n if (seen.has(row.id)) {\n continue;\n }\n seen.add(row.id);\n rows.push(row);\n added += 1;\n if (namesDish(row.title, question)) {\n onTopic += 1;\n }\n }\n\n return { added, onTopic };\n}\n\n/**\n * Interleave rows so no source opens the list twice before another has opened\n * it once.\n *\n * Ranking them against each other would need a score every source carries, and\n * there is none: one has reader ratings, another has no author and no rating by\n * nature, and a single ordering would rank on a field most rows cannot have.\n * Taking one from each in turn is an order a caller can describe exactly, which\n * is what the answer does.\n */\nexport function interleave(groups: RecipeRow[][]): RecipeRow[] {\n const merged: RecipeRow[] = [];\n const longest = Math.max(0, ...groups.map((group) => group.length));\n for (let index = 0; index < longest; index += 1) {\n for (const group of groups) {\n const row = group[index];\n if (row) {\n merged.push(row);\n }\n }\n }\n return merged;\n}\n\nexport class RecipesClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly sources: SourceAdapter[];\n\n constructor(options: RecipesClientOptions = {}) {\n this.config = withGuarantees({ ...loadConfig(), ...options.config });\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.sources = options.sources ?? buildSources(this.config, options.readers ?? {});\n }\n\n /** What every source sees this client call itself. */\n get userAgent(): string {\n return this.config.userAgent;\n }\n\n get intervalMs(): number {\n return this.config.minIntervalMs;\n }\n\n get timeoutMs(): number {\n return this.config.timeoutMs;\n }\n\n get maxRetries(): number {\n return this.config.maxRetries;\n }\n\n /** The sources this server reads, in the order an answer takes them. */\n get profiles(): Array<{ id: SourceId; name: string; language: string; homeUrl: string }> {\n return this.sources.map((source) => ({\n id: source.id,\n name: source.name,\n language: source.language,\n homeUrl: source.homeUrl,\n }));\n }\n\n /**\n * The backstop deadline over one source, covering every attempt it is\n * entitled to make and the pacing between them.\n */\n private get deadlineMs(): number {\n const attempts = this.config.maxRetries + 1;\n return this.config.timeoutMs * attempts + this.config.minIntervalMs * this.config.maxRetries;\n }\n\n /**\n * Ask every source the same question and merge the rows.\n *\n * The calls go out together, so the answer takes as long as the slowest\n * source rather than as long as all of them. Whichever of them fail, the rest\n * still come back, and the report says which was which.\n */\n async searchRecipes(\n query: string,\n limitPerSource: number,\n wanted?: readonly SourceId[],\n options: SearchOptions = {},\n ): Promise<MergedSearch> {\n const trimmed = query.trim();\n if (trimmed === \"\") {\n throw invalidInput(\"A search needs something to look for.\", \"Name a dish or an ingredient.\");\n }\n // A recipe index handed nothing it can match on answers with the page it\n // shows by default, and those rows would come back as this question's\n // recipes. One character is a word in the scripts that write one that way,\n // so what is asked for is a letter or a digit rather than a length.\n if (!LETTER_OR_DIGIT.test(trimmed)) {\n throw invalidInput(\n `\"${trimmed}\" carries no letter and no digit, so there is no word to search for.`,\n \"Name a dish or an ingredient.\",\n );\n }\n\n const chosen = selectSources(this.sources, wanted);\n const limit = Math.max(1, Math.min(MAX_LIMIT_PER_SOURCE, Math.trunc(limitPerSource)));\n const fanOut = options.fanOut ?? true;\n\n const attempts = await Promise.all(\n chosen.map((source) => this.searchOne(source, trimmed, limit, fanOut)),\n );\n\n const answered = attempts.map((attempt) => ({ attempt, rows: attempt.rows.slice(0, limit) }));\n return {\n rows: interleave(answered.map((one) => one.rows)),\n reports: answered.map(({ attempt, rows }) => reportOf(attempt, rows.length)),\n };\n }\n\n /**\n * Ask one source the question in several wordings, and union what comes back.\n *\n * The wordings go out one after another rather than together, because each\n * source is paced and a burst would spend the whole ladder's politeness at\n * once. They stop early: a wording is only sent when the ones before it left\n * the source short of what was asked for, so the ordinary case where the\n * question names a dish costs one request.\n *\n * Each wording is a different search rather than a further page of the same\n * one. One of these sites serves a single page of results and disallows\n * paging past it, so a second page is never asked for, here or anywhere else.\n */\n private async searchOne(\n source: SourceAdapter,\n question: string,\n limit: number,\n fanOut: boolean,\n ): Promise<Attempt> {\n const derived = deriveWordings(question);\n const wordings: WordingAttempt[] = [];\n const rows: RecipeRow[] = [];\n const seen = new Set<string>();\n\n let cached = false;\n let skipped = 0;\n let reportedTotal: number | null = null;\n let reportedTotalMeans: string | null = null;\n let answered = false;\n let failure: { code: string; message: string; hint?: string } | null = null;\n /**\n * Rows whose title carries a word naming the dish.\n *\n * This is what \"enough\" counts, rather than rows. A source that ranks on\n * the words it was handed fills a page with recipes sharing a question's\n * framing words, and stopping on the size of that page would stop on the\n * evidence that the dish has not been found. Every row is kept either way.\n */\n let onTopic = 0;\n\n for (const [index, wording] of derived.entries()) {\n if (index > 0 && !fanOut) {\n wordings.push(\n heldBack(wording, \"fan_out was turned off, so only the words as asked were sent\"),\n );\n continue;\n }\n if (index >= MAX_WORDINGS_PER_SOURCE) {\n wordings.push(\n heldBack(\n wording,\n `the ceiling of ${MAX_WORDINGS_PER_SOURCE} searches of one source was reached`,\n ),\n );\n continue;\n }\n if (failure !== null) {\n wordings.push(\n heldBack(wording, `${source.name} did not answer the wording before this one`),\n );\n continue;\n }\n if (onTopic >= limit) {\n wordings.push(\n heldBack(\n wording,\n \"the wordings already sent returned as many rows naming the dish as were asked for\",\n ),\n );\n continue;\n }\n\n try {\n const read = await withDeadline(\n source.search(wording.query, limit),\n this.deadlineMs,\n source,\n );\n answered = true;\n cached = cached || read.cached;\n skipped += read.skipped;\n if (reportedTotal === null && reportedTotalMeans === null) {\n reportedTotal = read.reportedTotal;\n reportedTotalMeans = read.reportedTotalMeans;\n }\n if (read.skipped > 0) {\n this.logger.warn(\n `${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`,\n );\n }\n\n const kept = keepNewRows(read, seen, rows, question);\n const added = kept.added;\n onTopic += kept.onTopic;\n\n wordings.push({\n query: wording.query,\n derivation: wording.derivation,\n ran: true,\n count: read.rows.length,\n added,\n notRunBecause: null,\n error: null,\n });\n } catch (error) {\n const known = fromSource(error, source.name);\n this.logger.warn(\n `${source.name} did not answer \"${wording.query}\": [${known.code}] ${known.message}`,\n );\n const reason = {\n code: known.code,\n message: known.message,\n ...(known.details.hint ? { hint: known.details.hint } : {}),\n };\n wordings.push({\n query: wording.query,\n derivation: wording.derivation,\n ran: true,\n count: null,\n added: null,\n notRunBecause: null,\n error: reason,\n });\n failure = reason;\n }\n }\n\n // Where several wordings contributed, the rows naming the dish are put in\n // front of the rows that do not, each group keeping the order it arrived\n // in. Without this, a first wording answering with a page of near-misses\n // fills the limit and cuts away the rows a later wording found. A single\n // wording is left in the order its source returned it, since there is\n // nothing to rescue it from.\n const contributing = wordings.filter((attempt) => (attempt.added ?? 0) > 0).length;\n const ordered =\n contributing > 1\n ? [\n ...rows.filter((row) => namesDish(row.title, question)),\n ...rows.filter((row) => !namesDish(row.title, question)),\n ]\n : rows;\n\n // A source that answered one wording answered. Reporting it as failed\n // because a later wording timed out would hide the rows it did return\n // behind a claim that it said nothing.\n return {\n source,\n rows: ordered,\n preferredByName: contributing > 1,\n cached,\n reportedTotal,\n reportedTotalMeans,\n skipped,\n error: answered ? null : failure,\n wordings,\n };\n }\n\n /**\n * Read one recipe, from whichever source the identifier names.\n *\n * Only that source is called. Trying another after a miss would turn \"this\n * source has no page by this name\" into somebody else's recipe with a\n * different title, which is a worse answer than the absence it replaced.\n */\n async getRecipe(\n id: string,\n ): Promise<{ recipe: RecipeDetail; cached: boolean; read: ResolvedId }> {\n const read = resolveId(id, this.sources);\n try {\n const outcome = await withDeadline(\n read.source.getRecipe(read.reference),\n this.deadlineMs,\n read.source,\n );\n return { recipe: outcome.recipe, cached: outcome.cached, read };\n } catch (error) {\n throw fromSource(error, read.source.name);\n }\n }\n}\n"],"mappings":"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A,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,IAAM,gBAAgB,oBAAI,IAAI;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA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","names":["SITE_URL","count"]}
1
+ {"version":3,"sources":["../../src/config.ts","../../src/version.ts","../../src/errors.ts","../../src/sources/ids.ts","../../src/sources/registry.ts","../../src/sources/adapter.ts","../../src/sources/cookbook.ts","../../src/sources/goodfood.ts","../../src/sources/marmiton.ts","../../src/sources/ptitchef.ts","../../src/sources/supertoinette.ts","../../src/sources/wordings.ts","../../src/sources/client.ts"],"sourcesContent":["/**\n * Settings, read from the environment.\n *\n * A value that cannot be read warns and falls back rather than stopping the\n * server: a typo in one variable should not take away every tool. Warnings go\n * to stderr, because stdout carries the protocol and anything written there\n * corrupts the session.\n *\n * One setting governs every source. Each is read through its own client, and\n * each of those keeps a pacing floor of its own that nothing here can lower, so\n * a value set here can only make this server more patient than it already is.\n */\n\nimport process from \"node:process\";\nimport { PKG_VERSION, REPO_URL } from \"./version.js\";\n\nexport const LOG_LEVELS = [\"silent\", \"error\", \"info\", \"debug\"] as const;\nexport type LogLevel = (typeof LOG_LEVELS)[number];\n\n/**\n * Spacing between two requests to the same site.\n *\n * No source publishes a ceiling for a client like this one, so the number is\n * self-imposed politeness: a second between requests is far below what a person\n * browsing one of these sites generates, and the answers are cached, so a\n * conversation that circles back to a recipe costs no traffic at all.\n */\nexport const DEFAULT_INTERVAL_MS = 1000;\n/**\n * Configuration can slow the server down. It cannot take the spacing below this\n * floor, whether the setting arrives through the environment or through a\n * configuration object handed to the published client entry point.\n */\nexport const MIN_ALLOWED_INTERVAL_MS = 500;\n/** Beyond this a request would look hung rather than paced. */\nexport const MAX_ALLOWED_INTERVAL_MS = 60_000;\n\nexport interface Config {\n userAgent: string;\n minIntervalMs: number;\n timeoutMs: number;\n maxRetries: number;\n cacheTtlMs: number;\n cacheMaxEntries: number;\n logLevel: LogLevel;\n}\n\nexport const DEFAULT_USER_AGENT = `mcp-recipes/${PKG_VERSION} (+${REPO_URL})`;\n\nexport interface Logger {\n debug: (message: string) => void;\n info: (message: string) => void;\n warn: (message: string) => void;\n error: (message: string) => void;\n}\n\nexport function createLogger(level: LogLevel): Logger {\n const rank = LOG_LEVELS.indexOf(level);\n const write = (at: LogLevel, message: string) => {\n if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) {\n return;\n }\n process.stderr.write(`[mcp-recipes] ${at}: ${message}\\n`);\n };\n return {\n debug: (m) => write(\"debug\", m),\n info: (m) => write(\"info\", m),\n // A warning goes out at the error level so it survives the default\n // setting: a caller has to know that a site was left out of an answer.\n warn: (m) => write(\"error\", m),\n error: (m) => write(\"error\", m),\n };\n}\n\nfunction readInteger(\n env: NodeJS.ProcessEnv,\n name: string,\n fallback: number,\n min: number,\n max: number,\n): number {\n const raw = env[name];\n if (raw === undefined || raw.trim() === \"\") {\n return fallback;\n }\n\n const value = Number(raw);\n if (!(Number.isFinite(value) && Number.isInteger(value))) {\n process.stderr.write(\n `[mcp-recipes] error: ${name}=\"${raw}\" is not a whole number; using ${fallback}.\\n`,\n );\n return fallback;\n }\n if (value < min || value > max) {\n // Clamping silently would let a caller believe a setting took effect when\n // the opposite is true, so the refusal is stated and the default stands.\n process.stderr.write(\n `[mcp-recipes] error: ${name}=${value} is outside ${min}..${max}; using ${fallback}.\\n`,\n );\n return fallback;\n }\n return value;\n}\n\nexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {\n const level = env.RECIPES_LOG_LEVEL as LogLevel | undefined;\n const logLevel = level && LOG_LEVELS.includes(level) ? level : \"error\";\n if (level && !LOG_LEVELS.includes(level)) {\n process.stderr.write(\n `[mcp-recipes] error: RECIPES_LOG_LEVEL=\"${level}\" is not one of ${LOG_LEVELS.join(\", \")}; using error.\\n`,\n );\n }\n\n const custom = env.RECIPES_USER_AGENT?.trim();\n\n return {\n // A caller who wants to be recognised may say who they are, and the\n // contact address stays attached: a site has to be able to reach a human\n // about traffic it did not expect.\n userAgent: custom ? `${custom} ${DEFAULT_USER_AGENT}` : DEFAULT_USER_AGENT,\n minIntervalMs: readInteger(\n env,\n \"RECIPES_MIN_INTERVAL_MS\",\n DEFAULT_INTERVAL_MS,\n MIN_ALLOWED_INTERVAL_MS,\n MAX_ALLOWED_INTERVAL_MS,\n ),\n timeoutMs: readInteger(env, \"RECIPES_TIMEOUT_MS\", 20_000, 1000, 120_000),\n maxRetries: readInteger(env, \"RECIPES_MAX_RETRIES\", 3, 0, 8),\n cacheTtlMs: readInteger(env, \"RECIPES_CACHE_TTL_MS\", 900_000, 0, 86_400_000),\n cacheMaxEntries: readInteger(env, \"RECIPES_CACHE_MAX_ENTRIES\", 200, 1, 5000),\n logLevel,\n };\n}\n","/**\n * Kept in step with the version in package.json. The User-Agent embeds it, so\n * the sites this server reads can tell which build is calling from their own\n * logs.\n */\nexport const PKG_VERSION = \"3.0.0\";\nexport const REPO_URL = \"https://github.com/smeet666/mcp-recipes\";\nexport const ISSUES_URL = \"https://github.com/smeet666/mcp-recipes/issues\";\n","/**\n * One error type, carrying a code the caller can branch on.\n *\n * The distinction that matters most is between \"no site holds this\" and \"the\n * question could not be asked\". Collapsing the two lets a model report an\n * absence it never established, which is a false statement about the world\n * rather than a missing feature.\n */\n\nimport { ISSUES_URL } from \"./version.js\";\n\nexport type ErrorCode =\n /** A site answered, and holds no such recipe. */\n | \"not_found\"\n /** The arguments cannot produce a request. */\n | \"invalid_input\"\n /** A site asked this client to slow down. */\n | \"rate_limited\"\n /** A response arrived in a shape this server cannot read. */\n | \"parse_failure\"\n /** The request could not be completed. */\n | \"network_error\"\n /** The request was abandoned before an answer arrived. */\n | \"timeout\";\n\nexport const ERROR_CODES: readonly ErrorCode[] = [\n \"not_found\",\n \"invalid_input\",\n \"rate_limited\",\n \"parse_failure\",\n \"network_error\",\n \"timeout\",\n];\n\nexport interface ErrorDetails {\n /** What the caller can do about it, when there is something. */\n hint?: string;\n /** The address that produced the failure, for a bug report. */\n url?: string;\n status?: number;\n /** What was raised underneath, kept for the bug report the hint asks for. */\n cause?: unknown;\n}\n\nexport class RecipesError extends Error {\n readonly code: ErrorCode;\n readonly details: ErrorDetails;\n\n constructor(code: ErrorCode, message: string, details: ErrorDetails = {}) {\n super(message, details.cause === undefined ? undefined : { cause: details.cause });\n this.name = \"RecipesError\";\n this.code = code;\n this.details = details;\n }\n}\n\nexport const notFound = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"not_found\", message, details ?? {});\n\nexport const invalidInput = (message: string, hint?: string, cause?: unknown) =>\n new RecipesError(\"invalid_input\", message, { ...(hint ? { hint } : {}), cause });\n\nexport const rateLimited = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"rate_limited\", message, {\n hint: \"Wait a moment and ask again. This says nothing about whether the recipe exists.\",\n ...details,\n });\n\nexport const parseFailure = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"parse_failure\", message, {\n hint: `A site may have changed how it answers. Please report this at ${ISSUES_URL} with the arguments you used.`,\n ...details,\n });\n\nexport const networkError = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"network_error\", message, details ?? {});\n\nexport const timeout = (message: string, details?: ErrorDetails) =>\n new RecipesError(\"timeout\", message, details ?? {});\n\n/**\n * Read the code off a failure raised by the library that reads a source.\n *\n * Those libraries throw their own error classes carrying the six codes above,\n * and the classes are not part of the interface this server imports. Reading\n * the field keeps the taxonomy intact without depending on a class identity a\n * library is free to change, and anything unrecognisable is reported as a\n * network failure, which is the reading that claims least.\n */\nexport function toRecipesError(error: unknown): RecipesError {\n if (error instanceof RecipesError) {\n return error;\n }\n\n const message = error instanceof Error ? error.message : String(error);\n const raw = error as { code?: unknown; details?: unknown } | null;\n const code = typeof raw?.code === \"string\" ? raw.code : \"\";\n const known = ERROR_CODES.find((candidate) => candidate === code);\n\n const details =\n raw && typeof raw.details === \"object\" && raw.details !== null\n ? (raw.details as ErrorDetails)\n : {};\n\n return new RecipesError(known ?? \"network_error\", message, details);\n}\n\n/**\n * Adopt a failure raised while reading a source, in this server's own words.\n *\n * The code is kept, since that is the taxonomy every layer branches on. The\n * sentence is not: what arrives with such a failure is written by whoever the\n * reader was talking to, in their vocabulary and about their internals, and\n * serving it as this answer's own sentence states as this server's finding\n * something it never established. It is quoted and credited to the source\n * instead, on one line, so the diagnostic survives without borrowing a voice\n * or a shape it has no claim to. The advice that came with it goes, because\n * advice addressed to somebody else's reader sends a caller somewhere this\n * server cannot answer for.\n */\nexport function fromSource(error: unknown, sourceName: string): RecipesError {\n if (error instanceof RecipesError) {\n return error;\n }\n\n const known = toRecipesError(error);\n const reported = known.message.replace(/\\s+/g, \" \").trim();\n const opening =\n known.code === \"not_found\"\n ? `${sourceName} holds nothing under this name.`\n : `${sourceName} could not be read.`;\n const message = reported === \"\" ? opening : `${opening} It reports: \"${reported}\"`;\n const details: ErrorDetails = {};\n if (known.details.url !== undefined) {\n details.url = known.details.url;\n }\n if (known.details.status !== undefined) {\n details.status = known.details.status;\n }\n\n switch (known.code) {\n case \"not_found\":\n return notFound(message, details);\n case \"invalid_input\":\n return invalidInput(message);\n case \"rate_limited\":\n return rateLimited(message, details);\n case \"parse_failure\":\n return parseFailure(message, details);\n case \"timeout\":\n return timeout(message, details);\n default:\n return networkError(message, details);\n }\n}\n","/**\n * Identifiers that name the source they came from.\n *\n * Every source hands back an opaque string, and a caller holding a merged list\n * has no way to tell one source's string from another's. Every identifier this\n * server returns therefore carries a prefix, and `get_recipe` routes on it\n * rather than trying each source in turn.\n */\n\nimport { invalidInput } from \"../errors.js\";\nimport type { SourceId } from \"../types.js\";\nimport type { SourceAdapter } from \"./adapter.js\";\n\nconst SEPARATOR = \":\";\n\nexport interface ResolvedId {\n source: SourceAdapter;\n /** The string that source's own reader takes. */\n reference: string;\n /** Set when the shape was read rather than stated, so the answer can say so. */\n inferred: string | null;\n}\n\n/**\n * Work out which source an identifier belongs to.\n *\n * A prefixed identifier is routed on what it says. A raw one is offered to each\n * source in turn, and a source claims only the shapes it mints: digits for one,\n * a namespaced page key for another. Exactly one claim routes the read and the\n * answer says which reading it used.\n *\n * More than one claim is refused rather than resolved, because picking a winner\n * would send the read somewhere a caller did not intend and answer with a\n * confident recipe for the wrong dish. No claim at all is refused too: a bare\n * dish name is a search, not an identifier, and guessing a page name from it\n * returns whatever that guess happens to hit.\n */\nexport function resolveId(rawId: string, sources: readonly SourceAdapter[]): ResolvedId {\n const trimmed = rawId.trim();\n if (trimmed === \"\") {\n throw invalidInput(\n \"A recipe identifier is required.\",\n `Use one of the ids search_recipes returned, such as ${example(sources)}.`,\n );\n }\n\n for (const source of sources) {\n const prefix = `${source.id}${SEPARATOR}`;\n if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {\n continue;\n }\n const reference = trimmed.slice(prefix.length).trim();\n if (reference === \"\") {\n throw invalidInput(\n `\"${trimmed}\" names a source and no recipe.`,\n \"Put the source's own identifier after the colon.\",\n );\n }\n return { source, reference, inferred: null };\n }\n\n const claimed = sources\n .map((source) => ({ source, claim: source.claims(trimmed) }))\n .filter((entry): entry is { source: SourceAdapter; claim: NonNullable<typeof entry.claim> } =>\n Boolean(entry.claim),\n );\n\n if (claimed.length > 1) {\n throw invalidInput(\n `\"${trimmed}\" could be an identifier on ${names(claimed.map((entry) => entry.source))}, so it names no one recipe.`,\n `Spell it with its source, as in ${claimed.map((entry) => `${entry.source.id}:${trimmed}`).join(\" or \")}.`,\n );\n }\n\n const only = claimed[0];\n if (!only) {\n throw invalidInput(\n `\"${trimmed}\" is not an identifier any of the sources this server reads would mint.`,\n `Call search_recipes with \"${trimmed}\" as the query and pass an id from a row, ` +\n `which reads like ${example(sources)}.`,\n );\n }\n\n return {\n source: only.source,\n reference: only.claim.reference,\n inferred: only.claim.guess\n ? `${only.claim.why}, which is a guess: check the recipe that comes back is the one you meant`\n : only.claim.why,\n };\n}\n\n/** Sources named the way a sentence names them. */\nfunction names(sources: readonly { name: string }[]): string {\n const all = sources.map((source) => source.name);\n if (all.length <= 1) {\n return all.join(\"\");\n }\n return `${all.slice(0, -1).join(\", \")} and ${all.at(-1)}`;\n}\n\n/** An identifier of the shape this server hands out, for a hint. */\nfunction example(sources: readonly SourceAdapter[]): string {\n const first = sources[0];\n return first ? `${first.id}${SEPARATOR}<the source's own id>` : \"<source>:<id>\";\n}\n\n/** Which source an identifier names, without resolving what it points at. */\nexport function sourceOf(rawId: string, sources: readonly SourceAdapter[]): SourceId | null {\n const prefix = rawId.split(SEPARATOR)[0]?.toLowerCase();\n return sources.find((source) => source.id.toLowerCase() === prefix)?.id ?? null;\n}\n","/**\n * The sources this server reads.\n *\n * One list, and everything above it is written for however long the list is.\n * Adding a corpus means writing an adapter and adding it here; no tool, no\n * merge and no error path has a branch per source.\n */\n\nimport { GoodFoodClient } from \"mcp-bbc-goodfood/client\";\nimport type { ClientOptions as GoodFoodClientOptions } from \"mcp-bbc-goodfood/client\";\nimport { MarmitonClient } from \"mcp-marmiton/client\";\nimport type { MarmitonClientOptions } from \"mcp-marmiton/client\";\nimport { PtitchefClient } from \"mcp-ptitchef/client\";\nimport type { ClientOptions as PtitchefClientOptions } from \"mcp-ptitchef/client\";\nimport { SupertoinetteClient } from \"mcp-supertoinette/client\";\nimport type { ClientOptions as SupertoinetteClientOptions } from \"mcp-supertoinette/client\";\nimport { CookbookClient } from \"mcp-wikibooks-cookbook/client\";\nimport type { ClientOptions as CookbookClientOptions } from \"mcp-wikibooks-cookbook/client\";\n\nimport type { Config, Logger } from \"../config.js\";\nimport { invalidInput } from \"../errors.js\";\nimport type { SourceId } from \"../types.js\";\nimport type { SourceAdapter } from \"./adapter.js\";\nimport { COOKBOOK_PROFILE, cookbookAdapter } from \"./cookbook.js\";\nimport type { CookbookReader } from \"./cookbook.js\";\nimport { GOODFOOD_PROFILE, goodfoodAdapter } from \"./goodfood.js\";\nimport type { GoodFoodReader } from \"./goodfood.js\";\nimport { MARMITON_PROFILE, marmitonAdapter } from \"./marmiton.js\";\nimport type { MarmitonReader } from \"./marmiton.js\";\nimport { PTITCHEF_PROFILE, ptitchefAdapter } from \"./ptitchef.js\";\nimport type { PtitchefReader } from \"./ptitchef.js\";\nimport { SUPERTOINETTE_PROFILE, supertoinetteAdapter } from \"./supertoinette.js\";\nimport type { SupertoinetteReader } from \"./supertoinette.js\";\n\n/**\n * A reader may be supplied in place of the one that talks to a site, so a\n * program embedding this server can put its own cache in front of a corpus, and\n * a test can drive it from fixed answers.\n *\n * Each key is the id of the source it stands in for, so what a caller has\n * replaced can be read off against what the registry builds.\n */\nexport interface Readers {\n marmiton?: MarmitonReader;\n cookbook?: CookbookReader;\n ptitchef?: PtitchefReader;\n goodfood?: GoodFoodReader;\n supertoinette?: SupertoinetteReader;\n}\n\n/**\n * Two bounds one of the readers takes that the rest do not: the largest page it\n * will read, and the time it gives a whole read including its retries.\n *\n * They are that reader's own defaults, repeated here because its options\n * require them. This server exposes no setting for either: a setting governing\n * one source out of five is one nobody can reason about, and the pacing,\n * timeout and retry settings that do govern every source are the ones a caller\n * can move.\n */\nconst PAGE_READ_BOUNDS = { maxBodyBytes: 8_000_000, budgetMs: 60_000 };\n\nexport function buildSources(config: Config, readers: Readers, logger: Logger): SourceAdapter[] {\n const shared = {\n userAgent: config.userAgent,\n minIntervalMs: config.minIntervalMs,\n timeoutMs: config.timeoutMs,\n maxRetries: config.maxRetries,\n cacheTtlMs: config.cacheTtlMs,\n cacheMaxEntries: config.cacheMaxEntries,\n logLevel: config.logLevel,\n };\n\n const marmitonOptions: MarmitonClientOptions = { config: shared };\n const cookbookOptions: CookbookClientOptions = { config: shared };\n const ptitchefOptions: PtitchefClientOptions = {\n config: { ...shared, ...PAGE_READ_BOUNDS },\n logger,\n };\n const goodfoodOptions: GoodFoodClientOptions = { config: shared, logger };\n const supertoinetteOptions: SupertoinetteClientOptions = { config: shared, logger };\n\n return [\n marmitonAdapter(readers.marmiton ?? new MarmitonClient(marmitonOptions)),\n cookbookAdapter(readers.cookbook ?? new CookbookClient(cookbookOptions)),\n ptitchefAdapter(readers.ptitchef ?? new PtitchefClient(ptitchefOptions)),\n goodfoodAdapter(readers.goodfood ?? new GoodFoodClient(goodfoodOptions)),\n supertoinetteAdapter(readers.supertoinette ?? new SupertoinetteClient(supertoinetteOptions)),\n ];\n}\n\n/** The sources a caller asked for, in the registry's own order. */\nexport function selectSources(\n sources: SourceAdapter[],\n wanted: readonly SourceId[] | undefined,\n): SourceAdapter[] {\n if (!wanted) {\n return sources;\n }\n\n const unknown = wanted.filter((id) => !sources.some((source) => source.id === id));\n if (unknown.length > 0) {\n throw invalidInput(\n `This server reads no source called ${unknown.map((id) => `\"${id}\"`).join(\", \")}.`,\n `It reads ${sources.map((source) => source.id).join(\", \")}.`,\n );\n }\n\n const chosen = sources.filter((source) => wanted.includes(source.id));\n if (chosen.length === 0) {\n throw invalidInput(\n \"A search needs at least one source.\",\n `Name one of ${sources.map((source) => source.id).join(\", \")}, or leave the argument out to ask them all.`,\n );\n }\n return chosen;\n}\n\n/** The profiles the registry holds, for a caller naming the sources in prose. */\nexport const PROFILES = [\n MARMITON_PROFILE,\n COOKBOOK_PROFILE,\n PTITCHEF_PROFILE,\n GOODFOOD_PROFILE,\n SUPERTOINETTE_PROFILE,\n] as const;\n","/**\n * What a source has to provide to be one.\n *\n * Everything above this file is written for however many sources the registry\n * holds. A source knows four things nothing else does: what it is called, how\n * to recognise one of its own identifiers, how to search it, and how to read one\n * recipe off it. Adding a corpus is writing those four and registering it.\n *\n * The helpers below are the ones every adapter needs, because no source answers\n * with a contract: a field can arrive missing, null, or holding something other\n * than the type it usually holds.\n */\n\nimport { parseFailure } from \"../errors.js\";\nimport type { Language } from \"../recipe/language.js\";\nimport type { RecipeDetail, RecipeRow, SourceId, SourceProfile } from \"../types.js\";\n\nconst NUMBER_AND_REST = /^\\s*(\\d+(?:[.,]\\d+)?)\\s*(.*)$/;\nconst RANGE_AND_REST =\n /^\\s*(\\d+(?:[.,]\\d+)?)\\s*(?:à|a|to|-|–|—|ou|or)\\s*(\\d+(?:[.,]\\d+)?)\\s*(.*)$/i;\n\n/** Rows that could be read, and how many could not. */\nexport interface ReadRows {\n rows: RecipeRow[];\n /** Rows the source sent in a shape this server could not read. */\n skipped: number;\n /** Rows the source said it saw, in the terms that source counts in. */\n reportedTotal: number | null;\n /** What `reportedTotal` counts on this source, in words. Null when it states none. */\n reportedTotalMeans: string | null;\n cached: boolean;\n}\n\nexport interface ReadRecipe {\n recipe: RecipeDetail;\n cached: boolean;\n}\n\n/** How a source recognised a raw identifier as one of its own. */\nexport interface Claim {\n /** The string this source's own reader takes. */\n reference: string;\n /** Why the shape was read this way, for an answer that has to say so. */\n why: string;\n /**\n * Whether reading it this way is a guess. A shape only this source mints is\n * not; a shape it would merely accept is, and the answer says so.\n */\n guess: boolean;\n}\n\nexport interface SourceAdapter extends SourceProfile {\n /**\n * Recognise a raw identifier, or decline it.\n *\n * A source claims a shape it mints and declines everything else, so a string\n * no source claims is refused, instead of being sent somewhere that would\n * answer it with a confident absence.\n */\n claims: (raw: string) => Claim | null;\n search: (query: string, limit: number) => Promise<ReadRows>;\n getRecipe: (reference: string) => Promise<ReadRecipe>;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Reading a value a source sent, without trusting its shape */\n/* -------------------------------------------------------------------------- */\n\n/**\n * The distinction these draw is between a row and a record. One row this server\n * cannot read is dropped and counted, because the rest of the list is still a\n * good answer. A record it cannot read is `parse_failure`, because there is\n * nothing left to return and a caller told \"network_error\" would retry a source\n * that has actually changed.\n */\nexport function text(value: unknown): string | null {\n return typeof value === \"string\" && value.trim() !== \"\" ? value : null;\n}\n\nexport function textList(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((entry): entry is string => typeof entry === \"string\")\n : [];\n}\n\nexport function count(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\nexport function required(value: unknown, field: string, source: SourceProfile): string {\n const found = text(value);\n if (found === null) {\n throw parseFailure(\n `${source.name} answered without a readable \"${field}\", so there is no recipe to return.`,\n );\n }\n return found;\n}\n\nexport function rowsOf<T>(value: unknown, source: SourceProfile): T[] {\n if (!Array.isArray(value)) {\n throw parseFailure(\n `${source.name} answered in a shape this server could not read: the list of results was missing.`,\n );\n }\n return value as T[];\n}\n\n/** Build the identifier this server hands out for a row from a source. */\nexport function namespacedId(source: SourceId, reference: string): string {\n return `${source}:${reference}`;\n}\n\n/**\n * Keep the entries a page actually published.\n *\n * A nutrition panel arrives with a slot for every figure the source knows how\n * to print, and the slots a page left empty are null. Passing those through\n * would put \"protein: null\" in front of a reader as though the page had said\n * something about protein.\n *\n * The panel is walked rather than typed, because each source names its slots\n * differently and a figure is repeated as the page wrote it either way.\n */\nexport function publishedFigures(panel: unknown): Record<string, string> | null {\n if (typeof panel !== \"object\" || panel === null) {\n return null;\n }\n const kept: Record<string, string> = {};\n for (const [key, value] of Object.entries(panel)) {\n if (typeof value === \"string\" && value.trim() !== \"\") {\n kept[key] = value.trim();\n }\n }\n return Object.keys(kept).length > 0 ? kept : null;\n}\n\n/**\n * Read a yield that names a span rather than a number.\n *\n * \"4 à 6 personnes\" and \"4 personnes\" are different claims, and a source that\n * reports the first as a plain count of four hands this server a factor that is\n * too large for anyone at the upper end. The span is read back off the published\n * wording so the answer can say which end it scaled from.\n */\nexport function readYieldSpan(published: string | null): {\n count: number | null;\n max: number | null;\n unit: string | null;\n} {\n if (published === null) {\n return { count: null, max: null, unit: null };\n }\n\n const span = RANGE_AND_REST.exec(published);\n if (span) {\n const [lowText = \"\", highText = \"\"] = span.slice(1);\n const low = Number(lowText.replace(\",\", \".\"));\n const high = Number(highText.replace(\",\", \".\"));\n if (Number.isFinite(low) && Number.isFinite(high) && high > low) {\n return { count: low, max: high, unit: text(span[3]) };\n }\n }\n\n const single = NUMBER_AND_REST.exec(published);\n if (single) {\n const [figure = \"\"] = single.slice(1);\n const value = Number(figure.replace(\",\", \".\"));\n if (Number.isFinite(value)) {\n return { count: value, max: null, unit: text(single[2]) };\n }\n }\n\n return { count: null, max: null, unit: null };\n}\n\n/** Make the language a source publishes in available to whatever reads its lines. */\nexport function detailBase(source: SourceProfile): {\n source: SourceId;\n sourceName: string;\n language: Language;\n attribution: string;\n} {\n return {\n source: source.id,\n sourceName: source.name,\n language: source.language,\n attribution: source.attribution,\n };\n}\n","/**\n * The Cookbook on the English Wikibooks, as a source.\n *\n * A wiki written by whoever edits it, published under a licence that requires\n * attribution, with no author and no reader rating by nature. Its identifiers\n * are page keys carrying the namespace every Cookbook page lives in.\n */\n\nimport { invalidInput } from \"../errors.js\";\nimport type { RecipeDetail, RecipeRow } from \"../types.js\";\nimport type { Claim, ReadRecipe, ReadRows, SourceAdapter } from \"./adapter.js\";\nimport {\n count,\n detailBase,\n namespacedId,\n publishedFigures,\n readYieldSpan,\n required,\n rowsOf,\n text,\n textList,\n} from \"./adapter.js\";\n\n/** A search row, as the Cookbook's own reader publishes one. */\nexport interface CookbookSummary {\n key: string;\n title: string;\n description: string | null;\n excerpt: string | null;\n imageUrl: string | null;\n sourceUrl: string;\n}\n\n/** A page, as the Cookbook's own reader publishes one. */\nexport interface CookbookRecipe {\n key: string;\n title: string;\n sourceUrl: string;\n license: { title: string; url: string } | null;\n description: string | null;\n category: string | null;\n servings: number | null;\n yieldText: string | null;\n yieldUnit: string | null;\n totalMinutes: number | null;\n ingredients: string[];\n equipment: string[];\n steps: string[];\n tips: string[];\n nutrition: unknown;\n sectionTitles: string[];\n}\n\n/** The part of the Cookbook's client this server uses. */\nexport interface CookbookReader {\n search: (\n query: string,\n limit: number,\n ) => Promise<{ data: { results: CookbookSummary[] }; cached: boolean }>;\n getRecipe: (reference: string) => Promise<{ data: CookbookRecipe; cached: boolean }>;\n}\n\nexport const COOKBOOK_PROFILE = {\n id: \"cookbook\",\n name: \"Wikibooks Cookbook\",\n homeUrl: \"https://en.wikibooks.org/wiki/Cookbook:Table_of_Contents\",\n language: \"en\" as const,\n attribution: \"Source: Wikibooks Cookbook\",\n // The namespace holds pages about salt and about a dish made with it alike.\n mixesReferencePages: true,\n};\n\nconst SITE_URL = /^https?:\\/\\/en\\.wikibooks\\.org\\/wiki\\//i;\n/** The namespace every page in the Cookbook lives under. */\nconst NAMESPACE = /^cookbook\\s*:/i;\n\n/**\n * Every page in the Cookbook is filed under one namespace, so its title arrives\n * as \"Cookbook:Spaghetti alla Carbonara\". The prefix is where the page lives on\n * the wiki rather than what the dish is called, and repeating it on every row of\n * a list says nothing and crowds out the names. The key keeps it, because that\n * is the string the site resolves.\n */\nfunction dishName(title: string): string {\n return title.replace(NAMESPACE, \"\").trim() || title;\n}\n\nexport function cookbookAdapter(reader: CookbookReader): SourceAdapter {\n return {\n ...COOKBOOK_PROFILE,\n\n claims(raw: string): Claim | null {\n if (SITE_URL.test(raw)) {\n let key: string;\n try {\n key = decodeURIComponent(raw.replace(SITE_URL, \"\")).replace(/_/g, \" \");\n } catch (cause) {\n // A percent sign that opens no escape is common in a wiki title, and\n // it is the caller's string rather than a site that failed.\n throw invalidInput(\n `\"${raw}\" carries a percent sign that is not a valid escape, so the page name cannot be read.`,\n \"Pass the id search_recipes returned instead of the address.\",\n cause,\n );\n }\n return {\n reference: key,\n why: \"the address is a page on the English Wikibooks\",\n guess: false,\n };\n }\n\n if (NAMESPACE.test(raw)) {\n return {\n reference: raw,\n why: \"the name opens with the namespace every Cookbook page lives under\",\n guess: false,\n };\n }\n\n return null;\n },\n\n async search(query: string, limit: number): Promise<ReadRows> {\n const outcome = await reader.search(query, limit);\n const list = rowsOf<Partial<CookbookSummary>>(\n (outcome.data as { results?: unknown })?.results,\n COOKBOOK_PROFILE,\n );\n const rows: RecipeRow[] = [];\n let skipped = 0;\n\n for (const summary of list) {\n const key = text(summary?.key);\n const title = text(summary?.title) ?? key;\n const url = text(summary?.sourceUrl);\n if (!(key && title && url)) {\n skipped += 1;\n continue;\n }\n rows.push({\n id: namespacedId(COOKBOOK_PROFILE.id, key),\n source: COOKBOOK_PROFILE.id,\n sourceName: COOKBOOK_PROFILE.name,\n title: dishName(title),\n url,\n imageUrl: text(summary?.imageUrl),\n excerpt: text(summary?.excerpt) ?? text(summary?.description),\n });\n }\n\n return {\n rows,\n skipped,\n // The gateway states no total and offers no second page, so there is no\n // number to report. Counting the rows returned and calling it a total\n // would invent one.\n reportedTotal: null,\n reportedTotalMeans: null,\n cached: outcome.cached,\n };\n },\n\n async getRecipe(reference: string): Promise<ReadRecipe> {\n const outcome = await reader.getRecipe(reference);\n return { recipe: cookbookDetail(outcome.data), cached: outcome.cached };\n },\n };\n}\n\nexport function cookbookDetail(payload: unknown): RecipeDetail {\n const recipe = (payload ?? {}) as Partial<CookbookRecipe>;\n const license = recipe.license as Partial<NonNullable<CookbookRecipe[\"license\"]>> | null;\n const licenseTitle = text(license?.title);\n const licenseUrl = text(license?.url);\n\n const published = text(recipe.yieldText);\n const span = readYieldSpan(published);\n\n return {\n ...detailBase(COOKBOOK_PROFILE),\n id: namespacedId(COOKBOOK_PROFILE.id, required(recipe.key, \"key\", COOKBOOK_PROFILE)),\n title: dishName(required(recipe.title ?? recipe.key, \"title\", COOKBOOK_PROFILE)),\n url: required(recipe.sourceUrl, \"url\", COOKBOOK_PROFILE),\n // The page source carries no picture this server can address.\n imageUrl: null,\n yieldCount: count(recipe.servings) ?? span.count,\n yieldMax: span.max,\n yieldText: published,\n yieldUnit: text(recipe.yieldUnit) ?? span.unit,\n ingredients: textList(recipe.ingredients),\n steps: textList(recipe.steps),\n // The page's own headings, which the reader reports. An answer holding no\n // ingredient line uses them to say whether the page announced a list.\n publishedSections: Array.isArray(recipe.sectionTitles) ? textList(recipe.sectionTitles) : null,\n // The Cookbook publishes one time for a recipe rather than splitting it.\n prepMinutes: null,\n cookMinutes: null,\n totalMinutes: count(recipe.totalMinutes),\n category: text(recipe.category),\n // The page is written by everyone who edited it, so it credits nobody in\n // particular and carries no reader rating.\n author: null,\n rating: null,\n nutrition: publishedFigures(recipe.nutrition),\n equipment: textList(recipe.equipment),\n tips: textList(recipe.tips),\n // The Cookbook publishes no resting time, says nothing about how a page\n // lays its method out, and keeps nothing behind a subscription.\n restMinutes: null,\n stepsAsOneBlock: null,\n withheld: null,\n // A licence is only stated when both halves of it arrived: a name with no\n // address, or an address with no name, is not terms anyone can follow.\n license: licenseTitle && licenseUrl ? { title: licenseTitle, url: licenseUrl } : null,\n };\n}\n","/**\n * BBC Good Food, as a source.\n *\n * An English recipe site addressing a recipe by the path of its page. Two of\n * its statements need care. Its total can sit on the largest number of rows one\n * search will serve, where it states a floor rather than a count. And it keeps\n * some recipes' ingredients and method for its subscribers while publishing the\n * rest of the page, which is a part withheld rather than a part this server\n * failed to read.\n *\n * A difficulty the site writes in a word of its own is not carried, since it\n * publishes no scale for it. Its rendition for readers in the United States is\n * not read either: taking lines from both would make a list in two measuring\n * systems that the site never published as one.\n */\n\nimport type { RecipeDetail, RecipeRow } from \"../types.js\";\nimport type { Claim, ReadRecipe, ReadRows, SourceAdapter } from \"./adapter.js\";\nimport {\n count,\n detailBase,\n namespacedId,\n readYieldSpan,\n required,\n rowsOf,\n text,\n} from \"./adapter.js\";\n\n/** A search row, as BBC Good Food's own reader publishes one. */\nexport interface GoodFoodRow {\n id: string;\n title: string;\n url: string;\n image_url: string | null;\n rating: number | null;\n rating_count: number | null;\n /** True where the recipe sits behind the site's subscription. */\n premium: boolean;\n total_minutes: number | null;\n difficulty: string | null;\n author: string | null;\n}\n\n/** A listing, as that reader publishes one. */\nexport interface GoodFoodReport {\n query: string;\n results: GoodFoodRow[];\n result_count: number;\n total_available: number | null;\n /** True where the total sits on the largest number of rows one search serves. */\n total_is_ceiling: boolean;\n rows_seen: number;\n restrictions_lifted: string[];\n}\n\n/** One line of an ingredient list, as that reader publishes it. */\nexport interface GoodFoodIngredient {\n text: string;\n amount: number | null;\n unit: string | null;\n item: string;\n note: string | null;\n /** The site's own normalised name for it, which no other source publishes. */\n term: string | null;\n}\n\nexport interface GoodFoodGroup {\n heading: string | null;\n ingredients: GoodFoodIngredient[];\n}\n\n/** A recipe, as that reader publishes one. */\nexport interface GoodFoodRecipe {\n id: string;\n title: string;\n url: string;\n premium: boolean;\n yield_text: string | null;\n yield_count: number | null;\n prep_minutes: number | null;\n cook_minutes: number | null;\n total_minutes: number | null;\n difficulty: string | null;\n diets: string[];\n author: string | null;\n rating: number | null;\n rating_count: number | null;\n description: string | null;\n /** Empty where the recipe sits behind the subscription. */\n ingredients: GoodFoodGroup[];\n /** Empty where the recipe sits behind the subscription. */\n steps: string[];\n nutrition: Array<{ label: string; value: number | null; unit: string }>;\n nutrition_per: string | null;\n us_edition: unknown;\n}\n\n/** The part of BBC Good Food's client this server uses. */\nexport interface GoodFoodReader {\n searchRecipes: (\n query: string,\n options?: { limit?: number },\n ) => Promise<{ data: GoodFoodReport; cached: boolean; skipped?: string[] }>;\n getRecipe: (id: string) => Promise<{ data: GoodFoodRecipe; cached: boolean; skipped?: string[] }>;\n}\n\nexport const GOODFOOD_PROFILE = {\n id: \"goodfood\",\n name: \"BBC Good Food\",\n homeUrl: \"https://www.bbcgoodfood.com\",\n language: \"en\" as const,\n attribution: \"Source: BBC Good Food\",\n // The search serves recipe pages; a question aimed at the site's reviews and\n // buying guides is answered with recipes all the same.\n mixesReferencePages: false,\n};\n\nconst SITE_URL = /^https?:\\/\\/(?:www\\.)?bbcgoodfood\\.com\\//i;\n/** The shape BBC Good Food mints: a recipe path. */\nconst REFERENCE = /^recipes\\/[^/]+$/;\n/** A segment walking up out of the path it was written in. The site names none. */\nconst DOT_SEGMENT = /(?:^|\\/)\\.\\.?(?:\\/|$)/;\nconst LEADING_SLASH = /^\\/+/;\n\nfunction asReference(raw: string): string | null {\n const path = raw.replace(LEADING_SLASH, \"\");\n return REFERENCE.test(path) && !DOT_SEGMENT.test(`/${path}`) ? path : null;\n}\n\n/** What the total counts, in words, for the way the site published it. */\nexport function meansFor(report: Partial<GoodFoodReport>): string | null {\n if (count(report.total_available) === null) {\n return null;\n }\n return report.total_is_ceiling === true\n ? \"recipes BBC Good Food says its catalogue matches this wording, cut at the largest number of rows one search will serve, so it states a floor rather than a count\"\n : \"recipes BBC Good Food says its whole catalogue matches this wording; it counts the catalogue rather than the rows served\";\n}\n\n/**\n * Repeat the panel the page published, in the site's own wording.\n *\n * The panel arrives as a list rather than as an object, and a figure the page\n * left empty carries a null value: passing it through would put a nutrient in\n * front of a reader that the page said nothing about.\n */\nfunction nutritionPanel(recipe: Partial<GoodFoodRecipe>): Record<string, string> | null {\n const facts = Array.isArray(recipe.nutrition) ? recipe.nutrition : [];\n const kept: Record<string, string> = {};\n\n for (const fact of facts) {\n const label = text(fact?.label);\n const value = count(fact?.value);\n if (label === null || value === null) {\n continue;\n }\n const unit = text(fact?.unit);\n kept[label] = unit === null ? String(value) : `${value} ${unit}`;\n }\n\n const per = text(recipe.nutrition_per);\n if (Object.keys(kept).length === 0) {\n return null;\n }\n // What the figures are stated per is part of the panel: a figure without it\n // is a number nobody can put beside another recipe's.\n if (per !== null) {\n kept.per = per;\n }\n return kept;\n}\n\n/**\n * The site answers a wording it matches nothing for with a total of zero and no\n * rows, so an absence arrives as an absence and there is no failure here to\n * read as one. Every error the reader raises is a real failure and travels on\n * as one.\n */\nexport function goodfoodAdapter(reader: GoodFoodReader): SourceAdapter {\n return {\n ...GOODFOOD_PROFILE,\n\n claims(raw: string): Claim | null {\n if (SITE_URL.test(raw)) {\n const path = asReference(new URL(raw).pathname);\n return path === null\n ? null\n : { reference: path, why: \"the address is a BBC Good Food recipe page\", guess: false };\n }\n const path = asReference(raw);\n return path === null\n ? null\n : {\n reference: path,\n why: \"the path is the shape BBC Good Food mints for a recipe\",\n guess: false,\n };\n },\n\n async search(query: string, limit: number): Promise<ReadRows> {\n // Only the page size is passed. The site's facets and its sort orders are\n // its own vocabulary, and ranking one source's rows while four others are\n // unranked would make a merged list that looks ordered and is not.\n const outcome = await reader.searchRecipes(query, { limit });\n const report = (outcome.data ?? {}) as Partial<GoodFoodReport>;\n const list = rowsOf<Partial<GoodFoodRow>>(report.results, GOODFOOD_PROFILE);\n const rows: RecipeRow[] = [];\n let skipped = outcome.skipped?.length ?? 0;\n\n for (const summary of list) {\n const id = text(summary?.id);\n const title = text(summary?.title);\n const url = text(summary?.url);\n if (!(id && title && url)) {\n skipped += 1;\n continue;\n }\n rows.push({\n id: namespacedId(GOODFOOD_PROFILE.id, id),\n source: GOODFOOD_PROFILE.id,\n sourceName: GOODFOOD_PROFILE.name,\n title,\n url,\n imageUrl: text(summary?.image_url),\n // A row carries a title and a picture and no prose.\n excerpt: null,\n });\n }\n\n return {\n rows,\n skipped,\n reportedTotal: count(report.total_available),\n reportedTotalMeans: meansFor(report),\n cached: outcome.cached,\n };\n },\n\n async getRecipe(reference: string): Promise<ReadRecipe> {\n const outcome = await reader.getRecipe(reference);\n return { recipe: goodfoodDetail(outcome.data), cached: outcome.cached };\n },\n };\n}\n\nexport function goodfoodDetail(payload: unknown): RecipeDetail {\n const recipe = (payload ?? {}) as Partial<GoodFoodRecipe>;\n\n const published = text(recipe.yield_text);\n const span = readYieldSpan(published);\n const value = count(recipe.rating);\n\n const groups = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];\n const lines: string[] = [];\n for (const group of groups) {\n // A heading is kept as a line of its own: dropped, two groups that each\n // call for cream would read as the same thing asked for twice.\n const heading = text(group?.heading);\n if (heading !== null) {\n lines.push(heading);\n }\n for (const entry of group?.ingredients ?? []) {\n const line = text(entry?.text);\n if (line !== null) {\n lines.push(line);\n }\n }\n }\n\n return {\n ...detailBase(GOODFOOD_PROFILE),\n id: namespacedId(GOODFOOD_PROFILE.id, required(recipe.id, \"id\", GOODFOOD_PROFILE)),\n title: required(recipe.title, \"title\", GOODFOOD_PROFILE),\n url: required(recipe.url, \"url\", GOODFOOD_PROFILE),\n // A row carries the picture; the recipe page's own is not read.\n imageUrl: null,\n yieldCount: count(recipe.yield_count) ?? span.count,\n yieldMax: span.max,\n yieldText: published,\n yieldUnit: span.unit,\n ingredients: lines,\n steps: Array.isArray(recipe.steps)\n ? recipe.steps.filter((step): step is string => typeof step === \"string\")\n : [],\n publishedSections: null,\n prepMinutes: count(recipe.prep_minutes),\n cookMinutes: count(recipe.cook_minutes),\n totalMinutes: count(recipe.total_minutes),\n // The site publishes no resting time of its own.\n restMinutes: null,\n stepsAsOneBlock: null,\n // The page carries these and the site sells access to them. What is said\n // here is what the site did, so an answer never reports a wall as a page\n // this server could not read.\n withheld:\n recipe.premium === true\n ? {\n parts: [\"ingredients\", \"method\"],\n why: \"BBC Good Food keeps this recipe's ingredients and method for its subscribers, and publishes its title, its times, its rating and its nutrition\",\n }\n : null,\n // The site files a recipe under collections rather than under one category\n // on the page itself.\n category: null,\n author: text(recipe.author),\n rating: value === null ? null : { value, count: count(recipe.rating_count), max: 5 },\n nutrition: nutritionPanel(recipe),\n equipment: [],\n tips: [],\n // The site states no licence on a recipe page, and silence is not a grant.\n license: null,\n };\n}\n","/**\n * Marmiton, as a source.\n *\n * A French recipe site with reader ratings, whose identifiers are digits and\n * whose search serves a single page it disallows paging past.\n */\n\nimport type { RecipeDetail, RecipeRow } from \"../types.js\";\nimport type { Claim, ReadRecipe, ReadRows, SourceAdapter } from \"./adapter.js\";\nimport {\n count,\n detailBase,\n namespacedId,\n publishedFigures,\n readYieldSpan,\n required,\n rowsOf,\n text,\n textList,\n} from \"./adapter.js\";\n\nconst ABSOLUTE_URL = /^https?:\\/\\//i;\n\n/** A search row, as Marmiton's own reader publishes one. */\nexport interface MarmitonSummary {\n id: string;\n title: string;\n url: string;\n imageUrl: string | null;\n}\n\n/** A recipe, as Marmiton's own reader publishes one. */\nexport interface MarmitonRecipe {\n id: string;\n title: string;\n url: string;\n imageUrl: string | null;\n ingredients: string[];\n steps: string[];\n recipeYield: { count: number | null; unit: string | null; text: string };\n prepMinutes: number | null;\n cookMinutes: number | null;\n totalMinutes: number | null;\n category: string | null;\n author: string | null;\n rating: { value: number; count: number | null; best: number | null } | null;\n nutrition: unknown;\n}\n\n/** The part of Marmiton's client this server uses. */\nexport interface MarmitonReader {\n search: (query: string) => Promise<{ data: MarmitonSummary[]; cached: boolean }>;\n getRecipe: (ref: {\n id?: string;\n url?: string;\n }) => Promise<{ data: MarmitonRecipe; cached: boolean }>;\n}\n\nexport const MARMITON_PROFILE = {\n id: \"marmiton\",\n name: \"Marmiton\",\n homeUrl: \"https://www.marmiton.org\",\n language: \"fr\" as const,\n attribution: \"Source: Marmiton\",\n // Every page the search returns is a recipe.\n mixesReferencePages: false,\n};\n\n/** Recipe pages, whose address ends in the numeric identifier. */\nconst SITE_URL = /^https?:\\/\\/(?:www\\.)?marmiton\\.org\\//i;\n/** The shape Marmiton mints: digits and nothing else. */\nconst REFERENCE = /^\\d+$/;\n\n/**\n * Ask Marmiton, and read the answer it gives to a wording it matches nothing\n * for.\n *\n * Marmiton serves a 404 on the results page when no recipe matches, which is\n * the site stating an absence. Handed on as a failure it would put \"Marmiton\n * did not answer\" in front of a reader for a search Marmiton answered, and\n * leave the corpus unspoken for. Every other failure stays a failure, so a site\n * that is unreachable is never read as a site that holds nothing.\n */\nasync function readSearch(\n reader: MarmitonReader,\n query: string,\n): Promise<{ data: MarmitonSummary[]; cached: boolean }> {\n try {\n return await reader.search(query);\n } catch (error) {\n const code = (error as { code?: string } | null)?.code;\n if (code === \"not_found\") {\n return { data: [], cached: false };\n }\n throw error;\n }\n}\n\nexport function marmitonAdapter(reader: MarmitonReader): SourceAdapter {\n return {\n ...MARMITON_PROFILE,\n\n claims(raw: string): Claim | null {\n if (SITE_URL.test(raw)) {\n return { reference: raw, why: \"the address is a Marmiton page\", guess: false };\n }\n if (REFERENCE.test(raw)) {\n return {\n reference: raw,\n why: \"a bare number is the shape Marmiton mints\",\n guess: false,\n };\n }\n return null;\n },\n\n async search(query: string): Promise<ReadRows> {\n const outcome = await readSearch(reader, query);\n const list = rowsOf<Partial<MarmitonSummary>>(outcome.data, MARMITON_PROFILE);\n const rows: RecipeRow[] = [];\n let skipped = 0;\n\n for (const summary of list) {\n // A row needs something to call it, somewhere to send a reader, and an\n // identifier that reads back. A row missing any of them is dropped\n // rather than returned with a hole in it.\n const id = text(summary?.id);\n const title = text(summary?.title);\n const url = text(summary?.url);\n if (!(id && title && url)) {\n skipped += 1;\n continue;\n }\n rows.push({\n id: namespacedId(MARMITON_PROFILE.id, id),\n source: MARMITON_PROFILE.id,\n sourceName: MARMITON_PROFILE.name,\n title,\n url,\n imageUrl: text(summary?.imageUrl),\n // The result list carries a title and a picture and no prose, so\n // there is nothing to quote here.\n excerpt: null,\n });\n }\n\n return {\n rows,\n skipped,\n // Rows on the page, including the ones this server could not read: the\n // site saw them, and reporting only the readable ones would understate\n // what it holds.\n reportedTotal: rows.length + skipped,\n reportedTotalMeans:\n \"rows on the single page of results Marmiton serves; the site disallows paging, so this is not a catalogue count\",\n cached: outcome.cached,\n };\n },\n\n async getRecipe(reference: string): Promise<ReadRecipe> {\n // The reader takes an identifier or an address and tells them apart.\n const ref = ABSOLUTE_URL.test(reference) ? { url: reference } : { id: reference };\n const outcome = await reader.getRecipe(ref);\n return { recipe: marmitonDetail(outcome.data), cached: outcome.cached };\n },\n };\n}\n\nexport function marmitonDetail(payload: unknown): RecipeDetail {\n const recipe = (payload ?? {}) as Partial<MarmitonRecipe>;\n const yields = (recipe.recipeYield ?? {}) as Partial<MarmitonRecipe[\"recipeYield\"]>;\n const rating = recipe.rating as Partial<NonNullable<MarmitonRecipe[\"rating\"]>> | null | undefined;\n const value = count(rating?.value);\n\n // The reader splits the published yield at the first number, which leaves the\n // rest of a span sitting where the word being counted belongs: \"4 à 6\n // personnes\" arrives as a count of four and a unit of \"à 6 personnes\". The\n // wording is read again here so both ends of the span are known.\n const published = text(yields.text);\n const span = readYieldSpan(published);\n\n return {\n ...detailBase(MARMITON_PROFILE),\n id: namespacedId(MARMITON_PROFILE.id, required(recipe.id, \"id\", MARMITON_PROFILE)),\n title: required(recipe.title, \"title\", MARMITON_PROFILE),\n url: required(recipe.url, \"url\", MARMITON_PROFILE),\n imageUrl: text(recipe.imageUrl),\n yieldCount: span.count ?? count(yields.count),\n yieldMax: span.max,\n yieldText: published,\n yieldUnit: span.unit ?? text(yields.unit),\n ingredients: textList(recipe.ingredients),\n steps: textList(recipe.steps),\n // The reader hands over the parts of a page it recognised and not the\n // headings the page carries, so there is nothing here to say how it is\n // laid out.\n publishedSections: null,\n prepMinutes: count(recipe.prepMinutes),\n cookMinutes: count(recipe.cookMinutes),\n totalMinutes: count(recipe.totalMinutes),\n category: text(recipe.category),\n author: text(recipe.author),\n rating:\n value === null ? null : { value, count: count(rating?.count), max: count(rating?.best) },\n nutrition: publishedFigures(recipe.nutrition),\n equipment: [],\n tips: [],\n // Marmiton publishes no resting time, no word on how it lays its method\n // out, and keeps nothing back.\n restMinutes: null,\n stepsAsOneBlock: null,\n withheld: null,\n // Marmiton states no licence on a recipe page, and silence is not a grant.\n license: null,\n };\n}\n","/**\n * Ptitchef, as a source.\n *\n * A French recipe site addressing a recipe by the path of its page. It answers\n * a search in several ways and prints one total for all of them, so the same\n * number counts a category on one answer and the rows served on another; what\n * it counts is said in words beside it rather than left to be guessed.\n *\n * Two figures the site publishes are not carried. It estimates what a recipe\n * costs in euros, which is a price where another source ranks a recipe inside\n * its own list, and one field holding both would invite the two to be compared.\n * It writes a difficulty in a word of its own and states no scale for it, so\n * the word sits on no axis a caller could put beside another source's.\n */\n\nimport type { RecipeDetail, RecipeRow } from \"../types.js\";\nimport type { Claim, ReadRecipe, ReadRows, SourceAdapter } from \"./adapter.js\";\nimport {\n count,\n detailBase,\n namespacedId,\n publishedFigures,\n readYieldSpan,\n required,\n rowsOf,\n text,\n} from \"./adapter.js\";\n\n/** How the site answered a wording, as its own reader reports it. */\nexport type PtitchefKind =\n | \"topic\"\n | \"free_text\"\n | \"category\"\n | \"guide\"\n | \"standing\"\n | \"fridge\"\n | \"recipe\"\n | \"unmatched\";\n\n/** A search row, as Ptitchef's own reader publishes one. */\nexport interface PtitchefRow {\n id: string;\n title: string;\n url: string;\n image_url: string | null;\n rating: number | null;\n /** How many readers rated it. */\n rating_count: number | null;\n /** How many wrote a review, which counts a different thing. */\n review_count: number | null;\n category: string | null;\n difficulty: string | null;\n total_minutes: number | null;\n calories: string | null;\n ingredients_preview: string | null;\n}\n\n/** A listing, as that reader publishes one. */\nexport interface PtitchefListing {\n asked: string;\n kind: PtitchefKind;\n topic_slug: string | null;\n title: string | null;\n results: PtitchefRow[];\n result_count: number;\n rows_seen: number;\n folded: number;\n total_available: number | null;\n page: number;\n single_page: boolean;\n url: string;\n}\n\n/** A recipe, as that reader publishes one. */\nexport interface PtitchefRecipe {\n id: string;\n title: string;\n url: string;\n description: string | null;\n image_url: string | null;\n category: string | null;\n cuisine: string | null;\n difficulty: string | null;\n author: string | null;\n author_url: string | null;\n published: string | null;\n modified: string | null;\n rating: number | null;\n rating_count: number | null;\n review_count: number | null;\n prep_minutes: number | null;\n cook_minutes: number | null;\n total_minutes: number | null;\n yield_count: number | null;\n yield_text: string | null;\n yield_unit: string | null;\n ingredients: string[];\n steps: Array<{ text: string; image_url: string | null }>;\n /** True where the site published its method as one block of prose. */\n steps_are_one_block: boolean;\n /** The panel as the site prints it, slot by slot, unread slots left null. */\n nutrition: unknown;\n estimated_cost: string | null;\n keywords: string[];\n faq: unknown[];\n translations: unknown[];\n}\n\n/** The part of Ptitchef's client this server uses. */\nexport interface PtitchefReader {\n searchRecipes: (\n query: string,\n ) => Promise<{ data: PtitchefListing; cached: boolean; skipped?: string[] }>;\n getRecipe: (id: string) => Promise<{ data: PtitchefRecipe; cached: boolean; skipped?: string[] }>;\n}\n\nexport const PTITCHEF_PROFILE = {\n id: \"ptitchef\",\n name: \"Ptitchef\",\n homeUrl: \"https://www.ptitchef.com\",\n language: \"fr\" as const,\n attribution: \"Source: Ptitchef\",\n // Every row a listing serves names a recipe; a topic the site answers with a\n // guide of its own groups recipes under headings rather than mixing in pages\n // about ingredients.\n mixesReferencePages: false,\n};\n\nconst SITE_URL = /^https?:\\/\\/(?:www\\.)?ptitchef\\.com\\//i;\n/** The shape Ptitchef mints: a recipe path ending on the page's number. */\nconst REFERENCE = /^recettes\\/[^/]+\\/[^/]+-fid-\\d+$/;\n/** A segment walking up out of the path it was written in. The site names none. */\nconst DOT_SEGMENT = /(?:^|\\/)\\.\\.?(?:\\/|$)/;\n\nconst LEADING_SLASH = /^\\/+/;\n\nfunction asReference(raw: string): string | null {\n const path = raw.replace(LEADING_SLASH, \"\");\n return REFERENCE.test(path) && !DOT_SEGMENT.test(`/${path}`) ? path : null;\n}\n\n/**\n * What the one total the site prints counts, in words, for the way it answered.\n *\n * The site reads a wording and may send the reader to a category page of its\n * own, whose total counts that category rather than what the words matched.\n * Reporting the two the same way would let a caller read a count of a whole\n * category as a count of what it searched for.\n */\nexport function meansFor(listing: Partial<PtitchefListing>): string | null {\n if (count(listing.total_available) === null) {\n return null;\n }\n switch (listing.kind) {\n case \"topic\":\n case \"category\":\n case \"standing\":\n return \"recipes Ptitchef says the category page it answered this wording with holds; it counts that page rather than what the words matched\";\n case \"free_text\":\n case \"fridge\":\n return \"rows Ptitchef served on the single page it answered this wording with; the site offers no more for it\";\n case \"recipe\":\n return \"the one recipe Ptitchef answered this wording with, by opening its page\";\n default:\n // A guide states no total of its own, and a wording the site matched\n // nothing for was never counted.\n return null;\n }\n}\n\n/**\n * The site answers a wording it matches nothing for with `kind: \"unmatched\"`,\n * no rows and no total, so an absence arrives as an absence and there is no\n * failure here to read as one. Every error the reader raises is a real failure\n * and travels on as one.\n */\nexport function ptitchefAdapter(reader: PtitchefReader): SourceAdapter {\n return {\n ...PTITCHEF_PROFILE,\n\n claims(raw: string): Claim | null {\n if (SITE_URL.test(raw)) {\n const path = asReference(new URL(raw).pathname);\n return path === null\n ? null\n : { reference: path, why: \"the address is a Ptitchef recipe page\", guess: false };\n }\n const path = asReference(raw);\n return path === null\n ? null\n : {\n reference: path,\n why: \"the path is the shape Ptitchef mints for a recipe\",\n guess: false,\n };\n },\n\n async search(query: string): Promise<ReadRows> {\n // The wording is asked as the site's own search. Its categories and its\n // fridge listing are vocabularies of its own, and a slug from one of them\n // names nothing on any other source.\n const outcome = await reader.searchRecipes(query);\n const listing = (outcome.data ?? {}) as Partial<PtitchefListing>;\n const list = rowsOf<Partial<PtitchefRow>>(listing.results, PTITCHEF_PROFILE);\n const rows: RecipeRow[] = [];\n let skipped = outcome.skipped?.length ?? 0;\n\n for (const summary of list) {\n const id = text(summary?.id);\n const title = text(summary?.title);\n const url = text(summary?.url);\n if (!(id && title && url)) {\n skipped += 1;\n continue;\n }\n rows.push({\n id: namespacedId(PTITCHEF_PROFILE.id, id),\n source: PTITCHEF_PROFILE.id,\n sourceName: PTITCHEF_PROFILE.name,\n title,\n url,\n imageUrl: text(summary?.image_url),\n excerpt: text(summary?.ingredients_preview),\n });\n }\n\n return {\n rows,\n skipped,\n reportedTotal: count(listing.total_available),\n reportedTotalMeans: meansFor(listing),\n cached: outcome.cached,\n };\n },\n\n async getRecipe(reference: string): Promise<ReadRecipe> {\n const outcome = await reader.getRecipe(reference);\n return { recipe: ptitchefDetail(outcome.data), cached: outcome.cached };\n },\n };\n}\n\nexport function ptitchefDetail(payload: unknown): RecipeDetail {\n const recipe = (payload ?? {}) as Partial<PtitchefRecipe>;\n\n const published = text(recipe.yield_text);\n const span = readYieldSpan(published);\n const value = count(recipe.rating);\n\n const steps = Array.isArray(recipe.steps) ? recipe.steps : [];\n const lines = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];\n\n return {\n ...detailBase(PTITCHEF_PROFILE),\n id: namespacedId(PTITCHEF_PROFILE.id, required(recipe.id, \"id\", PTITCHEF_PROFILE)),\n title: required(recipe.title, \"title\", PTITCHEF_PROFILE),\n url: required(recipe.url, \"url\", PTITCHEF_PROFILE),\n imageUrl: text(recipe.image_url),\n yieldCount: count(recipe.yield_count) ?? span.count,\n yieldMax: span.max,\n yieldText: published,\n yieldUnit: text(recipe.yield_unit) ?? span.unit,\n ingredients: lines.filter((line): line is string => typeof line === \"string\"),\n // A step carries a picture of itself, which is not part of the method.\n steps: steps.map((step) => text(step?.text)).filter((step): step is string => step !== null),\n publishedSections: null,\n prepMinutes: count(recipe.prep_minutes),\n cookMinutes: count(recipe.cook_minutes),\n totalMinutes: count(recipe.total_minutes),\n // The site publishes no resting time of its own.\n restMinutes: null,\n stepsAsOneBlock:\n typeof recipe.steps_are_one_block === \"boolean\" ? recipe.steps_are_one_block : null,\n withheld: null,\n category: text(recipe.category),\n author: text(recipe.author),\n // The site prints two counts beside a rating, one of readers who rated and\n // one of readers who wrote. Only the first is what a rating rests on, and\n // adding them would count twice anyone who did both.\n rating: value === null ? null : { value, count: count(recipe.rating_count), max: 5 },\n nutrition: publishedFigures(recipe.nutrition),\n equipment: [],\n tips: [],\n // The site states no licence on a recipe page, and silence is not a grant.\n license: null,\n };\n}\n","/**\n * Supertoinette, as a source.\n *\n * A French recipe site addressing a recipe by a number. It prints no total on\n * a search page and publishes no nutrition panel, and both of those are stated\n * here as silence: a count nobody published is not a count of zero.\n */\n\nimport type { RecipeDetail, RecipeRow } from \"../types.js\";\nimport type { Claim, ReadRecipe, ReadRows, SourceAdapter } from \"./adapter.js\";\nimport {\n count,\n detailBase,\n namespacedId,\n readYieldSpan,\n required,\n rowsOf,\n text,\n textList,\n} from \"./adapter.js\";\n\n/** A search row, as Supertoinette's own reader publishes one. */\nexport interface SupertoinetteListingRow {\n id: string;\n title: string;\n title_as_published: string;\n url: string;\n image_url: string | null;\n description: string | null;\n categories: string[];\n}\n\n/** One line of an ingredient list, as that reader publishes it. */\nexport interface SupertoinetteIngredient {\n amount_text: string | null;\n label: string;\n /** The whole line as published, quantity included. */\n raw: string;\n /** The ingredient page the site linked the words to. Not read: the site\n * links the wrong page often enough that pairing recipes by it would pair\n * the wrong recipes. */\n sheet: unknown;\n is_heading: boolean;\n}\n\n/** A recipe, as Supertoinette's own reader publishes one. */\nexport interface SupertoinetteRecipe {\n id: string;\n title: string;\n title_as_published: string;\n url: string;\n description: string | null;\n yield_text: string | null;\n ingredients: SupertoinetteIngredient[];\n steps: string[];\n intro: string | null;\n prep_minutes: number | null;\n cook_minutes: number | null;\n total_minutes: number | null;\n /** Time the recipe stands, which the site prints on its own. */\n rest_minutes: number | null;\n category: string | null;\n author: string | null;\n rating: { value: number; count: number; scale: number } | null;\n /** The site publishes none, and the field says so rather than being absent. */\n nutrition: null;\n difficulty: { label: string } | null;\n cost_level: { label: string; level: number; scale: number } | null;\n images: string[];\n tags: unknown[];\n ingredient_sheets: unknown[];\n faq: unknown[];\n}\n\ninterface SupertoinetteListing {\n results: SupertoinetteListingRow[];\n rows_published: number;\n total_available: null;\n last_page: number;\n facets: unknown[];\n matched_nothing: boolean;\n url: string;\n}\n\n/** The part of Supertoinette's client this server uses. */\nexport interface SupertoinetteReader {\n searchRecipes: (request: { query: string; page: number; category: string | null }) => Promise<{\n data: { listing: SupertoinetteListing; dropped_category: string | null };\n cached: boolean;\n skipped?: string[];\n }>;\n getRecipe: (\n id: string,\n ) => Promise<{ data: SupertoinetteRecipe; cached: boolean; skipped?: string[] }>;\n}\n\nexport const SUPERTOINETTE_PROFILE = {\n id: \"supertoinette\",\n name: \"Supertoinette\",\n homeUrl: \"https://www.supertoinette.com\",\n language: \"fr\" as const,\n attribution: \"Source: Supertoinette\",\n // The reader sets aside any row that opens onto something other than a\n // recipe, so what arrives here is recipes.\n mixesReferencePages: false,\n};\n\n/** Recipe pages, whose address carries the number the site addresses it by. */\nconst SITE_RECIPE_URL = /^https?:\\/\\/(?:www\\.)?supertoinette\\.com\\/recette\\/([1-9][0-9]{0,9})\\//i;\n/** The shape Supertoinette mints: a number, and never a leading zero. */\nconst REFERENCE = /^[1-9][0-9]{0,9}$/;\n\n/**\n * The site answers a wording it matches nothing for with an empty listing and\n * `matched_nothing`, so an absence arrives as an absence and there is no\n * failure here to read as one. Every error the reader raises is a real failure\n * and travels on as one.\n */\nexport function supertoinetteAdapter(reader: SupertoinetteReader): SourceAdapter {\n return {\n ...SUPERTOINETTE_PROFILE,\n\n claims(raw: string): Claim | null {\n const address = SITE_RECIPE_URL.exec(raw);\n if (address?.[1]) {\n return {\n reference: address[1],\n why: \"the address is a Supertoinette recipe page\",\n guess: false,\n };\n }\n if (REFERENCE.test(raw)) {\n return {\n reference: raw,\n why: \"a bare number is the shape Supertoinette mints\",\n guess: false,\n };\n }\n return null;\n },\n\n async search(query: string): Promise<ReadRows> {\n // The site pages rather than serving a longer first page, and a wording\n // is asked as one search rather than followed across pages. No category\n // is named, because a facet belongs to the site that writes it.\n const outcome = await reader.searchRecipes({ query, page: 1, category: null });\n const listing = (outcome.data?.listing ?? {}) as Partial<SupertoinetteListing>;\n const list = rowsOf<Partial<SupertoinetteListingRow>>(listing.results, SUPERTOINETTE_PROFILE);\n const rows: RecipeRow[] = [];\n // Rows the reader itself set aside, each with the reason it gave.\n let skipped = outcome.skipped?.length ?? 0;\n\n for (const summary of list) {\n const id = text(summary?.id);\n const title = text(summary?.title);\n const url = text(summary?.url);\n if (!(id && title && url)) {\n skipped += 1;\n continue;\n }\n rows.push({\n id: namespacedId(SUPERTOINETTE_PROFILE.id, id),\n source: SUPERTOINETTE_PROFILE.id,\n sourceName: SUPERTOINETTE_PROFILE.name,\n title,\n url,\n imageUrl: text(summary?.image_url),\n excerpt: text(summary?.description),\n });\n }\n\n return {\n rows,\n skipped,\n // The site prints no total anywhere on a search page. The rows of one\n // page are not put here in its place: the site pages, so they would\n // understate what it holds rather than count it.\n reportedTotal: null,\n reportedTotalMeans: null,\n cached: outcome.cached,\n };\n },\n\n async getRecipe(reference: string): Promise<ReadRecipe> {\n const outcome = await reader.getRecipe(reference);\n return { recipe: supertoinetteDetail(outcome.data), cached: outcome.cached };\n },\n };\n}\n\nexport function supertoinetteDetail(payload: unknown): RecipeDetail {\n const recipe = (payload ?? {}) as Partial<SupertoinetteRecipe>;\n const rating = recipe.rating as Partial<NonNullable<SupertoinetteRecipe[\"rating\"]>> | null;\n const value = count(rating?.value);\n\n // The site states its yield in words alone, so both the number and what it\n // counts are read back off the wording it published.\n const published = text(recipe.yield_text);\n const span = readYieldSpan(published);\n\n const lines = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];\n\n return {\n ...detailBase(SUPERTOINETTE_PROFILE),\n id: namespacedId(SUPERTOINETTE_PROFILE.id, required(recipe.id, \"id\", SUPERTOINETTE_PROFILE)),\n title: required(recipe.title, \"title\", SUPERTOINETTE_PROFILE),\n url: required(recipe.url, \"url\", SUPERTOINETTE_PROFILE),\n imageUrl: text(recipe.images?.[0]),\n yieldCount: span.count,\n yieldMax: span.max,\n yieldText: published,\n yieldUnit: span.unit,\n // The whole line as printed, headings included: a heading dropped would\n // merge two groups of a recipe that may each call for the same thing.\n // The ingredient page the site links a line to is not carried, since the\n // site links the wrong page often enough that pairing recipes by it would\n // pair the wrong recipes.\n ingredients: lines\n .map((line) => text(line?.raw))\n .filter((line): line is string => line !== null),\n steps: textList(recipe.steps),\n publishedSections: null,\n prepMinutes: count(recipe.prep_minutes),\n cookMinutes: count(recipe.cook_minutes),\n totalMinutes: count(recipe.total_minutes),\n // The site prints this on its own, and its own total already holds it.\n // Adding it to anything here would count it twice.\n restMinutes: count(recipe.rest_minutes),\n // The reader hands over the steps it read and says nothing about whether\n // the page numbered them.\n stepsAsOneBlock: null,\n withheld: null,\n category: text(recipe.category),\n author: text(recipe.author),\n // The scale is published, so a value is never read against a guess.\n rating:\n value === null ? null : { value, count: count(rating?.count), max: count(rating?.scale) },\n // This site publishes no nutrition panel at all, so there is nothing here\n // to repeat. An empty panel would read as a page that printed one and left\n // every figure out.\n nutrition: null,\n equipment: [],\n tips: [],\n // The site states no licence on a recipe page, and silence is not a grant.\n license: null,\n };\n}\n","/**\n * Several wordings from one question.\n *\n * A person asks for a recipe in a sentence, and a recipe index reads none of\n * it. One of the indexes behind this server requires every word given to appear\n * on the same page, so a sentence returns nothing where the corpus holds several\n * of the dish. The other ranks pages on the words it was handed, so the words\n * framing the question pull up whatever happens to share them and push the dish\n * down the list or out of it. Both come back to a reader looking like a\n * statement about a corpus when they are statements about a wording. Deriving\n * the shorter wordings and asking for the union is what keeps the two apart.\n *\n * Every wording is built from the words the question carried, with no\n * dictionary, no corpus statistics and no translation. That has two\n * consequences worth stating. A derived wording is one a reader can retype by\n * hand, so an answer built from several of them stays checkable. And the\n * derivations that would need a lexicon are not made: a question asked in one\n * language is never turned into the other, because a source that publishes in\n * the other language and holds nothing under the words written is an honest\n * absence, while a guessed translation is a word nobody wrote.\n *\n * The order runs from the wording closest to what was asked to the widest.\n *\n * A question also states what the recipe must not hold, and that part is read\n * by the role its words play rather than by the turn of phrase they are written\n * in: a negation, a sentence about how a food affects the person, or the name\n * of a diet. All three put a food out of the dish, so all three are set aside\n * from the derived wordings and named back to the caller. Reading them off a\n * list of phrasings is what lets a declared allergen become a word to search\n * for, and there is no phrasing whose absence from such a list is safe.\n */\n\nconst DIGITS_ONLY = /^\\d+$/;\nconst WORD_SEPARATORS = /[^\\p{L}\\p{N}-]+/u;\n\n/** One wording, and how it was arrived at. */\nexport interface Wording {\n query: string;\n /** How this wording was derived from the question, in words. */\n derivation: string;\n}\n\n/**\n * The most searches one source receives for one question.\n *\n * A ceiling per source rather than a budget shared between them, because the\n * sources are asked in parallel and each is paced on its own, so the worst case\n * is three of one source's intervals rather than six. Three is also the whole\n * ladder: the question as asked, the dish words, and the word naming the dish.\n */\nexport const MAX_WORDINGS_PER_SOURCE = 3;\n\n/* -------------------------------------------------------------------------- */\n/* The words a question is built out of, as opposed to the words naming a dish */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Words that frame a question rather than name what is cooked.\n *\n * Both languages are applied to every question. Telling them apart would need a\n * detector, and a detector that guesses wrong strips the wrong words; the two\n * lists barely overlap, and where they do the word frames a question in both.\n *\n * A word is left out of these lists wherever it is also something a person\n * eats. French \"son\" is a possessive and is also bran, so it stays. The same\n * caution governs anything added here: the cost of keeping a framing word is a\n * diluted ranking, and the cost of dropping an ingredient is the dish.\n */\nconst FRENCH_FRAME = new Set([\n \"a\",\n \"ai\",\n \"aimerais\",\n \"au\",\n \"aurais\",\n \"auriez\",\n \"aux\",\n \"avec\",\n \"avons\",\n \"bcp\",\n \"beaucoup\",\n \"bien\",\n \"bonjour\",\n \"bonne\",\n \"bonnes\",\n \"cherche\",\n \"chercher\",\n \"cherchez\",\n \"connais\",\n \"connaissez\",\n \"coucou\",\n \"ce\",\n \"ces\",\n \"cette\",\n \"comment\",\n \"d\",\n \"dans\",\n \"de\",\n \"des\",\n \"du\",\n \"en\",\n \"est\",\n \"et\",\n \"facile\",\n \"faire\",\n \"fais\",\n \"fait\",\n \"idee\",\n \"idees\",\n \"idée\",\n \"idées\",\n \"j\",\n \"je\",\n \"l\",\n \"la\",\n \"le\",\n \"les\",\n \"ma\",\n \"meilleur\",\n \"meilleure\",\n \"merci\",\n \"mes\",\n \"mon\",\n \"ou\",\n \"par\",\n \"peux\",\n \"plait\",\n \"plaît\",\n \"plus\",\n \"pour\",\n \"qu\",\n \"que\",\n \"quel\",\n \"quelle\",\n \"quelles\",\n \"quels\",\n \"qui\",\n \"rapide\",\n \"recette\",\n \"recettes\",\n \"recherche\",\n \"s\",\n \"salut\",\n \"se\",\n \"si\",\n \"simple\",\n \"sommes\",\n \"souhaite\",\n \"stp\",\n \"suis\",\n \"sur\",\n \"svp\",\n \"tres\",\n \"très\",\n \"un\",\n \"une\",\n \"veux\",\n \"voudrais\",\n \"vous\",\n \"à\",\n]);\n\nconst ENGLISH_FRAME = new Set([\n \"a\",\n \"about\",\n \"am\",\n \"an\",\n \"and\",\n \"any\",\n \"anything\",\n \"are\",\n \"at\",\n \"be\",\n \"best\",\n \"by\",\n \"can\",\n \"could\",\n \"do\",\n \"does\",\n \"easy\",\n \"find\",\n \"for\",\n \"from\",\n \"get\",\n \"good\",\n \"hello\",\n \"hi\",\n \"how\",\n \"i\",\n \"in\",\n \"into\",\n \"is\",\n \"it\",\n \"like\",\n \"looking\",\n \"make\",\n \"making\",\n \"me\",\n \"my\",\n \"need\",\n \"of\",\n \"on\",\n \"or\",\n \"please\",\n \"quick\",\n \"recipe\",\n \"recipes\",\n \"should\",\n \"simple\",\n \"some\",\n \"something\",\n \"suggest\",\n \"thank\",\n \"thanks\",\n \"that\",\n \"the\",\n \"less\",\n \"over\",\n \"than\",\n \"to\",\n \"under\",\n \"want\",\n \"what\",\n \"which\",\n \"will\",\n \"with\",\n \"would\",\n \"you\",\n \"your\",\n]);\n\n/**\n * What a condition is measured in.\n *\n * \"under 300 calories\" and \"in 20 minutes\" set a bound, and the word naming the\n * dimension is left in the sentence once the number has gone. No page is named\n * for it, so carrying it into a derived wording ranks pages that merely mention\n * counting calories.\n */\nconst CONDITION_UNITS = new Set([\n \"calorie\",\n \"calories\",\n \"couvert\",\n \"couverts\",\n \"heure\",\n \"heures\",\n \"kcal\",\n \"min\",\n \"mins\",\n \"minute\",\n \"minutes\",\n \"part\",\n \"parts\",\n \"people\",\n \"person\",\n \"persons\",\n \"personne\",\n \"personnes\",\n \"portion\",\n \"portions\",\n \"serving\",\n \"servings\",\n]);\n\n/**\n * Words whose whole job is to take something out of the dish.\n *\n * A negation is a function word, and function words are a closed class: the\n * ones a language has are the ones it has, and covering the class covers every\n * phrasing built on it, whether the question writes \"sans beurre\", \"without\n * butter\", \"no butter\" or \"pas de beurre\". That is what makes this a rule\n * rather than a collection of turns of phrase, and it is why the verbs that do\n * the same work, \"avoid\" and \"éviter\", sit here beside them.\n *\n * The price is a question such as \"no bake cookies\", where the negation is part\n * of a dish's name. It costs a caveat the reader can discard. Missing an\n * exclusion costs the reader the thing they wrote the question to avoid, so the\n * two errors are not weighed the same.\n */\nconst NEGATORS = new Set([\n \"aucun\",\n \"aucune\",\n \"avoid\",\n \"avoiding\",\n \"eviter\",\n \"evite\",\n \"evitez\",\n \"exclude\",\n \"excluding\",\n \"exclure\",\n \"except\",\n \"ni\",\n \"no\",\n \"non\",\n \"not\",\n \"pas\",\n \"sans\",\n \"sauf\",\n \"without\",\n \"zero\",\n]);\n\n/**\n * Words saying that a food does something to the person rather than to the\n * dish.\n *\n * A sentence about the eater names what the recipe must not hold just as\n * plainly as a negation does, and it is the one condition whose failure is a\n * matter of health, so it is read as its own kind and reported as one.\n */\nconst HEALTH_MARKERS = new Set([\n \"allergene\",\n \"allergenes\",\n \"allergen\",\n \"allergens\",\n \"allergic\",\n \"allergie\",\n \"allergies\",\n \"allergique\",\n \"allergiques\",\n \"allergy\",\n \"celiac\",\n \"coeliaque\",\n \"intolerance\",\n \"intolerances\",\n \"intolerant\",\n \"intolerante\",\n \"intolerantes\",\n \"intolerants\",\n]);\n\n/**\n * Diets, each of which names a class of recipes by what it leaves out.\n *\n * Naming one states an exclusion without writing a negation anywhere, which is\n * why a reading built on negations alone lets it through and hands the word\n * back to the index as though it named a dish.\n */\nconst DIETS = new Set([\n \"casher\",\n \"cetogene\",\n \"halal\",\n \"kascher\",\n \"keto\",\n \"ketogenic\",\n \"kosher\",\n \"paleo\",\n \"pescatarian\",\n \"pescetarien\",\n \"pescetarienne\",\n \"vegan\",\n \"vegane\",\n \"veganes\",\n \"vegans\",\n \"vegetalien\",\n \"vegetalienne\",\n \"vegetaliennes\",\n \"vegetaliens\",\n \"vegetarian\",\n \"vegetarians\",\n \"vegetarien\",\n \"vegetarienne\",\n \"vegetariennes\",\n \"vegetariens\",\n \"veggie\",\n]);\n\n/**\n * Where a condition ends.\n *\n * \"sans farine de blé et sans oeufs\" names two things to leave out, and the\n * conjunction is where the first one stops.\n */\nconst CONDITION_ENDS = new Set([\n \"et\",\n \"and\",\n \"ou\",\n \"or\",\n \"pour\",\n \"for\",\n \"avec\",\n \"with\",\n \"mais\",\n \"but\",\n \"dans\",\n \"sur\",\n]);\n\n/**\n * Words joining a marker to what it names, which name nothing themselves.\n *\n * \"pas de beurre\" and \"allergique aux noix\" put one of these between the word\n * carrying the condition and the food it is about. One of them standing right\n * after a marker is what says the food comes after it, which is the only sign\n * in the sentence of which side the marker reaches towards.\n */\nconst CONDITION_JOINERS = new Set([\n \"a\",\n \"au\",\n \"aux\",\n \"d\",\n \"de\",\n \"des\",\n \"du\",\n \"from\",\n \"l\",\n \"la\",\n \"le\",\n \"les\",\n \"my\",\n \"of\",\n \"the\",\n \"to\",\n]);\n\n/**\n * Words that carry a food's name on into a second noun.\n *\n * \"farine de blé\" and \"noix de coco\" are one food written in three words, and\n * the middle one is what says so. A food's name stops wherever no such word\n * carries it further, which is what keeps \"sans gluten gateau au chocolat\" from\n * reading as a condition on chocolate cake.\n */\nconst FOOD_LINKERS = new Set([\"d\", \"de\", \"des\", \"du\", \"of\"]);\n\n/**\n * How many words a condition names before the sentence resumes.\n *\n * What a recipe has to leave out is a food, so it is a short noun phrase:\n * \"beurre\", \"farine de blé\", \"noix de coco\". Past that the words belong to the\n * question again, and a scope with no end turns a negation used inside a dish's\n * name into a condition swallowing the rest of the sentence.\n */\nconst CONDITION_WORDS = 3;\n\n/* -------------------------------------------------------------------------- */\n/* Conditions */\n/* -------------------------------------------------------------------------- */\n\n/** How a question put a condition, which decides how it is reported back. */\nexport type ConditionKind =\n /** The question wrote the recipe must not hold this. */\n | \"excluded\"\n /** The question said the person reacts to this food. */\n | \"allergy\"\n /** The question named a diet, which is a class defined by what it leaves out. */\n | \"diet\";\n\n/** One thing a question asks the recipe not to hold. */\nexport interface Condition {\n /**\n * The words the question used for the food, or null where the question states\n * a condition and the sentence does not say plainly which food it covers.\n *\n * A word named here is a word the recipe must not hold and a word no wording\n * looks for, so naming the wrong one both hides a dish and leaves the food\n * being avoided in the search. Where the reading is not certain, the caller is\n * told a condition was stated and that its food was not read, which is a\n * smaller claim than either.\n */\n named: string | null;\n kind: ConditionKind;\n}\n\n/** What a question asks the dish to avoid, and how many it has to serve. */\nexport interface Conditions {\n /** What the dish must not hold, in the words the question used. */\n conditions: Condition[];\n /** How many the dish has to serve, when the question said. */\n servings: number | null;\n}\n\n/** How many the question asks the dish to serve, written either way round. */\nconst SERVING_PHRASES = [\n /\\bpour\\s+(\\d{1,3})\\s*(?:personnes?|parts?|couverts?)\\b/iu,\n /\\bfor\\s+(\\d{1,3})\\s*(?:people|persons?|servings?|portions?)\\b/iu,\n /\\bserves?\\s+(\\d{1,3})\\b/iu,\n /\\b(\\d{1,3})\\s*(?:personnes?|people|servings?)\\b/iu,\n];\n\n/** A food and the marker written as a single word: \"gluten-free\", \"sugarfree\". */\nconst FREE_OF = /\\b([\\p{L}]{3,})[\\s-]?free\\b/iu;\n\n/** What one reading of a question found in it. */\ninterface ReadQuestion {\n /** The words that name what is cooked, in the order the question wrote them. */\n dish: string[];\n conditions: Condition[];\n}\n\n/** Lowercase and strip diacritics, so \"végétarien\" and \"vegetarien\" read alike. */\nfunction foldWord(word: string): string {\n return word\n .toLowerCase()\n .normalize(\"NFD\")\n .replace(/\\p{M}+/gu, \"\")\n .replace(/œ/gu, \"oe\");\n}\n\n/**\n * Whether a word left over from a question names the dish.\n *\n * The framing of a question is not the dish it asks for, a number counts eaters\n * or minutes rather than naming anything, and what an apostrophe leaves behind\n * once it has split an elision is not a word at all.\n */\nfunction namesTheDish(word: string, key: string): boolean {\n if (FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key)) {\n return false;\n }\n if (FRENCH_FRAME.has(word) || ENGLISH_FRAME.has(word)) {\n return false;\n }\n if (DIGITS_ONLY.test(word)) {\n return false;\n }\n if (word.length < 2) {\n return false;\n }\n return !CONDITION_UNITS.has(word);\n}\n\n/**\n * Read a question once, deciding for every word whether it names the dish or\n * states a condition on it.\n *\n * One pass rather than two, because the two answers have to agree: a word read\n * as a condition here and as a dish word there is a condition reported to the\n * caller and searched for at the same time, which is the shape of the worst\n * thing this file can do. A single reading cannot disagree with itself.\n */\nfunction readQuestion(question: string): ReadQuestion {\n const words = tokenise(question);\n const conditions: Condition[] = [];\n const dish: string[] = [];\n\n const state = (named: string | null, kind: ConditionKind): void => {\n const trimmed = named === null ? null : named.trim();\n if (trimmed === \"\") {\n return;\n }\n if (conditions.some((held) => held.named === trimmed && held.kind === kind)) {\n return;\n }\n conditions.push({ named: trimmed, kind });\n };\n\n for (let index = 0; index < words.length; index += 1) {\n const word = words[index] ?? \"\";\n const key = foldWord(word);\n\n // A hyphen joins everywhere else, so \"sans-gluten\" and \"no-sugar\" arrive as\n // one word with the negation inside it.\n const hyphen = word.indexOf(\"-\");\n if (hyphen > 0 && NEGATORS.has(foldWord(word.slice(0, hyphen)))) {\n state(word.slice(hyphen + 1), \"excluded\");\n continue;\n }\n\n // \"gluten-free\" writes the food and the marker as one word.\n const free = FREE_OF.exec(word);\n if (free) {\n state(free[1] ?? \"\", \"excluded\");\n continue;\n }\n\n // \"free\" is also a price and a way of keeping hens, so it states a\n // condition only where the sentence puts a food on one side of it.\n if (key === \"free\") {\n const scope = scopeOf(words, index, dish);\n if (scope.named !== null) {\n state(scope.named, \"excluded\");\n index = scope.next - 1;\n }\n continue;\n }\n\n // A negation stands before what it negates, in both languages: \"sans\n // beurre\", \"without butter\", \"pas de beurre\".\n if (NEGATORS.has(key)) {\n const forward = takeFood(words, index + 1);\n state(forward.named === \"\" ? null : forward.named, \"excluded\");\n // The marker itself never names a dish, so it leaves whether or not it\n // turned out to scope over anything.\n index = forward.next - 1;\n continue;\n }\n\n if (HEALTH_MARKERS.has(key)) {\n const scope = scopeOf(words, index, dish);\n state(scope.named, \"allergy\");\n index = scope.next - 1;\n continue;\n }\n\n if (DIETS.has(key)) {\n state(word, \"diet\");\n continue;\n }\n\n if (namesTheDish(word, key) && !dish.includes(word)) {\n dish.push(word);\n }\n }\n\n return { dish, conditions };\n}\n\n/**\n * The food a marker covers, on whichever side of it the sentence put one.\n *\n * A joining word straight after the marker is the sentence saying its food\n * follows: \"allergique aux noix\", \"allergic to peanuts\", \"free of dairy\". With\n * no such word the food is the one the marker was written onto the end of:\n * \"peanut allergy\", \"lactose intolerant\", \"gluten free\". The two orders belong\n * to the two languages these sources publish in, so reading only one of them\n * leaves every question written in the other unread.\n *\n * Read the wrong way round, a marker takes a word out of the dish and leaves\n * the food being avoided in the search, which is both halves of the harm at\n * once. So a marker with a food on neither side covers none, and says so.\n *\n * A food found before the marker was already read as a word of the dish, and\n * leaves it here: the same word cannot be a thing to avoid and a thing to look\n * for.\n */\nfunction scopeOf(\n words: string[],\n marker: number,\n dish: string[],\n): { named: string | null; next: number } {\n if (CONDITION_JOINERS.has(foldWord(words[marker + 1] ?? \"\"))) {\n const forward = takeFood(words, marker + 1);\n return { named: forward.named === \"\" ? null : forward.named, next: forward.next };\n }\n\n const before = words[marker - 1];\n if (before !== undefined) {\n const held = dish.lastIndexOf(before);\n if (held !== -1) {\n dish.splice(held, 1);\n return { named: before, next: marker + 1 };\n }\n }\n\n return { named: null, next: marker + 1 };\n}\n\n/**\n * The food named from a point in a sentence onwards, and where the sentence\n * picks up again.\n *\n * A food is a short noun phrase, and it ends where nothing carries it further.\n * Taking a fixed number of words instead lets a condition run on into the dish,\n * so \"sans gluten gateau au chocolat\" comes back as a condition on the cake.\n */\nfunction takeFood(words: string[], from: number): { named: string; next: number } {\n let index = from;\n // An article or a quantifier can stand between the marker and the food:\n // \"pas de beurre\", \"without any butter\".\n while (index < words.length && isFiller(words[index] ?? \"\")) {\n index += 1;\n }\n\n const named: string[] = [];\n while (index < words.length && named.length < CONDITION_WORDS) {\n const word = words[index] ?? \"\";\n if (!namesFood(word)) {\n break;\n }\n named.push(word);\n index += 1;\n\n const beyond = words[index + 1];\n if (\n named.length + 2 <= CONDITION_WORDS &&\n FOOD_LINKERS.has(foldWord(words[index] ?? \"\")) &&\n beyond !== undefined &&\n namesFood(beyond)\n ) {\n named.push(words[index] ?? \"\");\n index += 1;\n continue;\n }\n break;\n }\n\n return { named: named.join(\" \"), next: index };\n}\n\n/** A word standing between a marker and its food, naming no food itself. */\nfunction isFiller(word: string): boolean {\n const key = foldWord(word);\n return CONDITION_JOINERS.has(key) || FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key);\n}\n\n/** Whether a word can be part of the name of something a recipe holds. */\nfunction namesFood(word: string): boolean {\n const key = foldWord(word);\n if (key.length < 2) {\n return false;\n }\n if (DIGITS_ONLY.test(key)) {\n return false;\n }\n if (key === \"free\") {\n return false;\n }\n if (CONDITION_ENDS.has(key)) {\n return false;\n }\n if (NEGATORS.has(key) || HEALTH_MARKERS.has(key)) {\n return false;\n }\n if (CONDITION_UNITS.has(key)) {\n return false;\n }\n return !(FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key));\n}\n\n/**\n * Read what the question asks the recipe to avoid or to serve.\n *\n * These are the parts of a question no recipe index answers. A page is not\n * named for what it leaves out, and the number of people at a table is not\n * printed in a title at all, so both are read here and handled as what they\n * are rather than as words to look for.\n */\nexport function readConditions(question: string): Conditions {\n const { conditions } = readQuestion(question);\n\n let servings: number | null = null;\n for (const phrase of SERVING_PHRASES) {\n const found = phrase.exec(question);\n if (found) {\n const value = Number(found[1]);\n if (Number.isFinite(value) && value > 0) {\n servings = value;\n break;\n }\n }\n }\n\n return { conditions, servings };\n}\n\n/* -------------------------------------------------------------------------- */\n/* The ladder */\n/* -------------------------------------------------------------------------- */\n\nexport function deriveWordings(question: string): Wording[] {\n const asked = tidy(question);\n const wordings: Wording[] = [];\n const seen = new Set<string>();\n\n const offer = (candidate: string, derivation: string): void => {\n const clean = tidy(candidate);\n if (clean === \"\") {\n return;\n }\n // Two wordings differing only in case or spacing are one wording to these\n // indexes, and sending both spends an interval to learn nothing.\n const key = clean.toLowerCase();\n if (seen.has(key)) {\n return;\n }\n seen.add(key);\n wordings.push({ query: clean, derivation });\n };\n\n offer(asked, \"the words as asked, condition words included\");\n\n const dish = dishWords(asked);\n offer(\n dish.join(\" \"),\n \"the words naming the dish, with the words framing the question and the conditions set aside, because a page is named for what it holds\",\n );\n\n offer(\n dish[0] ?? \"\",\n \"the leading dish word on its own, because an index that requires every word given returns nothing for a phrase and a great deal for the word naming the dish\",\n );\n\n // The ladder is three rungs by construction; the ceiling holds whatever a\n // future rung would add to it.\n return wordings.slice(0, MAX_WORDINGS_PER_SOURCE);\n}\n\n/**\n * Whether a row's title carries a word naming the dish that was asked for.\n *\n * This decides whether a wording answered, and nothing else: no row is dropped\n * and no row is moved for failing it. A source that ranks on the words it was\n * handed answers a sentence with a page of recipes that share its framing\n * words, and counting those as an answer would stop the search on the page that\n * proves it has not found the dish.\n *\n * Diacritics and punctuation fold away, so \"crêpes\" reads the same as \"crepes\"\n * and a namespace in front of a page name settles nothing. A question that\n * names no dish has nothing to check against, and every row passes.\n */\nexport function namesDish(title: string, question: string): boolean {\n const words = namingWords(question);\n if (words.length === 0) {\n return true;\n }\n return dishWordsMissing(title, question).length < words.length;\n}\n\n/**\n * The words naming the dish that a title does not carry.\n *\n * Sharing one word is what makes a row worth offering, and it is not what makes\n * a row the dish. \"Biscuits and gravy\" and a tin of Christmas biscuits share\n * the biscuit and nothing else, so a caller weighing one against the other has\n * to be told which of the words asked for went unanswered.\n *\n * Empty for a title carrying all of them, and empty for a question that names\n * no dish, since there is then nothing to go unanswered.\n */\nexport function dishWordsMissing(title: string, question: string): string[] {\n const haystack = ` ${fold(title)} `;\n return namingWords(question).filter((word) => !haystack.includes(` ${fold(word)}`));\n}\n\n/** The words of a question long enough to name what is cooked. */\nfunction namingWords(question: string): string[] {\n return dishWords(question).filter((word) => word.length > 2);\n}\n\nfunction fold(value: string): string {\n return value\n .normalize(\"NFD\")\n .replace(/\\p{M}+/gu, \"\")\n .replace(/œ/gu, \"oe\")\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \" \");\n}\n\n/**\n * The words of a question that name what is cooked.\n *\n * The framing words go because they rank pages that merely share them. The\n * condition words go for a different reason, and it is worth stating: a search\n * for \"sans gluten\" ranks pages about gluten, and a search for \"6 personnes\"\n * ranks nothing at all, so carrying them into a derived wording buries the dish\n * without excluding anything. Dropping them silently would be the worse move,\n * since the recipe found may hold exactly what the person was avoiding, and\n * that is why the question as asked is sent first and every condition set aside\n * is named back to the caller.\n */\nfunction dishWords(question: string): string[] {\n return readQuestion(question).dish;\n}\n\n/**\n * The words of a question, lowercased.\n *\n * An apostrophe separates: \"l'origan\" is the article and the herb, and the herb\n * is the word an index holds. A hyphen joins, because \"pot-au-feu\" and\n * \"gluten-free\" are each one word wherever they are written.\n */\nfunction tokenise(value: string): string[] {\n return value\n .toLowerCase()\n .replace(/['’]/gu, \" \")\n .split(WORD_SEPARATORS)\n .map((word) => word.replace(/^-+|-+$/g, \"\"))\n .filter((word) => word !== \"\");\n}\n\nfunction tidy(value: string): string {\n return value.replace(/\\s+/g, \" \").trim();\n}\n","/**\n * The one place that talks to the sources.\n *\n * It asks them all at once and merges what comes back, so a question is\n * answered by whichever of them holds something. It imports nothing from the\n * MCP layer and is published on its own, so the same code serves a plain\n * script.\n *\n * A source that fails is reported as a source that failed. It is never folded\n * into the answer as an absence: \"Marmiton was unreachable\" and \"Marmiton holds\n * no such recipe\" are different statements about the world, and a caller that\n * cannot tell them apart will say the second when only the first is true.\n */\n\nimport type { Config, Logger } from \"../config.js\";\nimport {\n MAX_ALLOWED_INTERVAL_MS,\n MIN_ALLOWED_INTERVAL_MS,\n createLogger,\n loadConfig,\n} from \"../config.js\";\nimport { fromSource, invalidInput, timeout as timedOut } from \"../errors.js\";\nimport type {\n MergedSearch,\n RecipeDetail,\n RecipeRow,\n SourceId,\n SourceReport,\n WordingAttempt,\n} from \"../types.js\";\nimport type { SourceAdapter } from \"./adapter.js\";\nimport { resolveId } from \"./ids.js\";\nimport type { ResolvedId } from \"./ids.js\";\nimport { buildSources, selectSources } from \"./registry.js\";\nimport type { Readers } from \"./registry.js\";\nimport { MAX_WORDINGS_PER_SOURCE, deriveWordings, namesDish } from \"./wordings.js\";\nimport type { Wording } from \"./wordings.js\";\n\nexport type { SourceAdapter, ReadRecipe, ReadRows } from \"./adapter.js\";\nexport type { CookbookReader } from \"./cookbook.js\";\nexport type { MarmitonReader } from \"./marmiton.js\";\nexport type { Readers } from \"./registry.js\";\nexport type {\n MergedSearch,\n RecipeDetail,\n RecipeRow,\n SourceId,\n SourceProfile,\n SourceReport,\n WordingAttempt,\n} from \"../types.js\";\n\nconst LETTER_OR_DIGIT = /[\\p{L}\\p{N}]/u;\n\nexport interface RecipesClientOptions {\n config?: Partial<Config>;\n logger?: Logger;\n /** Stands in for one or more of the site readers. */\n readers?: Readers;\n /** Replaces the registry outright, for a program bringing its own corpora. */\n sources?: SourceAdapter[];\n}\n\n/** The largest number of rows any one source is asked for in a single call. */\nexport const MAX_LIMIT_PER_SOURCE = 25;\n\nexport interface SearchOptions {\n /**\n * Whether a question may be sent in wordings derived from it as well as in\n * the words it was written in.\n *\n * On unless a caller turns it off. A recipe index answers the words it is\n * handed, so a question written as a sentence comes back empty from a corpus\n * holding several of the dish, and an empty answer is read as a corpus that\n * holds nothing. Off, a caller gets exactly the wording they typed and the\n * answer names the wordings that were not sent.\n */\n fanOut?: boolean;\n}\n\n/**\n * The pacing this server owes each source, applied to whatever it is handed.\n *\n * A configuration object assembled by a caller has not been through\n * `loadConfig`, so it can carry a missing value, a value of the wrong shape, or\n * a User-Agent that names somebody else. Every setting is held to the same range\n * the environment parser enforces: `timeoutMs: 0` is the usual way of writing\n * \"no deadline\", and a retry count of a hundred thousand aimed at a site paced\n * in milliseconds is hours of traffic from a single call.\n */\nfunction withGuarantees(config: Config): Config {\n const defaults = loadConfig({});\n\n const bounded = (value: unknown, fallback: number, min: number, max: number): number => {\n const parsed = Number(value);\n if (!Number.isFinite(parsed)) {\n return fallback;\n }\n return Math.min(max, Math.max(min, parsed));\n };\n\n const claimed = typeof config.userAgent === \"string\" ? config.userAgent.trim() : \"\";\n const identifier = defaults.userAgent;\n\n return {\n ...config,\n userAgent:\n claimed === \"\" || claimed.includes(identifier) ? identifier : `${claimed} ${identifier}`,\n minIntervalMs: bounded(\n config.minIntervalMs,\n defaults.minIntervalMs,\n MIN_ALLOWED_INTERVAL_MS,\n MAX_ALLOWED_INTERVAL_MS,\n ),\n timeoutMs: bounded(config.timeoutMs, defaults.timeoutMs, 1000, 120_000),\n maxRetries: bounded(config.maxRetries, defaults.maxRetries, 0, 8),\n cacheTtlMs: bounded(config.cacheTtlMs, defaults.cacheTtlMs, 0, 86_400_000),\n cacheMaxEntries: bounded(config.cacheMaxEntries, defaults.cacheMaxEntries, 1, 5000),\n logLevel: config.logLevel ?? defaults.logLevel,\n };\n}\n\n/**\n * A wall clock over one source, as a backstop under the deadline that source's\n * own reader keeps.\n *\n * That reader times out its own requests, so this only matters when it cannot:\n * a socket that stalls without erroring, a stand-in reader, a bug in a\n * dependency free to change under a caret range. Without it, one source that\n * never answers holds the whole call open and the rows the others had ready in\n * milliseconds are never returned.\n *\n * The allowance covers every attempt a reader is entitled to make, plus the\n * pacing between them, so this never fires before the reader's own deadline has.\n */\nfunction withDeadline<T>(work: Promise<T>, ms: number, source: SourceAdapter): Promise<T> {\n let timer: ReturnType<typeof setTimeout>;\n const alarm = new Promise<never>((_resolve, reject) => {\n timer = setTimeout(\n () =>\n reject(\n timedOut(`${source.name} did not answer within ${ms}ms and was left out of this answer.`),\n ),\n ms,\n );\n // A pending timer must not hold the process open once the answer is out.\n timer.unref?.();\n });\n return Promise.race([work, alarm]).finally(() => clearTimeout(timer));\n}\n\n/** What one source produced for one question, whether or not it produced anything. */\ninterface Attempt {\n source: SourceAdapter;\n rows: RecipeRow[];\n /** Whether rows naming the dish were put in front of rows that do not. */\n preferredByName: boolean;\n cached: boolean;\n reportedTotal: number | null;\n reportedTotalMeans: string | null;\n skipped: number;\n error: { code: string; message: string; hint?: string } | null;\n wordings: WordingAttempt[];\n}\n\nfunction reportOf(attempt: Attempt, count: number): SourceReport {\n return {\n source: attempt.source.id,\n name: attempt.source.name,\n status: attempt.error ? \"failed\" : \"answered\",\n count,\n reportedTotal: attempt.reportedTotal,\n reportedTotalMeans: attempt.reportedTotalMeans,\n skipped: attempt.skipped,\n mixesReferencePages: attempt.source.mixesReferencePages,\n cached: attempt.cached,\n error: attempt.error,\n wordings: attempt.wordings,\n preferredByName: attempt.preferredByName,\n };\n}\n\n/** A wording that was never sent, and the reason it was not. */\nfunction heldBack(wording: Wording, because: string): WordingAttempt {\n return {\n query: wording.query,\n derivation: wording.derivation,\n ran: false,\n count: null,\n added: null,\n notRunBecause: because,\n error: null,\n };\n}\n\n/**\n * Keep the rows a wording brought back that no earlier wording already did, and\n * count how many of them name the dish that was asked for.\n *\n * The identifier names its source, so two sources minting the same reference\n * stay two rows while one recipe reached twice stays one.\n */\nfunction keepNewRows<T extends { id: string; title: string }>(\n read: { rows: readonly T[] },\n seen: Set<string>,\n rows: T[],\n question: string,\n): { added: number; onTopic: number } {\n let added = 0;\n let onTopic = 0;\n\n for (const row of read.rows) {\n if (seen.has(row.id)) {\n continue;\n }\n seen.add(row.id);\n rows.push(row);\n added += 1;\n if (namesDish(row.title, question)) {\n onTopic += 1;\n }\n }\n\n return { added, onTopic };\n}\n\n/**\n * Interleave rows so no source opens the list twice before another has opened\n * it once.\n *\n * Ranking them against each other would need a score every source carries, and\n * there is none: one has reader ratings, another has no author and no rating by\n * nature, and a single ordering would rank on a field most rows cannot have.\n * Taking one from each in turn is an order a caller can describe exactly, which\n * is what the answer does.\n */\nexport function interleave(groups: RecipeRow[][]): RecipeRow[] {\n const merged: RecipeRow[] = [];\n const longest = Math.max(0, ...groups.map((group) => group.length));\n for (let index = 0; index < longest; index += 1) {\n for (const group of groups) {\n const row = group[index];\n if (row) {\n merged.push(row);\n }\n }\n }\n return merged;\n}\n\nexport class RecipesClient {\n private readonly config: Config;\n private readonly logger: Logger;\n private readonly sources: SourceAdapter[];\n\n constructor(options: RecipesClientOptions = {}) {\n this.config = withGuarantees({ ...loadConfig(), ...options.config });\n this.logger = options.logger ?? createLogger(this.config.logLevel);\n this.sources = options.sources ?? buildSources(this.config, options.readers ?? {}, this.logger);\n }\n\n /** What every source sees this client call itself. */\n get userAgent(): string {\n return this.config.userAgent;\n }\n\n get intervalMs(): number {\n return this.config.minIntervalMs;\n }\n\n get timeoutMs(): number {\n return this.config.timeoutMs;\n }\n\n get maxRetries(): number {\n return this.config.maxRetries;\n }\n\n /** The sources this server reads, in the order an answer takes them. */\n get profiles(): Array<{ id: SourceId; name: string; language: string; homeUrl: string }> {\n return this.sources.map((source) => ({\n id: source.id,\n name: source.name,\n language: source.language,\n homeUrl: source.homeUrl,\n }));\n }\n\n /**\n * The backstop deadline over one source, covering every attempt it is\n * entitled to make and the pacing between them.\n */\n private get deadlineMs(): number {\n const attempts = this.config.maxRetries + 1;\n return this.config.timeoutMs * attempts + this.config.minIntervalMs * this.config.maxRetries;\n }\n\n /**\n * Ask every source the same question and merge the rows.\n *\n * The calls go out together, so the answer takes as long as the slowest\n * source rather than as long as all of them. Whichever of them fail, the rest\n * still come back, and the report says which was which.\n */\n async searchRecipes(\n query: string,\n limitPerSource: number,\n wanted?: readonly SourceId[],\n options: SearchOptions = {},\n ): Promise<MergedSearch> {\n const trimmed = query.trim();\n if (trimmed === \"\") {\n throw invalidInput(\"A search needs something to look for.\", \"Name a dish or an ingredient.\");\n }\n // A recipe index handed nothing it can match on answers with the page it\n // shows by default, and those rows would come back as this question's\n // recipes. One character is a word in the scripts that write one that way,\n // so what is asked for is a letter or a digit rather than a length.\n if (!LETTER_OR_DIGIT.test(trimmed)) {\n throw invalidInput(\n `\"${trimmed}\" carries no letter and no digit, so there is no word to search for.`,\n \"Name a dish or an ingredient.\",\n );\n }\n\n const chosen = selectSources(this.sources, wanted);\n const limit = Math.max(1, Math.min(MAX_LIMIT_PER_SOURCE, Math.trunc(limitPerSource)));\n const fanOut = options.fanOut ?? true;\n\n const attempts = await Promise.all(\n chosen.map((source) => this.searchOne(source, trimmed, limit, fanOut)),\n );\n\n const answered = attempts.map((attempt) => ({ attempt, rows: attempt.rows.slice(0, limit) }));\n return {\n rows: interleave(answered.map((one) => one.rows)),\n reports: answered.map(({ attempt, rows }) => reportOf(attempt, rows.length)),\n };\n }\n\n /**\n * Ask one source the question in several wordings, and union what comes back.\n *\n * The wordings go out one after another rather than together, because each\n * source is paced and a burst would spend the whole ladder's politeness at\n * once. They stop early: a wording is only sent when the ones before it left\n * the source short of what was asked for, so the ordinary case where the\n * question names a dish costs one request.\n *\n * Each wording is a different search rather than a further page of the same\n * one. One of these sites serves a single page of results and disallows\n * paging past it, so a second page is never asked for, here or anywhere else.\n */\n private async searchOne(\n source: SourceAdapter,\n question: string,\n limit: number,\n fanOut: boolean,\n ): Promise<Attempt> {\n const derived = deriveWordings(question);\n const wordings: WordingAttempt[] = [];\n const rows: RecipeRow[] = [];\n const seen = new Set<string>();\n\n let cached = false;\n let skipped = 0;\n let reportedTotal: number | null = null;\n let reportedTotalMeans: string | null = null;\n let answered = false;\n let failure: { code: string; message: string; hint?: string } | null = null;\n /**\n * Rows whose title carries a word naming the dish.\n *\n * This is what \"enough\" counts, rather than rows. A source that ranks on\n * the words it was handed fills a page with recipes sharing a question's\n * framing words, and stopping on the size of that page would stop on the\n * evidence that the dish has not been found. Every row is kept either way.\n */\n let onTopic = 0;\n\n for (const [index, wording] of derived.entries()) {\n if (index > 0 && !fanOut) {\n wordings.push(\n heldBack(wording, \"fan_out was turned off, so only the words as asked were sent\"),\n );\n continue;\n }\n if (index >= MAX_WORDINGS_PER_SOURCE) {\n wordings.push(\n heldBack(\n wording,\n `the ceiling of ${MAX_WORDINGS_PER_SOURCE} searches of one source was reached`,\n ),\n );\n continue;\n }\n if (failure !== null) {\n wordings.push(\n heldBack(wording, `${source.name} did not answer the wording before this one`),\n );\n continue;\n }\n if (onTopic >= limit) {\n wordings.push(\n heldBack(\n wording,\n \"the wordings already sent returned as many rows naming the dish as were asked for\",\n ),\n );\n continue;\n }\n\n try {\n const read = await withDeadline(\n source.search(wording.query, limit),\n this.deadlineMs,\n source,\n );\n answered = true;\n cached = cached || read.cached;\n skipped += read.skipped;\n if (reportedTotal === null && reportedTotalMeans === null) {\n reportedTotal = read.reportedTotal;\n reportedTotalMeans = read.reportedTotalMeans;\n }\n if (read.skipped > 0) {\n this.logger.warn(\n `${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`,\n );\n }\n\n const kept = keepNewRows(read, seen, rows, question);\n const added = kept.added;\n onTopic += kept.onTopic;\n\n wordings.push({\n query: wording.query,\n derivation: wording.derivation,\n ran: true,\n count: read.rows.length,\n added,\n notRunBecause: null,\n error: null,\n });\n } catch (error) {\n const known = fromSource(error, source.name);\n this.logger.warn(\n `${source.name} did not answer \"${wording.query}\": [${known.code}] ${known.message}`,\n );\n const reason = {\n code: known.code,\n message: known.message,\n ...(known.details.hint ? { hint: known.details.hint } : {}),\n };\n wordings.push({\n query: wording.query,\n derivation: wording.derivation,\n ran: true,\n count: null,\n added: null,\n notRunBecause: null,\n error: reason,\n });\n failure = reason;\n }\n }\n\n // Where several wordings contributed, the rows naming the dish are put in\n // front of the rows that do not, each group keeping the order it arrived\n // in. Without this, a first wording answering with a page of near-misses\n // fills the limit and cuts away the rows a later wording found. A single\n // wording is left in the order its source returned it, since there is\n // nothing to rescue it from.\n const contributing = wordings.filter((attempt) => (attempt.added ?? 0) > 0).length;\n const ordered =\n contributing > 1\n ? [\n ...rows.filter((row) => namesDish(row.title, question)),\n ...rows.filter((row) => !namesDish(row.title, question)),\n ]\n : rows;\n\n // A source that answered one wording answered. Reporting it as failed\n // because a later wording timed out would hide the rows it did return\n // behind a claim that it said nothing.\n return {\n source,\n rows: ordered,\n preferredByName: contributing > 1,\n cached,\n reportedTotal,\n reportedTotalMeans,\n skipped,\n error: answered ? null : failure,\n wordings,\n };\n }\n\n /**\n * Read one recipe, from whichever source the identifier names.\n *\n * Only that source is called. Trying another after a miss would turn \"this\n * source has no page by this name\" into somebody else's recipe with a\n * different title, which is a worse answer than the absence it replaced.\n */\n async getRecipe(\n id: string,\n ): Promise<{ recipe: RecipeDetail; cached: boolean; read: ResolvedId }> {\n const read = resolveId(id, this.sources);\n try {\n const outcome = await withDeadline(\n read.source.getRecipe(read.reference),\n this.deadlineMs,\n read.source,\n );\n return { recipe: outcome.recipe, cached: outcome.cached, read };\n } catch (error) {\n throw fromSource(error, read.source.name);\n }\n }\n}\n"],"mappings":"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ACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,IAAM,gBAAgB,oBAAI,IAAI;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAUD,IAAM,kBAAkB,oBAAI,IAAI;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAiBD,IAAM,WAAW,oBAAI,IAAI;AAAA,EACvB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAUD,IAAM,iBAAiB,oBAAI,IAAI;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AASD,IAAM,QAAQ,oBAAI,IAAI;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAQD,IAAM,iBAAiB,oBAAI,IAAI;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAUD,IAAM,oBAAoB,oBAAI,IAAI;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAUD,IAAM,eAAe,oBAAI,IAAI,CAAC,KAAK,MAAM,OAAO,MAAM,IAAI,CAAC;AAU3D,IAAM,kBAAkB;AAgDxB,IAAM,UAAU;AAUhB,SAAS,SAAS,MAAsB;AACtC,SAAO,KACJ,YAAY,EACZ,UAAU,KAAK,EACf,QAAQ,WAAC,WAAO,IAAE,GAAE,EAAE,EACtB,QAAQ,OAAO,IAAI;AACxB;AASA,SAAS,aAAa,MAAc,KAAsB;AACxD,MAAI,aAAa,IAAI,GAAG,KAAK,cAAc,IAAI,GAAG,GAAG;AACnD,WAAO;AAAA,EACT;AACA,MAAI,aAAa,IAAI,IAAI,KAAK,cAAc,IAAI,IAAI,GAAG;AACrD,WAAO;AAAA,EACT;AACA,MAAI,YAAY,KAAK,IAAI,GAAG;AAC1B,WAAO;AAAA,EACT;AACA,MAAI,KAAK,SAAS,GAAG;AACnB,WAAO;AAAA,EACT;AACA,SAAO,CAAC,gBAAgB,IAAI,IAAI;AAClC;AAWA,SAAS,aAAa,UAAgC;AACpD,QAAM,QAAQ,SAAS,QAAQ;AAC/B,QAAM,aAA0B,CAAC;AACjC,QAAM,OAAiB,CAAC;AAExB,QAAM,QAAQ,CAAC,OAAsB,SAA8B;AACjE,UAAM,UAAU,UAAU,OAAO,OAAO,MAAM,KAAK;AACnD,QAAI,YAAY,IAAI;AAClB;AAAA,IACF;AACA,QAAI,WAAW,KAAK,CAAC,SAAS,KAAK,UAAU,WAAW,KAAK,SAAS,IAAI,GAAG;AAC3E;AAAA,IACF;AACA,eAAW,KAAK,EAAE,OAAO,SAAS,KAAK,CAAC;AAAA,EAC1C;AAEA,WAAS,QAAQ,GAAG,QAAQ,MAAM,QAAQ,SAAS,GAAG;AACpD,UAAM,OAAO,MAAM,KAAK,KAAK;AAC7B,UAAM,MAAM,SAAS,IAAI;AAIzB,UAAM,SAAS,KAAK,QAAQ,GAAG;AAC/B,QAAI,SAAS,KAAK,SAAS,IAAI,SAAS,KAAK,MAAM,GAAG,MAAM,CAAC,CAAC,GAAG;AAC/D,YAAM,KAAK,MAAM,SAAS,CAAC,GAAG,UAAU;AACxC;AAAA,IACF;AAGA,UAAM,OAAO,QAAQ,KAAK,IAAI;AAC9B,QAAI,MAAM;AACR,YAAM,KAAK,CAAC,KAAK,IAAI,UAAU;AAC/B;AAAA,IACF;AAIA,QAAI,QAAQ,QAAQ;AAClB,YAAM,QAAQ,QAAQ,OAAO,OAAO,IAAI;AACxC,UAAI,MAAM,UAAU,MAAM;AACxB,cAAM,MAAM,OAAO,UAAU;AAC7B,gBAAQ,MAAM,OAAO;AAAA,MACvB;AACA;AAAA,IACF;AAIA,QAAI,SAAS,IAAI,GAAG,GAAG;AACrB,YAAM,UAAU,SAAS,OAAO,QAAQ,CAAC;AACzC,YAAM,QAAQ,UAAU,KAAK,OAAO,QAAQ,OAAO,UAAU;AAG7D,cAAQ,QAAQ,OAAO;AACvB;AAAA,IACF;AAEA,QAAI,eAAe,IAAI,GAAG,GAAG;AAC3B,YAAM,QAAQ,QAAQ,OAAO,OAAO,IAAI;AACxC,YAAM,MAAM,OAAO,SAAS;AAC5B,cAAQ,MAAM,OAAO;AACrB;AAAA,IACF;AAEA,QAAI,MAAM,IAAI,GAAG,GAAG;AAClB,YAAM,MAAM,MAAM;AAClB;AAAA,IACF;AAEA,QAAI,aAAa,MAAM,GAAG,KAAK,CAAC,KAAK,SAAS,IAAI,GAAG;AACnD,WAAK,KAAK,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,SAAO,EAAE,MAAM,WAAW;AAC5B;AAoBA,SAAS,QACP,OACA,QACA,MACwC;AACxC,MAAI,kBAAkB,IAAI,SAAS,MAAM,SAAS,CAAC,KAAK,EAAE,CAAC,GAAG;AAC5D,UAAM,UAAU,SAAS,OAAO,SAAS,CAAC;AAC1C,WAAO,EAAE,OAAO,QAAQ,UAAU,KAAK,OAAO,QAAQ,OAAO,MAAM,QAAQ,KAAK;AAAA,EAClF;AAEA,QAAM,SAAS,MAAM,SAAS,CAAC;AAC/B,MAAI,WAAW,QAAW;AACxB,UAAM,OAAO,KAAK,YAAY,MAAM;AACpC,QAAI,SAAS,IAAI;AACf,WAAK,OAAO,MAAM,CAAC;AACnB,aAAO,EAAE,OAAO,QAAQ,MAAM,SAAS,EAAE;AAAA,IAC3C;AAAA,EACF;AAEA,SAAO,EAAE,OAAO,MAAM,MAAM,SAAS,EAAE;AACzC;AAUA,SAAS,SAAS,OAAiB,MAA+C;AAChF,MAAI,QAAQ;AAGZ,SAAO,QAAQ,MAAM,UAAU,SAAS,MAAM,KAAK,KAAK,EAAE,GAAG;AAC3D,aAAS;AAAA,EACX;AAEA,QAAM,QAAkB,CAAC;AACzB,SAAO,QAAQ,MAAM,UAAU,MAAM,SAAS,iBAAiB;AAC7D,UAAM,OAAO,MAAM,KAAK,KAAK;AAC7B,QAAI,CAAC,UAAU,IAAI,GAAG;AACpB;AAAA,IACF;AACA,UAAM,KAAK,IAAI;AACf,aAAS;AAET,UAAM,SAAS,MAAM,QAAQ,CAAC;AAC9B,QACE,MAAM,SAAS,KAAK,mBACpB,aAAa,IAAI,SAAS,MAAM,KAAK,KAAK,EAAE,CAAC,KAC7C,WAAW,UACX,UAAU,MAAM,GAChB;AACA,YAAM,KAAK,MAAM,KAAK,KAAK,EAAE;AAC7B,eAAS;AACT;AAAA,IACF;AACA;AAAA,EACF;AAEA,SAAO,EAAE,OAAO,MAAM,KAAK,GAAG,GAAG,MAAM,MAAM;AAC/C;AAGA,SAAS,SAAS,MAAuB;AACvC,QAAM,MAAM,SAAS,IAAI;AACzB,SAAO,kBAAkB,IAAI,GAAG,KAAK,aAAa,IAAI,GAAG,KAAK,cAAc,IAAI,GAAG;AACrF;AAGA,SAAS,UAAU,MAAuB;AACxC,QAAM,MAAM,SAAS,IAAI;AACzB,MAAI,IAAI,SAAS,GAAG;AAClB,WAAO;AAAA,EACT;AACA,MAAI,YAAY,KAAK,GAAG,GAAG;AACzB,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,QAAQ;AAClB,WAAO;AAAA,EACT;AACA,MAAI,eAAe,IAAI,GAAG,GAAG;AAC3B,WAAO;AAAA,EACT;AACA,MAAI,SAAS,IAAI,GAAG,KAAK,eAAe,IAAI,GAAG,GAAG;AAChD,WAAO;AAAA,EACT;AACA,MAAI,gBAAgB,IAAI,GAAG,GAAG;AAC5B,WAAO;AAAA,EACT;AACA,SAAO,EAAE,aAAa,IAAI,GAAG,KAAK,cAAc,IAAI,GAAG;AACzD;AAgCO,SAAS,eAAe,UAA6B;AAC1D,QAAM,QAAQ,KAAK,QAAQ;AAC3B,QAAM,WAAsB,CAAC;AAC7B,QAAM,OAAO,oBAAI,IAAY;AAE7B,QAAM,QAAQ,CAAC,WAAmB,eAA6B;AAC7D,UAAM,QAAQ,KAAK,SAAS;AAC5B,QAAI,UAAU,IAAI;AAChB;AAAA,IACF;AAGA,UAAM,MAAM,MAAM,YAAY;AAC9B,QAAI,KAAK,IAAI,GAAG,GAAG;AACjB;AAAA,IACF;AACA,SAAK,IAAI,GAAG;AACZ,aAAS,KAAK,EAAE,OAAO,OAAO,WAAW,CAAC;AAAA,EAC5C;AAEA,QAAM,OAAO,8CAA8C;AAE3D,QAAM,OAAO,UAAU,KAAK;AAC5B;AAAA,IACE,KAAK,KAAK,GAAG;AAAA,IACb;AAAA,EACF;AAEA;AAAA,IACE,KAAK,CAAC,KAAK;AAAA,IACX;AAAA,EACF;AAIA,SAAO,SAAS,MAAM,GAAG,uBAAuB;AAClD;AAeO,SAAS,UAAU,OAAe,UAA2B;AAClE,QAAM,QAAQ,YAAY,QAAQ;AAClC,MAAI,MAAM,WAAW,GAAG;AACtB,WAAO;AAAA,EACT;AACA,SAAO,iBAAiB,OAAO,QAAQ,EAAE,SAAS,MAAM;AAC1D;AAaO,SAAS,iBAAiB,OAAe,UAA4B;AAC1E,QAAM,WAAW,IAAI,KAAK,KAAK,CAAC;AAChC,SAAO,YAAY,QAAQ,EAAE,OAAO,CAAC,SAAS,CAAC,SAAS,SAAS,IAAI,KAAK,IAAI,CAAC,EAAE,CAAC;AACpF;AAGA,SAAS,YAAY,UAA4B;AAC/C,SAAO,UAAU,QAAQ,EAAE,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC;AAC7D;AAEA,SAAS,KAAK,OAAuB;AACnC,SAAO,MACJ,UAAU,KAAK,EACf,QAAQ,WAAC,WAAO,IAAE,GAAE,EAAE,EACtB,QAAQ,OAAO,IAAI,EACnB,YAAY,EACZ,QAAQ,eAAe,GAAG;AAC/B;AAcA,SAAS,UAAU,UAA4B;AAC7C,SAAO,aAAa,QAAQ,EAAE;AAChC;AASA,SAAS,SAAS,OAAyB;AACzC,SAAO,MACJ,YAAY,EACZ,QAAQ,UAAU,GAAG,EACrB,MAAM,eAAe,EACrB,IAAI,CAAC,SAAS,KAAK,QAAQ,YAAY,EAAE,CAAC,EAC1C,OAAO,CAAC,SAAS,SAAS,EAAE;AACjC;AAEA,SAAS,KAAK,OAAuB;AACnC,SAAO,MAAM,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACzC;;;ACpzBA,IAAM,kBAAkB;AAYjB,IAAM,uBAAuB;AA0BpC,SAAS,eAAe,QAAwB;AAC9C,QAAM,WAAW,WAAW,CAAC,CAAC;AAE9B,QAAM,UAAU,CAAC,OAAgB,UAAkB,KAAa,QAAwB;AACtF,UAAM,SAAS,OAAO,KAAK;AAC3B,QAAI,CAAC,OAAO,SAAS,MAAM,GAAG;AAC5B,aAAO;AAAA,IACT;AACA,WAAO,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,MAAM,CAAC;AAAA,EAC5C;AAEA,QAAM,UAAU,OAAO,OAAO,cAAc,WAAW,OAAO,UAAU,KAAK,IAAI;AACjF,QAAM,aAAa,SAAS;AAE5B,SAAO;AAAA,IACL,GAAG;AAAA,IACH,WACE,YAAY,MAAM,QAAQ,SAAS,UAAU,IAAI,aAAa,GAAG,OAAO,IAAI,UAAU;AAAA,IACxF,eAAe;AAAA,MACb,OAAO;AAAA,MACP,SAAS;AAAA,MACT;AAAA,MACA;AAAA,IACF;AAAA,IACA,WAAW,QAAQ,OAAO,WAAW,SAAS,WAAW,KAAM,IAAO;AAAA,IACtE,YAAY,QAAQ,OAAO,YAAY,SAAS,YAAY,GAAG,CAAC;AAAA,IAChE,YAAY,QAAQ,OAAO,YAAY,SAAS,YAAY,GAAG,KAAU;AAAA,IACzE,iBAAiB,QAAQ,OAAO,iBAAiB,SAAS,iBAAiB,GAAG,GAAI;AAAA,IAClF,UAAU,OAAO,YAAY,SAAS;AAAA,EACxC;AACF;AAeA,SAAS,aAAgB,MAAkB,IAAY,QAAmC;AACxF,MAAI;AACJ,QAAM,QAAQ,IAAI,QAAe,CAAC,UAAU,WAAW;AACrD,YAAQ;AAAA,MACN,MACE;AAAA,QACE,QAAS,GAAG,OAAO,IAAI,0BAA0B,EAAE,qCAAqC;AAAA,MAC1F;AAAA,MACF;AAAA,IACF;AAEA,UAAM,QAAQ;AAAA,EAChB,CAAC;AACD,SAAO,QAAQ,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,QAAQ,MAAM,aAAa,KAAK,CAAC;AACtE;AAgBA,SAAS,SAAS,SAAkBC,QAA6B;AAC/D,SAAO;AAAA,IACL,QAAQ,QAAQ,OAAO;AAAA,IACvB,MAAM,QAAQ,OAAO;AAAA,IACrB,QAAQ,QAAQ,QAAQ,WAAW;AAAA,IACnC,OAAAA;AAAA,IACA,eAAe,QAAQ;AAAA,IACvB,oBAAoB,QAAQ;AAAA,IAC5B,SAAS,QAAQ;AAAA,IACjB,qBAAqB,QAAQ,OAAO;AAAA,IACpC,QAAQ,QAAQ;AAAA,IAChB,OAAO,QAAQ;AAAA,IACf,UAAU,QAAQ;AAAA,IAClB,iBAAiB,QAAQ;AAAA,EAC3B;AACF;AAGA,SAAS,SAAS,SAAkB,SAAiC;AACnE,SAAO;AAAA,IACL,OAAO,QAAQ;AAAA,IACf,YAAY,QAAQ;AAAA,IACpB,KAAK;AAAA,IACL,OAAO;AAAA,IACP,OAAO;AAAA,IACP,eAAe;AAAA,IACf,OAAO;AAAA,EACT;AACF;AASA,SAAS,YACP,MACA,MACA,MACA,UACoC;AACpC,MAAI,QAAQ;AACZ,MAAI,UAAU;AAEd,aAAW,OAAO,KAAK,MAAM;AAC3B,QAAI,KAAK,IAAI,IAAI,EAAE,GAAG;AACpB;AAAA,IACF;AACA,SAAK,IAAI,IAAI,EAAE;AACf,SAAK,KAAK,GAAG;AACb,aAAS;AACT,QAAI,UAAU,IAAI,OAAO,QAAQ,GAAG;AAClC,iBAAW;AAAA,IACb;AAAA,EACF;AAEA,SAAO,EAAE,OAAO,QAAQ;AAC1B;AAYO,SAAS,WAAW,QAAoC;AAC7D,QAAM,SAAsB,CAAC;AAC7B,QAAM,UAAU,KAAK,IAAI,GAAG,GAAG,OAAO,IAAI,CAAC,UAAU,MAAM,MAAM,CAAC;AAClE,WAAS,QAAQ,GAAG,QAAQ,SAAS,SAAS,GAAG;AAC/C,eAAW,SAAS,QAAQ;AAC1B,YAAM,MAAM,MAAM,KAAK;AACvB,UAAI,KAAK;AACP,eAAO,KAAK,GAAG;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAEO,IAAM,gBAAN,MAAoB;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,UAAgC,CAAC,GAAG;AAC9C,SAAK,SAAS,eAAe,EAAE,GAAG,WAAW,GAAG,GAAG,QAAQ,OAAO,CAAC;AACnE,SAAK,SAAS,QAAQ,UAAU,aAAa,KAAK,OAAO,QAAQ;AACjE,SAAK,UAAU,QAAQ,WAAW,aAAa,KAAK,QAAQ,QAAQ,WAAW,CAAC,GAAG,KAAK,MAAM;AAAA,EAChG;AAAA;AAAA,EAGA,IAAI,YAAoB;AACtB,WAAO,KAAK,OAAO;AAAA,EACrB;AAAA,EAEA,IAAI,aAAqB;AACvB,WAAO,KAAK,OAAO;AAAA,EACrB;AAAA,EAEA,IAAI,YAAoB;AACtB,WAAO,KAAK,OAAO;AAAA,EACrB;AAAA,EAEA,IAAI,aAAqB;AACvB,WAAO,KAAK,OAAO;AAAA,EACrB;AAAA;AAAA,EAGA,IAAI,WAAqF;AACvF,WAAO,KAAK,QAAQ,IAAI,CAAC,YAAY;AAAA,MACnC,IAAI,OAAO;AAAA,MACX,MAAM,OAAO;AAAA,MACb,UAAU,OAAO;AAAA,MACjB,SAAS,OAAO;AAAA,IAClB,EAAE;AAAA,EACJ;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,IAAY,aAAqB;AAC/B,UAAM,WAAW,KAAK,OAAO,aAAa;AAC1C,WAAO,KAAK,OAAO,YAAY,WAAW,KAAK,OAAO,gBAAgB,KAAK,OAAO;AAAA,EACpF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,cACJ,OACA,gBACA,QACA,UAAyB,CAAC,GACH;AACvB,UAAM,UAAU,MAAM,KAAK;AAC3B,QAAI,YAAY,IAAI;AAClB,YAAM,aAAa,yCAAyC,+BAA+B;AAAA,IAC7F;AAKA,QAAI,CAAC,gBAAgB,KAAK,OAAO,GAAG;AAClC,YAAM;AAAA,QACJ,IAAI,OAAO;AAAA,QACX;AAAA,MACF;AAAA,IACF;AAEA,UAAM,SAAS,cAAc,KAAK,SAAS,MAAM;AACjD,UAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,IAAI,sBAAsB,KAAK,MAAM,cAAc,CAAC,CAAC;AACpF,UAAM,SAAS,QAAQ,UAAU;AAEjC,UAAM,WAAW,MAAM,QAAQ;AAAA,MAC7B,OAAO,IAAI,CAAC,WAAW,KAAK,UAAU,QAAQ,SAAS,OAAO,MAAM,CAAC;AAAA,IACvE;AAEA,UAAM,WAAW,SAAS,IAAI,CAAC,aAAa,EAAE,SAAS,MAAM,QAAQ,KAAK,MAAM,GAAG,KAAK,EAAE,EAAE;AAC5F,WAAO;AAAA,MACL,MAAM,WAAW,SAAS,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC;AAAA,MAChD,SAAS,SAAS,IAAI,CAAC,EAAE,SAAS,KAAK,MAAM,SAAS,SAAS,KAAK,MAAM,CAAC;AAAA,IAC7E;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,MAAc,UACZ,QACA,UACA,OACA,QACkB;AAClB,UAAM,UAAU,eAAe,QAAQ;AACvC,UAAM,WAA6B,CAAC;AACpC,UAAM,OAAoB,CAAC;AAC3B,UAAM,OAAO,oBAAI,IAAY;AAE7B,QAAI,SAAS;AACb,QAAI,UAAU;AACd,QAAI,gBAA+B;AACnC,QAAI,qBAAoC;AACxC,QAAI,WAAW;AACf,QAAI,UAAmE;AASvE,QAAI,UAAU;AAEd,eAAW,CAAC,OAAO,OAAO,KAAK,QAAQ,QAAQ,GAAG;AAChD,UAAI,QAAQ,KAAK,CAAC,QAAQ;AACxB,iBAAS;AAAA,UACP,SAAS,SAAS,8DAA8D;AAAA,QAClF;AACA;AAAA,MACF;AACA,UAAI,SAAS,yBAAyB;AACpC,iBAAS;AAAA,UACP;AAAA,YACE;AAAA,YACA,kBAAkB,uBAAuB;AAAA,UAC3C;AAAA,QACF;AACA;AAAA,MACF;AACA,UAAI,YAAY,MAAM;AACpB,iBAAS;AAAA,UACP,SAAS,SAAS,GAAG,OAAO,IAAI,6CAA6C;AAAA,QAC/E;AACA;AAAA,MACF;AACA,UAAI,WAAW,OAAO;AACpB,iBAAS;AAAA,UACP;AAAA,YACE;AAAA,YACA;AAAA,UACF;AAAA,QACF;AACA;AAAA,MACF;AAEA,UAAI;AACF,cAAM,OAAO,MAAM;AAAA,UACjB,OAAO,OAAO,QAAQ,OAAO,KAAK;AAAA,UAClC,KAAK;AAAA,UACL;AAAA,QACF;AACA,mBAAW;AACX,iBAAS,UAAU,KAAK;AACxB,mBAAW,KAAK;AAChB,YAAI,kBAAkB,QAAQ,uBAAuB,MAAM;AACzD,0BAAgB,KAAK;AACrB,+BAAqB,KAAK;AAAA,QAC5B;AACA,YAAI,KAAK,UAAU,GAAG;AACpB,eAAK,OAAO;AAAA,YACV,GAAG,OAAO,IAAI,SAAS,KAAK,OAAO;AAAA,UACrC;AAAA,QACF;AAEA,cAAM,OAAO,YAAY,MAAM,MAAM,MAAM,QAAQ;AACnD,cAAM,QAAQ,KAAK;AACnB,mBAAW,KAAK;AAEhB,iBAAS,KAAK;AAAA,UACZ,OAAO,QAAQ;AAAA,UACf,YAAY,QAAQ;AAAA,UACpB,KAAK;AAAA,UACL,OAAO,KAAK,KAAK;AAAA,UACjB;AAAA,UACA,eAAe;AAAA,UACf,OAAO;AAAA,QACT,CAAC;AAAA,MACH,SAAS,OAAO;AACd,cAAM,QAAQ,WAAW,OAAO,OAAO,IAAI;AAC3C,aAAK,OAAO;AAAA,UACV,GAAG,OAAO,IAAI,oBAAoB,QAAQ,KAAK,OAAO,MAAM,IAAI,KAAK,MAAM,OAAO;AAAA,QACpF;AACA,cAAM,SAAS;AAAA,UACb,MAAM,MAAM;AAAA,UACZ,SAAS,MAAM;AAAA,UACf,GAAI,MAAM,QAAQ,OAAO,EAAE,MAAM,MAAM,QAAQ,KAAK,IAAI,CAAC;AAAA,QAC3D;AACA,iBAAS,KAAK;AAAA,UACZ,OAAO,QAAQ;AAAA,UACf,YAAY,QAAQ;AAAA,UACpB,KAAK;AAAA,UACL,OAAO;AAAA,UACP,OAAO;AAAA,UACP,eAAe;AAAA,UACf,OAAO;AAAA,QACT,CAAC;AACD,kBAAU;AAAA,MACZ;AAAA,IACF;AAQA,UAAM,eAAe,SAAS,OAAO,CAAC,aAAa,QAAQ,SAAS,KAAK,CAAC,EAAE;AAC5E,UAAM,UACJ,eAAe,IACX;AAAA,MACE,GAAG,KAAK,OAAO,CAAC,QAAQ,UAAU,IAAI,OAAO,QAAQ,CAAC;AAAA,MACtD,GAAG,KAAK,OAAO,CAAC,QAAQ,CAAC,UAAU,IAAI,OAAO,QAAQ,CAAC;AAAA,IACzD,IACA;AAKN,WAAO;AAAA,MACL;AAAA,MACA,MAAM;AAAA,MACN,iBAAiB,eAAe;AAAA,MAChC;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,WAAW,OAAO;AAAA,MACzB;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,MAAM,UACJ,IACsE;AACtE,UAAM,OAAO,UAAU,IAAI,KAAK,OAAO;AACvC,QAAI;AACF,YAAM,UAAU,MAAM;AAAA,QACpB,KAAK,OAAO,UAAU,KAAK,SAAS;AAAA,QACpC,KAAK;AAAA,QACL,KAAK;AAAA,MACP;AACA,aAAO,EAAE,QAAQ,QAAQ,QAAQ,QAAQ,QAAQ,QAAQ,KAAK;AAAA,IAChE,SAAS,OAAO;AACd,YAAM,WAAW,OAAO,KAAK,OAAO,IAAI;AAAA,IAC1C;AAAA,EACF;AACF;","names":["SITE_URL","path","SITE_URL","REFERENCE","SITE_URL","REFERENCE","DOT_SEGMENT","LEADING_SLASH","asReference","meansFor","path","REFERENCE","count"]}