mcp-recipes 1.3.1 → 2.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 CHANGED
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import process2 from "process";
4
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
6
 
6
7
  // src/server.ts
7
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
9
 
10
+ // src/config.ts
11
+ import process from "process";
12
+
9
13
  // src/version.ts
10
- var PKG_VERSION = "1.3.1";
14
+ var PKG_VERSION = "2.0.0";
11
15
  var REPO_URL = "https://github.com/smeet666/mcp-recipes";
12
16
  var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
13
17
 
@@ -20,7 +24,9 @@ var DEFAULT_USER_AGENT = `mcp-recipes/${PKG_VERSION} (+${REPO_URL})`;
20
24
  function createLogger(level) {
21
25
  const rank = LOG_LEVELS.indexOf(level);
22
26
  const write = (at, message) => {
23
- if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) return;
27
+ if (rank === 0 || LOG_LEVELS.indexOf(at) > rank) {
28
+ return;
29
+ }
24
30
  process.stderr.write(`[mcp-recipes] ${at}: ${message}
25
31
  `);
26
32
  };
@@ -35,9 +41,11 @@ function createLogger(level) {
35
41
  }
36
42
  function readInteger(env, name, fallback, min, max) {
37
43
  const raw = env[name];
38
- if (raw === void 0 || raw.trim() === "") return fallback;
44
+ if (raw === void 0 || raw.trim() === "") {
45
+ return fallback;
46
+ }
39
47
  const value = Number(raw);
40
- if (!Number.isFinite(value) || !Number.isInteger(value)) {
48
+ if (!(Number.isFinite(value) && Number.isInteger(value))) {
41
49
  process.stderr.write(
42
50
  `[mcp-recipes] error: ${name}="${raw}" is not a whole number; using ${fallback}.
43
51
  `
@@ -96,14 +104,14 @@ var RecipesError = class extends Error {
96
104
  code;
97
105
  details;
98
106
  constructor(code, message, details = {}) {
99
- super(message);
107
+ super(message, details.cause === void 0 ? void 0 : { cause: details.cause });
100
108
  this.name = "RecipesError";
101
109
  this.code = code;
102
110
  this.details = details;
103
111
  }
104
112
  };
105
113
  var notFound = (message, details) => new RecipesError("not_found", message, details ?? {});
106
- var invalidInput = (message, hint) => new RecipesError("invalid_input", message, hint ? { hint } : {});
114
+ var invalidInput = (message, hint, cause) => new RecipesError("invalid_input", message, { ...hint ? { hint } : {}, cause });
107
115
  var rateLimited = (message, details) => new RecipesError("rate_limited", message, {
108
116
  hint: "Wait a moment and ask again. This says nothing about whether the recipe exists.",
109
117
  ...details
@@ -115,7 +123,9 @@ var parseFailure = (message, details) => new RecipesError("parse_failure", messa
115
123
  var networkError = (message, details) => new RecipesError("network_error", message, details ?? {});
116
124
  var timeout = (message, details) => new RecipesError("timeout", message, details ?? {});
117
125
  function toRecipesError(error) {
118
- if (error instanceof RecipesError) return error;
126
+ if (error instanceof RecipesError) {
127
+ return error;
128
+ }
119
129
  const message = error instanceof Error ? error.message : String(error);
120
130
  const raw = error;
121
131
  const code = typeof raw?.code === "string" ? raw.code : "";
@@ -124,14 +134,20 @@ function toRecipesError(error) {
124
134
  return new RecipesError(known ?? "network_error", message, details);
125
135
  }
126
136
  function fromSource(error, sourceName) {
127
- if (error instanceof RecipesError) return error;
137
+ if (error instanceof RecipesError) {
138
+ return error;
139
+ }
128
140
  const known = toRecipesError(error);
129
141
  const reported = known.message.replace(/\s+/g, " ").trim();
130
142
  const opening = known.code === "not_found" ? `${sourceName} holds nothing under this name.` : `${sourceName} could not be read.`;
131
143
  const message = reported === "" ? opening : `${opening} It reports: "${reported}"`;
132
144
  const details = {};
133
- if (known.details.url !== void 0) details.url = known.details.url;
134
- if (known.details.status !== void 0) details.status = known.details.status;
145
+ if (known.details.url !== void 0) {
146
+ details.url = known.details.url;
147
+ }
148
+ if (known.details.status !== void 0) {
149
+ details.status = known.details.status;
150
+ }
135
151
  switch (known.code) {
136
152
  case "not_found":
137
153
  return notFound(message, details);
@@ -148,72 +164,6 @@ function fromSource(error, sourceName) {
148
164
  }
149
165
  }
150
166
 
151
- // src/sources/adapter.ts
152
- function text(value) {
153
- return typeof value === "string" && value.trim() !== "" ? value : null;
154
- }
155
- function textList(value) {
156
- return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
157
- }
158
- function count(value) {
159
- return typeof value === "number" && Number.isFinite(value) ? value : null;
160
- }
161
- function required(value, field, source) {
162
- const found = text(value);
163
- if (found === null) {
164
- throw parseFailure(
165
- `${source.name} answered without a readable "${field}", so there is no recipe to return.`
166
- );
167
- }
168
- return found;
169
- }
170
- function rowsOf(value, source) {
171
- if (!Array.isArray(value)) {
172
- throw parseFailure(
173
- `${source.name} answered in a shape this server could not read: the list of results was missing.`
174
- );
175
- }
176
- return value;
177
- }
178
- function namespacedId(source, reference) {
179
- return `${source}:${reference}`;
180
- }
181
- function publishedFigures(panel) {
182
- if (typeof panel !== "object" || panel === null) return null;
183
- const kept = {};
184
- for (const [key, value] of Object.entries(panel)) {
185
- if (typeof value === "string" && value.trim() !== "") kept[key] = value.trim();
186
- }
187
- return Object.keys(kept).length > 0 ? kept : null;
188
- }
189
- function readYieldSpan(published) {
190
- if (published === null) return { count: null, max: null, unit: null };
191
- const span = /^\s*(\d+(?:[.,]\d+)?)\s*(?:à|a|to|-|–|—|ou|or)\s*(\d+(?:[.,]\d+)?)\s*(.*)$/i.exec(
192
- published
193
- );
194
- if (span) {
195
- const low = Number(span[1].replace(",", "."));
196
- const high = Number(span[2].replace(",", "."));
197
- if (Number.isFinite(low) && Number.isFinite(high) && high > low) {
198
- return { count: low, max: high, unit: text(span[3]) };
199
- }
200
- }
201
- const single = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/.exec(published);
202
- if (single) {
203
- const value = Number(single[1].replace(",", "."));
204
- if (Number.isFinite(value)) return { count: value, max: null, unit: text(single[2]) };
205
- }
206
- return { count: null, max: null, unit: null };
207
- }
208
- function detailBase(source) {
209
- return {
210
- source: source.id,
211
- sourceName: source.name,
212
- language: source.language,
213
- attribution: source.attribution
214
- };
215
- }
216
-
217
167
  // src/sources/ids.ts
218
168
  var SEPARATOR = ":";
219
169
  function resolveId(rawId, sources) {
@@ -226,7 +176,9 @@ function resolveId(rawId, sources) {
226
176
  }
227
177
  for (const source of sources) {
228
178
  const prefix = `${source.id}${SEPARATOR}`;
229
- if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) continue;
179
+ if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {
180
+ continue;
181
+ }
230
182
  const reference = trimmed.slice(prefix.length).trim();
231
183
  if (reference === "") {
232
184
  throw invalidInput(
@@ -260,8 +212,10 @@ function resolveId(rawId, sources) {
260
212
  }
261
213
  function names(sources) {
262
214
  const all = sources.map((source) => source.name);
263
- if (all.length <= 1) return all.join("");
264
- return `${all.slice(0, -1).join(", ")} and ${all[all.length - 1]}`;
215
+ if (all.length <= 1) {
216
+ return all.join("");
217
+ }
218
+ return `${all.slice(0, -1).join(", ")} and ${all.at(-1)}`;
265
219
  }
266
220
  function example(sources) {
267
221
  const first = sources[0];
@@ -272,6 +226,82 @@ function example(sources) {
272
226
  import { MarmitonClient } from "mcp-marmiton/client";
273
227
  import { CookbookClient } from "mcp-wikibooks-cookbook/client";
274
228
 
229
+ // src/sources/adapter.ts
230
+ var NUMBER_AND_REST = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/;
231
+ var RANGE_AND_REST = /^\s*(\d+(?:[.,]\d+)?)\s*(?:à|a|to|-|–|—|ou|or)\s*(\d+(?:[.,]\d+)?)\s*(.*)$/i;
232
+ function text(value) {
233
+ return typeof value === "string" && value.trim() !== "" ? value : null;
234
+ }
235
+ function textList(value) {
236
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
237
+ }
238
+ function count(value) {
239
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
240
+ }
241
+ function required(value, field, source) {
242
+ const found = text(value);
243
+ if (found === null) {
244
+ throw parseFailure(
245
+ `${source.name} answered without a readable "${field}", so there is no recipe to return.`
246
+ );
247
+ }
248
+ return found;
249
+ }
250
+ function rowsOf(value, source) {
251
+ if (!Array.isArray(value)) {
252
+ throw parseFailure(
253
+ `${source.name} answered in a shape this server could not read: the list of results was missing.`
254
+ );
255
+ }
256
+ return value;
257
+ }
258
+ function namespacedId(source, reference) {
259
+ return `${source}:${reference}`;
260
+ }
261
+ function publishedFigures(panel) {
262
+ if (typeof panel !== "object" || panel === null) {
263
+ return null;
264
+ }
265
+ const kept = {};
266
+ for (const [key, value] of Object.entries(panel)) {
267
+ if (typeof value === "string" && value.trim() !== "") {
268
+ kept[key] = value.trim();
269
+ }
270
+ }
271
+ return Object.keys(kept).length > 0 ? kept : null;
272
+ }
273
+ function readYieldSpan(published) {
274
+ if (published === null) {
275
+ return { count: null, max: null, unit: null };
276
+ }
277
+ const span = RANGE_AND_REST.exec(published);
278
+ if (span) {
279
+ const [lowText = "", highText = ""] = span.slice(1);
280
+ const low = Number(lowText.replace(",", "."));
281
+ const high = Number(highText.replace(",", "."));
282
+ if (Number.isFinite(low) && Number.isFinite(high) && high > low) {
283
+ return { count: low, max: high, unit: text(span[3]) };
284
+ }
285
+ }
286
+ const single = NUMBER_AND_REST.exec(published);
287
+ if (single) {
288
+ const [figure = ""] = single.slice(1);
289
+ const value = Number(figure.replace(",", "."));
290
+ if (Number.isFinite(value)) {
291
+ return { count: value, max: null, unit: text(single[2]) };
292
+ }
293
+ }
294
+ return { count: null, max: null, unit: null };
295
+ }
296
+ function detailBase(source) {
297
+ return {
298
+ source: source.id,
299
+ sourceName: source.name,
300
+ language: source.language,
301
+ attribution: source.attribution
302
+ };
303
+ }
304
+
275
305
  // src/sources/cookbook.ts
276
306
  var COOKBOOK_PROFILE = {
277
307
  id: "cookbook",
@@ -295,10 +325,11 @@ function cookbookAdapter(reader) {
295
325
  let key;
296
326
  try {
297
327
  key = decodeURIComponent(raw.replace(SITE_URL, "")).replace(/_/g, " ");
298
- } catch {
328
+ } catch (cause) {
299
329
  throw invalidInput(
300
330
  `"${raw}" carries a percent sign that is not a valid escape, so the page name cannot be read.`,
301
- "Pass the id search_recipes returned instead of the address."
331
+ "Pass the id search_recipes returned instead of the address.",
332
+ cause
302
333
  );
303
334
  }
304
335
  return {
@@ -328,7 +359,7 @@ function cookbookAdapter(reader) {
328
359
  const key = text(summary?.key);
329
360
  const title = text(summary?.title) ?? key;
330
361
  const url = text(summary?.sourceUrl);
331
- if (!key || !title || !url) {
362
+ if (!(key && title && url)) {
332
363
  skipped += 1;
333
364
  continue;
334
365
  }
@@ -401,6 +432,7 @@ function cookbookDetail(payload) {
401
432
  }
402
433
 
403
434
  // src/sources/marmiton.ts
435
+ var ABSOLUTE_URL = /^https?:\/\//i;
404
436
  var MARMITON_PROFILE = {
405
437
  id: "marmiton",
406
438
  name: "Marmiton",
@@ -417,7 +449,9 @@ async function readSearch(reader, query) {
417
449
  return await reader.search(query);
418
450
  } catch (error) {
419
451
  const code = error?.code;
420
- if (code === "not_found") return { data: [], cached: false };
452
+ if (code === "not_found") {
453
+ return { data: [], cached: false };
454
+ }
421
455
  throw error;
422
456
  }
423
457
  }
@@ -446,7 +480,7 @@ function marmitonAdapter(reader) {
446
480
  const id = text(summary?.id);
447
481
  const title = text(summary?.title);
448
482
  const url = text(summary?.url);
449
- if (!id || !title || !url) {
483
+ if (!(id && title && url)) {
450
484
  skipped += 1;
451
485
  continue;
452
486
  }
@@ -474,7 +508,7 @@ function marmitonAdapter(reader) {
474
508
  };
475
509
  },
476
510
  async getRecipe(reference) {
477
- const ref = /^https?:\/\//i.test(reference) ? { url: reference } : { id: reference };
511
+ const ref = ABSOLUTE_URL.test(reference) ? { url: reference } : { id: reference };
478
512
  const outcome = await reader.getRecipe(ref);
479
513
  return { recipe: marmitonDetail(outcome.data), cached: outcome.cached };
480
514
  }
@@ -537,7 +571,9 @@ function buildSources(config, readers = {}) {
537
571
  ];
538
572
  }
539
573
  function selectSources(sources, wanted) {
540
- if (!wanted) return sources;
574
+ if (!wanted) {
575
+ return sources;
576
+ }
541
577
  const unknown = wanted.filter((id) => !sources.some((source) => source.id === id));
542
578
  if (unknown.length > 0) {
543
579
  throw invalidInput(
@@ -556,6 +592,8 @@ function selectSources(sources, wanted) {
556
592
  }
557
593
 
558
594
  // src/sources/wordings.ts
595
+ var DIGITS_ONLY = /^\d+$/;
596
+ var WORD_SEPARATORS = /[^\p{L}\p{N}-]+/u;
559
597
  var MAX_WORDINGS_PER_SOURCE = 3;
560
598
  var FRENCH_FRAME = /* @__PURE__ */ new Set([
561
599
  "a",
@@ -855,18 +893,37 @@ var FREE_OF = /\b([\p{L}]{3,})[\s-]?free\b/iu;
855
893
  function foldWord(word) {
856
894
  return word.toLowerCase().normalize("NFD").replace(new RegExp("\\p{M}+", "gu"), "").replace(/œ/gu, "oe");
857
895
  }
896
+ function namesTheDish(word, key) {
897
+ if (FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key)) {
898
+ return false;
899
+ }
900
+ if (FRENCH_FRAME.has(word) || ENGLISH_FRAME.has(word)) {
901
+ return false;
902
+ }
903
+ if (DIGITS_ONLY.test(word)) {
904
+ return false;
905
+ }
906
+ if (word.length < 2) {
907
+ return false;
908
+ }
909
+ return !CONDITION_UNITS.has(word);
910
+ }
858
911
  function readQuestion(question) {
859
912
  const words = tokenise(question);
860
913
  const conditions = [];
861
914
  const dish = [];
862
915
  const state = (named, kind) => {
863
916
  const trimmed = named === null ? null : named.trim();
864
- if (trimmed === "") return;
865
- if (conditions.some((held) => held.named === trimmed && held.kind === kind)) return;
917
+ if (trimmed === "") {
918
+ return;
919
+ }
920
+ if (conditions.some((held) => held.named === trimmed && held.kind === kind)) {
921
+ return;
922
+ }
866
923
  conditions.push({ named: trimmed, kind });
867
924
  };
868
925
  for (let index = 0; index < words.length; index += 1) {
869
- const word = words[index];
926
+ const word = words[index] ?? "";
870
927
  const key = foldWord(word);
871
928
  const hyphen = word.indexOf("-");
872
929
  if (hyphen > 0 && NEGATORS.has(foldWord(word.slice(0, hyphen)))) {
@@ -875,7 +932,7 @@ function readQuestion(question) {
875
932
  }
876
933
  const free = FREE_OF.exec(word);
877
934
  if (free) {
878
- state(free[1], "excluded");
935
+ state(free[1] ?? "", "excluded");
879
936
  continue;
880
937
  }
881
938
  if (key === "free") {
@@ -902,12 +959,9 @@ function readQuestion(question) {
902
959
  state(word, "diet");
903
960
  continue;
904
961
  }
905
- if (FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key)) continue;
906
- if (FRENCH_FRAME.has(word) || ENGLISH_FRAME.has(word)) continue;
907
- if (/^\d+$/.test(word)) continue;
908
- if (word.length < 2) continue;
909
- if (CONDITION_UNITS.has(word)) continue;
910
- if (!dish.includes(word)) dish.push(word);
962
+ if (namesTheDish(word, key) && !dish.includes(word)) {
963
+ dish.push(word);
964
+ }
911
965
  }
912
966
  return { dish, conditions };
913
967
  }
@@ -928,15 +982,20 @@ function scopeOf(words, marker, dish) {
928
982
  }
929
983
  function takeFood(words, from) {
930
984
  let index = from;
931
- while (index < words.length && isFiller(words[index])) index += 1;
985
+ while (index < words.length && isFiller(words[index] ?? "")) {
986
+ index += 1;
987
+ }
932
988
  const named = [];
933
989
  while (index < words.length && named.length < CONDITION_WORDS) {
934
- if (!namesFood(words[index])) break;
935
- named.push(words[index]);
990
+ const word = words[index] ?? "";
991
+ if (!namesFood(word)) {
992
+ break;
993
+ }
994
+ named.push(word);
936
995
  index += 1;
937
996
  const beyond = words[index + 1];
938
997
  if (named.length + 2 <= CONDITION_WORDS && FOOD_LINKERS.has(foldWord(words[index] ?? "")) && beyond !== void 0 && namesFood(beyond)) {
939
- named.push(words[index]);
998
+ named.push(words[index] ?? "");
940
999
  index += 1;
941
1000
  continue;
942
1001
  }
@@ -950,12 +1009,24 @@ function isFiller(word) {
950
1009
  }
951
1010
  function namesFood(word) {
952
1011
  const key = foldWord(word);
953
- if (key.length < 2) return false;
954
- if (/^\d+$/.test(key)) return false;
955
- if (key === "free") return false;
956
- if (CONDITION_ENDS.has(key)) return false;
957
- if (NEGATORS.has(key) || HEALTH_MARKERS.has(key)) return false;
958
- if (CONDITION_UNITS.has(key)) return false;
1012
+ if (key.length < 2) {
1013
+ return false;
1014
+ }
1015
+ if (DIGITS_ONLY.test(key)) {
1016
+ return false;
1017
+ }
1018
+ if (key === "free") {
1019
+ return false;
1020
+ }
1021
+ if (CONDITION_ENDS.has(key)) {
1022
+ return false;
1023
+ }
1024
+ if (NEGATORS.has(key) || HEALTH_MARKERS.has(key)) {
1025
+ return false;
1026
+ }
1027
+ if (CONDITION_UNITS.has(key)) {
1028
+ return false;
1029
+ }
959
1030
  return !(FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key));
960
1031
  }
961
1032
  function readConditions(question) {
@@ -979,9 +1050,13 @@ function deriveWordings(question) {
979
1050
  const seen = /* @__PURE__ */ new Set();
980
1051
  const offer = (candidate, derivation) => {
981
1052
  const clean = tidy(candidate);
982
- if (clean === "") return;
1053
+ if (clean === "") {
1054
+ return;
1055
+ }
983
1056
  const key = clean.toLowerCase();
984
- if (seen.has(key)) return;
1057
+ if (seen.has(key)) {
1058
+ return;
1059
+ }
985
1060
  seen.add(key);
986
1061
  wordings.push({ query: clean, derivation });
987
1062
  };
@@ -999,7 +1074,9 @@ function deriveWordings(question) {
999
1074
  }
1000
1075
  function namesDish(title, question) {
1001
1076
  const words = namingWords(question);
1002
- if (words.length === 0) return true;
1077
+ if (words.length === 0) {
1078
+ return true;
1079
+ }
1003
1080
  return dishWordsMissing(title, question).length < words.length;
1004
1081
  }
1005
1082
  function dishWordsMissing(title, question) {
@@ -1016,19 +1093,22 @@ function dishWords(question) {
1016
1093
  return readQuestion(question).dish;
1017
1094
  }
1018
1095
  function tokenise(value) {
1019
- return value.toLowerCase().replace(/['’]/gu, " ").split(/[^\p{L}\p{N}-]+/u).map((word) => word.replace(/^-+|-+$/g, "")).filter((word) => word !== "");
1096
+ return value.toLowerCase().replace(/['’]/gu, " ").split(WORD_SEPARATORS).map((word) => word.replace(/^-+|-+$/g, "")).filter((word) => word !== "");
1020
1097
  }
1021
1098
  function tidy(value) {
1022
1099
  return value.replace(/\s+/g, " ").trim();
1023
1100
  }
1024
1101
 
1025
1102
  // src/sources/client.ts
1103
+ var LETTER_OR_DIGIT = /[\p{L}\p{N}]/u;
1026
1104
  var MAX_LIMIT_PER_SOURCE = 25;
1027
1105
  function withGuarantees(config) {
1028
1106
  const defaults = loadConfig({});
1029
1107
  const bounded = (value, fallback, min, max) => {
1030
1108
  const parsed = Number(value);
1031
- if (!Number.isFinite(parsed)) return fallback;
1109
+ if (!Number.isFinite(parsed)) {
1110
+ return fallback;
1111
+ }
1032
1112
  return Math.min(max, Math.max(min, parsed));
1033
1113
  };
1034
1114
  const claimed = typeof config.userAgent === "string" ? config.userAgent.trim() : "";
@@ -1089,13 +1169,31 @@ function heldBack(wording, because) {
1089
1169
  error: null
1090
1170
  };
1091
1171
  }
1172
+ function keepNewRows(read, seen, rows, question) {
1173
+ let added = 0;
1174
+ let onTopic = 0;
1175
+ for (const row of read.rows) {
1176
+ if (seen.has(row.id)) {
1177
+ continue;
1178
+ }
1179
+ seen.add(row.id);
1180
+ rows.push(row);
1181
+ added += 1;
1182
+ if (namesDish(row.title, question)) {
1183
+ onTopic += 1;
1184
+ }
1185
+ }
1186
+ return { added, onTopic };
1187
+ }
1092
1188
  function interleave(groups) {
1093
1189
  const merged = [];
1094
1190
  const longest = Math.max(0, ...groups.map((group) => group.length));
1095
1191
  for (let index = 0; index < longest; index += 1) {
1096
1192
  for (const group of groups) {
1097
1193
  const row = group[index];
1098
- if (row) merged.push(row);
1194
+ if (row) {
1195
+ merged.push(row);
1196
+ }
1099
1197
  }
1100
1198
  }
1101
1199
  return merged;
@@ -1151,7 +1249,7 @@ var RecipesClient = class {
1151
1249
  if (trimmed === "") {
1152
1250
  throw invalidInput("A search needs something to look for.", "Name a dish or an ingredient.");
1153
1251
  }
1154
- if (!/[\p{L}\p{N}]/u.test(trimmed)) {
1252
+ if (!LETTER_OR_DIGIT.test(trimmed)) {
1155
1253
  throw invalidInput(
1156
1254
  `"${trimmed}" carries no letter and no digit, so there is no word to search for.`,
1157
1255
  "Name a dish or an ingredient."
@@ -1163,10 +1261,10 @@ var RecipesClient = class {
1163
1261
  const attempts = await Promise.all(
1164
1262
  chosen.map((source) => this.searchOne(source, trimmed, limit, fanOut))
1165
1263
  );
1166
- const groups = attempts.map((attempt) => attempt.rows.slice(0, limit));
1264
+ const answered = attempts.map((attempt) => ({ attempt, rows: attempt.rows.slice(0, limit) }));
1167
1265
  return {
1168
- rows: interleave(groups),
1169
- reports: attempts.map((attempt, index) => reportOf(attempt, groups[index].length))
1266
+ rows: interleave(answered.map((one) => one.rows)),
1267
+ reports: answered.map(({ attempt, rows }) => reportOf(attempt, rows.length))
1170
1268
  };
1171
1269
  }
1172
1270
  /**
@@ -1243,14 +1341,9 @@ var RecipesClient = class {
1243
1341
  `${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`
1244
1342
  );
1245
1343
  }
1246
- let added = 0;
1247
- for (const row of read.rows) {
1248
- if (seen.has(row.id)) continue;
1249
- seen.add(row.id);
1250
- rows.push(row);
1251
- added += 1;
1252
- if (namesDish(row.title, question)) onTopic += 1;
1253
- }
1344
+ const kept = keepNewRows(read, seen, rows, question);
1345
+ const added = kept.added;
1346
+ onTopic += kept.onTopic;
1254
1347
  wordings.push({
1255
1348
  query: wording.query,
1256
1349
  derivation: wording.derivation,
@@ -1342,17 +1435,41 @@ function readLanguage(line, evidence = {
1342
1435
  }) {
1343
1436
  let french = 0;
1344
1437
  let english = 0;
1345
- if (FRENCH_WORDS.test(line)) french += STRUCTURAL;
1346
- if (FRENCH_LETTERS.test(line)) french += STRUCTURAL;
1347
- if (ENGLISH_WORDS.test(line)) english += STRUCTURAL;
1348
- if (evidence.frenchUnit) french += VOCABULARY;
1349
- if (evidence.englishUnit) english += VOCABULARY;
1350
- if (FRENCH_FOODS.test(line)) french += NAME;
1351
- if (ENGLISH_FOODS.test(line)) english += NAME;
1438
+ if (FRENCH_WORDS.test(line)) {
1439
+ french += STRUCTURAL;
1440
+ }
1441
+ if (FRENCH_LETTERS.test(line)) {
1442
+ french += STRUCTURAL;
1443
+ }
1444
+ if (ENGLISH_WORDS.test(line)) {
1445
+ english += STRUCTURAL;
1446
+ }
1447
+ if (evidence.frenchUnit) {
1448
+ french += VOCABULARY;
1449
+ }
1450
+ if (evidence.englishUnit) {
1451
+ english += VOCABULARY;
1452
+ }
1453
+ if (FRENCH_FOODS.test(line)) {
1454
+ french += NAME;
1455
+ }
1456
+ if (ENGLISH_FOODS.test(line)) {
1457
+ english += NAME;
1458
+ }
1352
1459
  return { language: french > english ? "fr" : "en", french, english };
1353
1460
  }
1354
1461
 
1355
1462
  // src/recipe/units.ts
1463
+ var AL_ENDING = /al$/i;
1464
+ var AUX_ENDING = /aux$/i;
1465
+ var EAUX_ENDING = /eaux$/i;
1466
+ var EAU_ENDING = /eau$/i;
1467
+ var FUL_MEASURE = /^[a-z]{3,}fuls?$/;
1468
+ var HEAD_BEFORE_DE = new RegExp("^\\s*(\\p{L}+)\\s+(?=(?:de|du|des)\\s|d')", "u");
1469
+ var HOUSEHOLD_MEASURE = /^(cup|tablespoon|Tbsp|teaspoon|tsp|cuillère à soupe|cuillère à café|tasse)$/;
1470
+ var SIBILANT_LETTER = /[sxz]$/i;
1471
+ var TRAILING_S_ANY_CASE = /s$/i;
1472
+ var VOWEL_THEN_S = /[aiou]s$/i;
1356
1473
  var SHARED_UNITS = {
1357
1474
  mg: { canonical: "mg", kind: "measured", system: "metric", symbol: true },
1358
1475
  g: { canonical: "g", kind: "measured", system: "metric", symbol: true },
@@ -1686,12 +1803,20 @@ var NOT_A_MEASURE = /* @__PURE__ */ new Set([
1686
1803
  "ensemble"
1687
1804
  ]);
1688
1805
  function readPartitiveMeasure(text2) {
1689
- const match = new RegExp("^\\s*(\\p{L}+)\\s+(?=(?:de|du|des)\\s|d')", "u").exec(text2);
1690
- if (!match) return null;
1691
- const word = match[1];
1692
- if (word.length < 3) return null;
1693
- if (NOT_A_MEASURE.has(normalizeUnitKey(word))) return null;
1694
- if (lookupUnit(word, "fr")) return null;
1806
+ const match = HEAD_BEFORE_DE.exec(text2);
1807
+ if (!match) {
1808
+ return null;
1809
+ }
1810
+ const [word = ""] = match.slice(1);
1811
+ if (word.length < 3) {
1812
+ return null;
1813
+ }
1814
+ if (NOT_A_MEASURE.has(normalizeUnitKey(word))) {
1815
+ return null;
1816
+ }
1817
+ if (lookupUnit(word, "fr")) {
1818
+ return null;
1819
+ }
1695
1820
  const canonical = frenchSingular(word);
1696
1821
  return {
1697
1822
  unit: {
@@ -1705,21 +1830,37 @@ function readPartitiveMeasure(text2) {
1705
1830
  }
1706
1831
  function readContainerLoad(word) {
1707
1832
  const key = normalizeUnitKey(word);
1708
- if (!/^[a-z]{3,}fuls?$/.test(key)) return null;
1833
+ if (!FUL_MEASURE.test(key)) {
1834
+ return null;
1835
+ }
1709
1836
  const canonical = key.endsWith("fuls") ? key.slice(0, -1) : key;
1710
1837
  return { canonical, kind: "approximate", system: "none", plural: `${canonical}s` };
1711
1838
  }
1712
1839
  function frenchSingular(word) {
1713
- if (/eaux$/i.test(word)) return word.slice(0, -1);
1714
- if (/aux$/i.test(word)) return `${word.slice(0, -3)}al`;
1715
- if (/[aiou]s$/i.test(word)) return word;
1716
- if (/s$/i.test(word) && word.length > 3) return word.slice(0, -1);
1840
+ if (EAUX_ENDING.test(word)) {
1841
+ return word.slice(0, -1);
1842
+ }
1843
+ if (AUX_ENDING.test(word)) {
1844
+ return `${word.slice(0, -3)}al`;
1845
+ }
1846
+ if (VOWEL_THEN_S.test(word)) {
1847
+ return word;
1848
+ }
1849
+ if (TRAILING_S_ANY_CASE.test(word) && word.length > 3) {
1850
+ return word.slice(0, -1);
1851
+ }
1717
1852
  return word;
1718
1853
  }
1719
1854
  function frenchPlural(word) {
1720
- if (/[sxz]$/i.test(word)) return word;
1721
- if (/eau$/i.test(word)) return `${word}x`;
1722
- if (/al$/i.test(word)) return `${word.slice(0, -2)}aux`;
1855
+ if (SIBILANT_LETTER.test(word)) {
1856
+ return word;
1857
+ }
1858
+ if (EAU_ENDING.test(word)) {
1859
+ return `${word}x`;
1860
+ }
1861
+ if (AL_ENDING.test(word)) {
1862
+ return `${word.slice(0, -2)}aux`;
1863
+ }
1723
1864
  return `${word}s`;
1724
1865
  }
1725
1866
  var APPROXIMATE_EQUIVALENT = {
@@ -1780,17 +1921,19 @@ var DEMOTIONS = {
1780
1921
  };
1781
1922
  function demoteUnit(unit) {
1782
1923
  const step = DEMOTIONS[normalizeUnitKey(unit.canonical)];
1783
- if (!step) return null;
1924
+ if (!step) {
1925
+ return null;
1926
+ }
1784
1927
  const target = lookupUnit(step.to, step.language);
1785
1928
  return target ? { unit: target, per: step.per } : null;
1786
1929
  }
1787
1930
  function isSpoonMeasure(unit) {
1788
- return /^(cup|tablespoon|Tbsp|teaspoon|tsp|cuillère à soupe|cuillère à café|tasse)$/.test(
1789
- unit.canonical
1790
- );
1931
+ return HOUSEHOLD_MEASURE.test(unit.canonical);
1791
1932
  }
1792
1933
  function unitDivisibility(unit) {
1793
- if (unit.kind === "approximate") return "whole";
1934
+ if (unit.kind === "approximate") {
1935
+ return "whole";
1936
+ }
1794
1937
  return QUARTERED_MEASURE.test(unit.canonical) ? "quarter" : "half";
1795
1938
  }
1796
1939
  var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|tranches?|slices?)\b/i;
@@ -1805,7 +1948,9 @@ function chooseReadableUnit(unit, amount) {
1805
1948
  let ratio = 1;
1806
1949
  while (amount * ratio < 1) {
1807
1950
  const step = demoteUnit(current);
1808
- if (!step) break;
1951
+ if (!step) {
1952
+ break;
1953
+ }
1809
1954
  ratio *= step.per;
1810
1955
  current = step.unit;
1811
1956
  }
@@ -1819,7 +1964,9 @@ function chooseReadableUnit(unit, amount) {
1819
1964
  }
1820
1965
  while (!writesExactly(amount * ratio)) {
1821
1966
  const step = demoteUnit(current);
1822
- if (!step) break;
1967
+ if (!step) {
1968
+ break;
1969
+ }
1823
1970
  ratio *= step.per;
1824
1971
  current = step.unit;
1825
1972
  }
@@ -1829,13 +1976,64 @@ function writesExactly(value) {
1829
1976
  return Math.abs(value * 100 - Math.round(value * 100)) < 1e-9;
1830
1977
  }
1831
1978
  function formatUnit(unit, amount, language) {
1832
- if (unit.symbol) return unit.canonical;
1979
+ if (unit.symbol) {
1980
+ return unit.canonical;
1981
+ }
1833
1982
  const singular = language === "fr" ? amount < 2 : amount <= 1;
1834
- if (singular) return unit.canonical;
1983
+ if (singular) {
1984
+ return unit.canonical;
1985
+ }
1835
1986
  return unit.plural ?? `${unit.canonical}s`;
1836
1987
  }
1837
1988
 
1838
1989
  // src/recipe/quantity.ts
1990
+ var BRACKETED_TAIL = /\s*(\([^()]*\))\s*$/;
1991
+ var LEADING_ARTICLE = /^(?:of\s+)?an?\s+/i;
1992
+ var LEADING_ARTICLE_FRENCH = /^(?:une|un)\s+/i;
1993
+ var LEADING_INDEFINITE_WITH_SPACE = /^an?\s+/i;
1994
+ var LEADING_PARTITIVE = /^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i;
1995
+ var OF_OPENING = /^of\s+/i;
1996
+ var OPENING_ARTICLE_FRENCH = /^\s*(un|une)\b\s*/i;
1997
+ var REPEATED_COMMA_GROUPS = /^\s*\d+,\d+,\d/;
1998
+ var SLASH_OUTSIDE_A_FRACTION = /(?<!\d)\/|\/(?!\d)/;
1999
+ var ATTACHED_DE = /\s+de\s+(?=\d)/i;
2000
+ var DECIMAL = /^(\d+(?:[.,]\d+)?)/;
2001
+ var DIGIT = /\d/;
2002
+ var ENGLISH_RANGE_JOINER = /^\s+(to|or)\s+/i;
2003
+ var FRACTION_WORD = /^(?:(a|an|one|two|three)[\s-]+)?(halves|half|thirds|third|quarters|quarter|fourths|fourth)\b/i;
2004
+ var FRENCH_RANGE_JOINER = /^\s+(à|a|ou)\s+/i;
2005
+ var GROUPED_DECIMAL = /^(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)/;
2006
+ var HYPHENATED_TAIL = new RegExp("^-\\p{L}", "u");
2007
+ var LEADING_DASH = /^\s*(–|—|-)\s*/;
2008
+ var LEADING_INDEFINITE = /^an?\s/i;
2009
+ var LEADING_OF = /^\s*of\s+/i;
2010
+ var LEADING_QUANTITY = /^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u;
2011
+ var LEADING_WORD = new RegExp("^\\s*(\\p{L}+)\\s+", "u");
2012
+ var MIXED_FRACTION = /^(\d+)\s+(\d+)\s*\/\s*(\d+)/;
2013
+ var PARTITIVE_BEFORE_BRACKET = /^(?:de\s+|du\s+|des\s+|d'|of\s+)(?=\()/i;
2014
+ var PARTITIVE_OPENING = /^(?:de\s|d'|du\s|des\s|,)/i;
2015
+ var SIMPLE_FRACTION = /^(\d+)\s*\/\s*(\d+)/;
2016
+ var TRAILING_S = /s$/;
2017
+ var VAGUE_ARTICLE = /^(?:un|une|quelques|of\s+an?|an?)\s+/iu;
2018
+ var WHITESPACE = /\s+/;
2019
+ function leadingMeasure(direct, behind) {
2020
+ if (direct.unit) {
2021
+ return direct;
2022
+ }
2023
+ return behind?.unit ? behind : direct;
2024
+ }
2025
+ function alternateStyleOf(count2, slashed, trailing) {
2026
+ if (count2 === 0) {
2027
+ return null;
2028
+ }
2029
+ if (slashed) {
2030
+ return "slash";
2031
+ }
2032
+ if (trailing) {
2033
+ return "trailing";
2034
+ }
2035
+ return "bracket";
2036
+ }
1839
2037
  var VULGAR_FRACTIONS = {
1840
2038
  "\xBD": 0.5,
1841
2039
  "\u2153": 1 / 3,
@@ -1875,13 +2073,18 @@ var NAMED_ENTITIES = {
1875
2073
  mdash: "\u2014"
1876
2074
  };
1877
2075
  function decodeEntities(text2) {
1878
- return text2.replace(/&#x([0-9a-f]+);/gi, (whole, hex) => codePoint(parseInt(hex, 16), whole)).replace(/&#(\d+);/g, (whole, digits) => codePoint(Number(digits), whole)).replace(
2076
+ return text2.replace(
2077
+ /&#x([0-9a-f]+);/gi,
2078
+ (whole, hex) => codePoint(Number.parseInt(hex, 16), whole)
2079
+ ).replace(/&#(\d+);/g, (whole, digits) => codePoint(Number(digits), whole)).replace(
1879
2080
  /&([a-z][a-z0-9]*);/gi,
1880
2081
  (whole, name) => NAMED_ENTITIES[name.toLowerCase()] ?? whole
1881
2082
  );
1882
2083
  }
1883
2084
  function codePoint(value, published) {
1884
- if (!Number.isInteger(value) || value < 32 || value > 1114111) return published;
2085
+ if (!Number.isInteger(value) || value < 32 || value > 1114111) {
2086
+ return published;
2087
+ }
1885
2088
  try {
1886
2089
  return String.fromCodePoint(value);
1887
2090
  } catch {
@@ -1898,37 +2101,59 @@ var APPROXIMATION_PREFIX = {
1898
2101
  };
1899
2102
  var CONTAINER_CONTENTS = /^\s*(?:de\s|d'|du\s|des\s)/i;
1900
2103
  function statesItemSize(item) {
1901
- const attached = /\s+de\s+(?=\d)/i.exec(item);
1902
- if (!attached) return false;
2104
+ const attached = ATTACHED_DE.exec(item);
2105
+ if (!attached) {
2106
+ return false;
2107
+ }
1903
2108
  const named = item.slice(0, attached.index).trim();
1904
- if (!named || /\d/.test(named)) return false;
2109
+ if (!named || DIGIT.test(named)) {
2110
+ return false;
2111
+ }
1905
2112
  return isStatedSize(item.slice(attached.index + attached[0].length));
1906
2113
  }
1907
2114
  function isStatedSize(text2) {
1908
2115
  const size = parseLeadingQuantity(text2, "fr");
1909
- if (!size) return false;
2116
+ if (!size) {
2117
+ return false;
2118
+ }
1910
2119
  const measure = takeUnit(text2.slice(size.length).trimStart(), "fr");
1911
- if (!measure.unit || measure.unit.kind !== "measured") return false;
2120
+ if (measure.unit?.kind !== "measured") {
2121
+ return false;
2122
+ }
1912
2123
  return !CONTAINER_CONTENTS.test(measure.rest);
1913
2124
  }
1914
2125
  function takeCompoundMember(text2, unit, language) {
1915
- if (!unit) return null;
2126
+ if (!unit) {
2127
+ return null;
2128
+ }
1916
2129
  const step = demoteUnit(unit);
1917
- if (!step) return null;
2130
+ if (!step) {
2131
+ return null;
2132
+ }
1918
2133
  const joiner = ADDS_UP.exec(text2);
1919
- if (joiner) text2 = text2.slice(joiner[0].length);
1920
- const second = parseLeadingQuantity(text2, language);
1921
- if (!second || second.amount <= 0 || second.amount >= step.per) return null;
1922
- const after = text2.slice(second.length);
2134
+ const behindTheJoiner = joiner ? text2.slice(joiner[0].length) : text2;
2135
+ const second = parseLeadingQuantity(behindTheJoiner, language);
2136
+ if (!second || second.amount <= 0 || second.amount >= step.per) {
2137
+ return null;
2138
+ }
2139
+ const after = behindTheJoiner.slice(second.length);
1923
2140
  const measure = takeUnit(after.trimStart(), language);
1924
2141
  if (measure.unit) {
1925
- if (measure.unit.canonical !== step.unit.canonical) return null;
2142
+ if (measure.unit.canonical !== step.unit.canonical) {
2143
+ return null;
2144
+ }
1926
2145
  return { adds: second.amount / step.per, rest: measure.rest.trimStart() };
1927
2146
  }
1928
- if (unit.kind !== "measured") return null;
1929
- if (!Number.isInteger(second.amount)) return null;
2147
+ if (unit.kind !== "measured") {
2148
+ return null;
2149
+ }
2150
+ if (!Number.isInteger(second.amount)) {
2151
+ return null;
2152
+ }
1930
2153
  const trailing = after.trimStart();
1931
- if (trailing !== "" && !/^(?:de\s|d'|du\s|des\s|,)/i.test(trailing)) return null;
2154
+ if (trailing !== "" && !PARTITIVE_OPENING.test(trailing)) {
2155
+ return null;
2156
+ }
1932
2157
  return { adds: second.amount / step.per, rest: trailing };
1933
2158
  }
1934
2159
  var ADDS_UP = /^\s*(?:\+|et|and|plus)\s+/i;
@@ -1976,12 +2201,17 @@ var MEASURE_ADJECTIVES = {
1976
2201
  ])
1977
2202
  };
1978
2203
  function takeMeasureAdjective(text2, language) {
1979
- const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text2);
1980
- if (!match) return { adjective: null, rest: text2 };
1981
- const folded = normalizeUnitKey(match[1]);
1982
- const listed = MEASURE_ADJECTIVES[language].has(folded) || MEASURE_ADJECTIVES[language].has(folded.replace(/s$/, ""));
1983
- if (!listed) return { adjective: null, rest: text2 };
1984
- return { adjective: match[1], rest: text2.slice(match[0].length) };
2204
+ const match = LEADING_WORD.exec(text2);
2205
+ if (!match) {
2206
+ return { adjective: null, rest: text2 };
2207
+ }
2208
+ const [adjective = ""] = match.slice(1);
2209
+ const folded = normalizeUnitKey(adjective);
2210
+ const listed = MEASURE_ADJECTIVES[language].has(folded) || MEASURE_ADJECTIVES[language].has(folded.replace(TRAILING_S, ""));
2211
+ if (!listed) {
2212
+ return { adjective: null, rest: text2 };
2213
+ }
2214
+ return { adjective, rest: text2.slice(match[0].length) };
1985
2215
  }
1986
2216
  var COMMA_GROUPED = /^\s*\d{1,3}(?:,\d{3})+(?!\d)/;
1987
2217
  var COMMA_DECIMAL = /^\s*\d+,\d+/;
@@ -1991,10 +2221,10 @@ function parseLeadingQuantity(text2, language) {
1991
2221
  const mixedGlyph = new RegExp(`^(\\d+)\\s*([${VULGAR_CLASS}])`).exec(trimmed);
1992
2222
  if (mixedGlyph) {
1993
2223
  const whole = Number(mixedGlyph[1]);
1994
- const fraction2 = VULGAR_FRACTIONS[mixedGlyph[2]];
2224
+ const fraction2 = VULGAR_FRACTIONS[mixedGlyph[2] ?? ""] ?? Number.NaN;
1995
2225
  return { amount: whole + fraction2, length: offset + mixedGlyph[0].length };
1996
2226
  }
1997
- const mixed = /^(\d+)\s+(\d+)\s*\/\s*(\d+)/.exec(trimmed);
2227
+ const mixed = MIXED_FRACTION.exec(trimmed);
1998
2228
  if (mixed) {
1999
2229
  const denominator = Number(mixed[3]);
2000
2230
  if (denominator !== 0) {
@@ -2004,27 +2234,33 @@ function parseLeadingQuantity(text2, language) {
2004
2234
  };
2005
2235
  }
2006
2236
  }
2007
- const fraction = /^(\d+)\s*\/\s*(\d+)/.exec(trimmed);
2237
+ const fraction = SIMPLE_FRACTION.exec(trimmed);
2008
2238
  if (fraction) {
2009
2239
  const denominator = Number(fraction[2]);
2010
- if (denominator === 0) return null;
2240
+ if (denominator === 0) {
2241
+ return null;
2242
+ }
2011
2243
  return { amount: Number(fraction[1]) / denominator, length: offset + fraction[0].length };
2012
2244
  }
2013
2245
  const glyph = trimmed[0];
2014
2246
  if (glyph && glyph in VULGAR_FRACTIONS) {
2015
- return { amount: VULGAR_FRACTIONS[glyph], length: offset + 1 };
2247
+ return { amount: VULGAR_FRACTIONS[glyph] ?? Number.NaN, length: offset + 1 };
2016
2248
  }
2017
- const decimal = (language === "fr" ? /^(\d+(?:[.,]\d+)?)/ : /^(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)/).exec(trimmed);
2249
+ const decimal = (language === "fr" ? DECIMAL : GROUPED_DECIMAL).exec(trimmed);
2018
2250
  if (decimal) {
2019
- const written = decimal[1];
2251
+ const [written = ""] = decimal.slice(1);
2020
2252
  const amount = Number(
2021
2253
  language === "fr" ? written.replace(",", ".") : written.replace(/,/g, "")
2022
2254
  );
2023
- if (Number.isFinite(amount)) return { amount, length: offset + decimal[0].length };
2255
+ if (Number.isFinite(amount)) {
2256
+ return { amount, length: offset + decimal[0].length };
2257
+ }
2024
2258
  }
2025
2259
  if (language === "en") {
2026
2260
  const written = parseWrittenFraction(trimmed);
2027
- if (written) return { amount: written.amount, length: offset + written.length };
2261
+ if (written) {
2262
+ return { amount: written.amount, length: offset + written.length };
2263
+ }
2028
2264
  }
2029
2265
  return null;
2030
2266
  }
@@ -2040,30 +2276,40 @@ var WRITTEN_DENOMINATORS = {
2040
2276
  fourths: 4
2041
2277
  };
2042
2278
  function parseWrittenFraction(text2) {
2043
- const match = /^(?:(a|an|one|two|three)[\s-]+)?(halves|half|thirds|third|quarters|quarter|fourths|fourth)\b/i.exec(
2044
- text2
2045
- );
2046
- if (!match) return null;
2279
+ const match = FRACTION_WORD.exec(text2);
2280
+ if (!match) {
2281
+ return null;
2282
+ }
2047
2283
  const numerator = match[1] ? WRITTEN_NUMERATORS[match[1].toLowerCase()] : 1;
2048
- const denominator = WRITTEN_DENOMINATORS[match[2].toLowerCase()];
2049
- if (!numerator || !denominator) return null;
2050
- const rest = text2.slice(match[0].length).replace(/^\s*of\s+/i, "").trimStart();
2051
- if (!/^an?\s/i.test(rest) && !takeUnit(rest, "en").unit) return null;
2284
+ const denominator = WRITTEN_DENOMINATORS[(match[2] ?? "").toLowerCase()];
2285
+ if (!(numerator && denominator)) {
2286
+ return null;
2287
+ }
2288
+ const rest = text2.slice(match[0].length).replace(LEADING_OF, "").trimStart();
2289
+ if (!(LEADING_INDEFINITE.test(rest) || takeUnit(rest, "en").unit)) {
2290
+ return null;
2291
+ }
2052
2292
  return { amount: numerator / denominator, length: match[0].length };
2053
2293
  }
2054
2294
  function parseLeadingRange(text2, language) {
2055
2295
  const low = parseLeadingQuantity(text2, language);
2056
- if (!low) return null;
2296
+ if (!low) {
2297
+ return null;
2298
+ }
2057
2299
  const after = text2.slice(low.length);
2058
- const written = language === "fr" ? /^\s+(à|a|ou)\s+/i : /^\s+(to|or)\s+/i;
2059
- const separator = written.exec(after) ?? /^\s*(–|—|-)\s*/.exec(after);
2060
- if (!separator) return null;
2300
+ const written = language === "fr" ? FRENCH_RANGE_JOINER : ENGLISH_RANGE_JOINER;
2301
+ const separator = written.exec(after) ?? LEADING_DASH.exec(after);
2302
+ if (!separator) {
2303
+ return null;
2304
+ }
2061
2305
  const high = parseLeadingQuantity(after.slice(separator[0].length), language);
2062
- if (!high || high.amount <= low.amount) return null;
2306
+ if (!high || high.amount <= low.amount) {
2307
+ return null;
2308
+ }
2063
2309
  return {
2064
2310
  amount: low.amount,
2065
2311
  max: high.amount,
2066
- separator: separator[1],
2312
+ separator: separator[1] ?? "",
2067
2313
  length: low.length + separator[0].length + high.length
2068
2314
  };
2069
2315
  }
@@ -2071,111 +2317,190 @@ var FRENCH_ARTICLES = { un: 1, une: 1 };
2071
2317
  function takeUnit(text2, language) {
2072
2318
  const normalized = normalizeUnitKey(text2);
2073
2319
  for (const key of unitKeys(language)) {
2074
- if (normalized !== key && !normalized.startsWith(`${key} `)) continue;
2320
+ if (normalized !== key && !normalized.startsWith(`${key} `)) {
2321
+ continue;
2322
+ }
2075
2323
  const unit = lookupUnit(key, language);
2076
- if (!unit) continue;
2324
+ if (!unit) {
2325
+ continue;
2326
+ }
2077
2327
  const rest = afterKey(text2, key);
2078
- if (rest === null) continue;
2328
+ if (rest === null) {
2329
+ continue;
2330
+ }
2079
2331
  return { unit, rest };
2080
2332
  }
2081
2333
  if (language === "en") {
2082
- const words = text2.trimStart().split(/\s+/);
2334
+ const words = text2.trimStart().split(WHITESPACE);
2083
2335
  const load = words[0] ? readContainerLoad(words[0]) : null;
2084
- if (load) return { unit: load, rest: words.slice(1).join(" ") };
2336
+ if (load) {
2337
+ return { unit: load, rest: words.slice(1).join(" ") };
2338
+ }
2085
2339
  }
2086
2340
  return { unit: null, rest: text2 };
2087
2341
  }
2088
2342
  function afterKey(text2, key) {
2089
- const words = text2.trim().split(/\s+/);
2343
+ const words = text2.trim().split(WHITESPACE);
2090
2344
  for (let count2 = 1; count2 <= words.length; count2 += 1) {
2091
- if (normalizeUnitKey(words.slice(0, count2).join(" ")) !== key) continue;
2345
+ if (normalizeUnitKey(words.slice(0, count2).join(" ")) !== key) {
2346
+ continue;
2347
+ }
2092
2348
  return words.slice(count2).join(" ");
2093
2349
  }
2094
2350
  return null;
2095
2351
  }
2096
2352
  function takeUnitEitherLanguage(text2, language) {
2097
2353
  const first = takeUnit(text2, language);
2098
- if (first.unit) return first;
2354
+ if (first.unit) {
2355
+ return first;
2356
+ }
2099
2357
  return takeUnit(text2, language === "fr" ? "en" : "fr");
2100
2358
  }
2101
2359
  function readEvidence(line) {
2102
2360
  const text2 = line.trim();
2103
- const afterFigures = text2.replace(/^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u, "").trimStart();
2104
- const afterArticle = afterFigures.replace(/^(?:un|une|quelques|of\s+an?|an?)\s+/iu, "");
2361
+ const afterFigures = text2.replace(LEADING_QUANTITY, "").trimStart();
2362
+ const afterArticle = afterFigures.replace(VAGUE_ARTICLE, "");
2105
2363
  const frenchUnit = takeUnit(afterArticle, "fr").unit !== null;
2106
2364
  const englishUnit = takeUnit(afterArticle, "en").unit !== null;
2107
2365
  return readLanguage(text2, { frenchUnit, englishUnit });
2108
2366
  }
2109
- function parseIngredient(line, choice = "auto") {
2110
- const original = line;
2111
- const marked = readable(line);
2112
- const decoration = takeDecoration(marked);
2113
- const text2 = decoration ? marked.slice(decoration.length).trimStart() : marked;
2114
- const evidence = readEvidence(text2);
2115
- const language = choice === "auto" ? evidence.language : choice;
2116
- const empty = (heldBack2) => ({
2117
- original,
2118
- language,
2119
- heldBack: heldBack2,
2120
- decoration,
2121
- approximation: null,
2122
- measureAdjective: null,
2123
- amount: null,
2124
- amountMax: null,
2125
- rangeSeparator: null,
2126
- unit: null,
2127
- alternates: [],
2128
- alternateStyle: null,
2129
- alternateIntro: null,
2130
- capacity: null,
2131
- item: text2,
2132
- articleWord: null,
2133
- countMultiplier: null
2134
- });
2135
- if (unreadableComma(text2, language, evidence)) return empty("ambiguousDecimal");
2136
- const loose = APPROXIMATION_PREFIX[language].exec(text2);
2137
- const stated = loose ? text2.slice(loose[0].length) : text2;
2138
- const range = parseLeadingRange(stated, language);
2139
- const article = range ? null : readArticle(stated, language);
2140
- const quantity = range ?? parseLeadingQuantity(stated, language) ?? article;
2141
- if (!quantity) return empty(null);
2142
- const behindFigure = stated.slice(quantity.length);
2143
- if (new RegExp("^-\\p{L}", "u").test(behindFigure)) return empty("sizeQualifier");
2144
- if (quantity !== article && ORDINAL_SUFFIX.test(behindFigure)) return empty(null);
2145
- if (TIME_UNIT[language].test(behindFigure.trimStart())) return empty("duration");
2146
- let rest = stated.slice(quantity.length).trimStart();
2147
- if (language === "en") rest = rest.replace(/^(?:of\s+)?an?\s+/i, "");
2148
- const multiplier = readCountMultiplier(rest);
2149
- if (multiplier) rest = multiplier.rest;
2150
- const times = multiplier?.times ?? 1;
2151
- const fromArticle = quantity === article;
2367
+ function readMeasure(rest, language, fromArticle) {
2152
2368
  const direct = takeLeadingUnit(rest, language, fromArticle);
2153
2369
  const described = direct.unit ? { adjective: null, rest } : takeMeasureAdjective(rest, language);
2154
2370
  const behind = described.adjective ? takeLeadingUnit(described.rest, language, fromArticle) : null;
2155
- const leading = direct.unit ? direct : behind?.unit ? behind : direct;
2156
- rest = leading.rest;
2157
- const compound = range ? null : takeCompoundMember(rest, leading.unit, language);
2158
- if (compound) rest = compound.rest;
2159
- const whole = quantity.amount + (compound?.adds ?? 0);
2160
- const introducedBracket = /^(?:de\s+|du\s+|des\s+|d'|of\s+)(?=\()/i.exec(rest);
2371
+ return { leading: leadingMeasure(direct, behind), described, behind };
2372
+ }
2373
+ function whatTheFigureIsNot(behindFigure, couldBeARank, language) {
2374
+ if (HYPHENATED_TAIL.test(behindFigure)) {
2375
+ return "sizeQualifier";
2376
+ }
2377
+ if (couldBeARank && ORDINAL_SUFFIX.test(behindFigure)) {
2378
+ return null;
2379
+ }
2380
+ if (TIME_UNIT[language].test(behindFigure.trimStart())) {
2381
+ return "duration";
2382
+ }
2383
+ return false;
2384
+ }
2385
+ function takeEquivalents(text2, language) {
2386
+ const introducedBracket = PARTITIVE_BEFORE_BRACKET.exec(text2);
2161
2387
  const bracketed = takeAlternates(
2162
- introducedBracket ? rest.slice(introducedBracket[0].length) : rest,
2388
+ introducedBracket ? text2.slice(introducedBracket[0].length) : text2,
2163
2389
  language
2164
2390
  );
2165
- rest = bracketed.measures.length > 0 ? bracketed.rest : rest;
2391
+ let rest = bracketed.measures.length > 0 ? bracketed.rest : text2;
2166
2392
  const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest, language);
2167
- if (slashed) rest = slashed.rest;
2393
+ if (slashed) {
2394
+ rest = slashed.rest;
2395
+ }
2168
2396
  let item = stripItemLead(rest, language);
2169
2397
  const trailing = bracketed.measures.length === 0 && !slashed ? takeTrailingAlternates(item, language) : null;
2170
- if (trailing) item = trailing.item;
2398
+ if (trailing) {
2399
+ item = trailing.item;
2400
+ }
2171
2401
  const group = trailing ?? (bracketed.measures.length > 0 ? bracketed : null);
2172
- const capacity = group !== null && group.measures.every((measure) => measure.unit?.kind === "measured") && (namesContainer(leading.unit?.canonical) || namesContainer(item)) ? group.published : null;
2173
- if (language === "fr" && !leading.unit && statesItemSize(item)) return empty("itemSize");
2174
- if (language === "fr" && leading.partitive && isStatedSize(item)) return empty("itemSize");
2402
+ return { rest, item, bracketed, slashed, trailing, group };
2403
+ }
2404
+ function whatTheFiguresSize(leading, item, language) {
2405
+ if (language === "fr" && !leading.unit && statesItemSize(item)) {
2406
+ return "itemSize";
2407
+ }
2408
+ if (language === "fr" && leading.partitive && isStatedSize(item)) {
2409
+ return "itemSize";
2410
+ }
2175
2411
  if (leading.unit?.kind === "measured" && CONTAINER_NOUN.test(normalizeUnitKey(item))) {
2176
- return empty("containerSize");
2412
+ return "containerSize";
2413
+ }
2414
+ return null;
2415
+ }
2416
+ function whatTheCountIsOf(behindFigure, language) {
2417
+ let rest = behindFigure.trimStart();
2418
+ if (language === "en") {
2419
+ rest = rest.replace(LEADING_ARTICLE, "");
2420
+ }
2421
+ const multiplier = readCountMultiplier(rest);
2422
+ if (multiplier) {
2423
+ rest = multiplier.rest;
2424
+ }
2425
+ return { rest, multiplier };
2426
+ }
2427
+ function capacityOfTheContainer(group, measureName, item) {
2428
+ if (!group?.measures.every((measure) => measure.unit?.kind === "measured")) {
2429
+ return null;
2430
+ }
2431
+ if (!(namesContainer(measureName) || namesContainer(item))) {
2432
+ return null;
2177
2433
  }
2178
- const alternates = capacity !== null ? [] : slashed?.measures ?? group?.measures ?? [];
2434
+ return group.published;
2435
+ }
2436
+ function parseIngredient(line, choice = "auto") {
2437
+ const original = line;
2438
+ const marked = readable(line);
2439
+ const decoration = takeDecoration(marked);
2440
+ const text2 = decoration ? marked.slice(decoration.length).trimStart() : marked;
2441
+ const evidence = readEvidence(text2);
2442
+ const language = choice === "auto" ? evidence.language : choice;
2443
+ const empty = (heldBack2) => ({
2444
+ original,
2445
+ language,
2446
+ heldBack: heldBack2,
2447
+ decoration,
2448
+ approximation: null,
2449
+ measureAdjective: null,
2450
+ amount: null,
2451
+ amountMax: null,
2452
+ rangeSeparator: null,
2453
+ unit: null,
2454
+ alternates: [],
2455
+ alternateStyle: null,
2456
+ alternateIntro: null,
2457
+ capacity: null,
2458
+ item: text2,
2459
+ articleWord: null,
2460
+ countMultiplier: null
2461
+ });
2462
+ if (unreadableComma(text2, language, evidence)) {
2463
+ return empty("ambiguousDecimal");
2464
+ }
2465
+ const loose = APPROXIMATION_PREFIX[language].exec(text2);
2466
+ const stated = loose ? text2.slice(loose[0].length) : text2;
2467
+ const range = parseLeadingRange(stated, language);
2468
+ const article = range ? null : readArticle(stated, language);
2469
+ const quantity = range ?? parseLeadingQuantity(stated, language) ?? article;
2470
+ if (!quantity) {
2471
+ return empty(null);
2472
+ }
2473
+ const notAnAmount = whatTheFigureIsNot(
2474
+ stated.slice(quantity.length),
2475
+ quantity !== article,
2476
+ language
2477
+ );
2478
+ if (notAnAmount !== false) {
2479
+ return empty(notAnAmount);
2480
+ }
2481
+ const counted = whatTheCountIsOf(stated.slice(quantity.length), language);
2482
+ let rest = counted.rest;
2483
+ const multiplier = counted.multiplier;
2484
+ const times = multiplier?.times ?? 1;
2485
+ const fromArticle = quantity === article;
2486
+ const measure = readMeasure(rest, language, fromArticle);
2487
+ const { leading, described, behind } = measure;
2488
+ rest = leading.rest;
2489
+ const compound = range ? null : takeCompoundMember(rest, leading.unit, language);
2490
+ if (compound) {
2491
+ rest = compound.rest;
2492
+ }
2493
+ const whole = quantity.amount + (compound?.adds ?? 0);
2494
+ const equivalents = takeEquivalents(rest, language);
2495
+ const { slashed, trailing, group } = equivalents;
2496
+ rest = equivalents.rest;
2497
+ const item = equivalents.item;
2498
+ const capacity = capacityOfTheContainer(group, leading.unit?.canonical, item);
2499
+ const sizedRatherThanCounted = whatTheFiguresSize(leading, item, language);
2500
+ if (sizedRatherThanCounted !== null) {
2501
+ return empty(sizedRatherThanCounted);
2502
+ }
2503
+ const alternates = capacity === null ? slashed?.measures ?? group?.measures ?? [] : [];
2179
2504
  return {
2180
2505
  original,
2181
2506
  language,
@@ -2188,8 +2513,8 @@ function parseIngredient(line, choice = "auto") {
2188
2513
  rangeSeparator: range?.separator ?? null,
2189
2514
  unit: leading.unit,
2190
2515
  alternates,
2191
- alternateStyle: alternates.length === 0 ? null : slashed ? "slash" : trailing ? "trailing" : "bracket",
2192
- alternateIntro: capacity !== null ? null : group?.intro ?? null,
2516
+ alternateStyle: alternateStyleOf(alternates.length, slashed !== null, trailing !== null),
2517
+ alternateIntro: capacity === null ? group?.intro ?? null : null,
2193
2518
  capacity,
2194
2519
  item,
2195
2520
  articleWord: fromArticle ? article?.word ?? null : null,
@@ -2201,21 +2526,35 @@ function unreadableComma(text2, language, evidence) {
2201
2526
  const settled = evidence.french !== evidence.english;
2202
2527
  return !settled;
2203
2528
  }
2204
- if (language === "en") return COMMA_DECIMAL.test(text2);
2205
- return /^\s*\d+,\d+,\d/.test(text2);
2529
+ if (language === "en") {
2530
+ return COMMA_DECIMAL.test(text2);
2531
+ }
2532
+ return REPEATED_COMMA_GROUPS.test(text2);
2206
2533
  }
2207
2534
  function readArticle(text2, language) {
2208
2535
  const counts = (rest) => takeLeadingUnit(rest, language, true).unit !== null || readCountMultiplier(rest) !== null;
2209
2536
  if (language === "fr") {
2210
- const match = /^\s*(un|une)\b\s*/i.exec(text2);
2211
- if (!match) return null;
2212
- if (!counts(text2.slice(match[0].length))) return null;
2213
- const word = match[1];
2214
- return { amount: FRENCH_ARTICLES[word.toLowerCase()], length: match[0].length, word };
2215
- }
2216
- const article = /^an?\s+/i.exec(text2);
2217
- if (!article) return null;
2218
- if (!counts(text2.slice(article[0].length))) return null;
2537
+ const match = OPENING_ARTICLE_FRENCH.exec(text2);
2538
+ if (!match) {
2539
+ return null;
2540
+ }
2541
+ if (!counts(text2.slice(match[0].length))) {
2542
+ return null;
2543
+ }
2544
+ const [word = ""] = match.slice(1);
2545
+ const amount = FRENCH_ARTICLES[word.toLowerCase()];
2546
+ if (amount === void 0) {
2547
+ return null;
2548
+ }
2549
+ return { amount, length: match[0].length, word };
2550
+ }
2551
+ const article = LEADING_INDEFINITE_WITH_SPACE.exec(text2);
2552
+ if (!article) {
2553
+ return null;
2554
+ }
2555
+ if (!counts(text2.slice(article[0].length))) {
2556
+ return null;
2557
+ }
2219
2558
  return { amount: 1, length: article[0].length, word: article[0].trim() };
2220
2559
  }
2221
2560
  var COUNT_MULTIPLIERS = {
@@ -2225,57 +2564,80 @@ var COUNT_MULTIPLIERS = {
2225
2564
  douzaines: 12
2226
2565
  };
2227
2566
  function readCountMultiplier(text2) {
2228
- const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text2);
2229
- if (!match) return null;
2230
- const times = COUNT_MULTIPLIERS[normalizeUnitKey(match[1])];
2231
- if (times === void 0) return null;
2567
+ const match = LEADING_WORD.exec(text2);
2568
+ if (!match) {
2569
+ return null;
2570
+ }
2571
+ const [word = ""] = match.slice(1);
2572
+ const times = COUNT_MULTIPLIERS[normalizeUnitKey(word)];
2573
+ if (times === void 0) {
2574
+ return null;
2575
+ }
2232
2576
  return { times, rest: text2.slice(match[0].length) };
2233
2577
  }
2234
2578
  function takeLeadingUnit(text2, language, fromArticle) {
2235
2579
  const taken = takeUnit(text2, language);
2236
- if (taken.unit) return { ...taken, partitive: false };
2580
+ if (taken.unit) {
2581
+ return { ...taken, partitive: false };
2582
+ }
2237
2583
  if (language === "fr" && fromArticle) {
2238
2584
  const measure = readPartitiveMeasure(text2);
2239
- if (measure) return { unit: measure.unit, rest: measure.rest, partitive: true };
2585
+ if (measure) {
2586
+ return { unit: measure.unit, rest: measure.rest, partitive: true };
2587
+ }
2240
2588
  }
2241
2589
  return { ...taken, partitive: false };
2242
2590
  }
2243
2591
  function stripItemLead(text2, language) {
2244
2592
  if (language === "fr") {
2245
- return text2.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").replace(/^(?:une|un)\s+/i, "").trim();
2593
+ return text2.replace(LEADING_PARTITIVE, "").replace(LEADING_ARTICLE_FRENCH, "").trim();
2246
2594
  }
2247
- return text2.replace(/^of\s+/i, "").replace(/^an?\s+/i, "").trim();
2595
+ return text2.replace(OF_OPENING, "").replace(LEADING_INDEFINITE_WITH_SPACE, "").trim();
2248
2596
  }
2249
2597
  function takeAlternates(text2, language) {
2250
2598
  const none = { measures: [], intro: null, published: "", rest: text2 };
2251
- if (!text2.startsWith("(")) return none;
2599
+ if (!text2.startsWith("(")) {
2600
+ return none;
2601
+ }
2252
2602
  const close = text2.indexOf(")");
2253
- if (close < 0) return none;
2603
+ if (close < 0) {
2604
+ return none;
2605
+ }
2254
2606
  const read = readBracket(text2.slice(0, close + 1), language);
2255
- if (!read) return none;
2607
+ if (!read) {
2608
+ return none;
2609
+ }
2256
2610
  return { ...read, rest: text2.slice(close + 1).trimStart() };
2257
2611
  }
2258
2612
  var EQUIVALENT_INTRODUCER = /^(?:soit|environ|about|approx\.?|approximately|or|ou|=)\s*/i;
2259
2613
  var MEASURE_PARTITIVE = /^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+|of\s+)/i;
2260
2614
  function takeMeasureAfterQuantity(text2, language) {
2261
2615
  const direct = takeUnitEitherLanguage(text2, language);
2262
- if (direct.unit) return direct;
2616
+ if (direct.unit) {
2617
+ return direct;
2618
+ }
2263
2619
  const partitive = MEASURE_PARTITIVE.exec(text2);
2264
- if (!partitive) return direct;
2620
+ if (!partitive) {
2621
+ return direct;
2622
+ }
2265
2623
  return takeUnitEitherLanguage(text2.slice(partitive[0].length), language);
2266
2624
  }
2267
2625
  function readBracket(bracket, language) {
2268
2626
  const inside = bracket.slice(1, -1);
2269
2627
  const intro = EQUIVALENT_INTRODUCER.exec(inside);
2270
2628
  const body = intro ? inside.slice(intro[0].length) : inside;
2271
- const parts = body.split(/(?<!\d)\/|\/(?!\d)/).map((part) => part.trim());
2629
+ const parts = body.split(SLASH_OUTSIDE_A_FRACTION).map((part) => part.trim());
2272
2630
  const measures = [];
2273
2631
  for (const part of parts) {
2274
2632
  const range = parseLeadingRange(part, language);
2275
2633
  const quantity = range ?? parseLeadingQuantity(part, language);
2276
- if (!quantity) return null;
2634
+ if (!quantity) {
2635
+ return null;
2636
+ }
2277
2637
  const after = takeMeasureAfterQuantity(part.slice(quantity.length).trimStart(), language);
2278
- if (!after.unit || after.rest.trim() !== "") return null;
2638
+ if (!after.unit || after.rest.trim() !== "") {
2639
+ return null;
2640
+ }
2279
2641
  measures.push({
2280
2642
  amount: quantity.amount,
2281
2643
  amountMax: range?.max ?? null,
@@ -2283,16 +2645,24 @@ function readBracket(bracket, language) {
2283
2645
  unit: after.unit
2284
2646
  });
2285
2647
  }
2286
- if (measures.length === 0) return null;
2648
+ if (measures.length === 0) {
2649
+ return null;
2650
+ }
2287
2651
  return { measures, intro: intro ? intro[0].trim() : null, published: bracket };
2288
2652
  }
2289
2653
  function takeTrailingAlternates(item, language) {
2290
- const closing = /\s*(\([^()]*\))\s*$/.exec(item);
2291
- if (!closing) return null;
2292
- const read = readBracket(closing[1], language);
2293
- if (!read) return null;
2654
+ const closing = BRACKETED_TAIL.exec(item);
2655
+ if (!closing) {
2656
+ return null;
2657
+ }
2658
+ const read = readBracket(closing[1] ?? "", language);
2659
+ if (!read) {
2660
+ return null;
2661
+ }
2294
2662
  const head = item.slice(0, closing.index).trim();
2295
- if (head === "") return null;
2663
+ if (head === "") {
2664
+ return null;
2665
+ }
2296
2666
  return { ...read, item: head };
2297
2667
  }
2298
2668
  function takeSlashAlternates(text2, language) {
@@ -2302,9 +2672,13 @@ function takeSlashAlternates(text2, language) {
2302
2672
  const after = rest.slice(1).trimStart();
2303
2673
  const range = parseLeadingRange(after, language);
2304
2674
  const quantity = range ?? parseLeadingQuantity(after, language);
2305
- if (!quantity) break;
2675
+ if (!quantity) {
2676
+ break;
2677
+ }
2306
2678
  const taken = takeUnitEitherLanguage(after.slice(quantity.length).trimStart(), language);
2307
- if (!taken.unit) break;
2679
+ if (!taken.unit) {
2680
+ break;
2681
+ }
2308
2682
  measures.push({
2309
2683
  amount: quantity.amount,
2310
2684
  amountMax: range?.max ?? null,
@@ -2321,9 +2695,15 @@ function formatAmount(amount, language, options = {}) {
2321
2695
  const rendered = String(rounded);
2322
2696
  return language === "fr" ? rendered.replace(".", ",") : rendered;
2323
2697
  };
2324
- if (!Number.isFinite(amount)) return "";
2325
- if (Number.isInteger(amount)) return String(amount);
2326
- if (options.fractions === false) return decimal(amount);
2698
+ if (!Number.isFinite(amount)) {
2699
+ return "";
2700
+ }
2701
+ if (Number.isInteger(amount)) {
2702
+ return String(amount);
2703
+ }
2704
+ if (options.fractions === false) {
2705
+ return decimal(amount);
2706
+ }
2327
2707
  const whole = Math.floor(amount);
2328
2708
  const rest = amount - whole;
2329
2709
  const known = [
@@ -2334,20 +2714,52 @@ function formatAmount(amount, language, options = {}) {
2334
2714
  [0.75, "3/4"]
2335
2715
  ];
2336
2716
  for (const [value, label] of known) {
2337
- if (Math.abs(rest - value) < 0.02) return whole > 0 ? `${whole} ${label}` : label;
2717
+ if (Math.abs(rest - value) < 0.02) {
2718
+ return whole > 0 ? `${whole} ${label}` : label;
2719
+ }
2338
2720
  }
2339
2721
  return decimal(amount);
2340
2722
  }
2341
2723
 
2342
2724
  // src/recipe/scale.ts
2725
+ var DASH_ONLY = /^[-–—]$/;
2726
+ var AL_ENDING2 = /al$/i;
2727
+ var AUX_ENDING2 = /aux$/i;
2728
+ var CLOVE = /\bcloves?\b/i;
2729
+ var CONSONANT_THEN_Y = /[^aeiou]y$/i;
2730
+ var DOUBLE_S_ENDING = /ss$/i;
2731
+ var EAUX_ENDING2 = /eaux$/i;
2732
+ var EAU_ENDING2 = /eau$/i;
2733
+ var F_ENDING = /(?:[^f]f|fe)$/i;
2734
+ var F_OR_FE_ENDING = /fe?$/i;
2735
+ var GARLIC = /\bgarlic\b/i;
2736
+ var IES_ENDING = /ies$/i;
2737
+ var LETTER = new RegExp("\\p{L}", "u");
2738
+ var LETTERS_ONLY = /^[A-Za-z]+$/;
2739
+ var OF_JOINER = / of /i;
2740
+ var PLURAL_ENDING = /s$|eaux$|aux$/i;
2741
+ var SIBILANT_ENDING = /(?:ch|sh|s|x|z)$/i;
2742
+ var SIBILANT_LETTER2 = /[sxz]$/i;
2743
+ var SIBILANT_PLURAL = /(?:ch|sh|s|x|z)es$/i;
2744
+ var TRAILING_S_ANY_CASE2 = /s$/i;
2745
+ var VOWEL_OPENING = /^[aeiouàâäéèêëîïôöûü]/i;
2746
+ var X_ENDING = /x$/i;
2343
2747
  function roundTo(value, step) {
2344
2748
  return Math.round(Math.round(value / step) * step * 100) / 100;
2345
2749
  }
2346
2750
  function roundMeasured(value) {
2347
- if (Number.isInteger(value)) return value;
2348
- if (value >= 100) return roundTo(value, 5);
2349
- if (value >= 10) return roundTo(value, 1);
2350
- if (value >= 1) return roundTo(value, 0.1);
2751
+ if (Number.isInteger(value)) {
2752
+ return value;
2753
+ }
2754
+ if (value >= 100) {
2755
+ return roundTo(value, 5);
2756
+ }
2757
+ if (value >= 10) {
2758
+ return roundTo(value, 1);
2759
+ }
2760
+ if (value >= 1) {
2761
+ return roundTo(value, 0.1);
2762
+ }
2351
2763
  return Math.round(value * 100) / 100;
2352
2764
  }
2353
2765
  var SMALLEST_USABLE_FRACTION = 0.25;
@@ -2363,38 +2775,50 @@ function trim(value) {
2363
2775
  return Math.round(value * 100) / 100;
2364
2776
  }
2365
2777
  function roundCountable(value, divisibility, ceiling) {
2366
- if (value <= 0) return { value: 0, clamped: false };
2778
+ if (value <= 0) {
2779
+ return { value: 0, clamped: false };
2780
+ }
2367
2781
  const floor = SMALLEST_USABLE[divisibility];
2368
2782
  if (divisibility !== "whole" && value >= floor && isHalfStep(value)) {
2369
2783
  return { value: trim(value), clamped: false };
2370
2784
  }
2371
2785
  if (divisibility === "whole") {
2372
- if (value < 0.5) return { value: floor, clamped: true };
2786
+ if (value < 0.5) {
2787
+ return { value: floor, clamped: true };
2788
+ }
2373
2789
  return { value: Math.round(value), clamped: false };
2374
2790
  }
2375
- if (value < floor) return { value: floor, clamped: true };
2791
+ if (value < floor) {
2792
+ return { value: floor, clamped: true };
2793
+ }
2376
2794
  if (value < 1) {
2377
2795
  const steps = divisibility === "quarter" ? [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1] : [0.5, 1];
2378
2796
  const candidates = steps.filter(
2379
2797
  (candidate) => candidate >= floor && candidate <= Math.max(ceiling, floor)
2380
2798
  );
2381
- let closest = candidates[0];
2799
+ let closest = candidates[0] ?? floor;
2382
2800
  for (const candidate of candidates) {
2383
- if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
2801
+ if (Math.abs(value - candidate) < Math.abs(value - closest)) {
2802
+ closest = candidate;
2803
+ }
2384
2804
  }
2385
2805
  return { value: trim(closest), clamped: false };
2386
2806
  }
2387
2807
  return { value: Math.round(value), clamped: false };
2388
2808
  }
2389
2809
  function roundSpoon(value, ceiling) {
2390
- if (value <= 0) return { value: 0, clamped: false };
2810
+ if (value <= 0) {
2811
+ return { value: 0, clamped: false };
2812
+ }
2391
2813
  if (value < 1) {
2392
2814
  const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(
2393
2815
  (candidate) => candidate <= Math.max(ceiling, SMALLEST_USABLE_FRACTION)
2394
2816
  );
2395
- let closest = candidates[0];
2817
+ let closest = candidates[0] ?? SMALLEST_USABLE_FRACTION;
2396
2818
  for (const candidate of candidates) {
2397
- if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
2819
+ if (Math.abs(value - candidate) < Math.abs(value - closest)) {
2820
+ closest = candidate;
2821
+ }
2398
2822
  }
2399
2823
  return { value: trim(closest), clamped: value < SMALLEST_USABLE_FRACTION };
2400
2824
  }
@@ -2405,7 +2829,9 @@ function stepDownSpoon(unit, reference) {
2405
2829
  let ratio = 1;
2406
2830
  while (reference * ratio < 1 && !isHalfStep(reference * ratio)) {
2407
2831
  const step = demoteUnit(current);
2408
- if (!step) break;
2832
+ if (!step) {
2833
+ break;
2834
+ }
2409
2835
  ratio *= step.per;
2410
2836
  current = step.unit;
2411
2837
  }
@@ -2415,24 +2841,33 @@ var EXACT_WITHIN = 0.01;
2415
2841
  var EXACT_SHARE = 5e-3;
2416
2842
  function landedExactly(exact, amount) {
2417
2843
  const gap = Math.abs(exact - amount);
2418
- if (gap > EXACT_WITHIN) return false;
2844
+ if (gap > EXACT_WITHIN) {
2845
+ return false;
2846
+ }
2419
2847
  return exact === 0 || gap / Math.abs(exact) <= EXACT_SHARE;
2420
2848
  }
2421
2849
  function boundLandedExactly(bound) {
2422
- const fine = bound.ratio >= 1 ? bound.ratio : 1;
2850
+ const fine = Math.max(bound.ratio, 1);
2423
2851
  return landedExactly(bound.raw * fine, bound.amount * (fine / bound.ratio));
2424
2852
  }
2425
2853
  function scaleMeasure(low, high, unit, factor, divisibility) {
2426
- const raws = high === null ? [low * factor] : [low * factor, high * factor];
2427
- const sources = high === null ? [low] : [low, high];
2854
+ const ends = high === null ? [{ published: low, raw: low * factor }] : [
2855
+ { published: low, raw: low * factor },
2856
+ { published: high, raw: high * factor }
2857
+ ];
2858
+ const eachEnd = (read) => [
2859
+ read(ends[0]),
2860
+ ...ends.slice(1).map(read)
2861
+ ];
2862
+ const raws = ends.map((end) => end.raw);
2428
2863
  const positive = raws.filter((raw) => raw > 0);
2429
- const reference = positive.length > 0 ? Math.min(...positive) : raws[0];
2864
+ const reference = positive.length > 0 ? Math.min(...positive) : low * factor;
2430
2865
  const inUnit = (target, ratio) => ({
2431
- bounds: raws.map((raw, index) => {
2866
+ bounds: eachEnd(({ published, raw }) => {
2432
2867
  const exact = raw * ratio;
2433
2868
  const rounded = ratio < 1 ? Number((roundMeasured(raw) * ratio).toPrecision(12)) : roundMeasured(exact);
2434
2869
  const usable = rounded === 0 && exact > 0 ? Number(exact.toPrecision(2)) : rounded;
2435
- const ceiling = factor < 1 ? sources[index] * ratio : Number.POSITIVE_INFINITY;
2870
+ const ceiling = factor < 1 ? published * ratio : Number.POSITIVE_INFINITY;
2436
2871
  return {
2437
2872
  amount: Math.min(usable, ceiling),
2438
2873
  exact,
@@ -2449,16 +2884,18 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
2449
2884
  }
2450
2885
  if (unit && isSpoonMeasure(unit)) {
2451
2886
  const stepped = stepDownSpoon(unit, reference);
2452
- if (stepped.ratio !== 1) return inUnit(stepped.unit, stepped.ratio);
2453
- const bounds2 = raws.map((raw, index) => {
2454
- const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
2887
+ if (stepped.ratio !== 1) {
2888
+ return inUnit(stepped.unit, stepped.ratio);
2889
+ }
2890
+ const bounds2 = eachEnd(({ published, raw }) => {
2891
+ const ceiling = factor < 1 ? published : Number.POSITIVE_INFINITY;
2455
2892
  const rounded = roundSpoon(raw, ceiling);
2456
2893
  return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw, ratio: 1 };
2457
2894
  });
2458
2895
  return { bounds: bounds2, unit };
2459
2896
  }
2460
- const bounds = raws.map((raw, index) => {
2461
- const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
2897
+ const bounds = eachEnd(({ published, raw }) => {
2898
+ const ceiling = factor < 1 ? published : Number.POSITIVE_INFINITY;
2462
2899
  const rounded = roundCountable(raw, divisibility, ceiling);
2463
2900
  return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw, ratio: 1 };
2464
2901
  });
@@ -2476,14 +2913,18 @@ var HALVED_ITEM = /\b(?:jus|juices?)\b/iu;
2476
2913
  var WHOLE_ITEM = /\b(?:eggs?|yolks?|egg\s+whites?|oeufs?|œufs?|jaunes?\s+d['e]|clous?|zestes?|zests?)\b/iu;
2477
2914
  var HALVED_CUT = /\b(?:breasts?|thighs?|drumsticks?|wings?|cutlets?|cuisses?|ailes?|pilons?|escalopes?|magrets?)\b/iu;
2478
2915
  function cloveDivisibility(unit, item) {
2479
- const counted = unit ? unit.canonical === "clove" : /\bcloves?\b/i.test(item);
2480
- if (!counted) return null;
2481
- return /\bgarlic\b/i.test(item) ? "half" : "whole";
2916
+ const counted = unit ? unit.canonical === "clove" : CLOVE.test(item);
2917
+ if (!counted) {
2918
+ return null;
2919
+ }
2920
+ return GARLIC.test(item) ? "half" : "whole";
2482
2921
  }
2483
2922
  var BLANC_OF = /\bblancs?\s+d(?:e\s|['’])/iu;
2484
2923
  var BLANC_OF_EGG = /\bblancs?\s+d(?:e\s|['’])\s*(?:oeufs?|œufs?)\b/iu;
2485
2924
  function blancDivisibility(item) {
2486
- if (!BLANC_OF.test(item)) return null;
2925
+ if (!BLANC_OF.test(item)) {
2926
+ return null;
2927
+ }
2487
2928
  return BLANC_OF_EGG.test(item) ? "whole" : "half";
2488
2929
  }
2489
2930
  function foldItem(item) {
@@ -2492,15 +2933,31 @@ function foldItem(item) {
2492
2933
  function divisibilityOf(unit, item) {
2493
2934
  const key = foldItem(item);
2494
2935
  const clove = cloveDivisibility(unit, key);
2495
- if (clove) return clove;
2496
- if (unit && !countsBarePieces(unit)) return unitDivisibility(unit);
2936
+ if (clove) {
2937
+ return clove;
2938
+ }
2939
+ if (unit && !countsBarePieces(unit)) {
2940
+ return unitDivisibility(unit);
2941
+ }
2497
2942
  const blanc = blancDivisibility(key);
2498
- if (blanc) return blanc;
2499
- if (WHOLE_ITEM.test(key)) return "whole";
2500
- if (PORTION_SIZED_ITEM.test(key)) return "whole";
2501
- if (HALVED_ITEM.test(key)) return "half";
2502
- if (HALVED_CUT.test(key)) return "half";
2503
- if (QUARTERED_MEASURE.test(key)) return "quarter";
2943
+ if (blanc) {
2944
+ return blanc;
2945
+ }
2946
+ if (WHOLE_ITEM.test(key)) {
2947
+ return "whole";
2948
+ }
2949
+ if (PORTION_SIZED_ITEM.test(key)) {
2950
+ return "whole";
2951
+ }
2952
+ if (HALVED_ITEM.test(key)) {
2953
+ return "half";
2954
+ }
2955
+ if (HALVED_CUT.test(key)) {
2956
+ return "half";
2957
+ }
2958
+ if (QUARTERED_MEASURE.test(key)) {
2959
+ return "quarter";
2960
+ }
2504
2961
  return QUARTERED_ITEM.test(key) ? "quarter" : "half";
2505
2962
  }
2506
2963
  var INVARIABLE_ITEM = /* @__PURE__ */ new Set([
@@ -2589,54 +3046,87 @@ var IRREGULAR_SINGULAR = Object.fromEntries(
2589
3046
  Object.entries(IRREGULAR_PLURAL).map(([one, many]) => [many, one])
2590
3047
  );
2591
3048
  function matchCase(source, replacement) {
2592
- const hasLetter = new RegExp("\\p{L}", "u").test(source);
2593
- if (hasLetter && source === source.toUpperCase()) return replacement.toUpperCase();
2594
- if (source[0] === source[0]?.toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
2595
- return replacement[0].toUpperCase() + replacement.slice(1);
3049
+ const hasLetter = LETTER.test(source);
3050
+ if (hasLetter && source === source.toUpperCase()) {
3051
+ return replacement.toUpperCase();
3052
+ }
3053
+ const first = source.slice(0, 1);
3054
+ if (first !== "" && first === first.toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
3055
+ return (replacement[0] ?? "").toUpperCase() + replacement.slice(1);
2596
3056
  }
2597
3057
  return replacement;
2598
3058
  }
2599
3059
  function toEnglishPlural(word) {
2600
3060
  const key = word.toLowerCase();
2601
- if (INVARIABLE_ITEM.has(key)) return word;
3061
+ if (INVARIABLE_ITEM.has(key)) {
3062
+ return word;
3063
+ }
2602
3064
  const irregular = IRREGULAR_PLURAL[key];
2603
- if (irregular) return matchCase(word, irregular);
2604
- if (/(?:ch|sh|s|x|z)$/i.test(word)) return `${word}es`;
2605
- if (/[^aeiou]y$/i.test(word)) return `${word.slice(0, -1)}ies`;
2606
- if (/(?:[^f]f|fe)$/i.test(word)) return `${word.replace(/fe?$/i, "")}ves`;
3065
+ if (irregular) {
3066
+ return matchCase(word, irregular);
3067
+ }
3068
+ if (SIBILANT_ENDING.test(word)) {
3069
+ return `${word}es`;
3070
+ }
3071
+ if (CONSONANT_THEN_Y.test(word)) {
3072
+ return `${word.slice(0, -1)}ies`;
3073
+ }
3074
+ if (F_ENDING.test(word)) {
3075
+ return `${word.replace(F_OR_FE_ENDING, "")}ves`;
3076
+ }
2607
3077
  return `${word}s`;
2608
3078
  }
2609
3079
  function toEnglishSingular(word) {
2610
3080
  const key = word.toLowerCase();
2611
- if (INVARIABLE_ITEM.has(key)) return word;
3081
+ if (INVARIABLE_ITEM.has(key)) {
3082
+ return word;
3083
+ }
2612
3084
  const irregular = IRREGULAR_SINGULAR[key];
2613
- if (irregular) return matchCase(word, irregular);
2614
- if (/ies$/i.test(word) && word.length > 4) return `${word.slice(0, -3)}y`;
2615
- if (/(?:ch|sh|s|x|z)es$/i.test(word)) return word.slice(0, -2);
2616
- if (/ss$/i.test(word)) return word;
2617
- if (/s$/i.test(word)) return word.slice(0, -1);
3085
+ if (irregular) {
3086
+ return matchCase(word, irregular);
3087
+ }
3088
+ if (IES_ENDING.test(word) && word.length > 4) {
3089
+ return `${word.slice(0, -3)}y`;
3090
+ }
3091
+ if (SIBILANT_PLURAL.test(word)) {
3092
+ return word.slice(0, -2);
3093
+ }
3094
+ if (DOUBLE_S_ENDING.test(word)) {
3095
+ return word;
3096
+ }
3097
+ if (TRAILING_S_ANY_CASE2.test(word)) {
3098
+ return word.slice(0, -1);
3099
+ }
2618
3100
  return word;
2619
3101
  }
2620
3102
  function agreeInEnglish(item, amount) {
2621
- if (!item) return item;
3103
+ if (!item) {
3104
+ return item;
3105
+ }
2622
3106
  const comma = item.indexOf(",");
2623
3107
  const head = comma < 0 ? item : item.slice(0, comma);
2624
3108
  const tail = comma < 0 ? "" : item.slice(comma);
2625
- const preposition = / of /i.exec(head);
3109
+ const preposition = OF_JOINER.exec(head);
2626
3110
  if (preposition) {
2627
3111
  const counted = agreeInEnglish(head.slice(0, preposition.index), amount);
2628
3112
  return `${counted}${head.slice(preposition.index)}${tail}`;
2629
3113
  }
2630
3114
  const words = head.trimEnd().split(" ");
2631
- const last = words[words.length - 1] ?? "";
2632
- if (!/^[A-Za-z]+$/.test(last) || last.length <= 2) return item;
3115
+ const last = words.at(-1) ?? "";
3116
+ if (!LETTERS_ONLY.test(last) || last.length <= 2) {
3117
+ return item;
3118
+ }
2633
3119
  const wantsPlural = amount > 1;
2634
3120
  const plural = toEnglishPlural(last);
2635
3121
  const singular = toEnglishSingular(last);
2636
3122
  const isPlural = last.toLowerCase() !== singular.toLowerCase();
2637
- if (wantsPlural && !isPlural) words[words.length - 1] = plural;
2638
- else if (!wantsPlural && isPlural) words[words.length - 1] = singular;
2639
- else return item;
3123
+ if (wantsPlural && !isPlural) {
3124
+ words[words.length - 1] = plural;
3125
+ } else if (!wantsPlural && isPlural) {
3126
+ words[words.length - 1] = singular;
3127
+ } else {
3128
+ return item;
3129
+ }
2640
3130
  return `${words.join(" ")}${tail}`;
2641
3131
  }
2642
3132
  var FRENCH_OU_PLURAL_IN_X = /* @__PURE__ */ new Set([
@@ -2715,62 +3205,109 @@ function agreeTrailingAdjective(word, wantsPlural) {
2715
3205
  const folded = foldWord2(word);
2716
3206
  const isPlural = folded.endsWith("s");
2717
3207
  const singular = isPlural ? word.slice(0, -1) : word;
2718
- if (!AGREEABLE_ADJECTIVES.has(foldWord2(singular))) return null;
2719
- if (wantsPlural === isPlural) return null;
3208
+ if (!AGREEABLE_ADJECTIVES.has(foldWord2(singular))) {
3209
+ return null;
3210
+ }
3211
+ if (wantsPlural === isPlural) {
3212
+ return null;
3213
+ }
2720
3214
  return wantsPlural ? `${word}s` : singular;
2721
3215
  }
3216
+ function agreeFrenchHead(head, number) {
3217
+ const { wantsPlural, isPlural, ouPlural } = number;
3218
+ if (wantsPlural && !isPlural) {
3219
+ if (SIBILANT_LETTER2.test(head)) {
3220
+ return head;
3221
+ }
3222
+ if (EAU_ENDING2.test(head)) {
3223
+ return `${head}x`;
3224
+ }
3225
+ if (FRENCH_OU_PLURAL_IN_X.has(foldWord2(head))) {
3226
+ return `${head}x`;
3227
+ }
3228
+ if (AL_ENDING2.test(head)) {
3229
+ return `${head.slice(0, -2)}aux`;
3230
+ }
3231
+ return `${head}s`;
3232
+ }
3233
+ if (!wantsPlural && isPlural) {
3234
+ if (EAUX_ENDING2.test(head)) {
3235
+ return head.slice(0, -1);
3236
+ }
3237
+ if (AUX_ENDING2.test(head)) {
3238
+ return `${head.slice(0, -3)}al`;
3239
+ }
3240
+ if (ouPlural) {
3241
+ return head.slice(0, -1);
3242
+ }
3243
+ if (INVARIABLE_FRENCH_NOUN.has(foldWord2(head))) {
3244
+ return head;
3245
+ }
3246
+ return head.slice(0, -1);
3247
+ }
3248
+ return head;
3249
+ }
2722
3250
  function agreeInFrench(item, amount) {
2723
- if (!item) return item;
3251
+ if (!item) {
3252
+ return item;
3253
+ }
2724
3254
  const words = item.split(" ");
2725
3255
  const head = words[0] ?? "";
2726
- if (head.length <= 3) return item;
2727
- const wantsPlural = amount >= 2;
2728
- const ouPlural = /x$/i.test(head) && FRENCH_OU_PLURAL_IN_X.has(foldWord2(head.slice(0, -1)));
2729
- const isPlural = /s$|eaux$|aux$/i.test(head) || ouPlural;
2730
- if (wantsPlural && !isPlural) {
2731
- if (/[sxz]$/i.test(head)) {
2732
- } else if (/eau$/i.test(head)) words[0] = `${head}x`;
2733
- else if (FRENCH_OU_PLURAL_IN_X.has(foldWord2(head))) words[0] = `${head}x`;
2734
- else if (/al$/i.test(head)) words[0] = `${head.slice(0, -2)}aux`;
2735
- else words[0] = `${head}s`;
2736
- } else if (!wantsPlural && isPlural) {
2737
- if (/eaux$/i.test(head)) words[0] = head.slice(0, -1);
2738
- else if (/aux$/i.test(head)) words[0] = `${head.slice(0, -3)}al`;
2739
- else if (ouPlural) words[0] = head.slice(0, -1);
2740
- else if (INVARIABLE_FRENCH_NOUN.has(foldWord2(head))) {
2741
- } else words[0] = head.slice(0, -1);
3256
+ if (head.length <= 3) {
3257
+ return item;
2742
3258
  }
3259
+ const wantsPlural = amount >= 2;
3260
+ const ouPlural = X_ENDING.test(head) && FRENCH_OU_PLURAL_IN_X.has(foldWord2(head.slice(0, -1)));
3261
+ const isPlural = PLURAL_ENDING.test(head) || ouPlural;
3262
+ words[0] = agreeFrenchHead(head, { wantsPlural, isPlural, ouPlural });
2743
3263
  const last = words.length - 1;
2744
- if (last > 0) {
2745
- const adjective = agreeTrailingAdjective(words[last], wantsPlural);
2746
- if (adjective) words[last] = adjective;
3264
+ const trailing = last > 0 ? words[last] : void 0;
3265
+ if (trailing !== void 0) {
3266
+ const adjective = agreeTrailingAdjective(trailing, wantsPlural);
3267
+ if (adjective) {
3268
+ words[last] = adjective;
3269
+ }
2747
3270
  }
2748
3271
  return words.join(" ");
2749
3272
  }
2750
3273
  function agreeLeadingAdjective(word, amount, language) {
2751
- if (language === "en") return word;
3274
+ if (language === "en") {
3275
+ return word;
3276
+ }
2752
3277
  const wantsPlural = amount >= 2;
2753
3278
  const folded = foldWord2(word);
2754
3279
  const isPlural = folded.endsWith("s") && !AGREEABLE_ADJECTIVES.has(folded);
2755
3280
  const singular = isPlural ? word.slice(0, -1) : word;
2756
- if (!AGREEABLE_ADJECTIVES.has(foldWord2(singular))) return word;
2757
- if (wantsPlural === isPlural) return word;
3281
+ if (!AGREEABLE_ADJECTIVES.has(foldWord2(singular))) {
3282
+ return word;
3283
+ }
3284
+ if (wantsPlural === isPlural) {
3285
+ return word;
3286
+ }
2758
3287
  return wantsPlural ? `${singular}s` : singular;
2759
3288
  }
2760
3289
  function agreeWithAmount(item, amount, language) {
2761
3290
  return language === "fr" ? agreeInFrench(item, amount) : agreeInEnglish(item, amount);
2762
3291
  }
2763
3292
  function agreeCountedContainer(item, amount, language) {
2764
- if (language === "fr") return agreeInFrench(item, amount);
3293
+ if (language === "fr") {
3294
+ return agreeInFrench(item, amount);
3295
+ }
2765
3296
  const space = item.indexOf(" ");
2766
- if (space < 0) return agreeInEnglish(item, amount);
3297
+ if (space < 0) {
3298
+ return agreeInEnglish(item, amount);
3299
+ }
2767
3300
  return `${agreeInEnglish(item.slice(0, space), amount)}${item.slice(space)}`;
2768
3301
  }
2769
3302
  var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
2770
3303
  function joinItem(item, language) {
2771
- if (!item) return "";
2772
- if (language === "en") return ` ${item}`;
2773
- const elides = /^[aeiouàâäéèêëîïôöûü]/i.test(item) || MUTE_H_WORDS.test(item);
3304
+ if (!item) {
3305
+ return "";
3306
+ }
3307
+ if (language === "en") {
3308
+ return ` ${item}`;
3309
+ }
3310
+ const elides = VOWEL_OPENING.test(item) || MUTE_H_WORDS.test(item);
2774
3311
  return elides ? ` d'${item}` : ` de ${item}`;
2775
3312
  }
2776
3313
  var BRANCH_SEPARATORS = {
@@ -2779,29 +3316,41 @@ var BRANCH_SEPARATORS = {
2779
3316
  };
2780
3317
  function splitBranch(text2, parsed) {
2781
3318
  const itemStart = parsed.item ? text2.indexOf(parsed.item) : text2.length;
2782
- if (itemStart < 0) return null;
3319
+ if (itemStart < 0) {
3320
+ return null;
3321
+ }
2783
3322
  const separator = BRANCH_SEPARATORS[parsed.language];
2784
3323
  separator.lastIndex = 0;
2785
3324
  for (let match = separator.exec(text2); match; match = separator.exec(text2)) {
2786
- if (match.index < itemStart) continue;
3325
+ if (match.index < itemStart) {
3326
+ continue;
3327
+ }
2787
3328
  const tail = text2.slice(match.index + match[0].length);
2788
- if (parseIngredient(tail, parsed.language).amount === null) continue;
3329
+ if (parseIngredient(tail, parsed.language).amount === null) {
3330
+ continue;
3331
+ }
2789
3332
  return { head: text2.slice(0, match.index), separator: match[0], tail };
2790
3333
  }
2791
3334
  return null;
2792
3335
  }
2793
3336
  function scaleIngredient(line, options) {
2794
3337
  const { factor } = options;
2795
- if (factor === 1) return passthroughIngredient(line, options.language);
3338
+ if (factor === 1) {
3339
+ return passthroughIngredient(line, options.language);
3340
+ }
2796
3341
  const text2 = line.trim();
2797
3342
  const parsed = parseIngredient(text2, options.language ?? "auto");
2798
3343
  const branch = splitBranch(text2, parsed);
2799
- if (branch) return scaleBranchedLine(line, branch, { ...options, language: parsed.language });
3344
+ if (branch) {
3345
+ return scaleBranchedLine(line, branch, { ...options, language: parsed.language });
3346
+ }
2800
3347
  return scaleSingleLine(line, options);
2801
3348
  }
2802
3349
  function scaleBranchedLine(line, branch, options) {
2803
3350
  const head = scaleSingleLine(branch.head, options);
2804
- if (head.scaling === "unscaled") return { ...head, text: line.trim(), original: line };
3351
+ if (head.scaling === "unscaled") {
3352
+ return { ...head, text: line.trim(), original: line };
3353
+ }
2805
3354
  const tail = scaleAlternative(branch.tail, options);
2806
3355
  const result = {
2807
3356
  ...head,
@@ -2816,11 +3365,128 @@ function scaleBranchedLine(line, branch, options) {
2816
3365
  function scaleAlternative(tail, options) {
2817
3366
  const parsed = parseIngredient(tail, options.language ?? "auto");
2818
3367
  const published = tail.trim();
2819
- if (parsed.amount === null) return { text: published, rewritten: false };
3368
+ if (parsed.amount === null) {
3369
+ return { text: published, rewritten: false };
3370
+ }
2820
3371
  const largest = (parsed.amountMax ?? parsed.amount) * options.factor;
2821
- if (largest < 1) return { text: published, rewritten: false };
3372
+ if (largest < 1) {
3373
+ return { text: published, rewritten: false };
3374
+ }
2822
3375
  return { text: scaleIngredient(tail, options).text, rewritten: true };
2823
3376
  }
3377
+ function alternateLabel(count2, style, texts, bracketed) {
3378
+ if (count2 === 0) {
3379
+ return "";
3380
+ }
3381
+ if (style === "slash") {
3382
+ return ` / ${texts.join(" / ")}`;
3383
+ }
3384
+ if (style === "trailing") {
3385
+ return "";
3386
+ }
3387
+ return bracketed;
3388
+ }
3389
+ function itemLabelFor(named, item, counted, language) {
3390
+ if (named) {
3391
+ return joinItem(item, language);
3392
+ }
3393
+ if (counted) {
3394
+ return ` ${counted}`;
3395
+ }
3396
+ return "";
3397
+ }
3398
+ function noteForScaledLine(outcome) {
3399
+ const {
3400
+ parsed,
3401
+ bounds: primaryBounds,
3402
+ unit,
3403
+ low,
3404
+ clamped,
3405
+ movedPrimary,
3406
+ movedAlternate,
3407
+ restated,
3408
+ collapsed,
3409
+ language,
3410
+ carriesMore
3411
+ } = outcome;
3412
+ let note;
3413
+ const asPublished = (value, source) => `${formatAmount(value, language, { fractions: false })}${source ? ` ${formatUnit(source, value, language)}` : ""}`;
3414
+ const sentences = [];
3415
+ if (clamped) {
3416
+ sentences.push(
3417
+ `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.`
3418
+ );
3419
+ } else if (movedPrimary) {
3420
+ const moved = primaryBounds.filter((bound) => !boundLandedExactly(bound));
3421
+ sentences.push(
3422
+ moved.map(
3423
+ (bound) => `Rounded ${bound.amount > bound.exact ? "up" : "down"} from ${asPublished(bound.raw, parsed.unit)}.`
3424
+ ).join(" ")
3425
+ );
3426
+ } else if (movedAlternate) {
3427
+ sentences.push(
3428
+ `The amount is exact; the equivalent ${restated ? "beside it" : "in brackets"} was rounded to stay readable.`
3429
+ );
3430
+ } else if (restated) {
3431
+ sentences.push(
3432
+ "This line states one quantity twice, and both readings were scaled. They agree as closely as the page wrote them, and no closer."
3433
+ );
3434
+ }
3435
+ if (carriesMore) {
3436
+ sentences.push(
3437
+ "This line carries a further quantity after the first one, and only the first was scaled. Read the rest as published."
3438
+ );
3439
+ }
3440
+ if (parsed.capacity) {
3441
+ sentences.push(
3442
+ `${parsed.capacity} is what one container holds, so it stays as published and the count is what the factor moved. Scaling this line is a matter of opening that many containers of the size named.`
3443
+ );
3444
+ }
3445
+ if (collapsed) {
3446
+ sentences.push("The page gave a range, and at this size both ends come to the same amount.");
3447
+ }
3448
+ if (unit?.kind === "measured" && low.amount > 0 && low.amount < 0.05) {
3449
+ sentences.push(
3450
+ "This is smaller than a kitchen scale resolves. Make a larger batch, or measure it by eye."
3451
+ );
3452
+ }
3453
+ if (parsed.approximation) {
3454
+ sentences.push(
3455
+ "The page gave this amount as an approximation, and the scaled figure is no firmer."
3456
+ );
3457
+ }
3458
+ if (sentences.length > 0) {
3459
+ note = sentences.join(" ");
3460
+ }
3461
+ if (parsed.unit && parsed.unit.kind === "approximate") {
3462
+ note = withApproximateNote(parsed.unit, note);
3463
+ }
3464
+ if (parsed.articleWord) {
3465
+ const stood = (parsed.amount ?? 0) / (parsed.countMultiplier ?? 1);
3466
+ const read = `"${parsed.articleWord}" read as ${formatAmount(stood, language)}.`;
3467
+ note = note ? `${read} ${note}` : read;
3468
+ }
3469
+ return note;
3470
+ }
3471
+ function renderScaledLine(parsed, alternates, unit, shown, language) {
3472
+ const named = unit && !countsBarePieces(unit) ? unit : null;
3473
+ const adjective = named && parsed.measureAdjective ? ` ${agreeLeadingAdjective(parsed.measureAdjective, shown, language)}` : "";
3474
+ const unitLabel = named ? `${adjective} ${formatUnit(named, shown, language)}` : "";
3475
+ const alternateTexts = alternates.map((entry) => entry.text);
3476
+ const introduced = parsed.alternateIntro ? `${parsed.alternateIntro} ` : "";
3477
+ const bracketed = alternates.length === 0 ? "" : ` (${introduced}${alternateTexts.join(" / ")})`;
3478
+ const altLabel = alternateLabel(
3479
+ alternates.length,
3480
+ parsed.alternateStyle,
3481
+ alternateTexts,
3482
+ bracketed
3483
+ );
3484
+ const trailingLabel = parsed.alternateStyle === "trailing" ? bracketed : "";
3485
+ const capacityLabel = parsed.capacity ? ` ${parsed.capacity}` : "";
3486
+ const counted = parsed.capacity ? agreeCountedContainer(parsed.item, shown, language) : agreeWithAmount(parsed.item, shown, language);
3487
+ const itemLabel = itemLabelFor(named, parsed.item, counted, language);
3488
+ return { named, unitLabel, capacityLabel, altLabel, trailingLabel, itemLabel };
3489
+ }
2824
3490
  var HELD_BACK_NOTE = {
2825
3491
  sizeQualifier: "The figures here give the size of one item rather than how many, so the line is left as published.",
2826
3492
  itemSize: "The measure standing behind the item gives the size of one of them rather than how many, so the line is left as published. Serving more people is a matter of taking a bigger one, and serving fewer a smaller one.",
@@ -2867,17 +3533,13 @@ function scaleSingleLine(line, options) {
2867
3533
  high === null || collapsed ? null : asText(high.amount),
2868
3534
  parsed.rangeSeparator
2869
3535
  );
2870
- const named = unit && !countsBarePieces(unit) ? unit : null;
2871
- const adjective = named && parsed.measureAdjective ? ` ${agreeLeadingAdjective(parsed.measureAdjective, shown, language)}` : "";
2872
- const unitLabel = named ? `${adjective} ${formatUnit(named, shown, language)}` : "";
2873
- const alternateTexts = alternates.map((entry) => entry.text);
2874
- const introduced = parsed.alternateIntro ? `${parsed.alternateIntro} ` : "";
2875
- const bracketed = alternates.length === 0 ? "" : ` (${introduced}${alternateTexts.join(" / ")})`;
2876
- const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : parsed.alternateStyle === "trailing" ? "" : bracketed;
2877
- const trailingLabel = parsed.alternateStyle === "trailing" ? bracketed : "";
2878
- const capacityLabel = parsed.capacity ? ` ${parsed.capacity}` : "";
2879
- const counted = parsed.capacity ? agreeCountedContainer(parsed.item, shown, language) : agreeWithAmount(parsed.item, shown, language);
2880
- const itemLabel = named ? joinItem(parsed.item, language) : counted ? ` ${counted}` : "";
3536
+ const { named, unitLabel, capacityLabel, altLabel, trailingLabel, itemLabel } = renderScaledLine(
3537
+ parsed,
3538
+ alternates,
3539
+ unit,
3540
+ shown,
3541
+ language
3542
+ );
2881
3543
  const result = {
2882
3544
  text: `${parsed.decoration ? `${parsed.decoration} ` : ""}${parsed.approximation ?? ""}${amountText}${unitLabel}${capacityLabel}${altLabel}${itemLabel}${trailingLabel}`.trim(),
2883
3545
  original: parsed.original,
@@ -2887,59 +3549,21 @@ function scaleSingleLine(line, options) {
2887
3549
  unit: named?.canonical ?? null,
2888
3550
  language
2889
3551
  };
2890
- const asPublished = (value, source) => `${formatAmount(value, language, { fractions: false })}${source ? ` ${formatUnit(source, value, language)}` : ""}`;
2891
- const sentences = [];
2892
- if (clamped) {
2893
- sentences.push(
2894
- `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.`
2895
- );
2896
- } else if (movedPrimary) {
2897
- const moved = primaryBounds.filter((bound) => !boundLandedExactly(bound));
2898
- sentences.push(
2899
- moved.map(
2900
- (bound) => `Rounded ${bound.amount > bound.exact ? "up" : "down"} from ${asPublished(bound.raw, parsed.unit)}.`
2901
- ).join(" ")
2902
- );
2903
- } else if (movedAlternate) {
2904
- sentences.push(
2905
- `The amount is exact; the equivalent ${restated ? "beside it" : "in brackets"} was rounded to stay readable.`
2906
- );
2907
- } else if (restated) {
2908
- sentences.push(
2909
- "This line states one quantity twice, and both readings were scaled. They agree as closely as the page wrote them, and no closer."
2910
- );
2911
- }
2912
- if (carriesMore) {
2913
- sentences.push(
2914
- "This line carries a further quantity after the first one, and only the first was scaled. Read the rest as published."
2915
- );
2916
- }
2917
- if (parsed.capacity) {
2918
- sentences.push(
2919
- `${parsed.capacity} is what one container holds, so it stays as published and the count is what the factor moved. Scaling this line is a matter of opening that many containers of the size named.`
2920
- );
2921
- }
2922
- if (collapsed) {
2923
- sentences.push("The page gave a range, and at this size both ends come to the same amount.");
2924
- }
2925
- if (unit?.kind === "measured" && low.amount > 0 && low.amount < 0.05) {
2926
- sentences.push(
2927
- "This is smaller than a kitchen scale resolves. Make a larger batch, or measure it by eye."
2928
- );
2929
- }
2930
- if (parsed.approximation) {
2931
- sentences.push(
2932
- "The page gave this amount as an approximation, and the scaled figure is no firmer."
2933
- );
2934
- }
2935
- if (sentences.length > 0) result.note = sentences.join(" ");
2936
- if (parsed.unit && parsed.unit.kind === "approximate") {
2937
- result.note = withApproximateNote(parsed.unit, result.note);
2938
- }
2939
- if (parsed.articleWord) {
2940
- const stood = (parsed.amount ?? 0) / (parsed.countMultiplier ?? 1);
2941
- const read = `"${parsed.articleWord}" read as ${formatAmount(stood, language)}.`;
2942
- result.note = result.note ? `${read} ${result.note}` : read;
3552
+ const note = noteForScaledLine({
3553
+ parsed,
3554
+ bounds: primaryBounds,
3555
+ unit,
3556
+ low,
3557
+ clamped,
3558
+ movedPrimary,
3559
+ movedAlternate,
3560
+ restated,
3561
+ collapsed,
3562
+ language,
3563
+ carriesMore
3564
+ });
3565
+ if (note !== void 0) {
3566
+ result.note = note;
2943
3567
  }
2944
3568
  return result;
2945
3569
  }
@@ -2971,8 +3595,10 @@ function renderMeasure(measure, factor, language) {
2971
3595
  };
2972
3596
  }
2973
3597
  function renderRange(low, high, separator) {
2974
- if (high === null || separator === null) return low;
2975
- return /^[-–—]$/.test(separator) ? `${low}${separator}${high}` : `${low} ${separator} ${high}`;
3598
+ if (high === null || separator === null) {
3599
+ return low;
3600
+ }
3601
+ return DASH_ONLY.test(separator) ? `${low}${separator}${high}` : `${low} ${separator} ${high}`;
2976
3602
  }
2977
3603
  function scaleIngredients(lines, options) {
2978
3604
  return lines.map((line) => scaleIngredient(line, options));
@@ -2989,9 +3615,11 @@ function passthroughIngredient(line, language = "auto") {
2989
3615
  unit: held ? null : parsed.unit?.canonical ?? null,
2990
3616
  language: parsed.language
2991
3617
  };
2992
- if (parsed.heldBack) result.note = HELD_BACK_NOTE[parsed.heldBack];
2993
- else if (parsed.amount === null) result.note = "No quantity given; adjust to taste.";
2994
- else if (parsed.unit?.kind === "approximate") {
3618
+ if (parsed.heldBack) {
3619
+ result.note = HELD_BACK_NOTE[parsed.heldBack];
3620
+ } else if (parsed.amount === null) {
3621
+ result.note = "No quantity given; adjust to taste.";
3622
+ } else if (parsed.unit?.kind === "approximate") {
2995
3623
  result.note = withApproximateNote(parsed.unit, void 0);
2996
3624
  }
2997
3625
  return result;
@@ -3006,7 +3634,9 @@ var HEADINGS = {
3006
3634
  method: /^\s*(?:procedure|proc[ée]d[ée]|directions?|instructions?|method|preparation|pr[ée]paration|r[ée]alisation|steps?|[ée]tapes?)\b/i
3007
3635
  };
3008
3636
  function headingFor(part, headings) {
3009
- if (headings === null) return null;
3637
+ if (headings === null) {
3638
+ return null;
3639
+ }
3010
3640
  return headings.find((heading) => HEADINGS[part].test(heading)) ?? null;
3011
3641
  }
3012
3642
 
@@ -3138,7 +3768,12 @@ function noteTexts(notes) {
3138
3768
  return notes.map(noteText);
3139
3769
  }
3140
3770
  function labelNote(label, note) {
3141
- return typeof note === "string" ? `${label}: ${note}` : mustKeep(`${label}: ${note.text}`);
3771
+ if (typeof note === "string") {
3772
+ const plain = note;
3773
+ return `${label}: ${plain}`;
3774
+ }
3775
+ const said = note.text;
3776
+ return mustKeep(`${label}: ${said}`);
3142
3777
  }
3143
3778
  function reportNotes(reports) {
3144
3779
  const notes = [];
@@ -3179,7 +3814,7 @@ function reportNotes(reports) {
3179
3814
  );
3180
3815
  }
3181
3816
  if (report.reportedTotal !== null && report.reportedTotalMeans !== null) {
3182
- const forWording = sent.length > 1 ? ` That number belongs to "${sent[0].query}" alone.` : "";
3817
+ const forWording = sent.length > 1 ? ` That number belongs to "${sent[0]?.query}" alone.` : "";
3183
3818
  notes.push(
3184
3819
  `${report.name} reported ${report.reportedTotal}: ${report.reportedTotalMeans}.${forWording}`
3185
3820
  );
@@ -3215,7 +3850,9 @@ function buildTrailer(options) {
3215
3850
  const held = /* @__PURE__ */ new Set();
3216
3851
  for (const note of options.notes ?? []) {
3217
3852
  const text2 = noteText(note);
3218
- if (held.has(text2)) continue;
3853
+ if (held.has(text2)) {
3854
+ continue;
3855
+ }
3219
3856
  held.add(text2);
3220
3857
  kept.push(note);
3221
3858
  }
@@ -3228,7 +3865,9 @@ function buildTrailer(options) {
3228
3865
  break;
3229
3866
  }
3230
3867
  }
3231
- if (victim === -1) break;
3868
+ if (victim === -1) {
3869
+ break;
3870
+ }
3232
3871
  kept.splice(victim, 1);
3233
3872
  }
3234
3873
  const credit = options.credit ?? "Source: this server called none.";
@@ -3250,7 +3889,9 @@ ${trailer}`;
3250
3889
  function toToolError(error) {
3251
3890
  const known = error instanceof RecipesError ? error : new RecipesError("network_error", error instanceof Error ? error.message : String(error));
3252
3891
  const lines = [`[${known.code}] ${quoteForeign(known.message)}`];
3253
- if (known.details.hint) lines.push(`Hint: ${quoteForeign(known.details.hint)}`);
3892
+ if (known.details.hint) {
3893
+ lines.push(`Hint: ${quoteForeign(known.details.hint)}`);
3894
+ }
3254
3895
  return { content: [{ type: "text", text: lines.join("\n") }], isError: true };
3255
3896
  }
3256
3897
  function roomForBody(options = {}) {
@@ -3261,7 +3902,9 @@ function fitLines(lines, room) {
3261
3902
  const kept = [];
3262
3903
  let used = 0;
3263
3904
  for (const line of lines) {
3264
- if (kept.length >= ALWAYS_SHOWN && used + line.length > room) break;
3905
+ if (kept.length >= ALWAYS_SHOWN && used + line.length > room) {
3906
+ break;
3907
+ }
3265
3908
  kept.push(line);
3266
3909
  used += line.length + 1;
3267
3910
  }
@@ -3271,7 +3914,9 @@ function omittedLinesLine(shown, total, what) {
3271
3914
  return `\u2026 ${shown} of ${total} ${what} shown here; all ${total} are in the structured output`;
3272
3915
  }
3273
3916
  function truncate(text2, maxChars) {
3274
- if (text2.length <= maxChars) return text2;
3917
+ if (text2.length <= maxChars) {
3918
+ return text2;
3919
+ }
3275
3920
  return `${text2.slice(0, Math.max(0, maxChars - 1)).trimEnd()}\u2026`;
3276
3921
  }
3277
3922
  function renderRows(rows) {
@@ -3282,7 +3927,9 @@ function renderRows(rows) {
3282
3927
  }).join("\n");
3283
3928
  }
3284
3929
  function creditLine(contributors) {
3285
- if (contributors.length === 0) return "No source contributed to this answer.";
3930
+ if (contributors.length === 0) {
3931
+ return "No source contributed to this answer.";
3932
+ }
3286
3933
  const names2 = contributors.map(
3287
3934
  (entry) => entry.url ? `${entry.attribution} \u2014 ${quoteForeign(entry.url)}` : entry.attribution
3288
3935
  );
@@ -3290,6 +3937,13 @@ function creditLine(contributors) {
3290
3937
  }
3291
3938
 
3292
3939
  // src/tools/recipeView.ts
3940
+ var SERVING_WORD = /personne|people|serving|portion|part\b/i;
3941
+ function whatTheOtherPartHolds(part, recipe) {
3942
+ if (part === "ingredients") {
3943
+ return recipe.steps.length > 0 ? `${recipe.steps.length} step(s) of method were read from it` : null;
3944
+ }
3945
+ return recipe.ingredients.length > 0 ? `${recipe.ingredients.length} ingredient line(s) were read from it` : null;
3946
+ }
3293
3947
  var yieldSchema = z2.object({
3294
3948
  original_count: z2.number().nullable().describe("Servings the page states as a number. Null when it states none."),
3295
3949
  original_max: z2.number().nullable().describe("The upper end when the page states a span, as in '4 \xE0 6 personnes'."),
@@ -3346,7 +4000,9 @@ var SECTIONS = [
3346
4000
  "equipment"
3347
4001
  ];
3348
4002
  function resolveFactor(recipe, servings) {
3349
- if (servings === null) return { factor: null, notes: [] };
4003
+ if (servings === null) {
4004
+ return { factor: null, notes: [] };
4005
+ }
3350
4006
  if (recipe.yieldCount === null || recipe.yieldCount <= 0) {
3351
4007
  return {
3352
4008
  factor: null,
@@ -3371,7 +4027,7 @@ function resolveFactor(recipe, servings) {
3371
4027
  );
3372
4028
  }
3373
4029
  const unit = recipe.yieldUnit;
3374
- if (unit && !/personne|people|serving|portion|part\b/i.test(unit)) {
4030
+ if (unit && !SERVING_WORD.test(unit)) {
3375
4031
  notes.push(
3376
4032
  `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)}.`
3377
4033
  );
@@ -3395,7 +4051,7 @@ var METHOD_PART = {
3395
4051
  };
3396
4052
  function emptyPartNote(recipe, words) {
3397
4053
  const heading = headingFor(words.part, recipe.publishedSections);
3398
- const counterpart = words.part === "ingredients" ? recipe.steps.length > 0 ? `${recipe.steps.length} step(s) of method were read from it` : null : recipe.ingredients.length > 0 ? `${recipe.ingredients.length} ingredient line(s) were read from it` : null;
4054
+ const counterpart = whatTheOtherPartHolds(words.part, recipe);
3399
4055
  const shown = [
3400
4056
  heading === null ? null : `the page heads a section "${quoteForeign(heading)}"`,
3401
4057
  counterpart
@@ -3498,18 +4154,33 @@ function buildRecipeView(recipe, options) {
3498
4154
  }
3499
4155
  function renderYield(payload) {
3500
4156
  const published = payload.yield.original_text ?? "an amount the page does not state";
3501
- if (payload.yield.factor === null) return `Yields ${quoteForeign(published)} (as published).`;
4157
+ if (payload.yield.factor === null) {
4158
+ return `Yields ${quoteForeign(published)} (as published).`;
4159
+ }
3502
4160
  return `Yields ${quoteForeign(published)} as published, scaled by ${payload.yield.factor} for ${payload.yield.requested}.`;
3503
4161
  }
3504
4162
 
3505
4163
  // src/tools/arguments.ts
3506
4164
  import { z as z3 } from "zod";
3507
4165
  var INVALID_INPUT = "invalid_input";
4166
+ var defaultMessage = z3.core.locales.en().localeError;
3508
4167
  function strictInput(shape) {
3509
4168
  const declared = Object.keys(shape);
3510
- return z3.strictObject(shape, {
3511
- error: (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : void 0
3512
- });
4169
+ const refuse = (issue) => issue.code === "unrecognized_keys" ? unknownArgumentMessage(issue.keys, declared) : `[${INVALID_INPUT}] ${defaultMessage(issue)}`;
4170
+ for (const argument of Object.values(shape)) {
4171
+ carryTheCode(argument, refuse);
4172
+ }
4173
+ return z3.strictObject(shape, { error: refuse });
4174
+ }
4175
+ function carryTheCode(schema, refuse) {
4176
+ const definition = schema._zod.def;
4177
+ definition.error = refuse;
4178
+ for (const check of definition.checks ?? []) {
4179
+ check._zod.def.error = refuse;
4180
+ }
4181
+ if (definition.innerType) {
4182
+ carryTheCode(definition.innerType, refuse);
4183
+ }
3513
4184
  }
3514
4185
  function unknownArgumentMessage(keys, declared) {
3515
4186
  const named = keys.map((key) => {
@@ -3521,15 +4192,21 @@ function unknownArgumentMessage(keys, declared) {
3521
4192
  function nearestArgument(key, declared) {
3522
4193
  const flatten = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
3523
4194
  const flat = flatten(key);
3524
- if (flat.length === 0) return void 0;
4195
+ if (flat.length === 0) {
4196
+ return void 0;
4197
+ }
3525
4198
  const sameName = declared.find((name) => flatten(name) === flat);
3526
- if (sameName) return sameName;
4199
+ if (sameName) {
4200
+ return sameName;
4201
+ }
3527
4202
  const overlapping = declared.find((name) => {
3528
4203
  const other = flatten(name);
3529
4204
  const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
3530
4205
  return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
3531
4206
  });
3532
- if (overlapping) return overlapping;
4207
+ if (overlapping) {
4208
+ return overlapping;
4209
+ }
3533
4210
  let closest;
3534
4211
  let shortest = Number.POSITIVE_INFINITY;
3535
4212
  for (const name of declared) {
@@ -3543,18 +4220,34 @@ function nearestArgument(key, declared) {
3543
4220
  }
3544
4221
  function editDistance(left, right) {
3545
4222
  let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
4223
+ let distance = right.length;
3546
4224
  for (let row = 1; row <= left.length; row += 1) {
3547
4225
  const current = [row];
3548
- for (let column = 1; column <= right.length; column += 1) {
3549
- const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
3550
- current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
4226
+ let diagonal = row - 1;
4227
+ let leftward = row;
4228
+ for (const [column, above] of previous.entries()) {
4229
+ if (column === 0) {
4230
+ continue;
4231
+ }
4232
+ const substitution = diagonal + (left[row - 1] === right[column - 1] ? 0 : 1);
4233
+ const cell = Math.min(substitution, above + 1, leftward + 1);
4234
+ current.push(cell);
4235
+ diagonal = above;
4236
+ leftward = cell;
3551
4237
  }
3552
4238
  previous = current;
4239
+ distance = leftward;
3553
4240
  }
3554
- return previous[right.length];
4241
+ return distance;
3555
4242
  }
3556
4243
 
3557
4244
  // src/tools/compareRecipes.ts
4245
+ function nothingWasCompared(someWereUnread, dish) {
4246
+ if (someWereUnread) {
4247
+ return `Every version of "${quoteForeign(dish)}" that was offered could not be read, so nothing was compared.`;
4248
+ }
4249
+ return `No source offered a recipe for "${quoteForeign(dish)}".`;
4250
+ }
3558
4251
  var compareRecipesDescription = [
3559
4252
  "Take a dish and show how each source writes it, side by side.",
3560
4253
  "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.",
@@ -3579,17 +4272,54 @@ var compareRecipesOutput = z4.object({
3579
4272
  var TEXT_INGREDIENT_LINES = 8;
3580
4273
  var TEXT_DIFFERENCE_LINES = 4;
3581
4274
  function listNames(names2) {
3582
- if (names2.length <= 1) return names2.join("");
3583
- return `${names2.slice(0, -1).join(", ")} and ${names2[names2.length - 1]}`;
4275
+ if (names2.length <= 1) {
4276
+ return names2.join("");
4277
+ }
4278
+ return `${names2.slice(0, -1).join(", ")} and ${names2.at(-1)}`;
4279
+ }
4280
+ function weighTitlesAgainstTheDish(payloads, dish, notes) {
4281
+ const weighed = payloads.map((payload) => ({
4282
+ payload,
4283
+ missing: dishWordsMissing(payload.title, dish)
4284
+ }));
4285
+ const carriesDish = weighed.some((one) => one.missing.length === 0);
4286
+ for (const { payload, missing } of weighed) {
4287
+ if (missing.length === 0) {
4288
+ continue;
4289
+ }
4290
+ notes.push(
4291
+ mustKeep(
4292
+ `${payload.source_name}'s closest row is ${quoteForeign(payload.title)}, whose title carries no ${missing.map((word) => `"${quoteForeign(word)}"`).join(" and no ")}. Read it as a candidate to check rather than as that dish.`
4293
+ )
4294
+ );
4295
+ }
4296
+ if (payloads.length > 0 && !carriesDish) {
4297
+ notes.push(
4298
+ `No version here carries the whole of "${quoteForeign(dish)}" in its title. These are the closest rows each source returned for that spelling, so read them as candidates to check rather than as that dish.`
4299
+ );
4300
+ }
4301
+ if (payloads.length === 1 && carriesDish) {
4302
+ notes.push(
4303
+ mustKeep(
4304
+ "This is one version rather than a comparison. Read it as what that one source publishes."
4305
+ )
4306
+ );
4307
+ }
4308
+ return weighed;
3584
4309
  }
3585
4310
  function describeDifferences(recipes, payloads, sections) {
3586
- if (recipes.length < 2) return [];
4311
+ if (recipes.length < 2) {
4312
+ return [];
4313
+ }
3587
4314
  const differences = [];
3588
4315
  const nameOf = (recipe) => recipe.sourceName;
3589
- const yields = payloads.map((payload) => payload.yield.original_text ?? "no stated amount");
3590
- if (new Set(yields).size > 1) {
4316
+ const stated = payloads.map((payload) => ({
4317
+ name: payload.source_name,
4318
+ text: payload.yield.original_text ?? "no stated amount"
4319
+ }));
4320
+ if (new Set(stated.map((one) => one.text)).size > 1) {
3591
4321
  differences.push(
3592
- `${payloads.map((payload, index) => `${payload.source_name} yields ${quoteForeign(yields[index])}`).join("; ")}.`
4322
+ `${stated.map((one) => `${one.name} yields ${quoteForeign(one.text)}`).join("; ")}.`
3593
4323
  );
3594
4324
  }
3595
4325
  const counts = payloads.map((payload) => payload.ingredients.length);
@@ -3626,14 +4356,17 @@ function describeDifferences(recipes, payloads, sections) {
3626
4356
  `Only ${listNames(credited.map(nameOf))} credits an author; the rest are written by whoever edited the page.`
3627
4357
  );
3628
4358
  }
3629
- const licensed = recipes.filter((recipe) => recipe.license !== null);
3630
- for (const recipe of licensed) {
4359
+ for (const recipe of recipes) {
4360
+ const license = recipe.license;
4361
+ if (license === null) {
4362
+ continue;
4363
+ }
3631
4364
  differences.push(
3632
- `${nameOf(recipe)} publishes under ${quoteForeign(recipe.license.title)}, which asks for attribution.`
4365
+ `${nameOf(recipe)} publishes under ${quoteForeign(license.title)}, which asks for attribution.`
3633
4366
  );
3634
4367
  }
3635
4368
  const silent = recipes.filter((recipe) => recipe.license === null);
3636
- if (silent.length > 0 && licensed.length > 0) {
4369
+ if (silent.length > 0 && silent.length < recipes.length) {
3637
4370
  differences.push(
3638
4371
  `${listNames(silent.map(nameOf))} states no terms of use. Silence is not permission.`
3639
4372
  );
@@ -3645,7 +4378,9 @@ async function runCompareRecipes(client, args) {
3645
4378
  const merged = await client.searchRecipes(args.dish, 3);
3646
4379
  const best = /* @__PURE__ */ new Map();
3647
4380
  for (const row of merged.rows) {
3648
- if (!best.has(row.source)) best.set(row.source, row.id);
4381
+ if (!best.has(row.source)) {
4382
+ best.set(row.source, row.id);
4383
+ }
3649
4384
  }
3650
4385
  const offered = [...best.entries()];
3651
4386
  const reads = await Promise.allSettled(offered.map(([, id]) => client.getRecipe(id)));
@@ -3653,7 +4388,10 @@ async function runCompareRecipes(client, args) {
3653
4388
  const unread = /* @__PURE__ */ new Map();
3654
4389
  const opened = /* @__PURE__ */ new Set();
3655
4390
  reads.forEach((read, index) => {
3656
- const source = offered[index][0];
4391
+ const source = offered[index]?.[0];
4392
+ if (source === void 0) {
4393
+ return;
4394
+ }
3657
4395
  opened.add(source);
3658
4396
  if (read.status === "fulfilled") {
3659
4397
  recipes.push(read.value.recipe);
@@ -3679,7 +4417,9 @@ async function runCompareRecipes(client, args) {
3679
4417
  );
3680
4418
  const payloads = views.map((view) => view.payload);
3681
4419
  const notes = reportNotes(merged.reports);
3682
- for (const view of views) notes.push(...view.notes);
4420
+ for (const view of views) {
4421
+ notes.push(...view.notes);
4422
+ }
3683
4423
  const omitted = SECTIONS.filter((section) => !args.sections.includes(section));
3684
4424
  if (omitted.length > 0) {
3685
4425
  notes.push(
@@ -3687,7 +4427,9 @@ async function runCompareRecipes(client, args) {
3687
4427
  );
3688
4428
  }
3689
4429
  for (const report of merged.reports) {
3690
- if (recipes.some((recipe) => recipe.source === report.source)) continue;
4430
+ if (recipes.some((recipe) => recipe.source === report.source)) {
4431
+ continue;
4432
+ }
3691
4433
  const failedRead = unread.get(report.source);
3692
4434
  if (report.status === "failed") {
3693
4435
  notes.push(
@@ -3713,32 +4455,11 @@ async function runCompareRecipes(client, args) {
3713
4455
  );
3714
4456
  }
3715
4457
  }
3716
- const shortfall = payloads.map((payload) => dishWordsMissing(payload.title, args.dish));
3717
- const named = payloads.filter((_payload, index) => shortfall[index].length === 0);
3718
- const carriesDish = named.length > 0;
3719
- payloads.forEach((payload, index) => {
3720
- const missing = shortfall[index];
3721
- if (missing.length === 0) return;
3722
- notes.push(
3723
- mustKeep(
3724
- `${payload.source_name}'s closest row is ${quoteForeign(payload.title)}, whose title carries no ${missing.map((word) => `"${quoteForeign(word)}"`).join(" and no ")}. Read it as a candidate to check rather than as that dish.`
3725
- )
3726
- );
3727
- });
3728
- if (payloads.length > 0 && !carriesDish) {
3729
- notes.push(
3730
- `No version here carries the whole of "${quoteForeign(args.dish)}" in its title. These are the closest rows each source returned for that spelling, so read them as candidates to check rather than as that dish.`
3731
- );
3732
- }
3733
- if (payloads.length === 1 && carriesDish) {
3734
- notes.push(
3735
- mustKeep(
3736
- "This is one version rather than a comparison. Read it as what that one source publishes."
3737
- )
3738
- );
3739
- }
4458
+ const weighed = weighTitlesAgainstTheDish(payloads, args.dish, notes);
3740
4459
  const comparable = recipes.filter(
3741
- (recipe) => named.some((payload) => payload.id === recipe.id && payload.source === recipe.source)
4460
+ (recipe) => weighed.some(
4461
+ ({ payload, missing }) => missing.length === 0 && payload.id === recipe.id && payload.source === recipe.source
4462
+ )
3742
4463
  );
3743
4464
  const differences = comparable.length === payloads.length ? describeDifferences(recipes, payloads, args.sections) : [];
3744
4465
  if (payloads.length > 1 && differences.length === 0 && comparable.length < payloads.length) {
@@ -3773,8 +4494,8 @@ ${shownDifferences.map((line) => `- ${line}`).join("\n")}${moreDifferences > 0 ?
3773
4494
  // for that many people.
3774
4495
  ` ${renderYield(payload)}`
3775
4496
  ];
3776
- const offered2 = payload.ingredients.slice(0, TEXT_INGREDIENT_LINES).map((entry) => ` - ${quoteForeign(entry.text)}`);
3777
- const fitted = fitLines(offered2, share - head.join("\n").length);
4497
+ const listed = payload.ingredients.slice(0, TEXT_INGREDIENT_LINES).map((entry) => ` - ${quoteForeign(entry.text)}`);
4498
+ const fitted = fitLines(listed, share - head.join("\n").length);
3778
4499
  const lines = [...head, ...fitted.lines];
3779
4500
  const hidden = payload.ingredients.length - fitted.lines.length;
3780
4501
  if (hidden > 0) {
@@ -3784,7 +4505,7 @@ ${shownDifferences.map((line) => `- ${line}`).join("\n")}${moreDifferences > 0 ?
3784
4505
  }
3785
4506
  return lines.join("\n");
3786
4507
  });
3787
- 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");
4508
+ const body = payloads.length === 0 ? nothingWasCompared(unread.size > 0, args.dish) : versionBlocks.join("\n\n");
3788
4509
  const summary = `${differencesBlock}${body}`;
3789
4510
  return ok(
3790
4511
  {
@@ -3795,7 +4516,9 @@ ${shownDifferences.map((line) => `- ${line}`).join("\n")}${moreDifferences > 0 ?
3795
4516
  // what became of the row it offered.
3796
4517
  per_source: merged.reports.map((report) => {
3797
4518
  const payload = toReportPayload(report);
3798
- if (!opened.has(report.source)) return payload;
4519
+ if (!opened.has(report.source)) {
4520
+ return payload;
4521
+ }
3799
4522
  const failedRead = unread.get(report.source);
3800
4523
  return withRead(
3801
4524
  payload,
@@ -3850,7 +4573,9 @@ async function runGetRecipe(client, args) {
3850
4573
  `"${quoteForeign(args.id)}" was read as ${read.source.name}'s, because ${read.inferred}. Spell an id with its source to leave nothing to infer.`
3851
4574
  );
3852
4575
  }
3853
- if (cached) notes.push("Served from this server's short-lived in-memory cache.");
4576
+ if (cached) {
4577
+ notes.push("Served from this server's short-lived in-memory cache.");
4578
+ }
3854
4579
  const payload = view.payload;
3855
4580
  const credit = creditLine([{ attribution: payload.attribution, url: payload.url }]);
3856
4581
  const head = [
@@ -3931,8 +4656,13 @@ function resolveFactor2(args) {
3931
4656
  `factor=${args.factor} and ${args.from_servings}\u2192${args.to_servings} would be two different multipliers.`
3932
4657
  );
3933
4658
  }
3934
- if (args.factor !== void 0) return args.factor;
3935
- if (hasPair) return args.to_servings / args.from_servings;
4659
+ if (args.factor !== void 0) {
4660
+ return args.factor;
4661
+ }
4662
+ const { from_servings: from, to_servings: to } = args;
4663
+ if (from !== void 0 && to !== void 0) {
4664
+ return to / from;
4665
+ }
3936
4666
  if (args.from_servings !== void 0 || args.to_servings !== void 0) {
3937
4667
  throw invalidInput(
3938
4668
  "Serving counts come in pairs: give both 'from_servings' and 'to_servings'.",
@@ -4004,6 +4734,22 @@ ${body}`,
4004
4734
 
4005
4735
  // src/tools/searchRecipes.ts
4006
4736
  import { z as z7 } from "zod";
4737
+ var WHITESPACE2 = /\s+/;
4738
+ function howTheRowsAreOrdered(contributed) {
4739
+ if (contributed.length > 1) {
4740
+ return "One row from each source in turn, in the order each source returned them. No score orders them against each other.";
4741
+ }
4742
+ if (contributed.length === 1) {
4743
+ return `Every row came from ${contributed[0]?.name}, in the order it returned them.`;
4744
+ }
4745
+ return "No source contributed a row.";
4746
+ }
4747
+ function nothingCameBack(answeredCount, query, alsoSent) {
4748
+ if (answeredCount === 0) {
4749
+ return `No source answered for "${query}", so nothing here says whether such a recipe exists.`;
4750
+ }
4751
+ return `Nothing came back for "${query}".${alsoSent}`;
4752
+ }
4007
4753
  var searchRecipesDescription = [
4008
4754
  "Search every recipe source this server reads, at the same time, for a dish or an ingredient, and get one merged list.",
4009
4755
  "Each row carries the id get_recipe takes, and that id names the source it came from, so nothing has to be guessed afterwards.",
@@ -4046,7 +4792,7 @@ async function runSearchRecipes(client, args) {
4046
4792
  const notes = reportNotes(merged.reports);
4047
4793
  const answered = merged.reports.filter((report) => report.status === "answered");
4048
4794
  const contributed = merged.reports.filter((report) => report.count > 0);
4049
- 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.";
4795
+ const order = howTheRowsAreOrdered(contributed);
4050
4796
  const conditions = readConditions(args.query);
4051
4797
  const named = (kind) => conditions.conditions.filter((condition) => condition.kind === kind && condition.named !== null).map((condition) => `"${quoteForeign(condition.named)}"`).join(" and ");
4052
4798
  const unread = (kind) => conditions.conditions.some(
@@ -4097,7 +4843,7 @@ async function runSearchRecipes(client, args) {
4097
4843
  )
4098
4844
  );
4099
4845
  }
4100
- if (args.query.trim().split(/\s+/).length > WORDS_BEFORE_SAYING_SO) {
4846
+ if (args.query.trim().split(WHITESPACE2).length > WORDS_BEFORE_SAYING_SO) {
4101
4847
  notes.push(
4102
4848
  mustKeep(
4103
4849
  "This query went to each source as free text and no filter was applied to it. Anything the question said the recipe must not contain went out as words like the rest of it, so read the rows rather than assuming they meet it."
@@ -4124,7 +4870,7 @@ async function runSearchRecipes(client, args) {
4124
4870
  const alsoSent = derived.length > 0 ? `
4125
4871
  Also searched for: ${derived.map((wording) => `"${quoteForeign(wording)}"`).join(", ")}.` : "";
4126
4872
  const body = results.length > 0 ? `${results.length} recipes for "${args.query}":
4127
- ${renderRows(results)}${alsoSent}` : answered.length === 0 ? `No source answered for "${args.query}", so nothing here says whether such a recipe exists.` : `Nothing came back for "${args.query}".${alsoSent}`;
4873
+ ${renderRows(results)}${alsoSent}` : nothingCameBack(answered.length, args.query, alsoSent);
4128
4874
  return ok(
4129
4875
  {
4130
4876
  query: args.query,
@@ -4241,15 +4987,15 @@ async function main() {
4241
4987
  await server.connect(transport);
4242
4988
  const shutdown = async () => {
4243
4989
  await server.close().catch(() => void 0);
4244
- process.exit(0);
4990
+ process2.exit(0);
4245
4991
  };
4246
- process.on("SIGINT", shutdown);
4247
- process.on("SIGTERM", shutdown);
4992
+ process2.on("SIGINT", shutdown);
4993
+ process2.on("SIGTERM", shutdown);
4248
4994
  }
4249
4995
  main().catch((error) => {
4250
4996
  const message = error instanceof Error ? error.message : String(error);
4251
- process.stderr.write(`[mcp-recipes] fatal: ${message}
4997
+ process2.stderr.write(`[mcp-recipes] fatal: ${message}
4252
4998
  `);
4253
- process.exit(1);
4999
+ process2.exit(1);
4254
5000
  });
4255
5001
  //# sourceMappingURL=index.js.map