mcp-recipes 1.0.0 → 1.1.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/CHANGELOG.md CHANGED
@@ -4,10 +4,44 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [1.1.0] - 2026-08-07
8
8
 
9
9
  ### Changed
10
10
 
11
+ - The three readings a comma can take are now told apart. English groups
12
+ thousands with it and French marks the decimal, so "1,500 g flour" is fifteen
13
+ hundred grams and "1,500 kg de farine" is a kilo and a half. Where a line
14
+ gives no sign which language it is in, both readings stand and they differ by
15
+ a factor of a thousand, so the line comes back as published and says why
16
+ rather than under a guess.
17
+
18
+ - A figure that was never a count is no longer multiplied. "4 to 5-pound
19
+ boneless pork loin roast" describes one roast and was doubled into eight to
20
+ ten of them, and an amount stated "par personne" or "per person" already
21
+ carries the change the factor asks for. Both come back as the page published
22
+ them, with a note saying which of the two it is.
23
+
24
+ - The quantity behind the word that hid it is read. "~1 cup water", "about 6
25
+ medium lemons" and "environ 6 citrons" carried an amount that was answered as
26
+ if the line had none, and "1 grosse pincée de sel" lost the pincée, and with it
27
+ the fact that a pincée is held to no better than the hand. Both now read
28
+ through to the figure and the measure, and put the word and the sign back
29
+ where the page had them.
30
+
31
+ - HTML entities are decoded, brackets a page left empty are dropped, "recipe"
32
+ naming another dish takes its plural on the count, and "livre" is read as the
33
+ pound a French page glosses a weight with.
34
+
35
+ - An approximate measure lands its count on a whole one. There is no half of a
36
+ hand: the size of one pincée is the cook's and the count is the whole of what
37
+ the measure can say, so "1 pincée de sel" halved keeps the pincée and the line
38
+ reports that it moved, where it answered "1/2 pincée" and called the
39
+ arithmetic exact.
40
+
41
+ - A gousse d'ail and a clove of garlic stop at the half rather than the quarter,
42
+ as the person who cooks these recipes has them, and a baie de genévrier and
43
+ une étoile de badiane are counted whole.
44
+
11
45
  - How finely a counted thing divides now follows the size of one of them against
12
46
  what a recipe puts in, and two families come out of that one comparison. Each
13
47
  family is read in both languages, because where a food falls on that
@@ -50,11 +84,9 @@ All notable changes to this project are documented here. The format follows
50
84
  Each holds enough that a quarter is still a portion someone serves, so the
51
85
  answer is now `1/4 pot de crème fraîche`, exact. The word is read wherever it
52
86
  stands, so `1 petit pot de crème` gets the same floor.
53
- - Une gousse, une tranche and a slice divide as far as the quarter. Each is
54
- already cut off something larger, and the board that produced one takes a
55
- corner off it in the same gesture, so `1 gousse d'ail` reduced to a quarter
56
- comes back as `1/4 gousse d'ail` where it came back as a half, and
57
- `4 gousses d'ail` reads `1 gousse d'ail`.
87
+ - Une tranche and a slice divide as far as the quarter. Each is already cut off
88
+ something larger, and the board that produced one takes a corner off it in the
89
+ same gesture, so a quarter of a tranche de pain is a crouton.
58
90
  - Une pastèque and a watermelon, une pintade and a guinea fowl, un poulet and a
59
91
  chicken, un poireau and a leek, une banane and a banana, une mangue and a
60
92
  mango, un rôti and a roast, un avocat, un reblochon and une bûche divide as
@@ -68,8 +100,8 @@ All notable changes to this project are documented here. The format follows
68
100
  `3 cuisses de poulet` at a half now that the whole poulet goes to the quarter.
69
101
  - `clove` is read on the line that writes it, having named two foods with one
70
102
  answer between them. Garlic in the line makes it the gousse d'ail, which a
71
- knife takes to a quarter, so `4 cloves garlic, minced` reduced to a quarter
72
- reads `1 clove garlic, minced`. On its own, or written `whole cloves`, it is
103
+ knife splits in two, so `4 cloves garlic, minced` reduced to a quarter reads
104
+ `1 clove garlic, minced`. On its own, or written `whole cloves`, it is
73
105
  the clou de girofle dropped into the pot and fished back out: nothing about it
