mcp-recipes 1.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.
package/dist/index.js ADDED
@@ -0,0 +1,3037 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/server.ts
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+
9
+ // src/version.ts
10
+ var PKG_VERSION = "1.0.0";
11
+ var REPO_URL = "https://github.com/smeet666/mcp-recipes";
12
+ var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
13
+
14
+ // src/config.ts
15
+ var LOG_LEVELS = ["silent", "error", "info", "debug"];
16
+ var DEFAULT_INTERVAL_MS = 1e3;
17
+ var MIN_ALLOWED_INTERVAL_MS = 500;
18
+ var MAX_ALLOWED_INTERVAL_MS = 6e4;
19
+ var DEFAULT_USER_AGENT = `mcp-recipes/${PKG_VERSION} (+${REPO_URL})`;
20
+ function createLogger(level) {
21
+ const rank = LOG_LEVELS.indexOf(level);
22
+ const write = (at, message) => {
23
+ if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) return;
24
+ process.stderr.write(`[mcp-recipes] ${at}: ${message}
25
+ `);
26
+ };
27
+ return {
28
+ debug: (m) => write("debug", m),
29
+ info: (m) => write("info", m),
30
+ // A warning goes out at the error level so it survives the default
31
+ // setting: a caller has to know that a site was left out of an answer.
32
+ warn: (m) => write("error", m),
33
+ error: (m) => write("error", m)
34
+ };
35
+ }
36
+ function readInteger(env, name, fallback, min, max) {
37
+ const raw = env[name];
38
+ if (raw === void 0 || raw.trim() === "") return fallback;
39
+ const value = Number(raw);
40
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
41
+ process.stderr.write(
42
+ `[mcp-recipes] error: ${name}="${raw}" is not a whole number; using ${fallback}.
43
+ `
44
+ );
45
+ return fallback;
46
+ }
47
+ if (value < min || value > max) {
48
+ process.stderr.write(
49
+ `[mcp-recipes] error: ${name}=${value} is outside ${min}..${max}; using ${fallback}.
50
+ `
51
+ );
52
+ return fallback;
53
+ }
54
+ return value;
55
+ }
56
+ function loadConfig(env = process.env) {
57
+ const level = env.RECIPES_LOG_LEVEL;
58
+ const logLevel = level && LOG_LEVELS.includes(level) ? level : "error";
59
+ if (level && !LOG_LEVELS.includes(level)) {
60
+ process.stderr.write(
61
+ `[mcp-recipes] error: RECIPES_LOG_LEVEL="${level}" is not one of ${LOG_LEVELS.join(", ")}; using error.
62
+ `
63
+ );
64
+ }
65
+ const custom = env.RECIPES_USER_AGENT?.trim();
66
+ return {
67
+ // A caller who wants to be recognised may say who they are, and the
68
+ // contact address stays attached: a site has to be able to reach a human
69
+ // about traffic it did not expect.
70
+ userAgent: custom ? `${custom} ${DEFAULT_USER_AGENT}` : DEFAULT_USER_AGENT,
71
+ minIntervalMs: readInteger(
72
+ env,
73
+ "RECIPES_MIN_INTERVAL_MS",
74
+ DEFAULT_INTERVAL_MS,
75
+ MIN_ALLOWED_INTERVAL_MS,
76
+ MAX_ALLOWED_INTERVAL_MS
77
+ ),
78
+ timeoutMs: readInteger(env, "RECIPES_TIMEOUT_MS", 2e4, 1e3, 12e4),
79
+ maxRetries: readInteger(env, "RECIPES_MAX_RETRIES", 3, 0, 8),
80
+ cacheTtlMs: readInteger(env, "RECIPES_CACHE_TTL_MS", 9e5, 0, 864e5),
81
+ cacheMaxEntries: readInteger(env, "RECIPES_CACHE_MAX_ENTRIES", 200, 1, 5e3),
82
+ logLevel
83
+ };
84
+ }
85
+
86
+ // src/errors.ts
87
+ var ERROR_CODES = [
88
+ "not_found",
89
+ "invalid_input",
90
+ "rate_limited",
91
+ "parse_failure",
92
+ "network_error",
93
+ "timeout"
94
+ ];
95
+ var RecipesError = class extends Error {
96
+ code;
97
+ details;
98
+ constructor(code, message, details = {}) {
99
+ super(message);
100
+ this.name = "RecipesError";
101
+ this.code = code;
102
+ this.details = details;
103
+ }
104
+ };
105
+ var invalidInput = (message, hint) => new RecipesError("invalid_input", message, hint ? { hint } : {});
106
+ var parseFailure = (message, details) => new RecipesError("parse_failure", message, {
107
+ hint: `A site may have changed how it answers. Please report this at ${ISSUES_URL} with the arguments you used.`,
108
+ ...details
109
+ });
110
+ var timeout = (message, details) => new RecipesError("timeout", message, details ?? {});
111
+ function toRecipesError(error) {
112
+ if (error instanceof RecipesError) return error;
113
+ const message = error instanceof Error ? error.message : String(error);
114
+ const raw = error;
115
+ const code = typeof raw?.code === "string" ? raw.code : "";
116
+ const known = ERROR_CODES.find((candidate) => candidate === code);
117
+ const details = raw && typeof raw.details === "object" && raw.details !== null ? raw.details : {};
118
+ return new RecipesError(known ?? "network_error", message, details);
119
+ }
120
+
121
+ // src/sources/adapter.ts
122
+ function text(value) {
123
+ return typeof value === "string" && value.trim() !== "" ? value : null;
124
+ }
125
+ function textList(value) {
126
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
127
+ }
128
+ function count(value) {
129
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
130
+ }
131
+ function required(value, field, source) {
132
+ const found = text(value);
133
+ if (found === null) {
134
+ throw parseFailure(
135
+ `${source.name} answered without a readable "${field}", so there is no recipe to return.`
136
+ );
137
+ }
138
+ return found;
139
+ }
140
+ function rowsOf(value, source) {
141
+ if (!Array.isArray(value)) {
142
+ throw parseFailure(
143
+ `${source.name} answered in a shape this server could not read: the list of results was missing.`
144
+ );
145
+ }
146
+ return value;
147
+ }
148
+ function namespacedId(source, reference) {
149
+ return `${source}:${reference}`;
150
+ }
151
+ function publishedFigures(panel) {
152
+ if (typeof panel !== "object" || panel === null) return null;
153
+ const kept = {};
154
+ for (const [key, value] of Object.entries(panel)) {
155
+ if (typeof value === "string" && value.trim() !== "") kept[key] = value.trim();
156
+ }
157
+ return Object.keys(kept).length > 0 ? kept : null;
158
+ }
159
+ function readYieldSpan(published) {
160
+ if (published === null) return { count: null, max: null, unit: null };
161
+ const span = /^\s*(\d+(?:[.,]\d+)?)\s*(?:à|a|to|-|–|—|ou|or)\s*(\d+(?:[.,]\d+)?)\s*(.*)$/i.exec(
162
+ published
163
+ );
164
+ if (span) {
165
+ const low = Number(span[1].replace(",", "."));
166
+ const high = Number(span[2].replace(",", "."));
167
+ if (Number.isFinite(low) && Number.isFinite(high) && high > low) {
168
+ return { count: low, max: high, unit: text(span[3]) };
169
+ }
170
+ }
171
+ const single = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/.exec(published);
172
+ if (single) {
173
+ const value = Number(single[1].replace(",", "."));
174
+ if (Number.isFinite(value)) return { count: value, max: null, unit: text(single[2]) };
175
+ }
176
+ return { count: null, max: null, unit: null };
177
+ }
178
+ function detailBase(source) {
179
+ return {
180
+ source: source.id,
181
+ sourceName: source.name,
182
+ language: source.language,
183
+ attribution: source.attribution
184
+ };
185
+ }
186
+
187
+ // src/sources/ids.ts
188
+ var SEPARATOR = ":";
189
+ function resolveId(rawId, sources) {
190
+ const trimmed = rawId.trim();
191
+ if (trimmed === "") {
192
+ throw invalidInput(
193
+ "A recipe identifier is required.",
194
+ `Use one of the ids search_recipes returned, such as ${example(sources)}.`
195
+ );
196
+ }
197
+ for (const source of sources) {
198
+ const prefix = `${source.id}${SEPARATOR}`;
199
+ if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) continue;
200
+ const reference = trimmed.slice(prefix.length).trim();
201
+ if (reference === "") {
202
+ throw invalidInput(
203
+ `"${trimmed}" names a source and no recipe.`,
204
+ "Put the source's own identifier after the colon."
205
+ );
206
+ }
207
+ return { source, reference, inferred: null };
208
+ }
209
+ const claimed = sources.map((source) => ({ source, claim: source.claims(trimmed) })).filter(
210
+ (entry) => Boolean(entry.claim)
211
+ );
212
+ if (claimed.length > 1) {
213
+ throw invalidInput(
214
+ `"${trimmed}" could be an identifier on ${names(claimed.map((entry) => entry.source))}, so it names no one recipe.`,
215
+ `Spell it with its source, as in ${claimed.map((entry) => `${entry.source.id}:${trimmed}`).join(" or ")}.`
216
+ );
217
+ }
218
+ const only = claimed[0];
219
+ if (!only) {
220
+ throw invalidInput(
221
+ `"${trimmed}" is not an identifier any of the sources this server reads would mint.`,
222
+ `Call search_recipes with "${trimmed}" as the query and pass an id from a row, which reads like ${example(sources)}.`
223
+ );
224
+ }
225
+ return {
226
+ source: only.source,
227
+ reference: only.claim.reference,
228
+ inferred: only.claim.guess ? `${only.claim.why}, which is a guess: check the recipe that comes back is the one you meant` : only.claim.why
229
+ };
230
+ }
231
+ function names(sources) {
232
+ const all = sources.map((source) => source.name);
233
+ if (all.length <= 1) return all.join("");
234
+ return `${all.slice(0, -1).join(", ")} and ${all[all.length - 1]}`;
235
+ }
236
+ function example(sources) {
237
+ const first = sources[0];
238
+ return first ? `${first.id}${SEPARATOR}<the source's own id>` : "<source>:<id>";
239
+ }
240
+
241
+ // src/sources/registry.ts
242
+ import { MarmitonClient } from "mcp-marmiton/client";
243
+ import { CookbookClient } from "mcp-wikibooks-cookbook/client";
244
+
245
+ // src/sources/cookbook.ts
246
+ var COOKBOOK_PROFILE = {
247
+ id: "cookbook",
248
+ name: "Wikibooks Cookbook",
249
+ homeUrl: "https://en.wikibooks.org/wiki/Cookbook:Table_of_Contents",
250
+ language: "en",
251
+ attribution: "Source: Wikibooks Cookbook",
252
+ // The namespace holds pages about salt and about a dish made with it alike.
253
+ mixesReferencePages: true
254
+ };
255
+ var SITE_URL = /^https?:\/\/en\.wikibooks\.org\/wiki\//i;
256
+ var NAMESPACE = /^cookbook\s*:/i;
257
+ function dishName(title) {
258
+ return title.replace(NAMESPACE, "").trim() || title;
259
+ }
260
+ function cookbookAdapter(reader) {
261
+ return {
262
+ ...COOKBOOK_PROFILE,
263
+ claims(raw) {
264
+ if (SITE_URL.test(raw)) {
265
+ let key;
266
+ try {
267
+ key = decodeURIComponent(raw.replace(SITE_URL, "")).replace(/_/g, " ");
268
+ } catch {
269
+ throw invalidInput(
270
+ `"${raw}" carries a percent sign that is not a valid escape, so the page name cannot be read.`,
271
+ "Pass the id search_recipes returned instead of the address."
272
+ );
273
+ }
274
+ return {
275
+ reference: key,
276
+ why: "the address is a page on the English Wikibooks",
277
+ guess: false
278
+ };
279
+ }
280
+ if (NAMESPACE.test(raw)) {
281
+ return {
282
+ reference: raw,
283
+ why: "the name opens with the namespace every Cookbook page lives under",
284
+ guess: false
285
+ };
286
+ }
287
+ return null;
288
+ },
289
+ async search(query, limit) {
290
+ const outcome = await reader.search(query, limit);
291
+ const list = rowsOf(
292
+ outcome.data?.results,
293
+ COOKBOOK_PROFILE
294
+ );
295
+ const rows = [];
296
+ let skipped = 0;
297
+ for (const summary of list) {
298
+ const key = text(summary?.key);
299
+ const title = text(summary?.title) ?? key;
300
+ const url = text(summary?.sourceUrl);
301
+ if (!key || !title || !url) {
302
+ skipped += 1;
303
+ continue;
304
+ }
305
+ rows.push({
306
+ id: namespacedId(COOKBOOK_PROFILE.id, key),
307
+ source: COOKBOOK_PROFILE.id,
308
+ sourceName: COOKBOOK_PROFILE.name,
309
+ title: dishName(title),
310
+ url,
311
+ imageUrl: text(summary?.imageUrl),
312
+ excerpt: text(summary?.excerpt) ?? text(summary?.description)
313
+ });
314
+ }
315
+ return {
316
+ rows,
317
+ skipped,
318
+ // The gateway states no total and offers no second page, so there is no
319
+ // number to report. Counting the rows returned and calling it a total
320
+ // would invent one.
321
+ reportedTotal: null,
322
+ reportedTotalMeans: null,
323
+ cached: outcome.cached
324
+ };
325
+ },
326
+ async getRecipe(reference) {
327
+ const outcome = await reader.getRecipe(reference);
328
+ return { recipe: cookbookDetail(outcome.data), cached: outcome.cached };
329
+ }
330
+ };
331
+ }
332
+ function cookbookDetail(payload) {
333
+ const recipe = payload ?? {};
334
+ const license = recipe.license;
335
+ const licenseTitle = text(license?.title);
336
+ const licenseUrl = text(license?.url);
337
+ const published = text(recipe.yieldText);
338
+ const span = readYieldSpan(published);
339
+ return {
340
+ ...detailBase(COOKBOOK_PROFILE),
341
+ id: namespacedId(COOKBOOK_PROFILE.id, required(recipe.key, "key", COOKBOOK_PROFILE)),
342
+ title: dishName(required(recipe.title ?? recipe.key, "title", COOKBOOK_PROFILE)),
343
+ url: required(recipe.sourceUrl, "url", COOKBOOK_PROFILE),
344
+ // The page source carries no picture this server can address.
345
+ imageUrl: null,
346
+ yieldCount: count(recipe.servings) ?? span.count,
347
+ yieldMax: span.max,
348
+ yieldText: published,
349
+ yieldUnit: text(recipe.yieldUnit) ?? span.unit,
350
+ ingredients: textList(recipe.ingredients),
351
+ steps: textList(recipe.steps),
352
+ // The Cookbook publishes one time for a recipe rather than splitting it.
353
+ prepMinutes: null,
354
+ cookMinutes: null,
355
+ totalMinutes: count(recipe.totalMinutes),
356
+ category: text(recipe.category),
357
+ // The page is written by everyone who edited it, so it credits nobody in
358
+ // particular and carries no reader rating.
359
+ author: null,
360
+ rating: null,
361
+ nutrition: publishedFigures(recipe.nutrition),
362
+ equipment: textList(recipe.equipment),
363
+ tips: textList(recipe.tips),
364
+ // A licence is only stated when both halves of it arrived: a name with no
365
+ // address, or an address with no name, is not terms anyone can follow.
366
+ license: licenseTitle && licenseUrl ? { title: licenseTitle, url: licenseUrl } : null
367
+ };
368
+ }
369
+
370
+ // src/sources/marmiton.ts
371
+ var MARMITON_PROFILE = {
372
+ id: "marmiton",
373
+ name: "Marmiton",
374
+ homeUrl: "https://www.marmiton.org",
375
+ language: "fr",
376
+ attribution: "Source: Marmiton",
377
+ // Every page the search returns is a recipe.
378
+ mixesReferencePages: false
379
+ };
380
+ var SITE_URL2 = /^https?:\/\/(?:www\.)?marmiton\.org\//i;
381
+ var REFERENCE = /^\d+$/;
382
+ function marmitonAdapter(reader) {
383
+ return {
384
+ ...MARMITON_PROFILE,
385
+ claims(raw) {
386
+ if (SITE_URL2.test(raw)) {
387
+ return { reference: raw, why: "the address is a Marmiton page", guess: false };
388
+ }
389
+ if (REFERENCE.test(raw)) {
390
+ return {
391
+ reference: raw,
392
+ why: "a bare number is the shape Marmiton mints",
393
+ guess: false
394
+ };
395
+ }
396
+ return null;
397
+ },
398
+ async search(query) {
399
+ const outcome = await reader.search(query);
400
+ const list = rowsOf(outcome.data, MARMITON_PROFILE);
401
+ const rows = [];
402
+ let skipped = 0;
403
+ for (const summary of list) {
404
+ const id = text(summary?.id);
405
+ const title = text(summary?.title);
406
+ const url = text(summary?.url);
407
+ if (!id || !title || !url) {
408
+ skipped += 1;
409
+ continue;
410
+ }
411
+ rows.push({
412
+ id: namespacedId(MARMITON_PROFILE.id, id),
413
+ source: MARMITON_PROFILE.id,
414
+ sourceName: MARMITON_PROFILE.name,
415
+ title,
416
+ url,
417
+ imageUrl: text(summary?.imageUrl),
418
+ // The result list carries a title and a picture and no prose, so
419
+ // there is nothing to quote here.
420
+ excerpt: null
421
+ });
422
+ }
423
+ return {
424
+ rows,
425
+ skipped,
426
+ // Rows on the page, including the ones this server could not read: the
427
+ // site saw them, and reporting only the readable ones would understate
428
+ // what it holds.
429
+ reportedTotal: rows.length + skipped,
430
+ reportedTotalMeans: "rows on the single page of results Marmiton serves; the site disallows paging, so this is not a catalogue count",
431
+ cached: outcome.cached
432
+ };
433
+ },
434
+ async getRecipe(reference) {
435
+ const ref = /^https?:\/\//i.test(reference) ? { url: reference } : { id: reference };
436
+ const outcome = await reader.getRecipe(ref);
437
+ return { recipe: marmitonDetail(outcome.data), cached: outcome.cached };
438
+ }
439
+ };
440
+ }
441
+ function marmitonDetail(payload) {
442
+ const recipe = payload ?? {};
443
+ const yields = recipe.recipeYield ?? {};
444
+ const rating = recipe.rating;
445
+ const value = count(rating?.value);
446
+ const published = text(yields.text);
447
+ const span = readYieldSpan(published);
448
+ return {
449
+ ...detailBase(MARMITON_PROFILE),
450
+ id: namespacedId(MARMITON_PROFILE.id, required(recipe.id, "id", MARMITON_PROFILE)),
451
+ title: required(recipe.title, "title", MARMITON_PROFILE),
452
+ url: required(recipe.url, "url", MARMITON_PROFILE),
453
+ imageUrl: text(recipe.imageUrl),
454
+ yieldCount: span.count ?? count(yields.count),
455
+ yieldMax: span.max,
456
+ yieldText: published,
457
+ yieldUnit: span.unit ?? text(yields.unit),
458
+ ingredients: textList(recipe.ingredients),
459
+ steps: textList(recipe.steps),
460
+ prepMinutes: count(recipe.prepMinutes),
461
+ cookMinutes: count(recipe.cookMinutes),
462
+ totalMinutes: count(recipe.totalMinutes),
463
+ category: text(recipe.category),
464
+ author: text(recipe.author),
465
+ rating: value === null ? null : { value, count: count(rating?.count), max: count(rating?.best) },
466
+ nutrition: publishedFigures(recipe.nutrition),
467
+ equipment: [],
468
+ tips: [],
469
+ // Marmiton states no licence on a recipe page, and silence is not a grant.
470
+ license: null
471
+ };
472
+ }
473
+
474
+ // src/sources/registry.ts
475
+ var SOURCE_IDS = [MARMITON_PROFILE.id, COOKBOOK_PROFILE.id];
476
+ function buildSources(config, readers = {}) {
477
+ const shared = {
478
+ userAgent: config.userAgent,
479
+ minIntervalMs: config.minIntervalMs,
480
+ timeoutMs: config.timeoutMs,
481
+ maxRetries: config.maxRetries,
482
+ cacheTtlMs: config.cacheTtlMs,
483
+ cacheMaxEntries: config.cacheMaxEntries,
484
+ logLevel: config.logLevel
485
+ };
486
+ const marmitonOptions = { config: shared };
487
+ const cookbookOptions = { config: shared };
488
+ return [
489
+ marmitonAdapter(readers.marmiton ?? new MarmitonClient(marmitonOptions)),
490
+ cookbookAdapter(readers.cookbook ?? new CookbookClient(cookbookOptions))
491
+ ];
492
+ }
493
+ function selectSources(sources, wanted) {
494
+ if (!wanted) return sources;
495
+ const unknown = wanted.filter((id) => !sources.some((source) => source.id === id));
496
+ if (unknown.length > 0) {
497
+ throw invalidInput(
498
+ `This server reads no source called ${unknown.map((id) => `"${id}"`).join(", ")}.`,
499
+ `It reads ${sources.map((source) => source.id).join(", ")}.`
500
+ );
501
+ }
502
+ const chosen = sources.filter((source) => wanted.includes(source.id));
503
+ if (chosen.length === 0) {
504
+ throw invalidInput(
505
+ "A search needs at least one source.",
506
+ `Name one of ${sources.map((source) => source.id).join(", ")}, or leave the argument out to ask them all.`
507
+ );
508
+ }
509
+ return chosen;
510
+ }
511
+
512
+ // src/sources/client.ts
513
+ var MAX_LIMIT_PER_SOURCE = 25;
514
+ function withGuarantees(config) {
515
+ const defaults = loadConfig({});
516
+ const bounded = (value, fallback, min, max) => {
517
+ const parsed = Number(value);
518
+ if (!Number.isFinite(parsed)) return fallback;
519
+ return Math.min(max, Math.max(min, parsed));
520
+ };
521
+ const claimed = typeof config.userAgent === "string" ? config.userAgent.trim() : "";
522
+ const identifier = defaults.userAgent;
523
+ return {
524
+ ...config,
525
+ userAgent: claimed === "" || claimed.includes(identifier) ? identifier : `${claimed} ${identifier}`,
526
+ minIntervalMs: bounded(
527
+ config.minIntervalMs,
528
+ defaults.minIntervalMs,
529
+ MIN_ALLOWED_INTERVAL_MS,
530
+ MAX_ALLOWED_INTERVAL_MS
531
+ ),
532
+ timeoutMs: bounded(config.timeoutMs, defaults.timeoutMs, 1e3, 12e4),
533
+ maxRetries: bounded(config.maxRetries, defaults.maxRetries, 0, 8),
534
+ cacheTtlMs: bounded(config.cacheTtlMs, defaults.cacheTtlMs, 0, 864e5),
535
+ cacheMaxEntries: bounded(config.cacheMaxEntries, defaults.cacheMaxEntries, 1, 5e3),
536
+ logLevel: config.logLevel ?? defaults.logLevel
537
+ };
538
+ }
539
+ function withDeadline(work, ms, source) {
540
+ let timer;
541
+ const alarm = new Promise((_resolve, reject) => {
542
+ timer = setTimeout(
543
+ () => reject(
544
+ timeout(
545
+ `${source.name} did not answer within ${ms}ms and was left out of this answer.`
546
+ )
547
+ ),
548
+ ms
549
+ );
550
+ timer.unref?.();
551
+ });
552
+ return Promise.race([work, alarm]).finally(() => clearTimeout(timer));
553
+ }
554
+ function reportOf(attempt, count2) {
555
+ return {
556
+ source: attempt.source.id,
557
+ name: attempt.source.name,
558
+ status: attempt.error ? "failed" : "answered",
559
+ count: count2,
560
+ reportedTotal: attempt.reportedTotal,
561
+ reportedTotalMeans: attempt.reportedTotalMeans,
562
+ skipped: attempt.skipped,
563
+ mixesReferencePages: attempt.source.mixesReferencePages,
564
+ cached: attempt.cached,
565
+ error: attempt.error
566
+ };
567
+ }
568
+ function interleave(groups) {
569
+ const merged = [];
570
+ const longest = Math.max(0, ...groups.map((group) => group.length));
571
+ for (let index = 0; index < longest; index += 1) {
572
+ for (const group of groups) {
573
+ const row = group[index];
574
+ if (row) merged.push(row);
575
+ }
576
+ }
577
+ return merged;
578
+ }
579
+ var RecipesClient = class {
580
+ config;
581
+ logger;
582
+ sources;
583
+ constructor(options = {}) {
584
+ this.config = withGuarantees({ ...loadConfig(), ...options.config });
585
+ this.logger = options.logger ?? createLogger(this.config.logLevel);
586
+ this.sources = options.sources ?? buildSources(this.config, options.readers ?? {});
587
+ }
588
+ /** What every source sees this client call itself. */
589
+ get userAgent() {
590
+ return this.config.userAgent;
591
+ }
592
+ get intervalMs() {
593
+ return this.config.minIntervalMs;
594
+ }
595
+ get timeoutMs() {
596
+ return this.config.timeoutMs;
597
+ }
598
+ get maxRetries() {
599
+ return this.config.maxRetries;
600
+ }
601
+ /** The sources this server reads, in the order an answer takes them. */
602
+ get profiles() {
603
+ return this.sources.map((source) => ({
604
+ id: source.id,
605
+ name: source.name,
606
+ language: source.language,
607
+ homeUrl: source.homeUrl
608
+ }));
609
+ }
610
+ /**
611
+ * The backstop deadline over one source, covering every attempt it is
612
+ * entitled to make and the pacing between them.
613
+ */
614
+ get deadlineMs() {
615
+ const attempts = this.config.maxRetries + 1;
616
+ return this.config.timeoutMs * attempts + this.config.minIntervalMs * this.config.maxRetries;
617
+ }
618
+ /**
619
+ * Ask every source the same question and merge the rows.
620
+ *
621
+ * The calls go out together, so the answer takes as long as the slowest
622
+ * source rather than as long as all of them. Whichever of them fail, the rest
623
+ * still come back, and the report says which was which.
624
+ */
625
+ async searchRecipes(query, limitPerSource, wanted) {
626
+ const trimmed = query.trim();
627
+ if (trimmed === "") {
628
+ throw invalidInput("A search needs something to look for.", "Name a dish or an ingredient.");
629
+ }
630
+ const chosen = selectSources(this.sources, wanted);
631
+ const limit = Math.max(1, Math.min(MAX_LIMIT_PER_SOURCE, Math.trunc(limitPerSource)));
632
+ const attempts = await Promise.all(
633
+ chosen.map((source) => this.searchOne(source, trimmed, limit))
634
+ );
635
+ const groups = attempts.map((attempt) => attempt.rows.slice(0, limit));
636
+ return {
637
+ rows: interleave(groups),
638
+ reports: attempts.map((attempt, index) => reportOf(attempt, groups[index].length))
639
+ };
640
+ }
641
+ async searchOne(source, query, limit) {
642
+ try {
643
+ const read = await withDeadline(source.search(query, limit), this.deadlineMs, source);
644
+ if (read.skipped > 0) {
645
+ this.logger.warn(
646
+ `${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`
647
+ );
648
+ }
649
+ return {
650
+ source,
651
+ rows: read.rows,
652
+ cached: read.cached,
653
+ reportedTotal: read.reportedTotal,
654
+ reportedTotalMeans: read.reportedTotalMeans,
655
+ skipped: read.skipped,
656
+ error: null
657
+ };
658
+ } catch (error) {
659
+ const known = toRecipesError(error);
660
+ this.logger.warn(`${source.name} did not answer: [${known.code}] ${known.message}`);
661
+ return {
662
+ source,
663
+ rows: [],
664
+ cached: false,
665
+ reportedTotal: null,
666
+ reportedTotalMeans: null,
667
+ skipped: 0,
668
+ error: {
669
+ code: known.code,
670
+ message: known.message,
671
+ ...known.details.hint ? { hint: known.details.hint } : {}
672
+ }
673
+ };
674
+ }
675
+ }
676
+ /**
677
+ * Read one recipe, from whichever source the identifier names.
678
+ *
679
+ * Only that source is called. Trying another after a miss would turn "this
680
+ * source has no page by this name" into somebody else's recipe with a
681
+ * different title, which is a worse answer than the absence it replaced.
682
+ */
683
+ async getRecipe(id) {
684
+ const read = resolveId(id, this.sources);
685
+ try {
686
+ const outcome = await withDeadline(
687
+ read.source.getRecipe(read.reference),
688
+ this.deadlineMs,
689
+ read.source
690
+ );
691
+ return { recipe: outcome.recipe, cached: outcome.cached, read };
692
+ } catch (error) {
693
+ throw toRecipesError(error);
694
+ }
695
+ }
696
+ };
697
+
698
+ // src/tools/compareRecipes.ts
699
+ import { z as z4 } from "zod";
700
+
701
+ // src/tools/recipeView.ts
702
+ import { z as z2 } from "zod";
703
+
704
+ // src/recipe/language.ts
705
+ var FRENCH_WORDS = /(?:^|[\s(])(?:de|du|des|à|au|aux|la|le|les|un|une|quelques|dans|avec|sans|pour|selon|environ)(?=[\s),.]|$)|\bd'|\bl'|\bqu'/iu;
706
+ var ENGLISH_WORDS = /(?:^|[\s(])(?:of|an|the|and|with|without|for|into|about|each|per|plus|freshly|finely|roughly|coarsely|optional)(?=[\s),.]|$)/iu;
707
+ var FRENCH_LETTERS = /[àâäçéèêëîïôöùûüœ]/iu;
708
+ var FRENCH_FOODS = /\b(?:oeufs?|œufs?|sel|poivre|sucres?|farine|beurre|lait|crème|creme|huile|ail|oignons?|fromage|pain|poulet|boeuf|bœuf|riz|pâtes|pates|jambon|citrons?|pommes?|vin|eau|persil|thym|miel|jaunes?|blancs?)\b/iu;
709
+ var ENGLISH_FOODS = /\b(?:eggs?|salt|pepper|sugar|flour|butter|milk|cream|oil|garlic|onions?|cheese|bread|chicken|beef|rice|pasta|ham|lemons?|apples?|wine|water|parsley|thyme|honey|yolks?|whites?)\b/iu;
710
+ var STRUCTURAL = 3;
711
+ var VOCABULARY = 2;
712
+ var NAME = 1;
713
+ function readLanguage(line, evidence = { frenchUnit: false, englishUnit: false }) {
714
+ let french = 0;
715
+ let english = 0;
716
+ if (FRENCH_WORDS.test(line)) french += STRUCTURAL;
717
+ if (FRENCH_LETTERS.test(line)) french += STRUCTURAL;
718
+ if (ENGLISH_WORDS.test(line)) english += STRUCTURAL;
719
+ if (evidence.frenchUnit) french += VOCABULARY;
720
+ if (evidence.englishUnit) english += VOCABULARY;
721
+ if (FRENCH_FOODS.test(line)) french += NAME;
722
+ if (ENGLISH_FOODS.test(line)) english += NAME;
723
+ return { language: french > english ? "fr" : "en", french, english };
724
+ }
725
+
726
+ // src/recipe/units.ts
727
+ var SHARED_UNITS = {
728
+ mg: { canonical: "mg", kind: "measured", system: "metric", symbol: true },
729
+ g: { canonical: "g", kind: "measured", system: "metric", symbol: true },
730
+ gr: { canonical: "g", kind: "measured", system: "metric", symbol: true },
731
+ kg: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
732
+ kilo: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
733
+ kilos: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
734
+ ml: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
735
+ cl: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
736
+ dl: { canonical: "dl", kind: "measured", system: "metric", symbol: true },
737
+ l: { canonical: "l", kind: "measured", system: "metric", symbol: true },
738
+ litre: { canonical: "l", kind: "measured", system: "metric", symbol: true },
739
+ litres: { canonical: "l", kind: "measured", system: "metric", symbol: true }
740
+ };
741
+ var FRENCH_UNITS = {
742
+ ...SHARED_UNITS,
743
+ gramme: { canonical: "g", kind: "measured", system: "metric", symbol: true },
744
+ grammes: { canonical: "g", kind: "measured", system: "metric", symbol: true },
745
+ kilogramme: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
746
+ kilogrammes: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
747
+ millilitre: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
748
+ millilitres: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
749
+ centilitre: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
750
+ centilitres: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
751
+ // Spoons and cups: real measures, but only in sensible fractions.
752
+ "cuillere a soupe": {
753
+ canonical: "cuill\xE8re \xE0 soupe",
754
+ kind: "portioned",
755
+ system: "none",
756
+ plural: "cuill\xE8res \xE0 soupe"
757
+ },
758
+ "cuilleres a soupe": {
759
+ canonical: "cuill\xE8re \xE0 soupe",
760
+ kind: "portioned",
761
+ system: "none",
762
+ plural: "cuill\xE8res \xE0 soupe"
763
+ },
764
+ "c a soupe": {
765
+ canonical: "cuill\xE8re \xE0 soupe",
766
+ kind: "portioned",
767
+ system: "none",
768
+ plural: "cuill\xE8res \xE0 soupe"
769
+ },
770
+ "c a s": {
771
+ canonical: "cuill\xE8re \xE0 soupe",
772
+ kind: "portioned",
773
+ system: "none",
774
+ plural: "cuill\xE8res \xE0 soupe"
775
+ },
776
+ cas: {
777
+ canonical: "cuill\xE8re \xE0 soupe",
778
+ kind: "portioned",
779
+ system: "none",
780
+ plural: "cuill\xE8res \xE0 soupe"
781
+ },
782
+ "cuillere a cafe": {
783
+ canonical: "cuill\xE8re \xE0 caf\xE9",
784
+ kind: "portioned",
785
+ system: "none",
786
+ plural: "cuill\xE8res \xE0 caf\xE9"
787
+ },
788
+ "cuilleres a cafe": {
789
+ canonical: "cuill\xE8re \xE0 caf\xE9",
790
+ kind: "portioned",
791
+ system: "none",
792
+ plural: "cuill\xE8res \xE0 caf\xE9"
793
+ },
794
+ "c a cafe": {
795
+ canonical: "cuill\xE8re \xE0 caf\xE9",
796
+ kind: "portioned",
797
+ system: "none",
798
+ plural: "cuill\xE8res \xE0 caf\xE9"
799
+ },
800
+ "c a c": {
801
+ canonical: "cuill\xE8re \xE0 caf\xE9",
802
+ kind: "portioned",
803
+ system: "none",
804
+ plural: "cuill\xE8res \xE0 caf\xE9"
805
+ },
806
+ cac: {
807
+ canonical: "cuill\xE8re \xE0 caf\xE9",
808
+ kind: "portioned",
809
+ system: "none",
810
+ plural: "cuill\xE8res \xE0 caf\xE9"
811
+ },
812
+ verre: { canonical: "verre", kind: "portioned", system: "none" },
813
+ verres: { canonical: "verre", kind: "portioned", system: "none" },
814
+ bol: { canonical: "bol", kind: "portioned", system: "none" },
815
+ bols: { canonical: "bol", kind: "portioned", system: "none" },
816
+ tasse: { canonical: "tasse", kind: "portioned", system: "none" },
817
+ tasses: { canonical: "tasse", kind: "portioned", system: "none" },
818
+ // Packaging and natural units: countable, so they round to whole things.
819
+ sachet: { canonical: "sachet", kind: "portioned", system: "none" },
820
+ sachets: { canonical: "sachet", kind: "portioned", system: "none" },
821
+ gousse: { canonical: "gousse", kind: "portioned", system: "none" },
822
+ gousses: { canonical: "gousse", kind: "portioned", system: "none" },
823
+ tranche: { canonical: "tranche", kind: "portioned", system: "none" },
824
+ tranches: { canonical: "tranche", kind: "portioned", system: "none" },
825
+ botte: { canonical: "botte", kind: "portioned", system: "none" },
826
+ bottes: { canonical: "botte", kind: "portioned", system: "none" },
827
+ boite: { canonical: "bo\xEEte", kind: "portioned", system: "none", plural: "bo\xEEtes" },
828
+ boites: { canonical: "bo\xEEte", kind: "portioned", system: "none", plural: "bo\xEEtes" },
829
+ pot: { canonical: "pot", kind: "portioned", system: "none" },
830
+ pots: { canonical: "pot", kind: "portioned", system: "none" },
831
+ brique: { canonical: "brique", kind: "portioned", system: "none" },
832
+ briques: { canonical: "brique", kind: "portioned", system: "none" },
833
+ feuille: { canonical: "feuille", kind: "portioned", system: "none" },
834
+ feuilles: { canonical: "feuille", kind: "portioned", system: "none" },
835
+ branche: { canonical: "branche", kind: "portioned", system: "none" },
836
+ branches: { canonical: "branche", kind: "portioned", system: "none" },
837
+ // Held to no better than a hand: the count scales, the size of one does not.
838
+ // `readPartitiveMeasure` explains what else lands here.
839
+ bouchon: { canonical: "bouchon", kind: "approximate", system: "none" },
840
+ bouchons: { canonical: "bouchon", kind: "approximate", system: "none" },
841
+ larme: { canonical: "larme", kind: "approximate", system: "none" },
842
+ larmes: { canonical: "larme", kind: "approximate", system: "none" },
843
+ doigt: { canonical: "doigt", kind: "approximate", system: "none" },
844
+ doigts: { canonical: "doigt", kind: "approximate", system: "none" },
845
+ nuage: { canonical: "nuage", kind: "approximate", system: "none" },
846
+ nuages: { canonical: "nuage", kind: "approximate", system: "none" },
847
+ louche: { canonical: "louche", kind: "approximate", system: "none" },
848
+ louches: { canonical: "louche", kind: "approximate", system: "none" },
849
+ lichette: { canonical: "lichette", kind: "approximate", system: "none" },
850
+ lichettes: { canonical: "lichette", kind: "approximate", system: "none" },
851
+ pointe: { canonical: "pointe", kind: "approximate", system: "none" },
852
+ pointes: { canonical: "pointe", kind: "approximate", system: "none" },
853
+ pincee: { canonical: "pinc\xE9e", kind: "approximate", system: "none", plural: "pinc\xE9es" },
854
+ pincees: { canonical: "pinc\xE9e", kind: "approximate", system: "none", plural: "pinc\xE9es" },
855
+ trait: { canonical: "trait", kind: "approximate", system: "none" },
856
+ traits: { canonical: "trait", kind: "approximate", system: "none" },
857
+ filet: { canonical: "filet", kind: "approximate", system: "none" },
858
+ filets: { canonical: "filet", kind: "approximate", system: "none" },
859
+ goutte: { canonical: "goutte", kind: "approximate", system: "none", plural: "gouttes" },
860
+ gouttes: { canonical: "goutte", kind: "approximate", system: "none", plural: "gouttes" },
861
+ poignee: { canonical: "poign\xE9e", kind: "approximate", system: "none", plural: "poign\xE9es" },
862
+ poignees: { canonical: "poign\xE9e", kind: "approximate", system: "none", plural: "poign\xE9es" },
863
+ // "noix" carries its own plural mark already.
864
+ noix: { canonical: "noix", kind: "approximate", system: "none", plural: "noix" },
865
+ soupcon: { canonical: "soup\xE7on", kind: "approximate", system: "none", plural: "soup\xE7ons" },
866
+ soupcons: { canonical: "soup\xE7on", kind: "approximate", system: "none", plural: "soup\xE7ons" }
867
+ };
868
+ var ENGLISH_UNITS = {
869
+ ...SHARED_UNITS,
870
+ milligram: { canonical: "mg", kind: "measured", system: "metric", symbol: true },
871
+ milligrams: { canonical: "mg", kind: "measured", system: "metric", symbol: true },
872
+ gram: { canonical: "g", kind: "measured", system: "metric", symbol: true },
873
+ grams: { canonical: "g", kind: "measured", system: "metric", symbol: true },
874
+ gramme: { canonical: "g", kind: "measured", system: "metric", symbol: true },
875
+ grammes: { canonical: "g", kind: "measured", system: "metric", symbol: true },
876
+ kilogram: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
877
+ kilograms: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
878
+ milliliter: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
879
+ milliliters: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
880
+ millilitre: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
881
+ millilitres: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
882
+ liter: { canonical: "l", kind: "measured", system: "metric", symbol: true },
883
+ liters: { canonical: "l", kind: "measured", system: "metric", symbol: true },
884
+ // Imperial mass
885
+ oz: { canonical: "oz", kind: "measured", system: "imperial", symbol: true },
886
+ ounce: { canonical: "ounce", kind: "measured", system: "imperial", plural: "ounces" },
887
+ ounces: { canonical: "ounce", kind: "measured", system: "imperial", plural: "ounces" },
888
+ lb: { canonical: "lb", kind: "measured", system: "imperial", symbol: true },
889
+ lbs: { canonical: "lb", kind: "measured", system: "imperial", symbol: true },
890
+ pound: { canonical: "pound", kind: "measured", system: "imperial", plural: "pounds" },
891
+ pounds: { canonical: "pound", kind: "measured", system: "imperial", plural: "pounds" },
892
+ // Imperial volume
893
+ "fl oz": { canonical: "fl oz", kind: "measured", system: "imperial", symbol: true },
894
+ "fluid ounce": {
895
+ canonical: "fluid ounce",
896
+ kind: "measured",
897
+ system: "imperial",
898
+ plural: "fluid ounces"
899
+ },
900
+ "fluid ounces": {
901
+ canonical: "fluid ounce",
902
+ kind: "measured",
903
+ system: "imperial",
904
+ plural: "fluid ounces"
905
+ },
906
+ pint: { canonical: "pint", kind: "measured", system: "imperial", plural: "pints" },
907
+ pints: { canonical: "pint", kind: "measured", system: "imperial", plural: "pints" },
908
+ quart: { canonical: "quart", kind: "measured", system: "imperial", plural: "quarts" },
909
+ quarts: { canonical: "quart", kind: "measured", system: "imperial", plural: "quarts" },
910
+ gallon: { canonical: "gallon", kind: "measured", system: "imperial", plural: "gallons" },
911
+ gallons: { canonical: "gallon", kind: "measured", system: "imperial", plural: "gallons" },
912
+ // Spoons and cups: real measures, but only in sensible fractions.
913
+ tsp: { canonical: "tsp", kind: "portioned", system: "imperial", symbol: true },
914
+ teaspoon: { canonical: "teaspoon", kind: "portioned", system: "imperial", plural: "teaspoons" },
915
+ teaspoons: { canonical: "teaspoon", kind: "portioned", system: "imperial", plural: "teaspoons" },
916
+ tbsp: { canonical: "Tbsp", kind: "portioned", system: "imperial", symbol: true },
917
+ tbs: { canonical: "Tbsp", kind: "portioned", system: "imperial", symbol: true },
918
+ tablespoon: {
919
+ canonical: "tablespoon",
920
+ kind: "portioned",
921
+ system: "imperial",
922
+ plural: "tablespoons"
923
+ },
924
+ tablespoons: {
925
+ canonical: "tablespoon",
926
+ kind: "portioned",
927
+ system: "imperial",
928
+ plural: "tablespoons"
929
+ },
930
+ cup: { canonical: "cup", kind: "portioned", system: "imperial", plural: "cups" },
931
+ cups: { canonical: "cup", kind: "portioned", system: "imperial", plural: "cups" },
932
+ // Packaging and natural units: countable, so they round to whole things.
933
+ can: { canonical: "can", kind: "portioned", system: "none" },
934
+ cans: { canonical: "can", kind: "portioned", system: "none" },
935
+ jar: { canonical: "jar", kind: "portioned", system: "none" },
936
+ jars: { canonical: "jar", kind: "portioned", system: "none" },
937
+ packet: { canonical: "packet", kind: "portioned", system: "none" },
938
+ packets: { canonical: "packet", kind: "portioned", system: "none" },
939
+ package: { canonical: "package", kind: "portioned", system: "none" },
940
+ packages: { canonical: "package", kind: "portioned", system: "none" },
941
+ sachet: { canonical: "sachet", kind: "portioned", system: "none" },
942
+ sachets: { canonical: "sachet", kind: "portioned", system: "none" },
943
+ clove: { canonical: "clove", kind: "portioned", system: "none" },
944
+ cloves: { canonical: "clove", kind: "portioned", system: "none" },
945
+ slice: { canonical: "slice", kind: "portioned", system: "none" },
946
+ slices: { canonical: "slice", kind: "portioned", system: "none" },
947
+ stick: { canonical: "stick", kind: "portioned", system: "none" },
948
+ sticks: { canonical: "stick", kind: "portioned", system: "none" },
949
+ stalk: { canonical: "stalk", kind: "portioned", system: "none" },
950
+ stalks: { canonical: "stalk", kind: "portioned", system: "none" },
951
+ sprig: { canonical: "sprig", kind: "portioned", system: "none" },
952
+ sprigs: { canonical: "sprig", kind: "portioned", system: "none" },
953
+ bunch: { canonical: "bunch", kind: "portioned", system: "none", plural: "bunches" },
954
+ bunches: { canonical: "bunch", kind: "portioned", system: "none", plural: "bunches" },
955
+ head: { canonical: "head", kind: "portioned", system: "none" },
956
+ heads: { canonical: "head", kind: "portioned", system: "none" },
957
+ sheet: { canonical: "sheet", kind: "portioned", system: "none" },
958
+ sheets: { canonical: "sheet", kind: "portioned", system: "none" },
959
+ leaf: { canonical: "leaf", kind: "portioned", system: "none", plural: "leaves" },
960
+ leaves: { canonical: "leaf", kind: "portioned", system: "none", plural: "leaves" },
961
+ ea: { canonical: "ea", kind: "portioned", system: "none", symbol: true },
962
+ // Held to no better than a hand: the count scales, the size of one does not.
963
+ // `readContainerLoad` explains what else lands here.
964
+ capful: { canonical: "capful", kind: "approximate", system: "none", plural: "capfuls" },
965
+ capfuls: { canonical: "capful", kind: "approximate", system: "none", plural: "capfuls" },
966
+ glug: { canonical: "glug", kind: "approximate", system: "none" },
967
+ glugs: { canonical: "glug", kind: "approximate", system: "none" },
968
+ dollop: { canonical: "dollop", kind: "approximate", system: "none" },
969
+ dollops: { canonical: "dollop", kind: "approximate", system: "none" },
970
+ squeeze: { canonical: "squeeze", kind: "approximate", system: "none" },
971
+ squeezes: { canonical: "squeeze", kind: "approximate", system: "none" },
972
+ sprinkle: { canonical: "sprinkle", kind: "approximate", system: "none" },
973
+ sprinkles: { canonical: "sprinkle", kind: "approximate", system: "none" },
974
+ sprinkling: { canonical: "sprinkling", kind: "approximate", system: "none" },
975
+ sprinklings: { canonical: "sprinkling", kind: "approximate", system: "none" },
976
+ grating: { canonical: "grating", kind: "approximate", system: "none" },
977
+ gratings: { canonical: "grating", kind: "approximate", system: "none" },
978
+ twist: { canonical: "twist", kind: "approximate", system: "none" },
979
+ twists: { canonical: "twist", kind: "approximate", system: "none" },
980
+ smidgen: { canonical: "smidgen", kind: "approximate", system: "none" },
981
+ smidgens: { canonical: "smidgen", kind: "approximate", system: "none" },
982
+ pinch: { canonical: "pinch", kind: "approximate", system: "none", plural: "pinches" },
983
+ pinches: { canonical: "pinch", kind: "approximate", system: "none", plural: "pinches" },
984
+ dash: { canonical: "dash", kind: "approximate", system: "none", plural: "dashes" },
985
+ dashes: { canonical: "dash", kind: "approximate", system: "none", plural: "dashes" },
986
+ splash: { canonical: "splash", kind: "approximate", system: "none", plural: "splashes" },
987
+ splashes: { canonical: "splash", kind: "approximate", system: "none", plural: "splashes" },
988
+ drizzle: { canonical: "drizzle", kind: "approximate", system: "none" },
989
+ drizzles: { canonical: "drizzle", kind: "approximate", system: "none" },
990
+ handful: { canonical: "handful", kind: "approximate", system: "none", plural: "handfuls" },
991
+ handfuls: { canonical: "handful", kind: "approximate", system: "none", plural: "handfuls" },
992
+ drop: { canonical: "drop", kind: "approximate", system: "none" },
993
+ drops: { canonical: "drop", kind: "approximate", system: "none" },
994
+ knob: { canonical: "knob", kind: "approximate", system: "none" },
995
+ knobs: { canonical: "knob", kind: "approximate", system: "none" }
996
+ };
997
+ var VOCABULARY2 = {
998
+ fr: FRENCH_UNITS,
999
+ en: ENGLISH_UNITS
1000
+ };
1001
+ function normalizeUnitKey(text2) {
1002
+ return text2.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\./g, " ").replace(/\((?:s|x|es)\)/g, "").replace(/\s+/g, " ").trim();
1003
+ }
1004
+ function orderedKeys(vocabulary) {
1005
+ return Object.keys(vocabulary).sort(
1006
+ (a, b) => b.split(" ").length - a.split(" ").length || b.length - a.length
1007
+ );
1008
+ }
1009
+ var UNIT_KEYS = {
1010
+ fr: orderedKeys(FRENCH_UNITS),
1011
+ en: orderedKeys(ENGLISH_UNITS)
1012
+ };
1013
+ function unitKeys(language) {
1014
+ return UNIT_KEYS[language];
1015
+ }
1016
+ function lookupUnit(text2, language) {
1017
+ return VOCABULARY2[language][normalizeUnitKey(text2)] ?? null;
1018
+ }
1019
+ var EMBEDDED = {
1020
+ fr: buildEmbedded("fr"),
1021
+ en: buildEmbedded("en")
1022
+ };
1023
+ function buildEmbedded(language) {
1024
+ const keys = UNIT_KEYS[language].map((key) => key.replace(/ /g, "\\s+"));
1025
+ return new RegExp(`\\d[\\d.,/]*\\s*(?:${keys.join("|")})\\b`, "i");
1026
+ }
1027
+ function hasEmbeddedMeasure(text2, language) {
1028
+ return EMBEDDED[language].test(text2);
1029
+ }
1030
+ var NOT_A_MEASURE = /* @__PURE__ */ new Set([
1031
+ "peu",
1032
+ "beaucoup",
1033
+ "plus",
1034
+ "moins",
1035
+ "assez",
1036
+ "trop",
1037
+ "autant",
1038
+ "tant",
1039
+ "moitie",
1040
+ "quart",
1041
+ "tiers",
1042
+ "reste",
1043
+ "melange",
1044
+ "ensemble"
1045
+ ]);
1046
+ function readPartitiveMeasure(text2) {
1047
+ const match = new RegExp("^\\s*(\\p{L}+)\\s+(?=(?:de|du|des)\\s|d')", "u").exec(text2);
1048
+ if (!match) return null;
1049
+ const word = match[1];
1050
+ if (word.length < 3) return null;
1051
+ if (NOT_A_MEASURE.has(normalizeUnitKey(word))) return null;
1052
+ if (lookupUnit(word, "fr")) return null;
1053
+ const canonical = frenchSingular(word);
1054
+ return {
1055
+ unit: {
1056
+ canonical,
1057
+ kind: "approximate",
1058
+ system: "none",
1059
+ plural: frenchPlural(canonical)
1060
+ },
1061
+ rest: text2.slice(match[0].length)
1062
+ };
1063
+ }
1064
+ function readContainerLoad(word) {
1065
+ const key = normalizeUnitKey(word);
1066
+ if (!/^[a-z]{3,}fuls?$/.test(key)) return null;
1067
+ const canonical = key.endsWith("fuls") ? key.slice(0, -1) : key;
1068
+ return { canonical, kind: "approximate", system: "none", plural: `${canonical}s` };
1069
+ }
1070
+ function frenchSingular(word) {
1071
+ if (/eaux$/i.test(word)) return word.slice(0, -1);
1072
+ if (/aux$/i.test(word)) return `${word.slice(0, -3)}al`;
1073
+ if (/[aiou]s$/i.test(word)) return word;
1074
+ if (/s$/i.test(word) && word.length > 3) return word.slice(0, -1);
1075
+ return word;
1076
+ }
1077
+ function frenchPlural(word) {
1078
+ if (/[sxz]$/i.test(word)) return word;
1079
+ if (/eau$/i.test(word)) return `${word}x`;
1080
+ if (/al$/i.test(word)) return `${word.slice(0, -2)}aux`;
1081
+ return `${word}s`;
1082
+ }
1083
+ var APPROXIMATE_EQUIVALENT = {
1084
+ capful: "commonly taken as about a tablespoon, the size of a bottle cap",
1085
+ glug: "commonly taken as about two tablespoons poured free-hand",
1086
+ dollop: "commonly taken as about a heaped tablespoon",
1087
+ pinch: "commonly taken as about half a teaspoon",
1088
+ dash: "commonly taken as about an eighth of a teaspoon",
1089
+ splash: "commonly taken as about a tablespoon",
1090
+ handful: "commonly taken as about a quarter of a cup",
1091
+ knob: "commonly taken as about a tablespoon of butter",
1092
+ drizzle: "commonly taken as about a teaspoon poured in a thin line",
1093
+ pincee: "commonly taken as about half a teaspoon",
1094
+ poignee: "commonly taken as about a quarter of a cup",
1095
+ bouchon: "commonly taken as about a tablespoon, the size of a bottle cap",
1096
+ goutte: "commonly taken as a single drop",
1097
+ filet: "commonly taken as about a teaspoon poured in a thin line",
1098
+ noix: "commonly taken as about a tablespoon of butter",
1099
+ louche: "commonly taken as about half a cup",
1100
+ soupcon: "commonly taken as the smallest amount a spoon tip carries"
1101
+ };
1102
+ function approximateEquivalent(unit) {
1103
+ return APPROXIMATE_EQUIVALENT[normalizeUnitKey(unit.canonical)] ?? null;
1104
+ }
1105
+ var PROMOTIONS = {
1106
+ mg: { to: "g", per: 1e3, language: "en" },
1107
+ g: { to: "kg", per: 1e3, language: "en" },
1108
+ ml: { to: "l", per: 1e3, language: "en" },
1109
+ cl: { to: "l", per: 100, language: "en" },
1110
+ dl: { to: "l", per: 10, language: "en" },
1111
+ oz: { to: "lb", per: 16, language: "en" },
1112
+ ounce: { to: "pound", per: 16, language: "en" },
1113
+ "fl oz": { to: "pint", per: 20, language: "en" },
1114
+ pint: { to: "quart", per: 2, language: "en" },
1115
+ quart: { to: "gallon", per: 4, language: "en" }
1116
+ };
1117
+ var DEMOTIONS = {
1118
+ // Spoons and cups hold a fixed volume, so a share of one is stated in the
1119
+ // smaller spoon rather than as a fraction no measuring set carries. The
1120
+ // spelling of the step matches the spelling of the unit it comes from, so a
1121
+ // line written in abbreviations stays in abbreviations, and a French line
1122
+ // stays in French.
1123
+ cup: { to: "tablespoon", per: 16, language: "en" },
1124
+ tablespoon: { to: "teaspoon", per: 3, language: "en" },
1125
+ tbsp: { to: "tsp", per: 3, language: "en" },
1126
+ "cuillere a soupe": { to: "cuillere a cafe", per: 3, language: "fr" },
1127
+ tasse: { to: "cuillere a soupe", per: 16, language: "fr" },
1128
+ kg: { to: "g", per: 1e3, language: "en" },
1129
+ g: { to: "mg", per: 1e3, language: "en" },
1130
+ l: { to: "cl", per: 100, language: "en" },
1131
+ dl: { to: "cl", per: 10, language: "en" },
1132
+ cl: { to: "ml", per: 10, language: "en" },
1133
+ lb: { to: "oz", per: 16, language: "en" },
1134
+ pound: { to: "ounce", per: 16, language: "en" },
1135
+ gallon: { to: "quart", per: 4, language: "en" },
1136
+ quart: { to: "pint", per: 2, language: "en" },
1137
+ pint: { to: "fl oz", per: 20, language: "en" }
1138
+ };
1139
+ function demoteUnit(unit) {
1140
+ const step = DEMOTIONS[normalizeUnitKey(unit.canonical)];
1141
+ if (!step) return null;
1142
+ const target = lookupUnit(step.to, step.language);
1143
+ return target ? { unit: target, per: step.per } : null;
1144
+ }
1145
+ function isSpoonMeasure(unit) {
1146
+ return /^(cup|tablespoon|Tbsp|teaspoon|tsp|cuillère à soupe|cuillère à café|tasse)$/.test(
1147
+ unit.canonical
1148
+ );
1149
+ }
1150
+ function unitDivisibility(unit) {
1151
+ return QUARTERED_MEASURE.test(unit.canonical) ? "quarter" : "half";
1152
+ }
1153
+ var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|gousses?|tranches?|slices?)\b/i;
1154
+ function countsBarePieces(unit) {
1155
+ return unit.canonical === "ea";
1156
+ }
1157
+ function chooseReadableUnit(unit, amount) {
1158
+ if (unit.kind !== "measured" || !Number.isFinite(amount) || amount <= 0) {
1159
+ return { unit, ratio: 1 };
1160
+ }
1161
+ let current = unit;
1162
+ let ratio = 1;
1163
+ while (amount * ratio < 1) {
1164
+ const step = demoteUnit(current);
1165
+ if (!step) break;
1166
+ ratio *= step.per;
1167
+ current = step.unit;
1168
+ }
1169
+ const up = PROMOTIONS[normalizeUnitKey(current.canonical)];
1170
+ if (up && amount * ratio >= up.per) {
1171
+ const target = lookupUnit(up.to, up.language);
1172
+ if (target) {
1173
+ ratio /= up.per;
1174
+ current = target;
1175
+ }
1176
+ }
1177
+ return { unit: current, ratio };
1178
+ }
1179
+ function formatUnit(unit, amount, language) {
1180
+ if (unit.symbol) return unit.canonical;
1181
+ const singular = language === "fr" ? amount < 2 : amount <= 1;
1182
+ if (singular) return unit.canonical;
1183
+ return unit.plural ?? `${unit.canonical}s`;
1184
+ }
1185
+
1186
+ // src/recipe/quantity.ts
1187
+ var VULGAR_FRACTIONS = {
1188
+ "\xBD": 0.5,
1189
+ "\u2153": 1 / 3,
1190
+ "\u2154": 2 / 3,
1191
+ "\xBC": 0.25,
1192
+ "\xBE": 0.75,
1193
+ "\u2155": 0.2,
1194
+ "\u2156": 0.4,
1195
+ "\u2157": 0.6,
1196
+ "\u2158": 0.8,
1197
+ "\u2159": 1 / 6,
1198
+ "\u215A": 5 / 6,
1199
+ "\u215B": 0.125,
1200
+ "\u215C": 0.375,
1201
+ "\u215D": 0.625,
1202
+ "\u215E": 0.875
1203
+ };
1204
+ var VULGAR_CLASS = Object.keys(VULGAR_FRACTIONS).join("");
1205
+ function parseLeadingQuantity(text2, language) {
1206
+ const trimmed = text2.trimStart();
1207
+ const offset = text2.length - trimmed.length;
1208
+ const mixedGlyph = new RegExp(`^(\\d+)\\s*([${VULGAR_CLASS}])`).exec(trimmed);
1209
+ if (mixedGlyph) {
1210
+ const whole = Number(mixedGlyph[1]);
1211
+ const fraction2 = VULGAR_FRACTIONS[mixedGlyph[2]];
1212
+ return { amount: whole + fraction2, length: offset + mixedGlyph[0].length };
1213
+ }
1214
+ const mixed = /^(\d+)\s+(\d+)\s*\/\s*(\d+)/.exec(trimmed);
1215
+ if (mixed) {
1216
+ const denominator = Number(mixed[3]);
1217
+ if (denominator !== 0) {
1218
+ return {
1219
+ amount: Number(mixed[1]) + Number(mixed[2]) / denominator,
1220
+ length: offset + mixed[0].length
1221
+ };
1222
+ }
1223
+ }
1224
+ const fraction = /^(\d+)\s*\/\s*(\d+)/.exec(trimmed);
1225
+ if (fraction) {
1226
+ const denominator = Number(fraction[2]);
1227
+ if (denominator === 0) return null;
1228
+ return { amount: Number(fraction[1]) / denominator, length: offset + fraction[0].length };
1229
+ }
1230
+ const glyph = trimmed[0];
1231
+ if (glyph && glyph in VULGAR_FRACTIONS) {
1232
+ return { amount: VULGAR_FRACTIONS[glyph], length: offset + 1 };
1233
+ }
1234
+ const decimal = (language === "fr" ? /^(\d+(?:[.,]\d+)?)/ : /^(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)/).exec(trimmed);
1235
+ if (decimal) {
1236
+ const written = decimal[1];
1237
+ const amount = Number(
1238
+ language === "fr" ? written.replace(",", ".") : written.replace(/,/g, "")
1239
+ );
1240
+ if (Number.isFinite(amount)) return { amount, length: offset + decimal[0].length };
1241
+ }
1242
+ if (language === "en") {
1243
+ const written = parseWrittenFraction(trimmed);
1244
+ if (written) return { amount: written.amount, length: offset + written.length };
1245
+ }
1246
+ return null;
1247
+ }
1248
+ var WRITTEN_NUMERATORS = { a: 1, an: 1, one: 1, two: 2, three: 3 };
1249
+ var WRITTEN_DENOMINATORS = {
1250
+ half: 2,
1251
+ halves: 2,
1252
+ third: 3,
1253
+ thirds: 3,
1254
+ quarter: 4,
1255
+ quarters: 4,
1256
+ fourth: 4,
1257
+ fourths: 4
1258
+ };
1259
+ function parseWrittenFraction(text2) {
1260
+ const match = /^(?:(a|an|one|two|three)[\s-]+)?(halves|half|thirds|third|quarters|quarter|fourths|fourth)\b/i.exec(
1261
+ text2
1262
+ );
1263
+ if (!match) return null;
1264
+ const numerator = match[1] ? WRITTEN_NUMERATORS[match[1].toLowerCase()] : 1;
1265
+ const denominator = WRITTEN_DENOMINATORS[match[2].toLowerCase()];
1266
+ if (!numerator || !denominator) return null;
1267
+ const rest = text2.slice(match[0].length).replace(/^\s*of\s+/i, "").trimStart();
1268
+ if (!/^an?\s/i.test(rest) && !takeUnit(rest, "en").unit) return null;
1269
+ return { amount: numerator / denominator, length: match[0].length };
1270
+ }
1271
+ function parseLeadingRange(text2, language) {
1272
+ const low = parseLeadingQuantity(text2, language);
1273
+ if (!low) return null;
1274
+ const after = text2.slice(low.length);
1275
+ const written = language === "fr" ? /^\s+(à|a|ou)\s+/i : /^\s+(to|or)\s+/i;
1276
+ const separator = written.exec(after) ?? /^\s*(–|—|-)\s*/.exec(after);
1277
+ if (!separator) return null;
1278
+ const high = parseLeadingQuantity(after.slice(separator[0].length), language);
1279
+ if (!high || high.amount <= low.amount) return null;
1280
+ return {
1281
+ amount: low.amount,
1282
+ max: high.amount,
1283
+ separator: separator[1],
1284
+ length: low.length + separator[0].length + high.length
1285
+ };
1286
+ }
1287
+ var FRENCH_ARTICLES = { un: 1, une: 1, quelques: 3 };
1288
+ function takeUnit(text2, language) {
1289
+ const normalized = normalizeUnitKey(text2);
1290
+ for (const key of unitKeys(language)) {
1291
+ if (normalized !== key && !normalized.startsWith(`${key} `)) continue;
1292
+ const unit = lookupUnit(key, language);
1293
+ if (!unit) continue;
1294
+ const wordCount = key.split(" ").length;
1295
+ const words = text2.trim().split(/\s+/);
1296
+ return { unit, rest: words.slice(wordCount).join(" ") };
1297
+ }
1298
+ if (language === "en") {
1299
+ const words = text2.trimStart().split(/\s+/);
1300
+ const load = words[0] ? readContainerLoad(words[0]) : null;
1301
+ if (load) return { unit: load, rest: words.slice(1).join(" ") };
1302
+ }
1303
+ return { unit: null, rest: text2 };
1304
+ }
1305
+ function takeUnitEitherLanguage(text2, language) {
1306
+ const first = takeUnit(text2, language);
1307
+ if (first.unit) return first;
1308
+ return takeUnit(text2, language === "fr" ? "en" : "fr");
1309
+ }
1310
+ function detectLanguage(line) {
1311
+ const text2 = line.trim();
1312
+ const afterFigures = text2.replace(/^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u, "").trimStart();
1313
+ const afterArticle = afterFigures.replace(/^(?:un|une|quelques|of\s+an?|an?)\s+/iu, "");
1314
+ const frenchUnit = takeUnit(afterArticle, "fr").unit !== null;
1315
+ const englishUnit = takeUnit(afterArticle, "en").unit !== null;
1316
+ return readLanguage(text2, { frenchUnit, englishUnit }).language;
1317
+ }
1318
+ function parseIngredient(line, choice = "auto") {
1319
+ const original = line;
1320
+ const text2 = line.trim();
1321
+ const language = choice === "auto" ? detectLanguage(text2) : choice;
1322
+ const empty = {
1323
+ original,
1324
+ language,
1325
+ amount: null,
1326
+ amountMax: null,
1327
+ rangeSeparator: null,
1328
+ unit: null,
1329
+ alternates: [],
1330
+ alternateStyle: null,
1331
+ item: text2,
1332
+ articleWord: null,
1333
+ countMultiplier: null
1334
+ };
1335
+ const range = parseLeadingRange(text2, language);
1336
+ const article = range ? null : readArticle(text2, language);
1337
+ const quantity = range ?? parseLeadingQuantity(text2, language) ?? article;
1338
+ if (!quantity) return empty;
1339
+ let rest = text2.slice(quantity.length).trimStart();
1340
+ if (language === "en") rest = rest.replace(/^(?:of\s+)?an?\s+/i, "");
1341
+ const multiplier = readCountMultiplier(rest);
1342
+ if (multiplier) rest = multiplier.rest;
1343
+ const times = multiplier?.times ?? 1;
1344
+ const leading = takeLeadingUnit(rest, language, quantity === article);
1345
+ rest = leading.rest;
1346
+ const bracketed = takeAlternates(rest, language);
1347
+ rest = bracketed.rest;
1348
+ const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest, language);
1349
+ if (slashed) rest = slashed.rest;
1350
+ return {
1351
+ original,
1352
+ language,
1353
+ amount: quantity.amount * times,
1354
+ amountMax: range === null ? null : range.max * times,
1355
+ rangeSeparator: range?.separator ?? null,
1356
+ unit: leading.unit,
1357
+ alternates: slashed ? slashed.measures : bracketed.measures,
1358
+ alternateStyle: slashed ? "slash" : bracketed.measures.length > 0 ? "bracket" : null,
1359
+ item: stripItemLead(rest, language),
1360
+ articleWord: quantity === article ? article?.word ?? null : null,
1361
+ countMultiplier: multiplier?.times ?? null
1362
+ };
1363
+ }
1364
+ function readArticle(text2, language) {
1365
+ const counts = (rest) => takeLeadingUnit(rest, language, true).unit !== null || readCountMultiplier(rest) !== null;
1366
+ if (language === "fr") {
1367
+ const match = /^\s*(un|une|quelques)\b\s*/i.exec(text2);
1368
+ if (!match) return null;
1369
+ if (!counts(text2.slice(match[0].length))) return null;
1370
+ const word = match[1];
1371
+ return { amount: FRENCH_ARTICLES[word.toLowerCase()], length: match[0].length, word };
1372
+ }
1373
+ const article = /^an?\s+/i.exec(text2);
1374
+ if (!article) return null;
1375
+ if (!counts(text2.slice(article[0].length))) return null;
1376
+ return { amount: 1, length: article[0].length, word: article[0].trim() };
1377
+ }
1378
+ var COUNT_MULTIPLIERS = {
1379
+ dozen: 12,
1380
+ dozens: 12,
1381
+ douzaine: 12,
1382
+ douzaines: 12
1383
+ };
1384
+ function readCountMultiplier(text2) {
1385
+ const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text2);
1386
+ if (!match) return null;
1387
+ const times = COUNT_MULTIPLIERS[normalizeUnitKey(match[1])];
1388
+ if (times === void 0) return null;
1389
+ return { times, rest: text2.slice(match[0].length) };
1390
+ }
1391
+ function takeLeadingUnit(text2, language, fromArticle) {
1392
+ const taken = takeUnit(text2, language);
1393
+ if (taken.unit) return taken;
1394
+ if (language === "fr" && fromArticle) {
1395
+ const measure = readPartitiveMeasure(text2);
1396
+ if (measure) return { unit: measure.unit, rest: measure.rest };
1397
+ }
1398
+ return taken;
1399
+ }
1400
+ function stripItemLead(text2, language) {
1401
+ if (language === "fr") {
1402
+ return text2.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").replace(/^(?:une|un)\s+/i, "").trim();
1403
+ }
1404
+ return text2.replace(/^of\s+/i, "").replace(/^an?\s+/i, "").trim();
1405
+ }
1406
+ function takeAlternates(text2, language) {
1407
+ if (!text2.startsWith("(")) return { measures: [], rest: text2 };
1408
+ const close = text2.indexOf(")");
1409
+ if (close < 0) return { measures: [], rest: text2 };
1410
+ const inside = text2.slice(1, close);
1411
+ const parts = inside.split("/").map((part) => part.trim());
1412
+ const measures = [];
1413
+ for (const part of parts) {
1414
+ const range = parseLeadingRange(part, language);
1415
+ const quantity = range ?? parseLeadingQuantity(part, language);
1416
+ if (!quantity) return { measures: [], rest: text2 };
1417
+ const after = takeUnitEitherLanguage(part.slice(quantity.length).trimStart(), language);
1418
+ if (!after.unit || after.rest.trim() !== "") return { measures: [], rest: text2 };
1419
+ measures.push({
1420
+ amount: quantity.amount,
1421
+ amountMax: range?.max ?? null,
1422
+ rangeSeparator: range?.separator ?? null,
1423
+ unit: after.unit
1424
+ });
1425
+ }
1426
+ if (measures.length === 0) return { measures: [], rest: text2 };
1427
+ return { measures, rest: text2.slice(close + 1).trimStart() };
1428
+ }
1429
+ function takeSlashAlternates(text2, language) {
1430
+ const measures = [];
1431
+ let rest = text2;
1432
+ while (rest.startsWith("/")) {
1433
+ const after = rest.slice(1).trimStart();
1434
+ const range = parseLeadingRange(after, language);
1435
+ const quantity = range ?? parseLeadingQuantity(after, language);
1436
+ if (!quantity) break;
1437
+ const taken = takeUnitEitherLanguage(after.slice(quantity.length).trimStart(), language);
1438
+ if (!taken.unit) break;
1439
+ measures.push({
1440
+ amount: quantity.amount,
1441
+ amountMax: range?.max ?? null,
1442
+ rangeSeparator: range?.separator ?? null,
1443
+ unit: taken.unit
1444
+ });
1445
+ rest = taken.rest.trimStart();
1446
+ }
1447
+ return measures.length > 0 ? { measures, rest } : null;
1448
+ }
1449
+ function formatAmount(amount, language, options = {}) {
1450
+ const decimal = (value) => {
1451
+ const rounded = value !== 0 && Math.abs(value) < 0.01 ? Number(value.toPrecision(2)) : Math.round(value * 100) / 100;
1452
+ const rendered = String(rounded);
1453
+ return language === "fr" ? rendered.replace(".", ",") : rendered;
1454
+ };
1455
+ if (!Number.isFinite(amount)) return "";
1456
+ if (Number.isInteger(amount)) return String(amount);
1457
+ if (options.fractions === false) return decimal(amount);
1458
+ const whole = Math.floor(amount);
1459
+ const rest = amount - whole;
1460
+ const known = [
1461
+ [0.25, "1/4"],
1462
+ [1 / 3, "1/3"],
1463
+ [0.5, "1/2"],
1464
+ [2 / 3, "2/3"],
1465
+ [0.75, "3/4"]
1466
+ ];
1467
+ for (const [value, label] of known) {
1468
+ if (Math.abs(rest - value) < 0.02) return whole > 0 ? `${whole} ${label}` : label;
1469
+ }
1470
+ return decimal(amount);
1471
+ }
1472
+
1473
+ // src/recipe/scale.ts
1474
+ function roundTo(value, step) {
1475
+ return Math.round(Math.round(value / step) * step * 100) / 100;
1476
+ }
1477
+ function roundMeasured(value) {
1478
+ if (value >= 100) return roundTo(value, 5);
1479
+ if (value >= 10) return roundTo(value, 1);
1480
+ if (value >= 1) return roundTo(value, 0.1);
1481
+ return Math.round(value * 100) / 100;
1482
+ }
1483
+ var SMALLEST_USABLE_FRACTION = 0.25;
1484
+ var SMALLEST_USABLE = {
1485
+ whole: 1,
1486
+ half: 0.5,
1487
+ quarter: 0.25
1488
+ };
1489
+ function isHalfStep(value) {
1490
+ return Math.abs(value * 2 - Math.round(value * 2)) < 1e-9;
1491
+ }
1492
+ function trim(value) {
1493
+ return Math.round(value * 100) / 100;
1494
+ }
1495
+ function roundCountable(value, divisibility, ceiling) {
1496
+ if (value <= 0) return { value: 0, clamped: false };
1497
+ const floor = SMALLEST_USABLE[divisibility];
1498
+ if (divisibility !== "whole" && value >= floor && isHalfStep(value)) {
1499
+ return { value: trim(value), clamped: false };
1500
+ }
1501
+ if (divisibility === "whole") {
1502
+ if (value < 0.5) return { value: floor, clamped: true };
1503
+ return { value: Math.round(value), clamped: false };
1504
+ }
1505
+ if (value < floor) return { value: floor, clamped: true };
1506
+ if (value < 1) {
1507
+ const steps = divisibility === "quarter" ? [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1] : [0.5, 1];
1508
+ const candidates = steps.filter(
1509
+ (candidate) => candidate >= floor && candidate <= Math.max(ceiling, floor)
1510
+ );
1511
+ let closest = candidates[0];
1512
+ for (const candidate of candidates) {
1513
+ if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
1514
+ }
1515
+ return { value: trim(closest), clamped: false };
1516
+ }
1517
+ return { value: Math.round(value), clamped: false };
1518
+ }
1519
+ function roundSpoon(value, ceiling) {
1520
+ if (value <= 0) return { value: 0, clamped: false };
1521
+ if (value < 1) {
1522
+ const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(
1523
+ (candidate) => candidate <= Math.max(ceiling, SMALLEST_USABLE_FRACTION)
1524
+ );
1525
+ let closest = candidates[0];
1526
+ for (const candidate of candidates) {
1527
+ if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
1528
+ }
1529
+ return { value: trim(closest), clamped: value < SMALLEST_USABLE_FRACTION };
1530
+ }
1531
+ return { value: roundTo(value, 0.5), clamped: false };
1532
+ }
1533
+ function stepDownSpoon(unit, reference) {
1534
+ let current = unit;
1535
+ let ratio = 1;
1536
+ while (reference * ratio < 1 && !isHalfStep(reference * ratio)) {
1537
+ const step = demoteUnit(current);
1538
+ if (!step) break;
1539
+ ratio *= step.per;
1540
+ current = step.unit;
1541
+ }
1542
+ return { unit: current, ratio };
1543
+ }
1544
+ var EXACT_WITHIN = 0.01;
1545
+ var EXACT_SHARE = 5e-3;
1546
+ function landedExactly(exact, amount) {
1547
+ const gap = Math.abs(exact - amount);
1548
+ if (gap > EXACT_WITHIN) return false;
1549
+ return exact === 0 || gap / Math.abs(exact) <= EXACT_SHARE;
1550
+ }
1551
+ function scaleMeasure(low, high, unit, factor, divisibility) {
1552
+ const raws = high === null ? [low * factor] : [low * factor, high * factor];
1553
+ const sources = high === null ? [low] : [low, high];
1554
+ const positive = raws.filter((raw) => raw > 0);
1555
+ const reference = positive.length > 0 ? Math.min(...positive) : raws[0];
1556
+ const inUnit = (target, ratio) => ({
1557
+ bounds: raws.map((raw, index) => {
1558
+ const exact = raw * ratio;
1559
+ const rounded = roundMeasured(exact);
1560
+ const usable = rounded === 0 && exact > 0 ? Number(exact.toPrecision(2)) : rounded;
1561
+ const ceiling = factor < 1 ? sources[index] * ratio : Number.POSITIVE_INFINITY;
1562
+ return {
1563
+ amount: Math.min(usable, ceiling),
1564
+ exact,
1565
+ clamped: false,
1566
+ raw
1567
+ };
1568
+ }),
1569
+ unit: target
1570
+ });
1571
+ if (unit && unit.kind === "measured") {
1572
+ const chosen = chooseReadableUnit(unit, reference);
1573
+ return inUnit(chosen.unit, chosen.ratio);
1574
+ }
1575
+ if (unit && isSpoonMeasure(unit)) {
1576
+ const stepped = stepDownSpoon(unit, reference);
1577
+ if (stepped.ratio !== 1) return inUnit(stepped.unit, stepped.ratio);
1578
+ const bounds2 = raws.map((raw, index) => {
1579
+ const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
1580
+ const rounded = roundSpoon(raw, ceiling);
1581
+ return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw };
1582
+ });
1583
+ return { bounds: bounds2, unit };
1584
+ }
1585
+ const bounds = raws.map((raw, index) => {
1586
+ const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
1587
+ const rounded = roundCountable(raw, divisibility, ceiling);
1588
+ return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw };
1589
+ });
1590
+ return { bounds, unit };
1591
+ }
1592
+ var PORTION_SIZED_ITEM = new RegExp(
1593
+ "\\b(?:shrimps?|prawns?|langoustines?|mussels?|hazelnuts?|peppercorns?|junipers?|grains?|anise|crevettes?|gambas|moules?|noisettes?|geni\xE8vres?|genievres?|anis)\\b",
1594
+ "iu"
1595
+ );
1596
+ var QUARTERED_ITEM = new RegExp(
1597
+ "\\b(?:onions?|shallots?|potatoes|potato|carrots?|apples?|pears?|lemons?|limes?|oranges?|tomato(?:es)?|cucumbers?|courgettes?|zucchinis?|aubergines?|eggplants?|squash(?:es)?|pumpkins?|cabbages?|melons?|watermelons?|peppers?|beets?|turnips?|parsnips?|leeks?|bananas?|mango(?:e?s)?|avocados?|legs? of lamb|lamb legs?|baguettes?|camemberts?|cheeses?|chorizos?|pineapples?|peach(?:es)?|apricots?|milk|chickens?|guinea fowls?|roasts?|oignons?|\xE9chalotes?|echalotes?|pommes? de terre|carottes?|pommes?|poires?|citrons?|tomates?|concombres?|courgettes?|aubergines?|potirons?|choux?|chou|melons?|poivrons?|betteraves?|navets?|panais|poireaux?|bananes?|mangues?|avocats?|past\xE8ques?|pasteques?|gigots?|fromages?|ch\xE8vres?|chevres?|ananas|p\xEAches?|peches?|abricots?|laits?|poulets?|pintades?|reblochons?|b\xFBches?|buches?|r\xF4tis?|rotis?)\\b",
1598
+ "iu"
1599
+ );
1600
+ var HALVED_ITEM = /\b(?:jus|juices?)\b/iu;
1601
+ var WHOLE_ITEM = /\b(?:eggs?|yolks?|egg\s+whites?|oeufs?|œufs?|jaunes?\s+d['e]|clous?|zestes?|zests?)\b/iu;
1602
+ var HALVED_CUT = /\b(?:breasts?|thighs?|drumsticks?|wings?|cutlets?|cuisses?|ailes?|pilons?|escalopes?|magrets?)\b/iu;
1603
+ function cloveDivisibility(unit, item) {
1604
+ const counted = unit ? unit.canonical === "clove" : /\bcloves?\b/i.test(item);
1605
+ if (!counted) return null;
1606
+ return /\bgarlic\b/i.test(item) ? "quarter" : "whole";
1607
+ }
1608
+ var BLANC_OF = /\bblancs?\s+d(?:e\s|['’])/iu;
1609
+ var BLANC_OF_EGG = /\bblancs?\s+d(?:e\s|['’])\s*(?:oeufs?|œufs?)\b/iu;
1610
+ function blancDivisibility(item) {
1611
+ if (!BLANC_OF.test(item)) return null;
1612
+ return BLANC_OF_EGG.test(item) ? "whole" : "half";
1613
+ }
1614
+ function divisibilityOf(unit, item) {
1615
+ const clove = cloveDivisibility(unit, item);
1616
+ if (clove) return clove;
1617
+ if (unit && !countsBarePieces(unit)) return unitDivisibility(unit);
1618
+ const blanc = blancDivisibility(item);
1619
+ if (blanc) return blanc;
1620
+ if (WHOLE_ITEM.test(item)) return "whole";
1621
+ if (PORTION_SIZED_ITEM.test(item)) return "whole";
1622
+ if (HALVED_ITEM.test(item)) return "half";
1623
+ if (HALVED_CUT.test(item)) return "half";
1624
+ if (QUARTERED_MEASURE.test(item)) return "quarter";
1625
+ return QUARTERED_ITEM.test(item) ? "quarter" : "half";
1626
+ }
1627
+ var INVARIABLE_ITEM = /* @__PURE__ */ new Set([
1628
+ "anise",
1629
+ "ananas",
1630
+ "asparagus",
1631
+ "bacon",
1632
+ "basil",
1633
+ "beef",
1634
+ "bison",
1635
+ "broccoli",
1636
+ "butter",
1637
+ "celery",
1638
+ "cilantro",
1639
+ "citrus",
1640
+ "cinnamon",
1641
+ "cocoa",
1642
+ "cod",
1643
+ "coriander",
1644
+ "corn",
1645
+ "cornstarch",
1646
+ "couscous",
1647
+ "cream",
1648
+ "deer",
1649
+ "fish",
1650
+ "flour",
1651
+ "gambas",
1652
+ "garlic",
1653
+ "ginger",
1654
+ "ham",
1655
+ "honey",
1656
+ "hummus",
1657
+ "jus",
1658
+ "kale",
1659
+ "lamb",
1660
+ "macaroni",
1661
+ "milk",
1662
+ "miso",
1663
+ "musk",
1664
+ "moose",
1665
+ "mutton",
1666
+ "nutmeg",
1667
+ "oil",
1668
+ "okra",
1669
+ "oregano",
1670
+ "parsley",
1671
+ "pasta",
1672
+ "pork",
1673
+ "quinoa",
1674
+ "rice",
1675
+ "rosemary",
1676
+ "saffron",
1677
+ "salmon",
1678
+ "salt",
1679
+ "shrimp",
1680
+ "spinach",
1681
+ "squid",
1682
+ "sugar",
1683
+ "thyme",
1684
+ "tofu",
1685
+ "trout",
1686
+ "tuna",
1687
+ "vanilla",
1688
+ "veal",
1689
+ "venison",
1690
+ "vinegar",
1691
+ "water",
1692
+ "yeast",
1693
+ "yogurt"
1694
+ ]);
1695
+ var IRREGULAR_PLURAL = {
1696
+ calf: "calves",
1697
+ chili: "chilies",
1698
+ chilli: "chillies",
1699
+ goose: "geese",
1700
+ half: "halves",
1701
+ knife: "knives",
1702
+ leaf: "leaves",
1703
+ loaf: "loaves",
1704
+ mango: "mangoes",
1705
+ potato: "potatoes",
1706
+ shelf: "shelves",
1707
+ tomato: "tomatoes"
1708
+ };
1709
+ var IRREGULAR_SINGULAR = Object.fromEntries(
1710
+ Object.entries(IRREGULAR_PLURAL).map(([one, many]) => [many, one])
1711
+ );
1712
+ function matchCase(source, replacement) {
1713
+ if (source[0] === source[0]?.toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
1714
+ return replacement[0].toUpperCase() + replacement.slice(1);
1715
+ }
1716
+ return replacement;
1717
+ }
1718
+ function toEnglishPlural(word) {
1719
+ const key = word.toLowerCase();
1720
+ if (INVARIABLE_ITEM.has(key)) return word;
1721
+ const irregular = IRREGULAR_PLURAL[key];
1722
+ if (irregular) return matchCase(word, irregular);
1723
+ if (/(?:ch|sh|s|x|z)$/i.test(word)) return `${word}es`;
1724
+ if (/[^aeiou]y$/i.test(word)) return `${word.slice(0, -1)}ies`;
1725
+ if (/(?:[^f]f|fe)$/i.test(word)) return `${word.replace(/fe?$/i, "")}ves`;
1726
+ return `${word}s`;
1727
+ }
1728
+ function toEnglishSingular(word) {
1729
+ const key = word.toLowerCase();
1730
+ if (INVARIABLE_ITEM.has(key)) return word;
1731
+ const irregular = IRREGULAR_SINGULAR[key];
1732
+ if (irregular) return matchCase(word, irregular);
1733
+ if (/ies$/i.test(word) && word.length > 4) return `${word.slice(0, -3)}y`;
1734
+ if (/(?:ch|sh|s|x|z)es$/i.test(word)) return word.slice(0, -2);
1735
+ if (/ss$/i.test(word)) return word;
1736
+ if (/s$/i.test(word)) return word.slice(0, -1);
1737
+ return word;
1738
+ }
1739
+ function agreeInEnglish(item, amount) {
1740
+ if (!item) return item;
1741
+ const comma = item.indexOf(",");
1742
+ const head = comma < 0 ? item : item.slice(0, comma);
1743
+ const tail = comma < 0 ? "" : item.slice(comma);
1744
+ const preposition = / of /i.exec(head);
1745
+ if (preposition) {
1746
+ const counted = agreeInEnglish(head.slice(0, preposition.index), amount);
1747
+ return `${counted}${head.slice(preposition.index)}${tail}`;
1748
+ }
1749
+ const words = head.trimEnd().split(" ");
1750
+ const last = words[words.length - 1] ?? "";
1751
+ if (!/^[A-Za-z]+$/.test(last) || last.length <= 2) return item;
1752
+ const wantsPlural = amount > 1;
1753
+ const plural = toEnglishPlural(last);
1754
+ const singular = toEnglishSingular(last);
1755
+ const isPlural = last.toLowerCase() !== singular.toLowerCase();
1756
+ if (wantsPlural && !isPlural) words[words.length - 1] = plural;
1757
+ else if (!wantsPlural && isPlural) words[words.length - 1] = singular;
1758
+ else return item;
1759
+ return `${words.join(" ")}${tail}`;
1760
+ }
1761
+ var INVARIABLE_FRENCH_NOUN = /* @__PURE__ */ new Set([
1762
+ "ananas",
1763
+ "anis",
1764
+ "brebis",
1765
+ "cassis",
1766
+ "colis",
1767
+ "coulis",
1768
+ "couscous",
1769
+ "gambas",
1770
+ "houmous",
1771
+ "jus",
1772
+ "mais",
1773
+ "pastis",
1774
+ "pois",
1775
+ "radis",
1776
+ "ris",
1777
+ "souris",
1778
+ "tamis",
1779
+ "tapas"
1780
+ ]);
1781
+ var AGREEABLE_ADJECTIVES = /* @__PURE__ */ new Set([
1782
+ "entier",
1783
+ "entiere",
1784
+ "etoile",
1785
+ "etoilee",
1786
+ "moyen",
1787
+ "moyenne",
1788
+ "petit",
1789
+ "petite",
1790
+ "grand",
1791
+ "grande",
1792
+ "mur",
1793
+ "mure",
1794
+ "vert",
1795
+ "verte",
1796
+ "rouge",
1797
+ "jaune",
1798
+ "noir",
1799
+ "noire",
1800
+ "blanc",
1801
+ "blanche",
1802
+ "rond",
1803
+ "ronde",
1804
+ "hache",
1805
+ "hachee",
1806
+ "coupe",
1807
+ "coupee",
1808
+ "rape",
1809
+ "rapee",
1810
+ "pele",
1811
+ "pelee",
1812
+ "epluche",
1813
+ "epluchee",
1814
+ "denoyaute",
1815
+ "denoyautee",
1816
+ "emince",
1817
+ "emincee"
1818
+ ]);
1819
+ function foldWord(word) {
1820
+ return word.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
1821
+ }
1822
+ function agreeTrailingAdjective(word, wantsPlural) {
1823
+ const folded = foldWord(word);
1824
+ const isPlural = folded.endsWith("s");
1825
+ const singular = isPlural ? word.slice(0, -1) : word;
1826
+ if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return null;
1827
+ if (wantsPlural === isPlural) return null;
1828
+ return wantsPlural ? `${word}s` : singular;
1829
+ }
1830
+ function agreeInFrench(item, amount) {
1831
+ if (!item) return item;
1832
+ const words = item.split(" ");
1833
+ const head = words[0] ?? "";
1834
+ if (head.length <= 3) return item;
1835
+ const wantsPlural = amount >= 2;
1836
+ const isPlural = /s$|eaux$|aux$/i.test(head);
1837
+ if (wantsPlural && !isPlural) {
1838
+ if (/[sxz]$/i.test(head)) {
1839
+ } else if (/eau$/i.test(head)) words[0] = `${head}x`;
1840
+ else if (/al$/i.test(head)) words[0] = `${head.slice(0, -2)}aux`;
1841
+ else words[0] = `${head}s`;
1842
+ } else if (!wantsPlural && isPlural) {
1843
+ if (/eaux$/i.test(head)) words[0] = head.slice(0, -1);
1844
+ else if (/aux$/i.test(head)) words[0] = `${head.slice(0, -3)}al`;
1845
+ else if (INVARIABLE_FRENCH_NOUN.has(foldWord(head))) {
1846
+ } else words[0] = head.slice(0, -1);
1847
+ }
1848
+ const last = words.length - 1;
1849
+ if (last > 0) {
1850
+ const adjective = agreeTrailingAdjective(words[last], wantsPlural);
1851
+ if (adjective) words[last] = adjective;
1852
+ }
1853
+ return words.join(" ");
1854
+ }
1855
+ function agreeWithAmount(item, amount, language) {
1856
+ return language === "fr" ? agreeInFrench(item, amount) : agreeInEnglish(item, amount);
1857
+ }
1858
+ var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
1859
+ function joinItem(item, language) {
1860
+ if (!item) return "";
1861
+ if (language === "en") return ` ${item}`;
1862
+ const elides = /^[aeiouàâäéèêëîïôöûü]/i.test(item) || MUTE_H_WORDS.test(item);
1863
+ return elides ? ` d'${item}` : ` de ${item}`;
1864
+ }
1865
+ var BRANCH_SEPARATORS = {
1866
+ en: /\s+or\s+/gi,
1867
+ fr: /\s+ou\s+/gi
1868
+ };
1869
+ function splitBranch(text2, parsed) {
1870
+ const itemStart = parsed.item ? text2.indexOf(parsed.item) : text2.length;
1871
+ if (itemStart < 0) return null;
1872
+ const separator = BRANCH_SEPARATORS[parsed.language];
1873
+ separator.lastIndex = 0;
1874
+ for (let match = separator.exec(text2); match; match = separator.exec(text2)) {
1875
+ if (match.index < itemStart) continue;
1876
+ const tail = text2.slice(match.index + match[0].length);
1877
+ if (parseIngredient(tail, parsed.language).amount === null) continue;
1878
+ return { head: text2.slice(0, match.index), separator: match[0], tail };
1879
+ }
1880
+ return null;
1881
+ }
1882
+ function scaleIngredient(line, options) {
1883
+ const { factor } = options;
1884
+ if (factor === 1) return passthroughIngredient(line, options.language);
1885
+ const text2 = line.trim();
1886
+ const parsed = parseIngredient(text2, options.language ?? "auto");
1887
+ const branch = splitBranch(text2, parsed);
1888
+ if (branch) return scaleBranchedLine(line, branch, { ...options, language: parsed.language });
1889
+ return scaleSingleLine(line, options);
1890
+ }
1891
+ function scaleBranchedLine(line, branch, options) {
1892
+ const head = scaleSingleLine(branch.head, options);
1893
+ if (head.scaling === "unscaled") return { ...head, text: line.trim(), original: line };
1894
+ const tail = scaleAlternative(branch.tail, options);
1895
+ const result = {
1896
+ ...head,
1897
+ original: line,
1898
+ text: `${head.text}${branch.separator}${tail.text}`,
1899
+ scaling: "rounded"
1900
+ };
1901
+ const branchNote = tail.rewritten ? "This line offers a choice between two quantities, and each was scaled on its own. How far one stands for the other is the page's own claim." : "This line carries a further quantity after the first one, and only the first was scaled. Read the rest as published.";
1902
+ result.note = head.note ? `${head.note} ${branchNote}` : branchNote;
1903
+ return result;
1904
+ }
1905
+ function scaleAlternative(tail, options) {
1906
+ const parsed = parseIngredient(tail, options.language ?? "auto");
1907
+ const published = tail.trim();
1908
+ if (parsed.amount === null) return { text: published, rewritten: false };
1909
+ const largest = (parsed.amountMax ?? parsed.amount) * options.factor;
1910
+ if (largest < 1) return { text: published, rewritten: false };
1911
+ return { text: scaleIngredient(tail, options).text, rewritten: true };
1912
+ }
1913
+ function scaleSingleLine(line, options) {
1914
+ const { factor } = options;
1915
+ const parsed = parseIngredient(line, options.language ?? "auto");
1916
+ const language = parsed.language;
1917
+ if (parsed.amount === null) {
1918
+ return {
1919
+ text: parsed.original,
1920
+ original: parsed.original,
1921
+ scaling: "unscaled",
1922
+ amount: null,
1923
+ amountMax: null,
1924
+ unit: null,
1925
+ language,
1926
+ note: "No quantity given; adjust to taste."
1927
+ };
1928
+ }
1929
+ const divisibility = divisibilityOf(parsed.unit, parsed.item);
1930
+ const primary = scaleMeasure(parsed.amount, parsed.amountMax, parsed.unit, factor, divisibility);
1931
+ const alternates = parsed.alternates.map((measure) => renderMeasure(measure, factor, language));
1932
+ const primaryBounds = primary.bounds;
1933
+ const alternateBounds = alternates.flatMap((entry) => entry.bounds);
1934
+ const movedPrimary = primaryBounds.some((b) => !landedExactly(b.exact, b.amount));
1935
+ const movedAlternate = alternateBounds.some((b) => !landedExactly(b.exact, b.amount));
1936
+ const clamped = [...primaryBounds, ...alternateBounds].find((bound) => bound.clamped) ?? null;
1937
+ const restated = parsed.alternateStyle === "slash";
1938
+ const low = primaryBounds[0];
1939
+ const high = primaryBounds[1] ?? null;
1940
+ const unit = primary.unit;
1941
+ const shown = high?.amount ?? low.amount;
1942
+ const asText = (value) => formatAmount(value, language, { fractions: unit?.kind !== "measured" });
1943
+ const collapsed = high !== null && high.amount === low.amount;
1944
+ const amountText = renderRange(
1945
+ asText(low.amount),
1946
+ high === null || collapsed ? null : asText(high.amount),
1947
+ parsed.rangeSeparator
1948
+ );
1949
+ const named = unit && !countsBarePieces(unit) ? unit : null;
1950
+ const unitLabel = named ? ` ${formatUnit(named, shown, language)}` : "";
1951
+ const alternateTexts = alternates.map((entry) => entry.text);
1952
+ const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : ` (${alternateTexts.join(" / ")})`;
1953
+ const counted = agreeWithAmount(parsed.item, shown, language);
1954
+ const itemLabel = named ? joinItem(parsed.item, language) : counted ? ` ${counted}` : "";
1955
+ const result = {
1956
+ text: `${amountText}${unitLabel}${altLabel}${itemLabel}`.trim(),
1957
+ original: parsed.original,
1958
+ scaling: movedPrimary || movedAlternate || restated ? "rounded" : "scaled",
1959
+ amount: low.amount,
1960
+ amountMax: collapsed ? null : high?.amount ?? null,
1961
+ unit: named?.canonical ?? null,
1962
+ language
1963
+ };
1964
+ const asPublished = (value, source) => `${formatAmount(value, language, { fractions: false })}${source ? ` ${formatUnit(source, value, language)}` : ""}`;
1965
+ const sentences = [];
1966
+ if (clamped) {
1967
+ sentences.push(
1968
+ `Clamped up to ${formatAmount(clamped.amount, language)} from ${formatAmount(Math.round(clamped.raw * 1e3) / 1e3, language)}, the smallest amount worth measuring. This line no longer holds its share of the recipe.`
1969
+ );
1970
+ } else if (movedPrimary) {
1971
+ const moved = primaryBounds.filter((bound) => !landedExactly(bound.exact, bound.amount));
1972
+ sentences.push(
1973
+ moved.map(
1974
+ (bound) => `Rounded ${bound.amount > bound.exact ? "up" : "down"} from ${asPublished(bound.raw, parsed.unit)}.`
1975
+ ).join(" ")
1976
+ );
1977
+ } else if (movedAlternate) {
1978
+ sentences.push(
1979
+ `The amount is exact; the equivalent ${restated ? "beside it" : "in brackets"} was rounded to stay readable.`
1980
+ );
1981
+ } else if (restated) {
1982
+ sentences.push(
1983
+ "This line states one quantity twice, and both readings were scaled. They agree as closely as the page wrote them, and no closer."
1984
+ );
1985
+ }
1986
+ if (hasEmbeddedMeasure(parsed.item, language)) {
1987
+ sentences.push(
1988
+ "This line carries a further quantity after the first one, and only the first was scaled. Read the rest as published."
1989
+ );
1990
+ }
1991
+ if (collapsed) {
1992
+ sentences.push("The page gave a range, and at this size both ends come to the same amount.");
1993
+ }
1994
+ if (unit?.kind === "measured" && low.amount > 0 && low.amount < 0.05) {
1995
+ sentences.push(
1996
+ "This is smaller than a kitchen scale resolves. Make a larger batch, or measure it by eye."
1997
+ );
1998
+ }
1999
+ if (sentences.length > 0) result.note = sentences.join(" ");
2000
+ if (parsed.unit && parsed.unit.kind === "approximate") {
2001
+ result.note = withApproximateNote(parsed.unit, result.note);
2002
+ }
2003
+ if (parsed.articleWord) {
2004
+ const stood = (parsed.amount ?? 0) / (parsed.countMultiplier ?? 1);
2005
+ const read = `"${parsed.articleWord}" read as ${formatAmount(stood, language)}.`;
2006
+ result.note = result.note ? `${read} ${result.note}` : read;
2007
+ }
2008
+ return result;
2009
+ }
2010
+ function withApproximateNote(unit, existing) {
2011
+ const equivalence = approximateEquivalent(unit);
2012
+ const sentence = `A ${unit.canonical} is an approximate measure${equivalence ? `, ${equivalence}` : ""}. The count was scaled and the size of one is the cook's.`;
2013
+ return existing ? `${existing} ${sentence}` : sentence;
2014
+ }
2015
+ function renderMeasure(measure, factor, language) {
2016
+ const scaled = scaleMeasure(
2017
+ measure.amount,
2018
+ measure.amountMax,
2019
+ measure.unit,
2020
+ factor,
2021
+ divisibilityOf(measure.unit, "")
2022
+ );
2023
+ const low = scaled.bounds[0];
2024
+ const high = scaled.bounds[1] ?? null;
2025
+ const unit = scaled.unit;
2026
+ const shown = high?.amount ?? low.amount;
2027
+ const asText = (value) => formatAmount(value, language, { fractions: unit?.kind !== "measured" });
2028
+ return {
2029
+ text: `${renderRange(
2030
+ asText(low.amount),
2031
+ high === null ? null : asText(high.amount),
2032
+ measure.rangeSeparator
2033
+ )}${unit ? ` ${formatUnit(unit, shown, language)}` : ""}`,
2034
+ bounds: scaled.bounds
2035
+ };
2036
+ }
2037
+ function renderRange(low, high, separator) {
2038
+ if (high === null || separator === null) return low;
2039
+ return /^[-–—]$/.test(separator) ? `${low}${separator}${high}` : `${low} ${separator} ${high}`;
2040
+ }
2041
+ function scaleIngredients(lines, options) {
2042
+ return lines.map((line) => scaleIngredient(line, options));
2043
+ }
2044
+ function passthroughIngredient(line, language = "auto") {
2045
+ const parsed = parseIngredient(line, language);
2046
+ const result = {
2047
+ text: parsed.original,
2048
+ original: parsed.original,
2049
+ scaling: parsed.amount === null ? "unscaled" : "scaled",
2050
+ amount: parsed.amount,
2051
+ amountMax: parsed.amountMax,
2052
+ unit: parsed.unit?.canonical ?? null,
2053
+ language: parsed.language
2054
+ };
2055
+ if (parsed.amount === null) result.note = "No quantity given; adjust to taste.";
2056
+ else if (parsed.unit?.kind === "approximate") {
2057
+ result.note = withApproximateNote(parsed.unit, void 0);
2058
+ }
2059
+ return result;
2060
+ }
2061
+ function passthroughIngredients(lines, language = "auto") {
2062
+ return lines.map((line) => passthroughIngredient(line, language));
2063
+ }
2064
+
2065
+ // src/tools/shared.ts
2066
+ import { z } from "zod";
2067
+ var MAX_TEXT_CHARS = 2200;
2068
+ var rowSchema = z.object({
2069
+ id: z.string().describe("Pass this to get_recipe. It names its source, so nothing has to be guessed."),
2070
+ source: z.string().describe("Which source published this row."),
2071
+ source_name: z.string(),
2072
+ title: z.string(),
2073
+ url: z.string().describe("The recipe's own page. Show this when citing it."),
2074
+ image_url: z.string().nullable(),
2075
+ excerpt: z.string().nullable().describe("The gloss or matching passage the source offered, when it offered one.")
2076
+ });
2077
+ var reportSchema = z.object({
2078
+ source: z.string(),
2079
+ name: z.string(),
2080
+ status: z.enum(["answered", "failed"]).describe("Whether this source replied at all."),
2081
+ count: z.number().int().describe("Rows this source contributed to the answer."),
2082
+ reported_total: z.number().int().nullable().describe("What the source said it saw. Null when it states no number at all."),
2083
+ reported_total_means: z.string().nullable().describe(
2084
+ "What 'reported_total' counts on this source. Counts are never summed across sources."
2085
+ ),
2086
+ skipped: z.number().int().describe("Rows this source sent in a shape the server could not read, and left out."),
2087
+ cached: z.boolean().describe("Served from this server's short-lived in-memory cache."),
2088
+ error: z.object({
2089
+ code: z.string(),
2090
+ message: z.string(),
2091
+ hint: z.string().optional()
2092
+ }).nullable().describe("Why this source did not answer. Null when it did.")
2093
+ });
2094
+ var ingredientSchema = z.object({
2095
+ text: z.string().describe("The line as it now reads."),
2096
+ original: z.string().describe("The line as published."),
2097
+ scaling: z.enum(["scaled", "rounded", "unscaled"]).describe(
2098
+ "'scaled' means the arithmetic landed on the exact product, 'rounded' that something had to move for the line to stay usable or that the line carries a second quantity whose match is the page's claim, 'unscaled' that there is nothing to multiply."
2099
+ ),
2100
+ amount: z.number().nullable().describe("The scaled quantity, in 'unit'. Read the two together, never the number alone."),
2101
+ amount_max: z.number().nullable().describe("Upper bound when the line gives a range."),
2102
+ unit: z.string().nullable().describe("The unit 'amount' is in, which may differ from the page's."),
2103
+ language: z.enum(["fr", "en"]).describe("The language the line was read and rewritten in."),
2104
+ note: z.string().optional().describe("What happened to this line, when anything did.")
2105
+ });
2106
+ function toIngredientPayload(entry) {
2107
+ return {
2108
+ text: entry.text,
2109
+ original: entry.original,
2110
+ scaling: entry.scaling,
2111
+ amount: entry.amount,
2112
+ amount_max: entry.amountMax,
2113
+ unit: entry.unit,
2114
+ language: entry.language,
2115
+ ...entry.note ? { note: entry.note } : {}
2116
+ };
2117
+ }
2118
+ function toRowPayload(row) {
2119
+ return {
2120
+ id: row.id,
2121
+ source: row.source,
2122
+ source_name: row.sourceName,
2123
+ title: row.title,
2124
+ url: row.url,
2125
+ image_url: row.imageUrl,
2126
+ // An excerpt is a source's own prose, and a long one crowds out the rows it
2127
+ // was meant to help choose between.
2128
+ excerpt: row.excerpt === null ? null : truncate(row.excerpt, MAX_EXCERPT_CHARS)
2129
+ };
2130
+ }
2131
+ var MAX_EXCERPT_CHARS = 200;
2132
+ function toReportPayload(report) {
2133
+ return {
2134
+ source: report.source,
2135
+ name: report.name,
2136
+ status: report.status,
2137
+ count: report.count,
2138
+ reported_total: report.reportedTotal,
2139
+ reported_total_means: report.reportedTotalMeans,
2140
+ skipped: report.skipped,
2141
+ cached: report.cached,
2142
+ error: report.error ?? null
2143
+ };
2144
+ }
2145
+ function reportNotes(reports) {
2146
+ const notes = [];
2147
+ const answered = reports.filter((report) => report.status === "answered");
2148
+ const failed = reports.filter((report) => report.status === "failed");
2149
+ for (const report of failed) {
2150
+ const consolation = answered.length > 0 ? `This answer holds what the other sources found, and says nothing about what ${report.name} holds.` : "Nothing here is evidence about what it holds.";
2151
+ notes.push(
2152
+ `${report.name} did not answer (${report.error?.code}): ${report.error?.message} ${consolation}`
2153
+ );
2154
+ }
2155
+ for (const report of answered) {
2156
+ if (report.count === 0) {
2157
+ notes.push(
2158
+ `${report.name} answered and offered no row for this wording. That is a statement about the wording as much as about the corpus: try the dish's name in another language, or a main ingredient.`
2159
+ );
2160
+ }
2161
+ if (report.skipped > 0) {
2162
+ notes.push(
2163
+ `${report.name} sent ${report.skipped} row(s) in a shape this server could not read, and they were left out.`
2164
+ );
2165
+ }
2166
+ if (report.reportedTotal !== null && report.reportedTotalMeans !== null) {
2167
+ notes.push(`${report.name} reported ${report.reportedTotal}: ${report.reportedTotalMeans}.`);
2168
+ } else {
2169
+ notes.push(
2170
+ `${report.name} states no total and offers no second page, so a short list here is not evidence that little exists.`
2171
+ );
2172
+ }
2173
+ }
2174
+ if (answered.some((report) => report.mixesReferencePages && report.count > 0)) {
2175
+ const mixing = answered.filter((report) => report.mixesReferencePages && report.count > 0);
2176
+ notes.push(
2177
+ `${mixing.map((report) => report.name).join(" and ")} files pages about an ingredient beside recipes using it, so a row here can be a page about an ingredient rather than a recipe. get_recipe says when a page carries no ingredient list.`
2178
+ );
2179
+ }
2180
+ if (answered.length > 1) {
2181
+ notes.push(
2182
+ "Each count above measures something different, and they are never added together into one total."
2183
+ );
2184
+ }
2185
+ if (reports.some((report) => report.cached)) {
2186
+ notes.push("Part of this answer came from this server's short-lived in-memory cache.");
2187
+ }
2188
+ return notes;
2189
+ }
2190
+ var SERVER_MARKERS = /^(\s*)(Note|Sources?|Hint|Ingredients|Steps|What differs)(\s*:)/gim;
2191
+ function quoteForeign(value) {
2192
+ return value.replace(/[\r\n\u2028\u2029\u0085]+/g, " ").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").replace(/!\[/g, "!\\[").replace(/\s{2,}/g, " ").trim().replace(SERVER_MARKERS, " $1$2$3");
2193
+ }
2194
+ var LOAD_BEARING = /did not answer|could not be read|no longer holds its share|Published under|licen[cs]e|https?:\/\/|rather than a comparison|states no number of servings|no ingredient list|offered no row/i;
2195
+ var NOTE_BUDGET = Math.round(MAX_TEXT_CHARS * 0.55);
2196
+ function buildTrailer(options) {
2197
+ const credit = options.credit ?? "Source: this server called none.";
2198
+ const kept = [...new Set(options.notes ?? [])];
2199
+ while (kept.length > 0 && kept.join("\n").length > NOTE_BUDGET) {
2200
+ let victim = kept.length - 1;
2201
+ for (let index = kept.length - 1; index >= 0; index -= 1) {
2202
+ if (!LOAD_BEARING.test(kept[index])) {
2203
+ victim = index;
2204
+ break;
2205
+ }
2206
+ }
2207
+ kept.splice(victim, 1);
2208
+ }
2209
+ return [...kept.map((note) => `Note: ${note}`), credit].join("\n");
2210
+ }
2211
+ function ok(structured, body, options = {}) {
2212
+ const trailer = buildTrailer(options);
2213
+ const cut = "\n\n[shortened; the full result is in the structured output]";
2214
+ const room = MAX_TEXT_CHARS - `
2215
+
2216
+ ${trailer}`.length;
2217
+ const text2 = body.length <= room ? `${body}
2218
+
2219
+ ${trailer}` : `${truncate(body, Math.max(0, room - cut.length))}${cut}
2220
+
2221
+ ${trailer}`;
2222
+ return { content: [{ type: "text", text: text2 }], structuredContent: structured };
2223
+ }
2224
+ function toToolError(error) {
2225
+ const known = error instanceof RecipesError ? error : new RecipesError("network_error", error instanceof Error ? error.message : String(error));
2226
+ const lines = [`[${known.code}] ${quoteForeign(known.message)}`];
2227
+ if (known.details.hint) lines.push(`Hint: ${quoteForeign(known.details.hint)}`);
2228
+ return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
2229
+ }
2230
+ function roomForBody(options = {}) {
2231
+ return Math.max(200, MAX_TEXT_CHARS - buildTrailer(options).length - 60);
2232
+ }
2233
+ function truncate(text2, maxChars) {
2234
+ if (text2.length <= maxChars) return text2;
2235
+ return `${text2.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
2236
+ }
2237
+ function renderRows(rows) {
2238
+ return rows.map((row, index) => {
2239
+ const head = `${index + 1}. ${quoteForeign(row.title)} \xB7 ${quoteForeign(row.source_name)} \xB7 id: ${row.id}`;
2240
+ return `${head}
2241
+ ${quoteForeign(row.url)}`;
2242
+ }).join("\n");
2243
+ }
2244
+ function creditLine(contributors) {
2245
+ if (contributors.length === 0) return "No source contributed to this answer.";
2246
+ const names2 = contributors.map(
2247
+ (entry) => entry.url ? `${entry.attribution} \u2014 ${quoteForeign(entry.url)}` : entry.attribution
2248
+ );
2249
+ return names2.join("\n");
2250
+ }
2251
+
2252
+ // src/tools/recipeView.ts
2253
+ var yieldSchema = z2.object({
2254
+ original_count: z2.number().nullable().describe("Servings the page states as a number. Null when it states none."),
2255
+ original_max: z2.number().nullable().describe("The upper end when the page states a span, as in '4 \xE0 6 personnes'."),
2256
+ original_text: z2.string().nullable().describe(
2257
+ "The yield in the page's own words. '4 \xE0 6 personnes' and '4 personnes' are different claims, so the wording is kept."
2258
+ ),
2259
+ requested: z2.number().int().nullable().describe("Servings asked for, when any were."),
2260
+ unit: z2.string().nullable().describe("What the yield counts when it counts something other than people, such as 'balls'."),
2261
+ factor: z2.number().nullable().describe("What the quantities were multiplied by. Null when nothing was rescaled.")
2262
+ });
2263
+ var ratingSchema = z2.object({
2264
+ value: z2.number(),
2265
+ count: z2.number().int().nullable().describe("How many readers rated it, when the source says."),
2266
+ max: z2.number().nullable().describe("The top of the scale. A rating read without its scale means nothing.")
2267
+ });
2268
+ var recipeSchema = z2.object({
2269
+ id: z2.string(),
2270
+ source: z2.string(),
2271
+ source_name: z2.string(),
2272
+ language: z2.enum(["fr", "en"]).describe("The language this recipe is written in."),
2273
+ title: z2.string(),
2274
+ url: z2.string(),
2275
+ image_url: z2.string().nullable(),
2276
+ yield: yieldSchema,
2277
+ ingredients: z2.array(ingredientSchema),
2278
+ steps: z2.array(z2.string()),
2279
+ prep_minutes: z2.number().int().nullable(),
2280
+ cook_minutes: z2.number().int().nullable(),
2281
+ total_minutes: z2.number().int().nullable(),
2282
+ category: z2.string().nullable(),
2283
+ author: z2.string().nullable().describe("Null on a source written by everyone who edited the page."),
2284
+ rating: ratingSchema.nullable().describe("Null on a source that carries no reader rating."),
2285
+ nutrition: z2.record(z2.string(), z2.string()).nullable().describe("The panel as published, key by key. Figures the page omits are absent, never zero."),
2286
+ equipment: z2.array(z2.string()),
2287
+ tips: z2.array(z2.string()),
2288
+ license: z2.object({ title: z2.string(), url: z2.string() }).nullable().describe("Terms the page is published under, when the source states them."),
2289
+ attribution: z2.string().describe("Show this, with the url, when repeating anything from here."),
2290
+ sections_returned: z2.array(z2.string()).describe("The sections this answer carries. Anything outside this list was not looked at."),
2291
+ sections_omitted: z2.array(z2.string()).describe(
2292
+ "Sections nobody asked for. A field belonging to one of these is empty because it was not requested, never because the page states nothing."
2293
+ ),
2294
+ scaling_summary: z2.object({
2295
+ scaled_count: z2.number().int(),
2296
+ rounded_count: z2.number().int(),
2297
+ unscaled_count: z2.number().int()
2298
+ })
2299
+ });
2300
+ var SECTIONS = [
2301
+ "ingredients",
2302
+ "steps",
2303
+ "times",
2304
+ "nutrition",
2305
+ "tips",
2306
+ "equipment"
2307
+ ];
2308
+ function resolveFactor(recipe, servings) {
2309
+ if (servings === null) return { factor: null, notes: [] };
2310
+ if (recipe.yieldCount === null || recipe.yieldCount <= 0) {
2311
+ return {
2312
+ factor: null,
2313
+ notes: [
2314
+ `${recipe.sourceName} states no number of servings for this recipe, so the quantities are as published. Use scale_ingredients with a factor you choose if you know what it serves.`
2315
+ ]
2316
+ };
2317
+ }
2318
+ const factor = servings / recipe.yieldCount;
2319
+ const notes = [];
2320
+ if (factor === 1) {
2321
+ return {
2322
+ factor: 1,
2323
+ notes: [`The page already yields ${recipe.yieldCount}, so nothing was multiplied.`]
2324
+ };
2325
+ }
2326
+ if (recipe.yieldMax !== null && recipe.yieldMax > recipe.yieldCount) {
2327
+ notes.push(
2328
+ `The page states a span rather than a number: ${quoteForeign(recipe.yieldText ?? "")}. The factor was taken from the lower end, so ${servings} is what this makes at that end. Taken from ${recipe.yieldMax} it would be ${round(servings / recipe.yieldMax)} instead.`
2329
+ );
2330
+ }
2331
+ const unit = recipe.yieldUnit;
2332
+ if (unit && !/personne|people|serving|portion|part\b/i.test(unit)) {
2333
+ notes.push(
2334
+ `This page states its yield in ${quoteForeign(unit)} rather than in servings, so asking for ${servings} multiplied the quantities by ${round(factor)} to give ${servings} ${quoteForeign(unit)}.`
2335
+ );
2336
+ }
2337
+ return { factor, notes };
2338
+ }
2339
+ function round(value) {
2340
+ return Math.round(value * 1e3) / 1e3;
2341
+ }
2342
+ function buildRecipeView(recipe, options) {
2343
+ const { factor, notes: yieldNotes } = resolveFactor(recipe, options.servings);
2344
+ const wants = (section) => options.sections.includes(section);
2345
+ const ingredients = factor === null || factor === 1 ? passthroughIngredients(recipe.ingredients, recipe.language) : scaleIngredients(recipe.ingredients, { factor, language: recipe.language });
2346
+ const notes = [...yieldNotes];
2347
+ if (recipe.ingredients.length === 0) {
2348
+ notes.push(
2349
+ `${recipe.sourceName} publishes no ingredient list on this page. It is likely a page about the dish rather than a recipe for it, so there is nothing here to scale. Search again and pick another row.`
2350
+ );
2351
+ }
2352
+ const rounded = ingredients.filter((entry) => entry.scaling === "rounded");
2353
+ const unscaled = ingredients.filter((entry) => entry.scaling === "unscaled");
2354
+ if (rounded.length > 0) {
2355
+ notes.push(
2356
+ `${rounded.length} line(s) did not land on the exact product; each says in its own note what happened to it.`
2357
+ );
2358
+ }
2359
+ if (unscaled.length > 0 && factor !== null && factor !== 1) {
2360
+ notes.push(
2361
+ `${unscaled.length} line(s) carry no quantity to multiply and are repeated as published.`
2362
+ );
2363
+ }
2364
+ if (recipe.license) {
2365
+ notes.push(
2366
+ `Published under ${quoteForeign(recipe.license.title)}: ${quoteForeign(recipe.license.url)}`
2367
+ );
2368
+ }
2369
+ const omitted = SECTIONS.filter((section) => !wants(section));
2370
+ if (options.announceSections !== false && omitted.length > 0) {
2371
+ notes.push(
2372
+ `Not requested, so not returned: ${omitted.join(", ")}. Name them in 'sections' to see them.`
2373
+ );
2374
+ }
2375
+ const steps = wants("steps") ? recipe.steps : [];
2376
+ const shownSteps = steps.slice(0, options.maxSteps ?? steps.length).map((step) => truncate(step, options.maxStepChars ?? step.length));
2377
+ if (steps.length > shownSteps.length) {
2378
+ notes.push(
2379
+ `The method runs to ${steps.length} steps and ${shownSteps.length} are here. Raise 'max_steps' for the rest.`
2380
+ );
2381
+ }
2382
+ const payload = {
2383
+ id: recipe.id,
2384
+ source: recipe.source,
2385
+ source_name: recipe.sourceName,
2386
+ language: recipe.language,
2387
+ title: recipe.title,
2388
+ url: recipe.url,
2389
+ image_url: recipe.imageUrl,
2390
+ yield: {
2391
+ original_count: recipe.yieldCount,
2392
+ original_max: recipe.yieldMax,
2393
+ original_text: recipe.yieldText,
2394
+ requested: options.servings,
2395
+ unit: recipe.yieldUnit,
2396
+ factor: factor === null ? null : round(factor)
2397
+ },
2398
+ ingredients: wants("ingredients") ? ingredients.map(toIngredientPayload) : [],
2399
+ steps: shownSteps,
2400
+ prep_minutes: wants("times") ? recipe.prepMinutes : null,
2401
+ cook_minutes: wants("times") ? recipe.cookMinutes : null,
2402
+ total_minutes: wants("times") ? recipe.totalMinutes : null,
2403
+ category: recipe.category,
2404
+ author: recipe.author,
2405
+ rating: recipe.rating,
2406
+ nutrition: wants("nutrition") ? recipe.nutrition : null,
2407
+ equipment: wants("equipment") ? recipe.equipment : [],
2408
+ tips: wants("tips") ? recipe.tips : [],
2409
+ license: recipe.license,
2410
+ attribution: recipe.attribution,
2411
+ sections_returned: [...options.sections],
2412
+ sections_omitted: omitted,
2413
+ scaling_summary: {
2414
+ scaled_count: ingredients.filter((entry) => entry.scaling === "scaled").length,
2415
+ rounded_count: rounded.length,
2416
+ unscaled_count: unscaled.length
2417
+ }
2418
+ };
2419
+ if (options.label) {
2420
+ const label = quoteForeign(options.label);
2421
+ return { payload, notes: notes.map((line) => `${label}: ${line}`), ingredients };
2422
+ }
2423
+ return { payload, notes, ingredients };
2424
+ }
2425
+ function renderYield(payload) {
2426
+ const published = payload.yield.original_text ?? "an amount the page does not state";
2427
+ if (payload.yield.factor === null) return `Yields ${quoteForeign(published)} (as published).`;
2428
+ return `Yields ${quoteForeign(published)} as published, scaled by ${payload.yield.factor} for ${payload.yield.requested}.`;
2429
+ }
2430
+
2431
+ // src/tools/arguments.ts
2432
+ import { z as z3 } from "zod";
2433
+ var INVALID_INPUT = "invalid_input";
2434
+ function strictInput(shape) {
2435
+ const declared = Object.keys(shape);
2436
+ return z3.strictObject(shape, {
2437
+ error: (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : void 0
2438
+ });
2439
+ }
2440
+ function unknownArgumentMessage(keys, declared) {
2441
+ const named = keys.map((key) => {
2442
+ const near = nearestArgument(key, declared);
2443
+ return near ? `'${key}' (did you mean '${near}'?)` : `'${key}'`;
2444
+ }).join(", ");
2445
+ return `[${INVALID_INPUT}] Unknown ${keys.length > 1 ? "arguments" : "argument"} ${named}. This tool takes: ${declared.join(", ")}.`;
2446
+ }
2447
+ function nearestArgument(key, declared) {
2448
+ const flatten = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
2449
+ const flat = flatten(key);
2450
+ if (flat.length === 0) return void 0;
2451
+ const sameName = declared.find((name) => flatten(name) === flat);
2452
+ if (sameName) return sameName;
2453
+ const overlapping = declared.find((name) => {
2454
+ const other = flatten(name);
2455
+ const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
2456
+ return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
2457
+ });
2458
+ if (overlapping) return overlapping;
2459
+ let closest;
2460
+ let shortest = Number.POSITIVE_INFINITY;
2461
+ for (const name of declared) {
2462
+ const distance = editDistance(flat, flatten(name));
2463
+ if (distance < shortest) {
2464
+ shortest = distance;
2465
+ closest = name;
2466
+ }
2467
+ }
2468
+ return shortest <= Math.max(1, Math.floor(flat.length / 3)) ? closest : void 0;
2469
+ }
2470
+ function editDistance(left, right) {
2471
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
2472
+ for (let row = 1; row <= left.length; row += 1) {
2473
+ const current = [row];
2474
+ for (let column = 1; column <= right.length; column += 1) {
2475
+ const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
2476
+ current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
2477
+ }
2478
+ previous = current;
2479
+ }
2480
+ return previous[right.length];
2481
+ }
2482
+
2483
+ // src/tools/compareRecipes.ts
2484
+ var compareRecipesDescription = [
2485
+ "Take a dish and show how each source writes it, side by side.",
2486
+ "Each source's closest match is read in full, and all of them can be rescaled to the same number of servings so the ingredient lists stand comparison.",
2487
+ "The answer states what differs and leaves it there: the quantities each asks for, what each measures in, what each yields, and which fields each source publishes at all.",
2488
+ "No version is ranked above another. Some sources carry reader ratings and some have no author and no rating by nature, so there is no score they share.",
2489
+ "When only one source answers, the answer says so and shows that one rather than presenting part of a comparison as the whole of it."
2490
+ ].join(" ");
2491
+ var compareRecipesInput = strictInput({
2492
+ dish: z4.string().min(1).max(200).describe("The dish to compare, in any of the languages the sources publish in."),
2493
+ servings: z4.number().int().min(1).max(500).optional().describe("Rescale every version to this many, which is what makes the lists comparable."),
2494
+ sections: z4.array(z4.enum(SECTIONS)).default(["ingredients"]).describe("Which parts of each version to return. Several full recipes is a lot of text."),
2495
+ max_steps: z4.number().int().min(1).max(100).default(10).describe("Steps to return per version."),
2496
+ max_step_chars: z4.number().int().min(80).max(4e3).default(600).describe("Characters kept per step. Raise it only if a step was cut mid-sentence.")
2497
+ });
2498
+ var compareRecipesOutput = z4.object({
2499
+ dish: z4.string(),
2500
+ versions: z4.array(recipeSchema),
2501
+ differences: z4.array(z4.string()).describe("What differs between the versions, stated as fact. No version is ranked."),
2502
+ per_source: z4.array(reportSchema),
2503
+ notes: z4.array(z4.string())
2504
+ });
2505
+ var TEXT_INGREDIENT_LINES = 8;
2506
+ var TEXT_DIFFERENCE_LINES = 4;
2507
+ function listNames(names2) {
2508
+ if (names2.length <= 1) return names2.join("");
2509
+ return `${names2.slice(0, -1).join(", ")} and ${names2[names2.length - 1]}`;
2510
+ }
2511
+ function describeDifferences(recipes, payloads) {
2512
+ if (recipes.length < 2) return [];
2513
+ const differences = [];
2514
+ const nameOf = (recipe) => recipe.sourceName;
2515
+ const yields = payloads.map((payload) => payload.yield.original_text ?? "no stated amount");
2516
+ if (new Set(yields).size > 1) {
2517
+ differences.push(
2518
+ `${payloads.map((payload, index) => `${payload.source_name} yields ${quoteForeign(yields[index])}`).join("; ")}.`
2519
+ );
2520
+ }
2521
+ const counts = payloads.map((payload) => payload.ingredients.length);
2522
+ if (new Set(counts).size > 1 && counts.every((value) => value > 0)) {
2523
+ differences.push(
2524
+ `${payloads.map(
2525
+ (payload) => `${payload.source_name} lists ${payload.ingredients.length} ingredient lines`
2526
+ ).join("; ")}.`
2527
+ );
2528
+ }
2529
+ const timed = recipes.filter((recipe) => recipe.totalMinutes !== null);
2530
+ const untimed = recipes.filter((recipe) => recipe.totalMinutes === null);
2531
+ if (timed.length > 0 && new Set(timed.map((recipe) => recipe.totalMinutes)).size > 1) {
2532
+ differences.push(
2533
+ `${timed.map((recipe) => `${nameOf(recipe)} states ${recipe.totalMinutes} minutes`).join("; ")}.`
2534
+ );
2535
+ }
2536
+ if (timed.length > 0 && untimed.length > 0) {
2537
+ differences.push(
2538
+ `${listNames(untimed.map(nameOf))} states no time for this recipe, so there is nothing there to compare against.`
2539
+ );
2540
+ }
2541
+ const rated = recipes.filter((recipe) => recipe.rating !== null);
2542
+ if (rated.length > 0 && rated.length < recipes.length) {
2543
+ differences.push(
2544
+ `Only ${listNames(rated.map(nameOf))} carries a reader rating, so the versions cannot be compared on one.`
2545
+ );
2546
+ }
2547
+ const credited = recipes.filter((recipe) => recipe.author !== null);
2548
+ if (credited.length > 0 && credited.length < recipes.length) {
2549
+ differences.push(
2550
+ `Only ${listNames(credited.map(nameOf))} credits an author; the rest are written by whoever edited the page.`
2551
+ );
2552
+ }
2553
+ const licensed = recipes.filter((recipe) => recipe.license !== null);
2554
+ for (const recipe of licensed) {
2555
+ differences.push(
2556
+ `${nameOf(recipe)} publishes under ${quoteForeign(recipe.license.title)}, which asks for attribution.`
2557
+ );
2558
+ }
2559
+ const silent = recipes.filter((recipe) => recipe.license === null);
2560
+ if (silent.length > 0 && licensed.length > 0) {
2561
+ differences.push(
2562
+ `${listNames(silent.map(nameOf))} states no terms of use. Silence is not permission.`
2563
+ );
2564
+ }
2565
+ return differences;
2566
+ }
2567
+ async function runCompareRecipes(client, args) {
2568
+ try {
2569
+ const merged = await client.searchRecipes(args.dish, 3);
2570
+ const best = /* @__PURE__ */ new Map();
2571
+ for (const row of merged.rows) {
2572
+ if (!best.has(row.source)) best.set(row.source, row.id);
2573
+ }
2574
+ const offered = [...best.entries()];
2575
+ const reads = await Promise.allSettled(offered.map(([, id]) => client.getRecipe(id)));
2576
+ const recipes = [];
2577
+ const unread = /* @__PURE__ */ new Map();
2578
+ reads.forEach((read, index) => {
2579
+ const source = offered[index][0];
2580
+ if (read.status === "fulfilled") {
2581
+ recipes.push(read.value.recipe);
2582
+ return;
2583
+ }
2584
+ const reason = read.reason;
2585
+ unread.set(source, `${reason?.code ?? "network_error"}: ${reason?.message ?? "unknown"}`);
2586
+ });
2587
+ const views = recipes.map(
2588
+ (recipe) => buildRecipeView(recipe, {
2589
+ servings: args.servings ?? null,
2590
+ sections: args.sections,
2591
+ maxSteps: args.max_steps,
2592
+ maxStepChars: args.max_step_chars,
2593
+ label: recipe.sourceName,
2594
+ // The choice of sections belongs to the call rather than to a version,
2595
+ // so it is said once below instead of once per recipe.
2596
+ announceSections: false
2597
+ })
2598
+ );
2599
+ const payloads = views.map((view) => view.payload);
2600
+ const notes = reportNotes(merged.reports);
2601
+ for (const view of views) notes.push(...view.notes);
2602
+ const omitted = SECTIONS.filter((section) => !args.sections.includes(section));
2603
+ if (omitted.length > 0) {
2604
+ notes.push(
2605
+ `Not requested, so not returned on any version: ${omitted.join(", ")}. Name them in 'sections' to see them.`
2606
+ );
2607
+ }
2608
+ for (const report of merged.reports) {
2609
+ if (recipes.some((recipe) => recipe.source === report.source)) continue;
2610
+ const failedRead = unread.get(report.source);
2611
+ if (report.status === "failed") {
2612
+ notes.push(
2613
+ `${report.name} is missing from this comparison because its search did not answer, not because it holds nothing.`
2614
+ );
2615
+ } else if (failedRead) {
2616
+ notes.push(
2617
+ `${report.name} offered a recipe and it could not be read (${quoteForeign(failedRead)}), so its version is missing rather than absent.`
2618
+ );
2619
+ } else {
2620
+ notes.push(
2621
+ `${report.name} answered and offered nothing close enough to "${quoteForeign(args.dish)}" to compare.`
2622
+ );
2623
+ }
2624
+ }
2625
+ if (payloads.length === 1) {
2626
+ notes.push(
2627
+ "This is one version rather than a comparison. Read it as what that one source publishes."
2628
+ );
2629
+ }
2630
+ const differences = describeDifferences(recipes, payloads);
2631
+ if (payloads.length > 1) {
2632
+ notes.push(
2633
+ "Quantities are shown in the units each source published. Nothing was converted between measuring systems, so a gram in one version and a cup in another are two ways of writing a recipe rather than one quantity restated."
2634
+ );
2635
+ }
2636
+ const credit = creditLine(
2637
+ payloads.map((payload) => ({ attribution: payload.attribution, url: payload.url }))
2638
+ );
2639
+ const shownDifferences = differences.slice(0, TEXT_DIFFERENCE_LINES);
2640
+ const moreDifferences = differences.length - shownDifferences.length;
2641
+ const differencesBlock = differences.length > 0 ? `What differs:
2642
+ ${shownDifferences.map((line) => `- ${line}`).join("\n")}${moreDifferences > 0 ? `
2643
+ - \u2026 and ${moreDifferences} more, in full in the structured output` : ""}
2644
+
2645
+ ` : "";
2646
+ const share = payloads.length === 0 ? 0 : (roomForBody({ notes, credit }) - differencesBlock.length) / payloads.length;
2647
+ const versionBlocks = payloads.map((payload) => {
2648
+ const head = [
2649
+ `${quoteForeign(payload.source_name)}: ${quoteForeign(payload.title)}`,
2650
+ ` ${quoteForeign(payload.url)}`,
2651
+ ` yields ${quoteForeign(payload.yield.original_text ?? "an unstated amount")}${payload.yield.factor === null ? "" : `, scaled by ${payload.yield.factor}`}`
2652
+ ];
2653
+ const lines = [...head];
2654
+ let used = head.join("\n").length;
2655
+ let shown = 0;
2656
+ for (const entry of payload.ingredients.slice(0, TEXT_INGREDIENT_LINES)) {
2657
+ const line = ` - ${quoteForeign(entry.text)}`;
2658
+ if (used + line.length > share) break;
2659
+ lines.push(line);
2660
+ used += line.length + 1;
2661
+ shown += 1;
2662
+ }
2663
+ const hidden = payload.ingredients.length - shown;
2664
+ if (hidden > 0) lines.push(` \u2026 and ${hidden} more, in full in the structured output`);
2665
+ return lines.join("\n");
2666
+ });
2667
+ const body = payloads.length === 0 ? unread.size > 0 ? `Every version of "${quoteForeign(args.dish)}" that was offered could not be read, so nothing was compared.` : `No source offered a recipe for "${quoteForeign(args.dish)}".` : versionBlocks.join("\n\n");
2668
+ const summary = `${differencesBlock}${body}`;
2669
+ return ok(
2670
+ {
2671
+ dish: args.dish,
2672
+ versions: payloads,
2673
+ differences,
2674
+ per_source: merged.reports.map(toReportPayload),
2675
+ notes
2676
+ },
2677
+ summary,
2678
+ { notes, credit }
2679
+ );
2680
+ } catch (error) {
2681
+ return toToolError(error);
2682
+ }
2683
+ }
2684
+
2685
+ // src/tools/getRecipe.ts
2686
+ import { z as z5 } from "zod";
2687
+ var getRecipeDescription = [
2688
+ "Read one recipe in full: its ingredients, its steps, what it yields, and whatever times, rating and nutrition its source publishes.",
2689
+ "'id' must come from search_recipes. It names the source, so this reads the right one without guessing; an identifier no source would have minted is refused, because sending it anywhere would answer about the wrong dish.",
2690
+ "Pass 'servings' to rescale. Quantities land where a kitchen can follow them: an egg stays whole, anything that pours or cuts can halve, a small measurement moves to a smaller unit before it is rounded, and anything unmultipliable is flagged rather than scaled.",
2691
+ "A page that states no number of servings comes back as published and says so, because dividing by a yield nobody wrote would answer for a number of people the page never claimed.",
2692
+ "'sections' decides what comes back, and 'sections_omitted' names what was left out: a field belonging to an omitted section is empty because nobody asked for it, never because the page states nothing.",
2693
+ "A field a source does not publish is null, never zero. Credit the source and link the url when you repeat any of it."
2694
+ ].join(" ");
2695
+ var getRecipeInput = strictInput({
2696
+ id: z5.string().min(1).max(500).describe("From search_recipes, such as 'marmiton:44078' or 'cookbook:Cookbook:Carbonara'."),
2697
+ servings: z5.number().int().min(1).max(500).optional().describe("Rescale to this many. Left out, the quantities come back as published."),
2698
+ sections: z5.array(z5.enum(SECTIONS)).default(["ingredients", "steps"]).describe("Which parts to return. A full recipe is a lot of text, so this defaults to two."),
2699
+ max_steps: z5.number().int().min(1).max(100).default(20).describe("Steps to return. The answer says how many more there are."),
2700
+ max_step_chars: z5.number().int().min(80).max(4e3).default(600).describe("Characters kept per step. Raise it only if a step was cut mid-sentence.")
2701
+ });
2702
+ var getRecipeOutput = z5.object({
2703
+ recipe: recipeSchema,
2704
+ id_read_as: z5.string().nullable().describe("How a raw identifier was routed, when it was not spelled with its source."),
2705
+ notes: z5.array(z5.string())
2706
+ });
2707
+ async function runGetRecipe(client, args) {
2708
+ try {
2709
+ const { recipe, cached, read } = await client.getRecipe(args.id);
2710
+ const view = buildRecipeView(recipe, {
2711
+ servings: args.servings ?? null,
2712
+ sections: args.sections,
2713
+ maxSteps: args.max_steps,
2714
+ maxStepChars: args.max_step_chars
2715
+ });
2716
+ const notes = [...view.notes];
2717
+ if (read.inferred) {
2718
+ notes.unshift(
2719
+ `"${quoteForeign(args.id)}" was read as ${read.source.name}'s, because ${read.inferred}. Spell an id with its source to leave nothing to infer.`
2720
+ );
2721
+ }
2722
+ if (cached) notes.push("Served from this server's short-lived in-memory cache.");
2723
+ const payload = view.payload;
2724
+ const lines = [
2725
+ `${quoteForeign(payload.title)} \xB7 ${quoteForeign(payload.source_name)}`,
2726
+ quoteForeign(payload.url),
2727
+ renderYield(payload)
2728
+ ];
2729
+ if (payload.ingredients.length > 0) {
2730
+ lines.push(
2731
+ "",
2732
+ "Ingredients:",
2733
+ ...payload.ingredients.map(
2734
+ (entry) => entry.scaling === "unscaled" ? `- ${quoteForeign(entry.text)} (no quantity)` : `- ${quoteForeign(entry.text)}`
2735
+ )
2736
+ );
2737
+ }
2738
+ if (payload.steps.length > 0) {
2739
+ lines.push(
2740
+ "",
2741
+ "Steps:",
2742
+ ...payload.steps.map((step, index) => `${index + 1}. ${quoteForeign(step)}`)
2743
+ );
2744
+ }
2745
+ return ok({ recipe: payload, id_read_as: read.inferred, notes }, lines.join("\n"), {
2746
+ notes,
2747
+ credit: creditLine([{ attribution: payload.attribution, url: payload.url }])
2748
+ });
2749
+ } catch (error) {
2750
+ return toToolError(error);
2751
+ }
2752
+ }
2753
+
2754
+ // src/tools/scaleIngredients.ts
2755
+ import { z as z6 } from "zod";
2756
+ var scaleIngredientsDescription = [
2757
+ "Multiply a list of ingredient lines, in French, in English, or in a list holding both.",
2758
+ "Give either 'factor', or 'from_servings' and 'to_servings' and the factor is worked out from them.",
2759
+ "Quantities land where a kitchen can follow them: an egg stays whole because half of one is not something a cook takes out of the shell, while anything that pours, weighs or cuts can halve, a spoonful shrinks into the smaller spoon before it is rounded, and a pinch keeps whatever size a hand gives it while its count is multiplied.",
2760
+ "Every line comes back with 'scaling': 'scaled' when the arithmetic landed on the exact product, 'rounded' when something had to move for the line to stay usable, 'unscaled' when the line carries nothing to multiply. A rounded line says what it was rounded from and in which direction.",
2761
+ "No quantity is converted between measuring systems: grams stay grams and cups stay cups, because a conversion changes what the recipe said."
2762
+ ].join(" ");
2763
+ var scaleIngredientsInput = strictInput({
2764
+ ingredients: z6.array(z6.string().max(500)).min(1).max(200).describe(
2765
+ "The lines as written, one ingredient each, such as '200 g de farine' or '3 eggs'. One line, not a whole recipe."
2766
+ ),
2767
+ factor: z6.number().gt(0).max(1e3).optional().describe(
2768
+ "What to multiply by. Give exactly one of: 'factor', or both 'from_servings' and 'to_servings'."
2769
+ ),
2770
+ from_servings: z6.number().int().min(1).max(500).optional().describe("What the list serves now. Give it with 'to_servings', and without 'factor'."),
2771
+ to_servings: z6.number().int().min(1).max(500).optional().describe("What it should serve. Give it with 'from_servings', and without 'factor'."),
2772
+ language: z6.enum(["auto", "fr", "en"]).default("auto").describe(
2773
+ "'auto' reads each line on its own, which is what a list holding both languages needs. Name a language to read every line that way."
2774
+ )
2775
+ });
2776
+ var scaleIngredientsOutput = z6.object({
2777
+ factor: z6.number().describe("What every quantity was multiplied by."),
2778
+ language: z6.string().describe("How the lines were read."),
2779
+ ingredients: z6.array(ingredientSchema),
2780
+ scaled_count: z6.number().int().describe("Lines whose arithmetic came out exact."),
2781
+ rounded_count: z6.number().int().describe("Lines whose value moved to stay usable."),
2782
+ unscaled_count: z6.number().int().describe("Lines carrying nothing that can be multiplied."),
2783
+ notes: z6.array(z6.string())
2784
+ });
2785
+ function resolveFactor2(args) {
2786
+ const hasPair = args.from_servings !== void 0 && args.to_servings !== void 0;
2787
+ if (args.factor !== void 0 && hasPair) {
2788
+ throw invalidInput(
2789
+ "Give either 'factor' or the pair of serving counts, not both.",
2790
+ `factor=${args.factor} and ${args.from_servings}\u2192${args.to_servings} would be two different multipliers.`
2791
+ );
2792
+ }
2793
+ if (args.factor !== void 0) return args.factor;
2794
+ if (hasPair) return args.to_servings / args.from_servings;
2795
+ if (args.from_servings !== void 0 || args.to_servings !== void 0) {
2796
+ throw invalidInput(
2797
+ "Serving counts come in pairs: give both 'from_servings' and 'to_servings'.",
2798
+ "Or give 'factor' on its own."
2799
+ );
2800
+ }
2801
+ throw invalidInput(
2802
+ "Nothing said how much to multiply by.",
2803
+ "Give 'factor', or both 'from_servings' and 'to_servings'."
2804
+ );
2805
+ }
2806
+ function runScaleIngredients(args) {
2807
+ try {
2808
+ const factor = resolveFactor2(args);
2809
+ const scaled = scaleIngredients(args.ingredients, {
2810
+ factor,
2811
+ language: args.language
2812
+ });
2813
+ const counts = {
2814
+ scaled_count: scaled.filter((entry) => entry.scaling === "scaled").length,
2815
+ rounded_count: scaled.filter((entry) => entry.scaling === "rounded").length,
2816
+ unscaled_count: scaled.filter((entry) => entry.scaling === "unscaled").length
2817
+ };
2818
+ const notes = [];
2819
+ if (counts.rounded_count > 0) {
2820
+ notes.push(
2821
+ `${counts.rounded_count} line(s) did not land on the exact product; each says in its own note what happened to it.`
2822
+ );
2823
+ }
2824
+ if (counts.unscaled_count > 0) {
2825
+ notes.push(
2826
+ `${counts.unscaled_count} line(s) carry no quantity to multiply and are repeated as given.`
2827
+ );
2828
+ }
2829
+ if (args.language === "auto") {
2830
+ const languages = new Set(scaled.map((entry) => entry.language));
2831
+ if (languages.size > 1) {
2832
+ notes.push(
2833
+ "This list holds lines in both languages, and each was read and rewritten in its own."
2834
+ );
2835
+ }
2836
+ }
2837
+ if (scaled.some((entry) => entry.note?.includes("approximate measure"))) {
2838
+ notes.push(
2839
+ "A pinch, a handful and their kind were multiplied by their count. The size of one stays whatever the cook's hand gives it, and none of them was turned into grams or spoons."
2840
+ );
2841
+ }
2842
+ const body = scaled.map((entry, index) => `${index + 1}. ${quoteForeign(entry.text)}`).join("\n");
2843
+ return ok(
2844
+ {
2845
+ factor: Math.round(factor * 1e3) / 1e3,
2846
+ language: args.language,
2847
+ ingredients: scaled.map(toIngredientPayload),
2848
+ ...counts,
2849
+ notes
2850
+ },
2851
+ `Scaled by ${Math.round(factor * 1e3) / 1e3}:
2852
+ ${body}`,
2853
+ { notes, credit: "Source: the list you supplied. Scaled offline; nothing was fetched." }
2854
+ );
2855
+ } catch (error) {
2856
+ return toToolError(error);
2857
+ }
2858
+ }
2859
+
2860
+ // src/tools/searchRecipes.ts
2861
+ import { z as z7 } from "zod";
2862
+ var searchRecipesDescription = [
2863
+ "Search every recipe source this server reads, at the same time, for a dish or an ingredient, and get one merged list.",
2864
+ "Each row carries the id get_recipe takes, and that id names the source it came from, so nothing has to be guessed afterwards.",
2865
+ "The sources are written in different languages and count their own results differently, so 'per_source' says what each one answered, what its own number means, and names any that failed. A short list is never evidence of what exists.",
2866
+ "Some sources keep recipes and reference pages together, so a row can be a page about an ingredient rather than a recipe using it. Only get_recipe can tell them apart, and it says when a page carries no ingredient list.",
2867
+ "The query goes to each source's own search as free text. There is no filtering: a word naming a diet, a time or a calorie count matches only where that source's index happens to carry it.",
2868
+ "Rows are interleaved one source at a time rather than ranked, because the sources share no score that could order them against each other."
2869
+ ].join(" ");
2870
+ var searchRecipesInput = strictInput({
2871
+ query: z7.string().min(1).max(200).describe("A dish or an ingredient, in any of the languages the sources publish in."),
2872
+ limit_per_source: z7.number().int().min(1).max(25).default(5).describe("Rows to take from each source, so one source cannot fill the whole list."),
2873
+ sources: z7.array(z7.string()).optional().describe(
2874
+ "Sources to ask, by id. Left out, they are all asked, which is the point of this tool. The ids are the ones 'per_source' reports."
2875
+ )
2876
+ });
2877
+ var searchRecipesOutput = z7.object({
2878
+ query: z7.string(),
2879
+ results: z7.array(rowSchema),
2880
+ result_count: z7.number().int().describe("Rows in this answer, across every source."),
2881
+ per_source: z7.array(reportSchema),
2882
+ order: z7.string().describe("How the list was built, in words."),
2883
+ notes: z7.array(z7.string())
2884
+ });
2885
+ var WORDS_BEFORE_SAYING_SO = 4;
2886
+ async function runSearchRecipes(client, args) {
2887
+ try {
2888
+ const merged = await client.searchRecipes(
2889
+ args.query,
2890
+ args.limit_per_source,
2891
+ args.sources
2892
+ );
2893
+ const results = merged.rows.map(toRowPayload);
2894
+ const notes = reportNotes(merged.reports);
2895
+ const answered = merged.reports.filter((report) => report.status === "answered");
2896
+ const contributed = merged.reports.filter((report) => report.count > 0);
2897
+ const order = contributed.length > 1 ? "One row from each source in turn, in the order each source returned them. No score orders them against each other." : contributed.length === 1 ? `Every row came from ${contributed[0].name}, in the order it returned them.` : "No source contributed a row.";
2898
+ if (args.query.trim().split(/\s+/).length > WORDS_BEFORE_SAYING_SO) {
2899
+ notes.push(
2900
+ "This query went to each source as free text and no filter was applied to it. Words like 'vegan' or 'under 300 calories' help only where a source's own index happens to match them, so read the rows rather than assuming they meet every condition."
2901
+ );
2902
+ }
2903
+ if (results.length === 0 && answered.length === merged.reports.length) {
2904
+ notes.push(
2905
+ "Every source answered and none holds anything under this wording. Try the dish's name in another language, or name a main ingredient instead."
2906
+ );
2907
+ }
2908
+ const body = results.length > 0 ? `${results.length} recipes for "${args.query}":
2909
+ ${renderRows(results)}` : answered.length === 0 ? `No source answered for "${args.query}", so nothing here says whether such a recipe exists.` : `Nothing came back for "${args.query}".`;
2910
+ return ok(
2911
+ {
2912
+ query: args.query,
2913
+ results,
2914
+ result_count: results.length,
2915
+ per_source: merged.reports.map(toReportPayload),
2916
+ order,
2917
+ notes
2918
+ },
2919
+ body,
2920
+ {
2921
+ notes,
2922
+ credit: creditLine(
2923
+ contributed.map((report) => ({ attribution: `Source: ${report.name}` }))
2924
+ )
2925
+ }
2926
+ );
2927
+ } catch (error) {
2928
+ return toToolError(error);
2929
+ }
2930
+ }
2931
+
2932
+ // src/server.ts
2933
+ var READ_ONLY = {
2934
+ readOnlyHint: true,
2935
+ destructiveHint: false,
2936
+ idempotentHint: true,
2937
+ openWorldHint: true
2938
+ };
2939
+ function buildInstructions(sources) {
2940
+ const named = sources.map((source) => `${source.name} (${source.language})`).join(", ");
2941
+ return [
2942
+ `Tools for recipes, reading ${sources.length} sources: ${named}. No API key and no account are needed.`,
2943
+ "search_recipes asks every source at once and returns one list; every id it hands back names the source it came from, and get_recipe routes on that.",
2944
+ "Use compare_recipes when the question is how a dish differs between traditions: it reads each source's closest version and can rescale them all to the same number of servings so the lists stand comparison.",
2945
+ "Do not rescale quantities yourself. get_recipe and scale_ingredients keep an egg whole, halve what pours or cuts, move a small measurement to a smaller unit before rounding it, and flag what cannot be scaled, which is what stops answers like '2.4 eggs'.",
2946
+ "scale_ingredients works offline on a list from anywhere and reads French and English lines in one call, each in its own language.",
2947
+ "A source that fails is named as a source that failed. An answer holding rows from some sources is never evidence about what the others hold, and 'per_source' says which was which.",
2948
+ "The sources share no scale. Their result counts count different things and are never added; some carry reader ratings and some have no author and no rating by nature, so rows are interleaved rather than ranked.",
2949
+ "Nothing is converted between measuring systems, and a yield is repeated in the source's own words, because '4 \xE0 6 personnes' and '4 personnes' are different claims. A page stating no yield cannot be rescaled and says so.",
2950
+ "Every result carries a url. Credit the source you took a recipe from and link it; some publish under a licence that requires it, and one that states no terms at all has not granted permission."
2951
+ ].join(" ");
2952
+ }
2953
+ var INSTRUCTIONS = buildInstructions([
2954
+ { name: "Marmiton", language: "fr" },
2955
+ { name: "Wikibooks Cookbook", language: "en" }
2956
+ ]);
2957
+ function createServer(options = {}) {
2958
+ const config = options.config ?? loadConfig();
2959
+ const logger = options.logger ?? createLogger(config.logLevel);
2960
+ const client = options.client ?? new RecipesClient({ config, logger });
2961
+ const server = new McpServer(
2962
+ { name: "mcp-recipes", version: PKG_VERSION },
2963
+ { instructions: buildInstructions(client.profiles) }
2964
+ );
2965
+ server.registerTool(
2966
+ "search_recipes",
2967
+ {
2968
+ title: "Search every recipe source at once",
2969
+ description: searchRecipesDescription,
2970
+ inputSchema: searchRecipesInput,
2971
+ outputSchema: searchRecipesOutput,
2972
+ annotations: READ_ONLY
2973
+ },
2974
+ async (args) => runSearchRecipes(client, args)
2975
+ );
2976
+ server.registerTool(
2977
+ "get_recipe",
2978
+ {
2979
+ title: "Read one recipe, optionally rescaled",
2980
+ description: getRecipeDescription,
2981
+ inputSchema: getRecipeInput,
2982
+ outputSchema: getRecipeOutput,
2983
+ annotations: READ_ONLY
2984
+ },
2985
+ async (args) => runGetRecipe(client, args)
2986
+ );
2987
+ server.registerTool(
2988
+ "scale_ingredients",
2989
+ {
2990
+ title: "Scale an ingredient list, in either language",
2991
+ description: scaleIngredientsDescription,
2992
+ inputSchema: scaleIngredientsInput,
2993
+ outputSchema: scaleIngredientsOutput,
2994
+ annotations: {
2995
+ ...READ_ONLY,
2996
+ // Nothing is fetched, so this tool reaches nothing outside itself.
2997
+ openWorldHint: false
2998
+ }
2999
+ },
3000
+ async (args) => runScaleIngredients(args)
3001
+ );
3002
+ server.registerTool(
3003
+ "compare_recipes",
3004
+ {
3005
+ title: "One dish, as each source writes it",
3006
+ description: compareRecipesDescription,
3007
+ inputSchema: compareRecipesInput,
3008
+ outputSchema: compareRecipesOutput,
3009
+ annotations: READ_ONLY
3010
+ },
3011
+ async (args) => runCompareRecipes(client, args)
3012
+ );
3013
+ logger.info(
3014
+ `ready: ${client.profiles.length} sources (${client.profiles.map((source) => source.id).join(", ")}), user-agent="${client.userAgent}", ${client.intervalMs}ms between requests to one source`
3015
+ );
3016
+ return server;
3017
+ }
3018
+
3019
+ // src/index.ts
3020
+ async function main() {
3021
+ const server = createServer();
3022
+ const transport = new StdioServerTransport();
3023
+ await server.connect(transport);
3024
+ const shutdown = async () => {
3025
+ await server.close().catch(() => void 0);
3026
+ process.exit(0);
3027
+ };
3028
+ process.on("SIGINT", shutdown);
3029
+ process.on("SIGTERM", shutdown);
3030
+ }
3031
+ main().catch((error) => {
3032
+ const message = error instanceof Error ? error.message : String(error);
3033
+ process.stderr.write(`[mcp-recipes] fatal: ${message}
3034
+ `);
3035
+ process.exit(1);
3036
+ });
3037
+ //# sourceMappingURL=index.js.map