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 +56 -8
- package/LICENSE +0 -0
- package/README.md +4 -5
- package/dist/index.d.ts +0 -0
- package/dist/index.js +182 -36
- package/dist/index.js.map +1 -1
- package/dist/language-BgfmjwtF.d.ts +0 -0
- package/dist/recipe/scale.d.ts +0 -0
- package/dist/recipe/scale.js +181 -35
- package/dist/recipe/scale.js.map +1 -1
- package/dist/sources/client.d.ts +0 -0
- package/dist/sources/client.js +1 -1
- package/dist/sources/client.js.map +1 -1
- package/package.json +1 -1
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
|
-
## [
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
72
|
-
|
|
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
|
|
63
|
-
|
|
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
|
|
86
|
-
|
|
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.
|
|
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?|
|
|
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
|
|
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 })
|
|
1410
|
+
return readLanguage(text2, { frenchUnit, englishUnit });
|
|
1317
1411
|
}
|
|
1318
1412
|
function parseIngredient(line, choice = "auto") {
|
|
1319
1413
|
const original = line;
|
|
1320
|
-
const text2 = line
|
|
1321
|
-
const
|
|
1322
|
-
const
|
|
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
|
-
|
|
1336
|
-
const
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
|
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:
|
|
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?|
|
|
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) ? "
|
|
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
|
|
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(
|
|
1739
|
+
const blanc = blancDivisibility(key);
|
|
1619
1740
|
if (blanc) return blanc;
|
|
1620
|
-
if (WHOLE_ITEM.test(
|
|
1621
|
-
if (PORTION_SIZED_ITEM.test(
|
|
1622
|
-
if (HALVED_ITEM.test(
|
|
1623
|
-
if (HALVED_CUT.test(
|
|
1624
|
-
if (QUARTERED_MEASURE.test(
|
|
1625
|
-
return QUARTERED_ITEM.test(
|
|
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
|
|
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:
|
|
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.
|
|
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
|
}
|