74
106
  is measured, so `4 whole cloves` halved reads `2 whole cloves` where it read
75
107
  `1 whole clof`. The French words need none of this, each naming one food and
@@ -99,6 +131,21 @@ All notable changes to this project are documented here. The format follows
99
131
  unchanged, and the divisibility now comes from the thing counted, so
100
132
  `1 ea. onion` goes to a quarter and `12 ea. eggs` lands on a whole number.
101
133
 
134
+ ### Fixed
135
+
136
+ - A French produce name opening on an accent is recognised again. A word
137
+ boundary sits between ASCII letters, so the pattern never matched at the start
138
+ of "échalote", and a shallot reduced to a quarter was clamped up to a half and
139
+ told it no longer held its share. The name is now read with its accents folded
140
+ away, so it comes back as "1/4 échalote", exact.
141
+
142
+ - A second quantity left at its published size is reported on a French line. The
143
+ vocabulary is keyed without accents and the line writes "cuillère à soupe" with
144
+ them, so the sentence was never reached.
145
+
146
+ - Rounding happens in the smaller of the two units when the answer moves to a
147
+ bigger one, so a promotion no longer costs precision the page had.
148
+
102
149
  ## [1.0.0] - 2026-02-06
103
150
 
104
151
  First release.
@@ -144,4 +191,5 @@ First release.
144
191
  - A nightly canary against the real sites, which opens an issue when one of them
145
192
  changes something this server reads.
146
193
 
194
+ [1.1.0]: https://github.com/smeet666/mcp-recipes/releases/tag/v1.1.0
147
195
  [1.0.0]: https://github.com/smeet666/mcp-recipes/releases/tag/v1.0.0
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -59,9 +59,8 @@ clou de girofle, a zeste and a zest go with it, having no half anyone takes.
59
59
 
60
60
  A few measures go the other way and divide further. A pot, a bouteille, a
61
61
  bottle, a jar and a block hold enough for a quarter to be a portion someone
62
- serves. Une gousse, une tranche and a slice are already cut off something
63
- larger, and the board that produced one takes a corner off it in the same
64
- gesture. And a word can state a number of things rather than a measure of them,
62
+ serves. Une tranche and a slice are already cut off something larger, and the
63
+ board that produced one takes a corner off it in the same gesture. And a word can state a number of things rather than a measure of them,
65
64
  so `2 dozen mushrooms` at three quarters comes back as **18 mushrooms**. A blanc
66
65
  belongs to whichever food the line named: the white of an egg is counted whole,
67
66
  a blanc de poulet is meat and halves.
@@ -82,8 +81,8 @@ taken by squeezing half the fruit, and a quarter of one has to be poured out and
82
81
  measured back.
83
82
 
84
83
  The word `clove` names two foods, and the line says which. Garlic in the line
85
- makes it the wedge broken off a bulb, the gousse d'ail, which a knife takes to a
86
- quarter; on its own, or written `whole cloves`, it is the dried flower bud, the
84
+ makes it the wedge broken off a bulb, the gousse d'ail, which a knife splits in
85
+ two; on its own, or written `whole cloves`, it is the dried flower bud, the
87
86
  clou de girofle, and a count of those lands on a whole number.
88
87
 
89
88
  - `3 eggs` taken from six people to twenty-five gives **13 eggs**, and never a
package/dist/index.d.ts CHANGED
File without changes
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.1.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
 
