mcp-recipes 1.0.0 → 1.2.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
@@ -7,7 +7,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
 
9
9
  // src/version.ts
10
- var PKG_VERSION = "1.0.0";
10
+ var PKG_VERSION = "1.2.0";
11
11
  var REPO_URL = "https://github.com/smeet666/mcp-recipes";
12
12
  var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
13
13
 
@@ -379,6 +379,15 @@ var MARMITON_PROFILE = {
379
379
  };
380
380
  var SITE_URL2 = /^https?:\/\/(?:www\.)?marmiton\.org\//i;
381
381
  var REFERENCE = /^\d+$/;
382
+ async function readSearch(reader, query) {
383
+ try {
384
+ return await reader.search(query);
385
+ } catch (error) {
386
+ const code = error?.code;
387
+ if (code === "not_found") return { data: [], cached: false };
388
+ throw error;
389
+ }
390
+ }
382
391
  function marmitonAdapter(reader) {
383
392
  return {
384
393
  ...MARMITON_PROFILE,
@@ -396,7 +405,7 @@ function marmitonAdapter(reader) {
396
405
  return null;
397
406
  },
398
407
  async search(query) {
399
- const outcome = await reader.search(query);
408
+ const outcome = await readSearch(reader, query);
400
409
  const list = rowsOf(outcome.data, MARMITON_PROFILE);
401
410
  const rows = [];
402
411
  let skipped = 0;
@@ -748,6 +757,12 @@ var FRENCH_UNITS = {
748
757
  millilitres: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
749
758
  centilitre: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
750
759
  centilitres: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
760
+ // A page glossing a metric weight names the pound, which French writes as a
761
+ // livre: "450 g (1 livre) de spaghetti".
762
+ livre: { canonical: "livre", kind: "measured", system: "imperial", plural: "livres" },
763
+ livres: { canonical: "livre", kind: "measured", system: "imperial", plural: "livres" },
764
+ lb: { canonical: "lb", kind: "measured", system: "imperial", symbol: true },
765
+ lbs: { canonical: "lb", kind: "measured", system: "imperial", symbol: true },
751
766
  // Spoons and cups: real measures, but only in sensible fractions.
752
767
  "cuillere a soupe": {
753
768
  canonical: "cuill\xE8re \xE0 soupe",
@@ -959,6 +974,11 @@ var ENGLISH_UNITS = {
959
974
  leaf: { canonical: "leaf", kind: "portioned", system: "none", plural: "leaves" },
960
975
  leaves: { canonical: "leaf", kind: "portioned", system: "none", plural: "leaves" },
961
976
  ea: { canonical: "ea", kind: "portioned", system: "none", symbol: true },
977
+ // A dish of the book standing as an ingredient of another: "1 recipe Flaky
978
+ // Pie Crust". The count is what the word measures, so the plural mark belongs
979
+ // on it and not on the name of the dish.
980
+ recipe: { canonical: "recipe", kind: "portioned", system: "none", plural: "recipes" },
981
+ recipes: { canonical: "recipe", kind: "portioned", system: "none", plural: "recipes" },
962
982
  // Held to no better than a hand: the count scales, the size of one does not.
963
983
  // `readContainerLoad` explains what else lands here.
964
984
  capful: { canonical: "capful", kind: "approximate", system: "none", plural: "capfuls" },
@@ -1025,7 +1045,7 @@ function buildEmbedded(language) {
1025
1045
  return new RegExp(`\\d[\\d.,/]*\\s*(?:${keys.join("|")})\\b`, "i");
1026
1046
  }
1027
1047
  function hasEmbeddedMeasure(text2, language) {
1028
- return EMBEDDED[language].test(text2);
1048
+ return EMBEDDED[language].test(normalizeUnitKey(text2));
1029
1049
  }
1030
1050
  var NOT_A_MEASURE = /* @__PURE__ */ new Set([
1031
1051
  "peu",
@@ -1148,9 +1168,10 @@ function isSpoonMeasure(unit) {
1148
1168
  );
1149
1169
  }
1150
1170
  function unitDivisibility(unit) {
1171
+ if (unit.kind === "approximate") return "whole";
1151
1172
  return QUARTERED_MEASURE.test(unit.canonical) ? "quarter" : "half";
1152
1173
  }
1153
- var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|gousses?|tranches?|slices?)\b/i;
1174
+ var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|tranches?|slices?)\b/i;
1154
1175
  function countsBarePieces(unit) {
1155
1176
  return unit.canonical === "ea";
1156
1177
  }
@@ -1202,6 +1223,103 @@ var VULGAR_FRACTIONS = {
1202
1223
  "\u215E": 0.875
1203
1224
  };
1204
1225
  var VULGAR_CLASS = Object.keys(VULGAR_FRACTIONS).join("");
1226
+ var NAMED_ENTITIES = {
1227
+ frac12: "\xBD",
1228
+ frac13: "\u2153",
1229
+ frac23: "\u2154",
1230
+ frac14: "\xBC",
1231
+ frac34: "\xBE",
1232
+ frac15: "\u2155",
1233
+ frac18: "\u215B",
1234
+ nbsp: " ",
1235
+ amp: "&",
1236
+ lt: "<",
1237
+ gt: ">",
1238
+ quot: '"',
1239
+ apos: "'",
1240
+ deg: "\xB0",
1241
+ times: "\xD7",
1242
+ minus: "\u2212",
1243
+ ndash: "\u2013",
1244
+ mdash: "\u2014"
1245
+ };
1246
+ function decodeEntities(text2) {
1247
+ 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(
1248
+ /&([a-z][a-z0-9]*);/gi,
1249
+ (whole, name) => NAMED_ENTITIES[name.toLowerCase()] ?? whole
1250
+ );
1251
+ }
1252
+ function codePoint(value, published) {
1253
+ if (!Number.isInteger(value) || value < 32 || value > 1114111) return published;
1254
+ try {
1255
+ return String.fromCodePoint(value);
1256
+ } catch {
1257
+ return published;
1258
+ }
1259
+ }
1260
+ var EMPTY_BRACKETS = /\(\s*\)/g;
1261
+ function readable(text2) {
1262
+ return decodeEntities(text2).replace(EMPTY_BRACKETS, " ").replace(/\s+/g, " ").trim();
1263
+ }
1264
+ var APPROXIMATION_PREFIX = {
1265
+ en: /^(?:~|\u2248|about|approx\.?|approximately|around|roughly)\s*/i,
1266
+ fr: /^(?:~|\u2248|environ|approximativement|\u00e0 peu pr\u00e8s|a peu pres)\s*/i
1267
+ };
1268
+ var CONTAINER_CONTENTS = /^\s*(?:de\s|d'|du\s|des\s)/i;
1269
+ function statesItemSize(item) {
1270
+ const attached = /\s+de\s+(?=\d)/i.exec(item);
1271
+ if (!attached) return false;
1272
+ const named = item.slice(0, attached.index).trim();
1273
+ if (!named || /\d/.test(named)) return false;
1274
+ return isStatedSize(item.slice(attached.index + attached[0].length));
1275
+ }
1276
+ function isStatedSize(text2) {
1277
+ const size = parseLeadingQuantity(text2, "fr");
1278
+ if (!size) return false;
1279
+ const measure = takeUnit(text2.slice(size.length).trimStart(), "fr");
1280
+ if (!measure.unit || measure.unit.kind !== "measured") return false;
1281
+ return !CONTAINER_CONTENTS.test(measure.rest);
1282
+ }
1283
+ var PER_PERSON = {
1284
+ en: /\bper\s+(?:person|head|serving|guest|diner)\b/i,
1285
+ fr: /\bpar\s+(?:personne|convive|part|t\u00eate)\b/i
1286
+ };
1287
+ var MEASURE_ADJECTIVES = {
1288
+ en: /* @__PURE__ */ new Set([
1289
+ "big",
1290
+ "generous",
1291
+ "good",
1292
+ "heaped",
1293
+ "heaping",
1294
+ "large",
1295
+ "level",
1296
+ "medium",
1297
+ "scant",
1298
+ "small"
1299
+ ]),
1300
+ fr: /* @__PURE__ */ new Set([
1301
+ "beau",
1302
+ "belle",
1303
+ "bon",
1304
+ "bonne",
1305
+ "grand",
1306
+ "grande",
1307
+ "gros",
1308
+ "grosse",
1309
+ "petit",
1310
+ "petite"
1311
+ ])
1312
+ };
1313
+ function takeMeasureAdjective(text2, language) {
1314
+ const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text2);
1315
+ if (!match) return { adjective: null, rest: text2 };
1316
+ const folded = normalizeUnitKey(match[1]);
1317
+ const listed = MEASURE_ADJECTIVES[language].has(folded) || MEASURE_ADJECTIVES[language].has(folded.replace(/s$/, ""));
1318
+ if (!listed) return { adjective: null, rest: text2 };
1319
+ return { adjective: match[1], rest: text2.slice(match[0].length) };
1320
+ }
1321
+ var COMMA_GROUPED = /^\s*\d{1,3}(?:,\d{3})+(?!\d)/;
1322
+ var COMMA_DECIMAL = /^\s*\d+,\d+/;
1205
1323
  function parseLeadingQuantity(text2, language) {
1206
1324
  const trimmed = text2.trimStart();
1207
1325
  const offset = text2.length - trimmed.length;
@@ -1307,21 +1425,25 @@ function takeUnitEitherLanguage(text2, language) {
1307
1425
  if (first.unit) return first;
1308
1426
  return takeUnit(text2, language === "fr" ? "en" : "fr");
1309
1427
  }
1310
- function detectLanguage(line) {
1428
+ function readEvidence(line) {
1311
1429
  const text2 = line.trim();
1312
1430
  const afterFigures = text2.replace(/^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u, "").trimStart();
1313
1431
  const afterArticle = afterFigures.replace(/^(?:un|une|quelques|of\s+an?|an?)\s+/iu, "");
1314
1432
  const frenchUnit = takeUnit(afterArticle, "fr").unit !== null;
1315
1433
  const englishUnit = takeUnit(afterArticle, "en").unit !== null;
1316
- return readLanguage(text2, { frenchUnit, englishUnit }).language;
1434
+ return readLanguage(text2, { frenchUnit, englishUnit });
1317
1435
  }
1318
1436
  function parseIngredient(line, choice = "auto") {
1319
1437
  const original = line;
1320
- const text2 = line.trim();
1321
- const language = choice === "auto" ? detectLanguage(text2) : choice;
1322
- const empty = {
1438
+ const text2 = readable(line);
1439
+ const evidence = readEvidence(text2);
1440
+ const language = choice === "auto" ? evidence.language : choice;
1441
+ const empty = (heldBack) => ({
1323
1442
  original,
1324
1443
  language,
1444
+ heldBack,
1445
+ approximation: null,
1446
+ measureAdjective: null,
1325
1447
  amount: null,
1326
1448
  amountMax: null,
1327
1449
  rangeSeparator: null,
@@ -1331,25 +1453,39 @@ function parseIngredient(line, choice = "auto") {
1331
1453
  item: text2,
1332
1454
  articleWord: null,
1333
1455
  countMultiplier: null
1334
- };
1335
- const range = parseLeadingRange(text2, language);
1336
- const article = range ? null : readArticle(text2, language);
1337
- const quantity = range ?? parseLeadingQuantity(text2, language) ?? article;
1338
- if (!quantity) return empty;
1339
- let rest = text2.slice(quantity.length).trimStart();
1456
+ });
1457
+ if (unreadableComma(text2, language, evidence)) return empty("ambiguousDecimal");
1458
+ const loose = APPROXIMATION_PREFIX[language].exec(text2);
1459
+ const stated = loose ? text2.slice(loose[0].length) : text2;
1460
+ const range = parseLeadingRange(stated, language);
1461
+ const article = range ? null : readArticle(stated, language);
1462
+ const quantity = range ?? parseLeadingQuantity(stated, language) ?? article;
1463
+ if (!quantity) return empty(null);
1464
+ if (new RegExp("^-\\p{L}", "u").test(stated.slice(quantity.length))) return empty("sizeQualifier");
1465
+ let rest = stated.slice(quantity.length).trimStart();
1340
1466
  if (language === "en") rest = rest.replace(/^(?:of\s+)?an?\s+/i, "");
1341
1467
  const multiplier = readCountMultiplier(rest);
1342
1468
  if (multiplier) rest = multiplier.rest;
1343
1469
  const times = multiplier?.times ?? 1;
1344
- const leading = takeLeadingUnit(rest, language, quantity === article);
1470
+ const fromArticle = quantity === article;
1471
+ const direct = takeLeadingUnit(rest, language, fromArticle);
1472
+ const described = direct.unit ? { adjective: null, rest } : takeMeasureAdjective(rest, language);
1473
+ const behind = described.adjective ? takeLeadingUnit(described.rest, language, fromArticle) : null;
1474
+ const leading = direct.unit ? direct : behind?.unit ? behind : direct;
1345
1475
  rest = leading.rest;
1346
1476
  const bracketed = takeAlternates(rest, language);
1347
1477
  rest = bracketed.rest;
1348
1478
  const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest, language);
1349
1479
  if (slashed) rest = slashed.rest;
1480
+ const item = stripItemLead(rest, language);
1481
+ if (language === "fr" && !leading.unit && statesItemSize(item)) return empty("itemSize");
1482
+ if (language === "fr" && leading.partitive && isStatedSize(item)) return empty("itemSize");
1350
1483
  return {
1351
1484
  original,
1352
1485
  language,
1486
+ heldBack: PER_PERSON[language].test(text2) ? "perPerson" : null,
1487
+ approximation: loose ? loose[0] : null,
1488
+ measureAdjective: behind?.unit ? described.adjective : null,
1353
1489
  amount: quantity.amount * times,
1354
1490
  amountMax: range === null ? null : range.max * times,
1355
1491
  rangeSeparator: range?.separator ?? null,
@@ -1357,10 +1493,18 @@ function parseIngredient(line, choice = "auto") {
1357
1493
  alternates: slashed ? slashed.measures : bracketed.measures,
1358
1494
  alternateStyle: slashed ? "slash" : bracketed.measures.length > 0 ? "bracket" : null,
1359
1495
  item: stripItemLead(rest, language),
1360
- articleWord: quantity === article ? article?.word ?? null : null,
1496
+ articleWord: fromArticle ? article?.word ?? null : null,
1361
1497
  countMultiplier: multiplier?.times ?? null
1362
1498
  };
1363
1499
  }
1500
+ function unreadableComma(text2, language, evidence) {
1501
+ if (COMMA_GROUPED.test(text2)) {
1502
+ const settled = evidence.french !== evidence.english;
1503
+ return !settled;
1504
+ }
1505
+ if (language === "en") return COMMA_DECIMAL.test(text2);
1506
+ return /^\s*\d+,\d+,\d/.test(text2);
1507
+ }
1364
1508
  function readArticle(text2, language) {
1365
1509
  const counts = (rest) => takeLeadingUnit(rest, language, true).unit !== null || readCountMultiplier(rest) !== null;
1366
1510
  if (language === "fr") {
@@ -1390,12 +1534,12 @@ function readCountMultiplier(text2) {
1390
1534
  }
1391
1535
  function takeLeadingUnit(text2, language, fromArticle) {
1392
1536
  const taken = takeUnit(text2, language);
1393
- if (taken.unit) return taken;
1537
+ if (taken.unit) return { ...taken, partitive: false };
1394
1538
  if (language === "fr" && fromArticle) {
1395
1539
  const measure = readPartitiveMeasure(text2);
1396
- if (measure) return { unit: measure.unit, rest: measure.rest };
1540
+ if (measure) return { unit: measure.unit, rest: measure.rest, partitive: true };
1397
1541
  }
1398
- return taken;
1542
+ return { ...taken, partitive: false };
1399
1543
  }
1400
1544
  function stripItemLead(text2, language) {
1401
1545
  if (language === "fr") {
@@ -1556,7 +1700,7 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
1556
1700
  const inUnit = (target, ratio) => ({
1557
1701
  bounds: raws.map((raw, index) => {
1558
1702
  const exact = raw * ratio;
1559
- const rounded = roundMeasured(exact);
1703
+ const rounded = ratio < 1 ? Number((roundMeasured(raw) * ratio).toPrecision(12)) : roundMeasured(exact);
1560
1704
  const usable = rounded === 0 && exact > 0 ? Number(exact.toPrecision(2)) : rounded;
1561
1705
  const ceiling = factor < 1 ? sources[index] * ratio : Number.POSITIVE_INFINITY;
1562
1706
  return {
@@ -1590,7 +1734,7 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
1590
1734
  return { bounds, unit };
1591
1735
  }
1592
1736
  var PORTION_SIZED_ITEM = new RegExp(
1593
- "\\b(?:shrimps?|prawns?|langoustines?|mussels?|hazelnuts?|peppercorns?|junipers?|grains?|anise|crevettes?|gambas|moules?|noisettes?|geni\xE8vres?|genievres?|anis)\\b",
1737
+ "\\b(?:shrimps?|prawns?|langoustines?|mussels?|hazelnuts?|peppercorns?|junipers?|grains?|anise|crevettes?|gambas|moules?|noisettes?|genievres?|genevriers?|badianes?|anis)\\b",
1594
1738
  "iu"
1595
1739
  );
1596
1740
  var QUARTERED_ITEM = new RegExp(
@@ -1603,7 +1747,7 @@ var HALVED_CUT = /\b(?:breasts?|thighs?|drumsticks?|wings?|cutlets?|cuisses?|ail
1603
1747
  function cloveDivisibility(unit, item) {
1604
1748
  const counted = unit ? unit.canonical === "clove" : /\bcloves?\b/i.test(item);
1605
1749
  if (!counted) return null;
1606
- return /\bgarlic\b/i.test(item) ? "quarter" : "whole";
1750
+ return /\bgarlic\b/i.test(item) ? "half" : "whole";
1607
1751
  }
1608
1752
  var BLANC_OF = /\bblancs?\s+d(?:e\s|['’])/iu;
1609
1753
  var BLANC_OF_EGG = /\bblancs?\s+d(?:e\s|['’])\s*(?:oeufs?|œufs?)\b/iu;
@@ -1611,18 +1755,22 @@ function blancDivisibility(item) {
1611
1755
  if (!BLANC_OF.test(item)) return null;
1612
1756
  return BLANC_OF_EGG.test(item) ? "whole" : "half";
1613
1757
  }
1758
+ function foldItem(item) {
1759
+ return item.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0153/g, "oe").replace(/\u0152/g, "OE");
1760
+ }
1614
1761
  function divisibilityOf(unit, item) {
1615
- const clove = cloveDivisibility(unit, item);
1762
+ const key = foldItem(item);
1763
+ const clove = cloveDivisibility(unit, key);
1616
1764
  if (clove) return clove;
1617
1765
  if (unit && !countsBarePieces(unit)) return unitDivisibility(unit);
1618
- const blanc = blancDivisibility(item);
1766
+ const blanc = blancDivisibility(key);
1619
1767
  if (blanc) return blanc;
1620
- if (WHOLE_ITEM.test(item)) return "whole";
1621
- if (PORTION_SIZED_ITEM.test(item)) return "whole";
1622
- if (HALVED_ITEM.test(item)) return "half";
1623
- if (HALVED_CUT.test(item)) return "half";
1624
- if (QUARTERED_MEASURE.test(item)) return "quarter";
1625
- return QUARTERED_ITEM.test(item) ? "quarter" : "half";
1768
+ if (WHOLE_ITEM.test(key)) return "whole";
1769
+ if (PORTION_SIZED_ITEM.test(key)) return "whole";
1770
+ if (HALVED_ITEM.test(key)) return "half";
1771
+ if (HALVED_CUT.test(key)) return "half";
1772
+ if (QUARTERED_MEASURE.test(key)) return "quarter";
1773
+ return QUARTERED_ITEM.test(key) ? "quarter" : "half";
1626
1774
  }
1627
1775
  var INVARIABLE_ITEM = /* @__PURE__ */ new Set([
1628
1776
  "anise",
@@ -1789,6 +1937,8 @@ var AGREEABLE_ADJECTIVES = /* @__PURE__ */ new Set([
1789
1937
  "petite",
1790
1938
  "grand",
1791
1939
  "grande",
1940
+ "gros",
1941
+ "grosse",
1792
1942
  "mur",
1793
1943
  "mure",
1794
1944
  "vert",
@@ -1852,6 +2002,16 @@ function agreeInFrench(item, amount) {
1852
2002
  }
1853
2003
  return words.join(" ");
1854
2004
  }
2005
+ function agreeLeadingAdjective(word, amount, language) {
2006
+ if (language === "en") return word;
2007
+ const wantsPlural = amount >= 2;
2008
+ const folded = foldWord(word);
2009
+ const isPlural = folded.endsWith("s") && !AGREEABLE_ADJECTIVES.has(folded);
2010
+ const singular = isPlural ? word.slice(0, -1) : word;
2011
+ if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return word;
2012
+ if (wantsPlural === isPlural) return word;
2013
+ return wantsPlural ? `${singular}s` : singular;
2014
+ }
1855
2015
  function agreeWithAmount(item, amount, language) {
1856
2016
  return language === "fr" ? agreeInFrench(item, amount) : agreeInEnglish(item, amount);
1857
2017
  }
@@ -1910,11 +2070,17 @@ function scaleAlternative(tail, options) {
1910
2070
  if (largest < 1) return { text: published, rewritten: false };
1911
2071
  return { text: scaleIngredient(tail, options).text, rewritten: true };
1912
2072
  }
2073
+ var HELD_BACK_NOTE = {
2074
+ sizeQualifier: "The figures here give the size of one item rather than how many, so the line is left as published.",
2075
+ 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.",
2076
+ perPerson: "This line already states an amount for one person, and the factor is what changes how many people the recipe serves, so the line is left as published.",
2077
+ ambiguousDecimal: "The comma in this number marks thousands in one convention and the decimal point in another, and the line gives no sign which was meant, so it is left as published."
2078
+ };
1913
2079
  function scaleSingleLine(line, options) {
1914
2080
  const { factor } = options;
1915
2081
  const parsed = parseIngredient(line, options.language ?? "auto");
1916
2082
  const language = parsed.language;
1917
- if (parsed.amount === null) {
2083
+ if (parsed.amount === null || parsed.heldBack) {
1918
2084
  return {
1919
2085
  text: parsed.original,
1920
2086
  original: parsed.original,
@@ -1923,7 +2089,7 @@ function scaleSingleLine(line, options) {
1923
2089
  amountMax: null,
1924
2090
  unit: null,
1925
2091
  language,
1926
- note: "No quantity given; adjust to taste."
2092
+ note: parsed.heldBack ? HELD_BACK_NOTE[parsed.heldBack] : "No quantity given; adjust to taste."
1927
2093
  };
1928
2094
  }
1929
2095
  const divisibility = divisibilityOf(parsed.unit, parsed.item);
@@ -1947,13 +2113,14 @@ function scaleSingleLine(line, options) {
1947
2113
  parsed.rangeSeparator
1948
2114
  );
1949
2115
  const named = unit && !countsBarePieces(unit) ? unit : null;
1950
- const unitLabel = named ? ` ${formatUnit(named, shown, language)}` : "";
2116
+ const adjective = named && parsed.measureAdjective ? ` ${agreeLeadingAdjective(parsed.measureAdjective, shown, language)}` : "";
2117
+ const unitLabel = named ? `${adjective} ${formatUnit(named, shown, language)}` : "";
1951
2118
  const alternateTexts = alternates.map((entry) => entry.text);
1952
2119
  const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : ` (${alternateTexts.join(" / ")})`;
1953
2120
  const counted = agreeWithAmount(parsed.item, shown, language);
1954
2121
  const itemLabel = named ? joinItem(parsed.item, language) : counted ? ` ${counted}` : "";
1955
2122
  const result = {
1956
- text: `${amountText}${unitLabel}${altLabel}${itemLabel}`.trim(),
2123
+ text: `${parsed.approximation ?? ""}${amountText}${unitLabel}${altLabel}${itemLabel}`.trim(),
1957
2124
  original: parsed.original,
1958
2125
  scaling: movedPrimary || movedAlternate || restated ? "rounded" : "scaled",
1959
2126
  amount: low.amount,
@@ -1996,6 +2163,11 @@ function scaleSingleLine(line, options) {
1996
2163
  "This is smaller than a kitchen scale resolves. Make a larger batch, or measure it by eye."
1997
2164
  );
1998
2165
  }
2166
+ if (parsed.approximation) {
2167
+ sentences.push(
2168
+ "The page gave this amount as an approximation, and the scaled figure is no firmer."
2169
+ );
2170
+ }
1999
2171
  if (sentences.length > 0) result.note = sentences.join(" ");
2000
2172
  if (parsed.unit && parsed.unit.kind === "approximate") {
2001
2173
  result.note = withApproximateNote(parsed.unit, result.note);
@@ -2043,16 +2215,18 @@ function scaleIngredients(lines, options) {
2043
2215
  }
2044
2216
  function passthroughIngredient(line, language = "auto") {
2045
2217
  const parsed = parseIngredient(line, language);
2218
+ const held = parsed.amount === null || parsed.heldBack !== null;
2046
2219
  const result = {
2047
2220
  text: parsed.original,
2048
2221
  original: parsed.original,
2049
- scaling: parsed.amount === null ? "unscaled" : "scaled",
2050
- amount: parsed.amount,
2051
- amountMax: parsed.amountMax,
2052
- unit: parsed.unit?.canonical ?? null,
2222
+ scaling: held ? "unscaled" : "scaled",
2223
+ amount: held ? null : parsed.amount,
2224
+ amountMax: held ? null : parsed.amountMax,
2225
+ unit: held ? null : parsed.unit?.canonical ?? null,
2053
2226
  language: parsed.language
2054
2227
  };
2055
- if (parsed.amount === null) result.note = "No quantity given; adjust to taste.";
2228
+ if (parsed.heldBack) result.note = HELD_BACK_NOTE[parsed.heldBack];
2229
+ else if (parsed.amount === null) result.note = "No quantity given; adjust to taste.";
2056
2230
  else if (parsed.unit?.kind === "approximate") {
2057
2231
  result.note = withApproximateNote(parsed.unit, void 0);
2058
2232
  }
@@ -2089,7 +2263,13 @@ var reportSchema = z.object({
2089
2263
  code: z.string(),
2090
2264
  message: z.string(),
2091
2265
  hint: z.string().optional()
2092
- }).nullable().describe("Why this source did not answer. Null when it did.")
2266
+ }).nullable().describe("Why this source did not answer. Null when it did."),
2267
+ read: z.object({
2268
+ status: z.enum(["read", "failed"]),
2269
+ error: z.object({ code: z.string(), message: z.string(), hint: z.string().optional() }).nullable()
2270
+ }).nullable().describe(
2271
+ "What became of the row this source offered, once it was opened. Null when no row was opened, which is what a search that did not answer and a search holding nothing both come to. A search and a read are two moments, and this is the second one."
2272
+ )
2093
2273
  });
2094
2274
  var ingredientSchema = z.object({
2095
2275
  text: z.string().describe("The line as it now reads."),
@@ -2139,9 +2319,15 @@ function toReportPayload(report) {
2139
2319
  reported_total_means: report.reportedTotalMeans,
2140
2320
  skipped: report.skipped,
2141
2321
  cached: report.cached,
2142
- error: report.error ?? null
2322
+ error: report.error ?? null,
2323
+ // A search alone opens nothing, so only a tool that reads a row fills this
2324
+ // in, through `withRead`.
2325
+ read: null
2143
2326
  };
2144
2327
  }
2328
+ function withRead(payload, read) {
2329
+ return { ...payload, read };
2330
+ }
2145
2331
  function reportNotes(reports) {
2146
2332
  const notes = [];
2147
2333
  const answered = reports.filter((report) => report.status === "answered");
@@ -2575,14 +2761,19 @@ async function runCompareRecipes(client, args) {
2575
2761
  const reads = await Promise.allSettled(offered.map(([, id]) => client.getRecipe(id)));
2576
2762
  const recipes = [];
2577
2763
  const unread = /* @__PURE__ */ new Map();
2764
+ const opened = /* @__PURE__ */ new Set();
2578
2765
  reads.forEach((read, index) => {
2579
2766
  const source = offered[index][0];
2767
+ opened.add(source);
2580
2768
  if (read.status === "fulfilled") {
2581
2769
  recipes.push(read.value.recipe);
2582
2770
  return;
2583
2771
  }
2584
2772
  const reason = read.reason;
2585
- unread.set(source, `${reason?.code ?? "network_error"}: ${reason?.message ?? "unknown"}`);
2773
+ unread.set(source, {
2774
+ code: reason?.code ?? "network_error",
2775
+ message: reason?.message ?? "unknown"
2776
+ });
2586
2777
  });
2587
2778
  const views = recipes.map(
2588
2779
  (recipe) => buildRecipeView(recipe, {
@@ -2610,11 +2801,15 @@ async function runCompareRecipes(client, args) {
2610
2801
  const failedRead = unread.get(report.source);
2611
2802
  if (report.status === "failed") {
2612
2803
  notes.push(
2613
- `${report.name} is missing from this comparison because its search did not answer, not because it holds nothing.`
2804
+ `${report.name} is missing from this comparison because its search did not answer (${quoteForeign(
2805
+ `${report.error?.code ?? "unknown"}: ${report.error?.message ?? "unknown"}`
2806
+ )}). Nothing here is evidence about what it holds.`
2614
2807
  );
2615
2808
  } else if (failedRead) {
2616
2809
  notes.push(
2617
- `${report.name} offered a recipe and it could not be read (${quoteForeign(failedRead)}), so its version is missing rather than absent.`
2810
+ `${report.name}'s search answered and offered a version, and that version could not be read (${quoteForeign(
2811
+ `${failedRead.code}: ${failedRead.message}`
2812
+ )}). The failure is in the reading, so nothing here says whether ${report.name} holds this dish.`
2618
2813
  );
2619
2814
  } else {
2620
2815
  notes.push(
@@ -2671,7 +2866,17 @@ ${shownDifferences.map((line) => `- ${line}`).join("\n")}${moreDifferences > 0 ?
2671
2866
  dish: args.dish,
2672
2867
  versions: payloads,
2673
2868
  differences,
2674
- per_source: merged.reports.map(toReportPayload),
2869
+ // Two moments per source, told apart: whether the search answered, and
2870
+ // what became of the row it offered.
2871
+ per_source: merged.reports.map((report) => {
2872
+ const payload = toReportPayload(report);
2873
+ if (!opened.has(report.source)) return payload;
2874
+ const failedRead = unread.get(report.source);
2875
+ return withRead(
2876
+ payload,
2877
+ failedRead ? { status: "failed", error: { code: failedRead.code, message: failedRead.message } } : { status: "read", error: null }
2878
+ );
2879
+ }),
2675
2880
  notes
2676
2881
  },
2677
2882
  summary,
@@ -2883,6 +3088,13 @@ var searchRecipesOutput = z7.object({
2883
3088
  notes: z7.array(z7.string())
2884
3089
  });
2885
3090
  var WORDS_BEFORE_SAYING_SO = 4;
3091
+ function titleCarries(title, query) {
3092
+ const fold = (text2) => text2.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/œ/g, "oe").toLowerCase().replace(/[^a-z0-9]+/g, " ");
3093
+ const haystack = ` ${fold(title)} `;
3094
+ const words = fold(query).split(" ").filter((word) => word.length > 2);
3095
+ if (words.length === 0) return true;
3096
+ return words.some((word) => haystack.includes(` ${word}`));
3097
+ }
2886
3098
  async function runSearchRecipes(client, args) {
2887
3099
  try {
2888
3100
  const merged = await client.searchRecipes(
@@ -2900,6 +3112,11 @@ async function runSearchRecipes(client, args) {
2900
3112
  "This query went to each source as free text and no filter was applied to it. Words like 'vegan' or 'under 300 calories' help only where a source's own index happens to match them, so read the rows rather than assuming they meet every condition."
2901
3113
  );
2902
3114
  }
3115
+ if (results.length > 0 && !results.some((row) => titleCarries(row.title, args.query))) {
3116
+ notes.push(
3117
+ `No title here carries a word of "${args.query}". These rows are what the sources ranked for that spelling, so read them as candidates to check rather than as recipes for the dish.`
3118
+ );
3119
+ }
2903
3120
  if (results.length === 0 && answered.length === merged.reports.length) {
2904
3121
  notes.push(
2905
3122
  "Every source answered and none holds anything under this wording. Try the dish's name in another language, or name a main ingredient instead."