@@ -748,6 +748,12 @@ var FRENCH_UNITS = {
748
748
  millilitres: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
749
749
  centilitre: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
750
750
  centilitres: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
751
+ // A page glossing a metric weight names the pound, which French writes as a
752
+ // livre: "450 g (1 livre) de spaghetti".
753
+ livre: { canonical: "livre", kind: "measured", system: "imperial", plural: "livres" },
754
+ livres: { canonical: "livre", kind: "measured", system: "imperial", plural: "livres" },
755
+ lb: { canonical: "lb", kind: "measured", system: "imperial", symbol: true },
756
+ lbs: { canonical: "lb", kind: "measured", system: "imperial", symbol: true },
751
757
  // Spoons and cups: real measures, but only in sensible fractions.
752
758
  "cuillere a soupe": {
753
759
  canonical: "cuill\xE8re \xE0 soupe",
@@ -959,6 +965,11 @@ var ENGLISH_UNITS = {
959
965
  leaf: { canonical: "leaf", kind: "portioned", system: "none", plural: "leaves" },
960
966
  leaves: { canonical: "leaf", kind: "portioned", system: "none", plural: "leaves" },
961
967
  ea: { canonical: "ea", kind: "portioned", system: "none", symbol: true },
968
+ // A dish of the book standing as an ingredient of another: "1 recipe Flaky
969
+ // Pie Crust". The count is what the word measures, so the plural mark belongs
970
+ // on it and not on the name of the dish.
971
+ recipe: { canonical: "recipe", kind: "portioned", system: "none", plural: "recipes" },
972
+ recipes: { canonical: "recipe", kind: "portioned", system: "none", plural: "recipes" },
962
973
  // Held to no better than a hand: the count scales, the size of one does not.
963
974
  // `readContainerLoad` explains what else lands here.
964
975
  capful: { canonical: "capful", kind: "approximate", system: "none", plural: "capfuls" },
@@ -1025,7 +1036,7 @@ function buildEmbedded(language) {
1025
1036
  return new RegExp(`\\d[\\d.,/]*\\s*(?:${keys.join("|")})\\b`, "i");
1026
1037
  }
1027
1038
  function hasEmbeddedMeasure(text2, language) {
1028
- return EMBEDDED[language].test(text2);
1039
+ return EMBEDDED[language].test(normalizeUnitKey(text2));
1029
1040
  }
1030
1041
  var NOT_A_MEASURE = /* @__PURE__ */ new Set([
1031
1042
  "peu",
@@ -1148,9 +1159,10 @@ function isSpoonMeasure(unit) {
1148
1159
  );
1149
1160
  }
1150
1161
  function unitDivisibility(unit) {
1162
+ if (unit.kind === "approximate") return "whole";
1151
1163
  return QUARTERED_MEASURE.test(unit.canonical) ? "quarter" : "half";
1152
1164
  }
1153
- var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|gousses?|tranches?|slices?)\b/i;
1165
+ var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|tranches?|slices?)\b/i;
1154
1166
  function countsBarePieces(unit) {
1155
1167
  return unit.canonical === "ea";
1156
1168
  }
@@ -1202,6 +1214,88 @@ var VULGAR_FRACTIONS = {
1202
1214
  "\u215E": 0.875
1203
1215
  };
1204
1216
  var VULGAR_CLASS = Object.keys(VULGAR_FRACTIONS).join("");
1217
+ var NAMED_ENTITIES = {
1218
+ frac12: "\xBD",
1219
+ frac13: "\u2153",
1220
+ frac23: "\u2154",
1221
+ frac14: "\xBC",
1222
+ frac34: "\xBE",
1223
+ frac15: "\u2155",
1224
+ frac18: "\u215B",
1225
+ nbsp: " ",
1226
+ amp: "&",
1227
+ lt: "<",
1228
+ gt: ">",
1229
+ quot: '"',
1230
+ apos: "'",
1231
+ deg: "\xB0",
1232
+ times: "\xD7",
1233
+ minus: "\u2212",
1234
+ ndash: "\u2013",
1235
+ mdash: "\u2014"
1236
+ };
1237
+ function decodeEntities(text2) {
1238
+ 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(
1239
+ /&([a-z][a-z0-9]*);/gi,
1240
+ (whole, name) => NAMED_ENTITIES[name.toLowerCase()] ?? whole
1241
+ );
1242
+ }
1243
+ function codePoint(value, published) {
1244
+ if (!Number.isInteger(value) || value < 32 || value > 1114111) return published;
1245
+ try {
1246
+ return String.fromCodePoint(value);
1247
+ } catch {
1248
+ return published;
1249
+ }
1250
+ }
1251
+ var EMPTY_BRACKETS = /\(\s*\)/g;
1252
+ function readable(text2) {
1253
+ return decodeEntities(text2).replace(EMPTY_BRACKETS, " ").replace(/\s+/g, " ").trim();
1254
+ }
1255
+ var APPROXIMATION_PREFIX = {
1256
+ en: /^(?:~|\u2248|about|approx\.?|approximately|around|roughly)\s*/i,
1257
+ fr: /^(?:~|\u2248|environ|approximativement|\u00e0 peu pr\u00e8s|a peu pres)\s*/i
1258
+ };
1259
+ var PER_PERSON = {
1260
+ en: /\bper\s+(?:person|head|serving|guest|diner)\b/i,
1261
+ fr: /\bpar\s+(?:personne|convive|part|t\u00eate)\b/i
1262
+ };
1263
+ var MEASURE_ADJECTIVES = {
1264
+ en: /* @__PURE__ */ new Set([
1265
+ "big",
1266
+ "generous",
1267
+ "good",
1268
+ "heaped",
1269
+ "heaping",
1270
+ "large",
1271
+ "level",
1272
+ "medium",
1273
+ "scant",
1274
+ "small"
1275
+ ]),
1276
+ fr: /* @__PURE__ */ new Set([
1277
+ "beau",
1278
+ "belle",
1279
+ "bon",
1280
+ "bonne",
1281
+ "grand",
1282
+ "grande",
1283
+ "gros",
1284
+ "grosse",
1285
+ "petit",
1286
+ "petite"
1287
+ ])
1288
+ };
1289
+ function takeMeasureAdjective(text2, language) {
1290
+ const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text2);
1291
+ if (!match) return { adjective: null, rest: text2 };
1292
+ const folded = normalizeUnitKey(match[1]);
1293
+ const listed = MEASURE_ADJECTIVES[language].has(folded) || MEASURE_ADJECTIVES[language].has(folded.replace(/s$/, ""));
1294
+ if (!listed) return { adjective: null, rest: text2 };
1295
+ return { adjective: match[1], rest: text2.slice(match[0].length) };
1296
+ }
1297
+ var COMMA_GROUPED = /^\s*\d{1,3}(?:,\d{3})+(?!\d)/;
1298
+ var COMMA_DECIMAL = /^\s*\d+,\d+/;
1205
1299
  function parseLeadingQuantity(text2, language) {
1206
1300
  const trimmed = text2.trimStart();
1207
1301
  const offset = text2.length - trimmed.length;
@@ -1307,21 +1401,25 @@ function takeUnitEitherLanguage(text2, language) {
1307
1401
  if (first.unit) return first;
1308
1402
  return takeUnit(text2, language === "fr" ? "en" : "fr");
1309
1403
  }
1310
- function detectLanguage(line) {
1404
+ function readEvidence(line) {
1311
1405
  const text2 = line.trim();
1312
1406
  const afterFigures = text2.replace(/^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u, "").trimStart();
1313
1407
  const afterArticle = afterFigures.replace(/^(?:un|une|quelques|of\s+an?|an?)\s+/iu, "");
1314
1408
  const frenchUnit = takeUnit(afterArticle, "fr").unit !== null;
1315
1409
  const englishUnit = takeUnit(afterArticle, "en").unit !== null;
1316
- return readLanguage(text2, { frenchUnit, englishUnit }).language;
1410
+ return readLanguage(text2, { frenchUnit, englishUnit });
1317
1411
  }
1318
1412
  function parseIngredient(line, choice = "auto") {
1319
1413
  const original = line;
1320
- const text2 = line.trim();
1321
- const language = choice === "auto" ? detectLanguage(text2) : choice;
1322
- const empty = {
1414
+ const text2 = readable(line);
1415
+ const evidence = readEvidence(text2);
1416
+ const language = choice === "auto" ? evidence.language : choice;
1417
+ const empty = (heldBack) => ({
1323
1418
  original,
1324
1419
  language,
1420
+ heldBack,
1421
+ approximation: null,
1422
+ measureAdjective: null,
1325
1423
  amount: null,
1326
1424
  amountMax: null,
1327
1425
  rangeSeparator: null,
@@ -1331,17 +1429,25 @@ function parseIngredient(line, choice = "auto") {
1331
1429
  item: text2,
1332
1430
  articleWord: null,
1333
1431
  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();
1432
+ });
1433
+ if (unreadableComma(text2, language, evidence)) return empty("ambiguousDecimal");
1434
+ const loose = APPROXIMATION_PREFIX[language].exec(text2);
1435
+ const stated = loose ? text2.slice(loose[0].length) : text2;
1436
+ const range = parseLeadingRange(stated, language);
1437
+ const article = range ? null : readArticle(stated, language);
1438
+ const quantity = range ?? parseLeadingQuantity(stated, language) ?? article;
1439
+ if (!quantity) return empty(null);
1440
+ if (new RegExp("^-\\p{L}", "u").test(stated.slice(quantity.length))) return empty("sizeQualifier");
1441
+ let rest = stated.slice(quantity.length).trimStart();
1340
1442
  if (language === "en") rest = rest.replace(/^(?:of\s+)?an?\s+/i, "");
1341
1443
  const multiplier = readCountMultiplier(rest);
1342
1444
  if (multiplier) rest = multiplier.rest;
1343
1445
  const times = multiplier?.times ?? 1;
1344
- const leading = takeLeadingUnit(rest, language, quantity === article);
1446
+ const fromArticle = quantity === article;
1447
+ const direct = takeLeadingUnit(rest, language, fromArticle);
1448
+ const described = direct.unit ? { adjective: null, rest } : takeMeasureAdjective(rest, language);
1449
+ const behind = described.adjective ? takeLeadingUnit(described.rest, language, fromArticle) : null;
1450
+ const leading = direct.unit ? direct : behind?.unit ? behind : direct;
1345
1451
  rest = leading.rest;
1346
1452
  const bracketed = takeAlternates(rest, language);
1347
1453
  rest = bracketed.rest;
@@ -1350,6 +1456,9 @@ function parseIngredient(line, choice = "auto") {
1350
1456
  return {
1351
1457
  original,
1352
1458
  language,
1459
+ heldBack: PER_PERSON[language].test(text2) ? "perPerson" : null,
1460
+ approximation: loose ? loose[0] : null,
1461
+ measureAdjective: behind?.unit ? described.adjective : null,
1353
1462
  amount: quantity.amount * times,
1354
1463
  amountMax: range === null ? null : range.max * times,
1355
1464
  rangeSeparator: range?.separator ?? null,
@@ -1357,10 +1466,18 @@ function parseIngredient(line, choice = "auto") {
1357
1466
  alternates: slashed ? slashed.measures : bracketed.measures,
1358
1467
  alternateStyle: slashed ? "slash" : bracketed.measures.length > 0 ? "bracket" : null,
1359
1468
  item: stripItemLead(rest, language),
1360
- articleWord: quantity === article ? article?.word ?? null : null,
1469
+ articleWord: fromArticle ? article?.word ?? null : null,
1361
1470
  countMultiplier: multiplier?.times ?? null
1362
1471
  };
1363
1472
  }
1473
+ function unreadableComma(text2, language, evidence) {
1474
+ if (COMMA_GROUPED.test(text2)) {
1475
+ const settled = evidence.french !== evidence.english;
1476
+ return !settled;
1477
+ }
1478
+ if (language === "en") return COMMA_DECIMAL.test(text2);
1479
+ return /^\s*\d+,\d+,\d/.test(text2);
1480
+ }
1364
1481
  function readArticle(text2, language) {
1365
1482
  const counts = (rest) => takeLeadingUnit(rest, language, true).unit !== null || readCountMultiplier(rest) !== null;
1366
1483
  if (language === "fr") {
@@ -1556,7 +1673,7 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
1556
1673
  const inUnit = (target, ratio) => ({
1557
1674
  bounds: raws.map((raw, index) => {
1558
1675
  const exact = raw * ratio;
1559
- const rounded = roundMeasured(exact);
1676
+ const rounded = ratio < 1 ? Number((roundMeasured(raw) * ratio).toPrecision(12)) : roundMeasured(exact);
1560
1677
  const usable = rounded === 0 && exact > 0 ? Number(exact.toPrecision(2)) : rounded;
1561
1678
  const ceiling = factor < 1 ? sources[index] * ratio : Number.POSITIVE_INFINITY;
1562
1679
  return {
@@ -1590,7 +1707,7 @@ function scaleMeasure(low, high, unit, factor, divisibility) {
1590
1707
  return { bounds, unit };
1591
1708
  }
1592
1709
  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",
1710
+ "\\b(?:shrimps?|prawns?|langoustines?|mussels?|hazelnuts?|peppercorns?|junipers?|grains?|anise|crevettes?|gambas|moules?|noisettes?|genievres?|genevriers?|badianes?|anis)\\b",
1594
1711
  "iu"
1595
1712
  );
1596
1713
  var QUARTERED_ITEM = new RegExp(
@@ -1603,7 +1720,7 @@ var HALVED_CUT = /\b(?:breasts?|thighs?|drumsticks?|wings?|cutlets?|cuisses?|ail
1603
1720
  function cloveDivisibility(unit, item) {
1604
1721
  const counted = unit ? unit.canonical === "clove" : /\bcloves?\b/i.test(item);
1605
1722
  if (!counted) return null;
1606
- return /\bgarlic\b/i.test(item) ? "quarter" : "whole";
1723
+ return /\bgarlic\b/i.test(item) ? "half" : "whole";
1607
1724
  }
1608
1725
  var BLANC_OF = /\bblancs?\s+d(?:e\s|['’])/iu;
1609
1726
  var BLANC_OF_EGG = /\bblancs?\s+d(?:e\s|['’])\s*(?:oeufs?|œufs?)\b/iu;
@@ -1611,18 +1728,22 @@ function blancDivisibility(item) {
1611
1728
  if (!BLANC_OF.test(item)) return null;
1612
1729
  return BLANC_OF_EGG.test(item) ? "whole" : "half";
1613
1730
  }
1731
+ function foldItem(item) {
1732
+ return item.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/\u0153/g, "oe").replace(/\u0152/g, "OE");
1733
+ }
1614
1734
  function divisibilityOf(unit, item) {
1615
- const clove = cloveDivisibility(unit, item);
1735
+ const key = foldItem(item);
1736
+ const clove = cloveDivisibility(unit, key);
1616
1737
  if (clove) return clove;
1617
1738
  if (unit && !countsBarePieces(unit)) return unitDivisibility(unit);
1618
- const blanc = blancDivisibility(item);
1739
+ const blanc = blancDivisibility(key);
1619
1740
  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";
1741
+ if (WHOLE_ITEM.test(key)) return "whole";
1742
+ if (PORTION_SIZED_ITEM.test(key)) return "whole";
1743
+ if (HALVED_ITEM.test(key)) return "half";
1744
+ if (HALVED_CUT.test(key)) return "half";
1745
+ if (QUARTERED_MEASURE.test(key)) return "quarter";
1746
+ return QUARTERED_ITEM.test(key) ? "quarter" : "half";
1626
1747
  }
1627
1748
  var INVARIABLE_ITEM = /* @__PURE__ */ new Set([
1628
1749
  "anise",
@@ -1789,6 +1910,8 @@ var AGREEABLE_ADJECTIVES = /* @__PURE__ */ new Set([
1789
1910
  "petite",
1790
1911
  "grand",
1791
1912
  "grande",
1913
+ "gros",
1914
+ "grosse",
1792
1915
  "mur",
1793
1916
  "mure",
1794
1917
  "vert",
@@ -1852,6 +1975,16 @@ function agreeInFrench(item, amount) {
1852
1975
  }
1853
1976
  return words.join(" ");
1854
1977
  }
1978
+ function agreeLeadingAdjective(word, amount, language) {
1979
+ if (language === "en") return word;
1980
+ const wantsPlural = amount >= 2;
1981
+ const folded = foldWord(word);
1982
+ const isPlural = folded.endsWith("s") && !AGREEABLE_ADJECTIVES.has(folded);
1983
+ const singular = isPlural ? word.slice(0, -1) : word;
1984
+ if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return word;
1985
+ if (wantsPlural === isPlural) return word;
1986
+ return wantsPlural ? `${singular}s` : singular;
1987
+ }
1855
1988
  function agreeWithAmount(item, amount, language) {
1856
1989
  return language === "fr" ? agreeInFrench(item, amount) : agreeInEnglish(item, amount);
1857
1990
  }
@@ -1910,11 +2043,16 @@ function scaleAlternative(tail, options) {
1910
2043
  if (largest < 1) return { text: published, rewritten: false };
1911
2044
  return { text: scaleIngredient(tail, options).text, rewritten: true };
1912
2045
  }
2046
+ var HELD_BACK_NOTE = {
2047
+ sizeQualifier: "The figures here give the size of one item rather than how many, so the line is left as published.",
2048
+ 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.",
2049
+ 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."
2050
+ };
1913
2051
  function scaleSingleLine(line, options) {
1914
2052
  const { factor } = options;
1915
2053
  const parsed = parseIngredient(line, options.language ?? "auto");
1916
2054
  const language = parsed.language;
1917
- if (parsed.amount === null) {
2055
+ if (parsed.amount === null || parsed.heldBack) {
1918
2056
  return {
1919
2057
  text: parsed.original,
1920
2058
  original: parsed.original,
@@ -1923,7 +2061,7 @@ function scaleSingleLine(line, options) {
1923
2061
  amountMax: null,
1924
2062
  unit: null,
1925
2063
  language,
1926
- note: "No quantity given; adjust to taste."
2064
+ note: parsed.heldBack ? HELD_BACK_NOTE[parsed.heldBack] : "No quantity given; adjust to taste."
1927
2065
  };
1928
2066
  }
1929
2067
  const divisibility = divisibilityOf(parsed.unit, parsed.item);
@@ -1947,13 +2085,14 @@ function scaleSingleLine(line, options) {
1947
2085
  parsed.rangeSeparator
1948
2086
  );
1949
2087
  const named = unit && !countsBarePieces(unit) ? unit : null;
1950
- const unitLabel = named ? ` ${formatUnit(named, shown, language)}` : "";
2088
+ const adjective = named && parsed.measureAdjective ? ` ${agreeLeadingAdjective(parsed.measureAdjective, shown, language)}` : "";
2089
+ const unitLabel = named ? `${adjective} ${formatUnit(named, shown, language)}` : "";
1951
2090
  const alternateTexts = alternates.map((entry) => entry.text);
1952
2091
  const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : ` (${alternateTexts.join(" / ")})`;
1953
2092
  const counted = agreeWithAmount(parsed.item, shown, language);
1954
2093
  const itemLabel = named ? joinItem(parsed.item, language) : counted ? ` ${counted}` : "";
1955
2094
  const result = {
1956
- text: `${amountText}${unitLabel}${altLabel}${itemLabel}`.trim(),
2095
+ text: `${parsed.approximation ?? ""}${amountText}${unitLabel}${altLabel}${itemLabel}`.trim(),
1957
2096
  original: parsed.original,
1958
2097
  scaling: movedPrimary || movedAlternate || restated ? "rounded" : "scaled",
1959
2098
  amount: low.amount,
@@ -1996,6 +2135,11 @@ function scaleSingleLine(line, options) {
1996
2135
  "This is smaller than a kitchen scale resolves. Make a larger batch, or measure it by eye."
1997
2136
  );
1998
2137
  }
2138
+ if (parsed.approximation) {
2139
+ sentences.push(
2140
+ "The page gave this amount as an approximation, and the scaled figure is no firmer."
2141
+ );
2142
+ }
1999
2143
  if (sentences.length > 0) result.note = sentences.join(" ");
2000
2144
  if (parsed.unit && parsed.unit.kind === "approximate") {
2001
2145
  result.note = withApproximateNote(parsed.unit, result.note);
@@ -2043,16 +2187,18 @@ function scaleIngredients(lines, options) {
2043
2187
  }
2044
2188
  function passthroughIngredient(line, language = "auto") {
2045
2189
  const parsed = parseIngredient(line, language);
2190
+ const held = parsed.amount === null || parsed.heldBack !== null;
2046
2191
  const result = {
2047
2192
  text: parsed.original,
2048
2193
  original: parsed.original,
2049
- scaling: parsed.amount === null ? "unscaled" : "scaled",
2050
- amount: parsed.amount,
2051
- amountMax: parsed.amountMax,
2052
- unit: parsed.unit?.canonical ?? null,
2194
+ scaling: held ? "unscaled" : "scaled",
2195
+ amount: held ? null : parsed.amount,
2196
+ amountMax: held ? null : parsed.amountMax,
2197
+ unit: held ? null : parsed.unit?.canonical ?? null,
2053
2198
  language: parsed.language
2054
2199
  };
2055
- if (parsed.amount === null) result.note = "No quantity given; adjust to taste.";
2200
+ if (parsed.heldBack) result.note = HELD_BACK_NOTE[parsed.heldBack];
2201
+ else if (parsed.amount === null) result.note = "No quantity given; adjust to taste.";
2056
2202
  else if (parsed.unit?.kind === "approximate") {
2057
2203
  result.note = withApproximateNote(parsed.unit, void 0);
2058
2204
  }