mcp-recipes 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/LICENSE +21 -0
- package/README.md +684 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3037 -0
- package/dist/index.js.map +1 -0
- package/dist/language-BgfmjwtF.d.ts +14 -0
- package/dist/recipe/scale.d.ts +86 -0
- package/dist/recipe/scale.js +1367 -0
- package/dist/recipe/scale.js.map +1 -0
- package/dist/sources/client.d.ts +448 -0
- package/dist/sources/client.js +697 -0
- package/dist/sources/client.js.map +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1,1367 @@
|
|
|
1
|
+
// src/recipe/language.ts
|
|
2
|
+
var FRENCH_WORDS = /(?:^|[\s(])(?:de|du|des|à|au|aux|la|le|les|un|une|quelques|dans|avec|sans|pour|selon|environ)(?=[\s),.]|$)|\bd'|\bl'|\bqu'/iu;
|
|
3
|
+
var ENGLISH_WORDS = /(?:^|[\s(])(?:of|an|the|and|with|without|for|into|about|each|per|plus|freshly|finely|roughly|coarsely|optional)(?=[\s),.]|$)/iu;
|
|
4
|
+
var FRENCH_LETTERS = /[àâäçéèêëîïôöùûüœ]/iu;
|
|
5
|
+
var FRENCH_FOODS = /\b(?:oeufs?|œufs?|sel|poivre|sucres?|farine|beurre|lait|crème|creme|huile|ail|oignons?|fromage|pain|poulet|boeuf|bœuf|riz|pâtes|pates|jambon|citrons?|pommes?|vin|eau|persil|thym|miel|jaunes?|blancs?)\b/iu;
|
|
6
|
+
var ENGLISH_FOODS = /\b(?:eggs?|salt|pepper|sugar|flour|butter|milk|cream|oil|garlic|onions?|cheese|bread|chicken|beef|rice|pasta|ham|lemons?|apples?|wine|water|parsley|thyme|honey|yolks?|whites?)\b/iu;
|
|
7
|
+
var STRUCTURAL = 3;
|
|
8
|
+
var VOCABULARY = 2;
|
|
9
|
+
var NAME = 1;
|
|
10
|
+
function readLanguage(line, evidence = { frenchUnit: false, englishUnit: false }) {
|
|
11
|
+
let french = 0;
|
|
12
|
+
let english = 0;
|
|
13
|
+
if (FRENCH_WORDS.test(line)) french += STRUCTURAL;
|
|
14
|
+
if (FRENCH_LETTERS.test(line)) french += STRUCTURAL;
|
|
15
|
+
if (ENGLISH_WORDS.test(line)) english += STRUCTURAL;
|
|
16
|
+
if (evidence.frenchUnit) french += VOCABULARY;
|
|
17
|
+
if (evidence.englishUnit) english += VOCABULARY;
|
|
18
|
+
if (FRENCH_FOODS.test(line)) french += NAME;
|
|
19
|
+
if (ENGLISH_FOODS.test(line)) english += NAME;
|
|
20
|
+
return { language: french > english ? "fr" : "en", french, english };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/recipe/units.ts
|
|
24
|
+
var SHARED_UNITS = {
|
|
25
|
+
mg: { canonical: "mg", kind: "measured", system: "metric", symbol: true },
|
|
26
|
+
g: { canonical: "g", kind: "measured", system: "metric", symbol: true },
|
|
27
|
+
gr: { canonical: "g", kind: "measured", system: "metric", symbol: true },
|
|
28
|
+
kg: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
|
|
29
|
+
kilo: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
|
|
30
|
+
kilos: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
|
|
31
|
+
ml: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
|
|
32
|
+
cl: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
|
|
33
|
+
dl: { canonical: "dl", kind: "measured", system: "metric", symbol: true },
|
|
34
|
+
l: { canonical: "l", kind: "measured", system: "metric", symbol: true },
|
|
35
|
+
litre: { canonical: "l", kind: "measured", system: "metric", symbol: true },
|
|
36
|
+
litres: { canonical: "l", kind: "measured", system: "metric", symbol: true }
|
|
37
|
+
};
|
|
38
|
+
var FRENCH_UNITS = {
|
|
39
|
+
...SHARED_UNITS,
|
|
40
|
+
gramme: { canonical: "g", kind: "measured", system: "metric", symbol: true },
|
|
41
|
+
grammes: { canonical: "g", kind: "measured", system: "metric", symbol: true },
|
|
42
|
+
kilogramme: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
|
|
43
|
+
kilogrammes: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
|
|
44
|
+
millilitre: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
|
|
45
|
+
millilitres: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
|
|
46
|
+
centilitre: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
|
|
47
|
+
centilitres: { canonical: "cl", kind: "measured", system: "metric", symbol: true },
|
|
48
|
+
// Spoons and cups: real measures, but only in sensible fractions.
|
|
49
|
+
"cuillere a soupe": {
|
|
50
|
+
canonical: "cuill\xE8re \xE0 soupe",
|
|
51
|
+
kind: "portioned",
|
|
52
|
+
system: "none",
|
|
53
|
+
plural: "cuill\xE8res \xE0 soupe"
|
|
54
|
+
},
|
|
55
|
+
"cuilleres a soupe": {
|
|
56
|
+
canonical: "cuill\xE8re \xE0 soupe",
|
|
57
|
+
kind: "portioned",
|
|
58
|
+
system: "none",
|
|
59
|
+
plural: "cuill\xE8res \xE0 soupe"
|
|
60
|
+
},
|
|
61
|
+
"c a soupe": {
|
|
62
|
+
canonical: "cuill\xE8re \xE0 soupe",
|
|
63
|
+
kind: "portioned",
|
|
64
|
+
system: "none",
|
|
65
|
+
plural: "cuill\xE8res \xE0 soupe"
|
|
66
|
+
},
|
|
67
|
+
"c a s": {
|
|
68
|
+
canonical: "cuill\xE8re \xE0 soupe",
|
|
69
|
+
kind: "portioned",
|
|
70
|
+
system: "none",
|
|
71
|
+
plural: "cuill\xE8res \xE0 soupe"
|
|
72
|
+
},
|
|
73
|
+
cas: {
|
|
74
|
+
canonical: "cuill\xE8re \xE0 soupe",
|
|
75
|
+
kind: "portioned",
|
|
76
|
+
system: "none",
|
|
77
|
+
plural: "cuill\xE8res \xE0 soupe"
|
|
78
|
+
},
|
|
79
|
+
"cuillere a cafe": {
|
|
80
|
+
canonical: "cuill\xE8re \xE0 caf\xE9",
|
|
81
|
+
kind: "portioned",
|
|
82
|
+
system: "none",
|
|
83
|
+
plural: "cuill\xE8res \xE0 caf\xE9"
|
|
84
|
+
},
|
|
85
|
+
"cuilleres a cafe": {
|
|
86
|
+
canonical: "cuill\xE8re \xE0 caf\xE9",
|
|
87
|
+
kind: "portioned",
|
|
88
|
+
system: "none",
|
|
89
|
+
plural: "cuill\xE8res \xE0 caf\xE9"
|
|
90
|
+
},
|
|
91
|
+
"c a cafe": {
|
|
92
|
+
canonical: "cuill\xE8re \xE0 caf\xE9",
|
|
93
|
+
kind: "portioned",
|
|
94
|
+
system: "none",
|
|
95
|
+
plural: "cuill\xE8res \xE0 caf\xE9"
|
|
96
|
+
},
|
|
97
|
+
"c a c": {
|
|
98
|
+
canonical: "cuill\xE8re \xE0 caf\xE9",
|
|
99
|
+
kind: "portioned",
|
|
100
|
+
system: "none",
|
|
101
|
+
plural: "cuill\xE8res \xE0 caf\xE9"
|
|
102
|
+
},
|
|
103
|
+
cac: {
|
|
104
|
+
canonical: "cuill\xE8re \xE0 caf\xE9",
|
|
105
|
+
kind: "portioned",
|
|
106
|
+
system: "none",
|
|
107
|
+
plural: "cuill\xE8res \xE0 caf\xE9"
|
|
108
|
+
},
|
|
109
|
+
verre: { canonical: "verre", kind: "portioned", system: "none" },
|
|
110
|
+
verres: { canonical: "verre", kind: "portioned", system: "none" },
|
|
111
|
+
bol: { canonical: "bol", kind: "portioned", system: "none" },
|
|
112
|
+
bols: { canonical: "bol", kind: "portioned", system: "none" },
|
|
113
|
+
tasse: { canonical: "tasse", kind: "portioned", system: "none" },
|
|
114
|
+
tasses: { canonical: "tasse", kind: "portioned", system: "none" },
|
|
115
|
+
// Packaging and natural units: countable, so they round to whole things.
|
|
116
|
+
sachet: { canonical: "sachet", kind: "portioned", system: "none" },
|
|
117
|
+
sachets: { canonical: "sachet", kind: "portioned", system: "none" },
|
|
118
|
+
gousse: { canonical: "gousse", kind: "portioned", system: "none" },
|
|
119
|
+
gousses: { canonical: "gousse", kind: "portioned", system: "none" },
|
|
120
|
+
tranche: { canonical: "tranche", kind: "portioned", system: "none" },
|
|
121
|
+
tranches: { canonical: "tranche", kind: "portioned", system: "none" },
|
|
122
|
+
botte: { canonical: "botte", kind: "portioned", system: "none" },
|
|
123
|
+
bottes: { canonical: "botte", kind: "portioned", system: "none" },
|
|
124
|
+
boite: { canonical: "bo\xEEte", kind: "portioned", system: "none", plural: "bo\xEEtes" },
|
|
125
|
+
boites: { canonical: "bo\xEEte", kind: "portioned", system: "none", plural: "bo\xEEtes" },
|
|
126
|
+
pot: { canonical: "pot", kind: "portioned", system: "none" },
|
|
127
|
+
pots: { canonical: "pot", kind: "portioned", system: "none" },
|
|
128
|
+
brique: { canonical: "brique", kind: "portioned", system: "none" },
|
|
129
|
+
briques: { canonical: "brique", kind: "portioned", system: "none" },
|
|
130
|
+
feuille: { canonical: "feuille", kind: "portioned", system: "none" },
|
|
131
|
+
feuilles: { canonical: "feuille", kind: "portioned", system: "none" },
|
|
132
|
+
branche: { canonical: "branche", kind: "portioned", system: "none" },
|
|
133
|
+
branches: { canonical: "branche", kind: "portioned", system: "none" },
|
|
134
|
+
// Held to no better than a hand: the count scales, the size of one does not.
|
|
135
|
+
// `readPartitiveMeasure` explains what else lands here.
|
|
136
|
+
bouchon: { canonical: "bouchon", kind: "approximate", system: "none" },
|
|
137
|
+
bouchons: { canonical: "bouchon", kind: "approximate", system: "none" },
|
|
138
|
+
larme: { canonical: "larme", kind: "approximate", system: "none" },
|
|
139
|
+
larmes: { canonical: "larme", kind: "approximate", system: "none" },
|
|
140
|
+
doigt: { canonical: "doigt", kind: "approximate", system: "none" },
|
|
141
|
+
doigts: { canonical: "doigt", kind: "approximate", system: "none" },
|
|
142
|
+
nuage: { canonical: "nuage", kind: "approximate", system: "none" },
|
|
143
|
+
nuages: { canonical: "nuage", kind: "approximate", system: "none" },
|
|
144
|
+
louche: { canonical: "louche", kind: "approximate", system: "none" },
|
|
145
|
+
louches: { canonical: "louche", kind: "approximate", system: "none" },
|
|
146
|
+
lichette: { canonical: "lichette", kind: "approximate", system: "none" },
|
|
147
|
+
lichettes: { canonical: "lichette", kind: "approximate", system: "none" },
|
|
148
|
+
pointe: { canonical: "pointe", kind: "approximate", system: "none" },
|
|
149
|
+
pointes: { canonical: "pointe", kind: "approximate", system: "none" },
|
|
150
|
+
pincee: { canonical: "pinc\xE9e", kind: "approximate", system: "none", plural: "pinc\xE9es" },
|
|
151
|
+
pincees: { canonical: "pinc\xE9e", kind: "approximate", system: "none", plural: "pinc\xE9es" },
|
|
152
|
+
trait: { canonical: "trait", kind: "approximate", system: "none" },
|
|
153
|
+
traits: { canonical: "trait", kind: "approximate", system: "none" },
|
|
154
|
+
filet: { canonical: "filet", kind: "approximate", system: "none" },
|
|
155
|
+
filets: { canonical: "filet", kind: "approximate", system: "none" },
|
|
156
|
+
goutte: { canonical: "goutte", kind: "approximate", system: "none", plural: "gouttes" },
|
|
157
|
+
gouttes: { canonical: "goutte", kind: "approximate", system: "none", plural: "gouttes" },
|
|
158
|
+
poignee: { canonical: "poign\xE9e", kind: "approximate", system: "none", plural: "poign\xE9es" },
|
|
159
|
+
poignees: { canonical: "poign\xE9e", kind: "approximate", system: "none", plural: "poign\xE9es" },
|
|
160
|
+
// "noix" carries its own plural mark already.
|
|
161
|
+
noix: { canonical: "noix", kind: "approximate", system: "none", plural: "noix" },
|
|
162
|
+
soupcon: { canonical: "soup\xE7on", kind: "approximate", system: "none", plural: "soup\xE7ons" },
|
|
163
|
+
soupcons: { canonical: "soup\xE7on", kind: "approximate", system: "none", plural: "soup\xE7ons" }
|
|
164
|
+
};
|
|
165
|
+
var ENGLISH_UNITS = {
|
|
166
|
+
...SHARED_UNITS,
|
|
167
|
+
milligram: { canonical: "mg", kind: "measured", system: "metric", symbol: true },
|
|
168
|
+
milligrams: { canonical: "mg", kind: "measured", system: "metric", symbol: true },
|
|
169
|
+
gram: { canonical: "g", kind: "measured", system: "metric", symbol: true },
|
|
170
|
+
grams: { canonical: "g", kind: "measured", system: "metric", symbol: true },
|
|
171
|
+
gramme: { canonical: "g", kind: "measured", system: "metric", symbol: true },
|
|
172
|
+
grammes: { canonical: "g", kind: "measured", system: "metric", symbol: true },
|
|
173
|
+
kilogram: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
|
|
174
|
+
kilograms: { canonical: "kg", kind: "measured", system: "metric", symbol: true },
|
|
175
|
+
milliliter: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
|
|
176
|
+
milliliters: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
|
|
177
|
+
millilitre: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
|
|
178
|
+
millilitres: { canonical: "ml", kind: "measured", system: "metric", symbol: true },
|
|
179
|
+
liter: { canonical: "l", kind: "measured", system: "metric", symbol: true },
|
|
180
|
+
liters: { canonical: "l", kind: "measured", system: "metric", symbol: true },
|
|
181
|
+
// Imperial mass
|
|
182
|
+
oz: { canonical: "oz", kind: "measured", system: "imperial", symbol: true },
|
|
183
|
+
ounce: { canonical: "ounce", kind: "measured", system: "imperial", plural: "ounces" },
|
|
184
|
+
ounces: { canonical: "ounce", kind: "measured", system: "imperial", plural: "ounces" },
|
|
185
|
+
lb: { canonical: "lb", kind: "measured", system: "imperial", symbol: true },
|
|
186
|
+
lbs: { canonical: "lb", kind: "measured", system: "imperial", symbol: true },
|
|
187
|
+
pound: { canonical: "pound", kind: "measured", system: "imperial", plural: "pounds" },
|
|
188
|
+
pounds: { canonical: "pound", kind: "measured", system: "imperial", plural: "pounds" },
|
|
189
|
+
// Imperial volume
|
|
190
|
+
"fl oz": { canonical: "fl oz", kind: "measured", system: "imperial", symbol: true },
|
|
191
|
+
"fluid ounce": {
|
|
192
|
+
canonical: "fluid ounce",
|
|
193
|
+
kind: "measured",
|
|
194
|
+
system: "imperial",
|
|
195
|
+
plural: "fluid ounces"
|
|
196
|
+
},
|
|
197
|
+
"fluid ounces": {
|
|
198
|
+
canonical: "fluid ounce",
|
|
199
|
+
kind: "measured",
|
|
200
|
+
system: "imperial",
|
|
201
|
+
plural: "fluid ounces"
|
|
202
|
+
},
|
|
203
|
+
pint: { canonical: "pint", kind: "measured", system: "imperial", plural: "pints" },
|
|
204
|
+
pints: { canonical: "pint", kind: "measured", system: "imperial", plural: "pints" },
|
|
205
|
+
quart: { canonical: "quart", kind: "measured", system: "imperial", plural: "quarts" },
|
|
206
|
+
quarts: { canonical: "quart", kind: "measured", system: "imperial", plural: "quarts" },
|
|
207
|
+
gallon: { canonical: "gallon", kind: "measured", system: "imperial", plural: "gallons" },
|
|
208
|
+
gallons: { canonical: "gallon", kind: "measured", system: "imperial", plural: "gallons" },
|
|
209
|
+
// Spoons and cups: real measures, but only in sensible fractions.
|
|
210
|
+
tsp: { canonical: "tsp", kind: "portioned", system: "imperial", symbol: true },
|
|
211
|
+
teaspoon: { canonical: "teaspoon", kind: "portioned", system: "imperial", plural: "teaspoons" },
|
|
212
|
+
teaspoons: { canonical: "teaspoon", kind: "portioned", system: "imperial", plural: "teaspoons" },
|
|
213
|
+
tbsp: { canonical: "Tbsp", kind: "portioned", system: "imperial", symbol: true },
|
|
214
|
+
tbs: { canonical: "Tbsp", kind: "portioned", system: "imperial", symbol: true },
|
|
215
|
+
tablespoon: {
|
|
216
|
+
canonical: "tablespoon",
|
|
217
|
+
kind: "portioned",
|
|
218
|
+
system: "imperial",
|
|
219
|
+
plural: "tablespoons"
|
|
220
|
+
},
|
|
221
|
+
tablespoons: {
|
|
222
|
+
canonical: "tablespoon",
|
|
223
|
+
kind: "portioned",
|
|
224
|
+
system: "imperial",
|
|
225
|
+
plural: "tablespoons"
|
|
226
|
+
},
|
|
227
|
+
cup: { canonical: "cup", kind: "portioned", system: "imperial", plural: "cups" },
|
|
228
|
+
cups: { canonical: "cup", kind: "portioned", system: "imperial", plural: "cups" },
|
|
229
|
+
// Packaging and natural units: countable, so they round to whole things.
|
|
230
|
+
can: { canonical: "can", kind: "portioned", system: "none" },
|
|
231
|
+
cans: { canonical: "can", kind: "portioned", system: "none" },
|
|
232
|
+
jar: { canonical: "jar", kind: "portioned", system: "none" },
|
|
233
|
+
jars: { canonical: "jar", kind: "portioned", system: "none" },
|
|
234
|
+
packet: { canonical: "packet", kind: "portioned", system: "none" },
|
|
235
|
+
packets: { canonical: "packet", kind: "portioned", system: "none" },
|
|
236
|
+
package: { canonical: "package", kind: "portioned", system: "none" },
|
|
237
|
+
packages: { canonical: "package", kind: "portioned", system: "none" },
|
|
238
|
+
sachet: { canonical: "sachet", kind: "portioned", system: "none" },
|
|
239
|
+
sachets: { canonical: "sachet", kind: "portioned", system: "none" },
|
|
240
|
+
clove: { canonical: "clove", kind: "portioned", system: "none" },
|
|
241
|
+
cloves: { canonical: "clove", kind: "portioned", system: "none" },
|
|
242
|
+
slice: { canonical: "slice", kind: "portioned", system: "none" },
|
|
243
|
+
slices: { canonical: "slice", kind: "portioned", system: "none" },
|
|
244
|
+
stick: { canonical: "stick", kind: "portioned", system: "none" },
|
|
245
|
+
sticks: { canonical: "stick", kind: "portioned", system: "none" },
|
|
246
|
+
stalk: { canonical: "stalk", kind: "portioned", system: "none" },
|
|
247
|
+
stalks: { canonical: "stalk", kind: "portioned", system: "none" },
|
|
248
|
+
sprig: { canonical: "sprig", kind: "portioned", system: "none" },
|
|
249
|
+
sprigs: { canonical: "sprig", kind: "portioned", system: "none" },
|
|
250
|
+
bunch: { canonical: "bunch", kind: "portioned", system: "none", plural: "bunches" },
|
|
251
|
+
bunches: { canonical: "bunch", kind: "portioned", system: "none", plural: "bunches" },
|
|
252
|
+
head: { canonical: "head", kind: "portioned", system: "none" },
|
|
253
|
+
heads: { canonical: "head", kind: "portioned", system: "none" },
|
|
254
|
+
sheet: { canonical: "sheet", kind: "portioned", system: "none" },
|
|
255
|
+
sheets: { canonical: "sheet", kind: "portioned", system: "none" },
|
|
256
|
+
leaf: { canonical: "leaf", kind: "portioned", system: "none", plural: "leaves" },
|
|
257
|
+
leaves: { canonical: "leaf", kind: "portioned", system: "none", plural: "leaves" },
|
|
258
|
+
ea: { canonical: "ea", kind: "portioned", system: "none", symbol: true },
|
|
259
|
+
// Held to no better than a hand: the count scales, the size of one does not.
|
|
260
|
+
// `readContainerLoad` explains what else lands here.
|
|
261
|
+
capful: { canonical: "capful", kind: "approximate", system: "none", plural: "capfuls" },
|
|
262
|
+
capfuls: { canonical: "capful", kind: "approximate", system: "none", plural: "capfuls" },
|
|
263
|
+
glug: { canonical: "glug", kind: "approximate", system: "none" },
|
|
264
|
+
glugs: { canonical: "glug", kind: "approximate", system: "none" },
|
|
265
|
+
dollop: { canonical: "dollop", kind: "approximate", system: "none" },
|
|
266
|
+
dollops: { canonical: "dollop", kind: "approximate", system: "none" },
|
|
267
|
+
squeeze: { canonical: "squeeze", kind: "approximate", system: "none" },
|
|
268
|
+
squeezes: { canonical: "squeeze", kind: "approximate", system: "none" },
|
|
269
|
+
sprinkle: { canonical: "sprinkle", kind: "approximate", system: "none" },
|
|
270
|
+
sprinkles: { canonical: "sprinkle", kind: "approximate", system: "none" },
|
|
271
|
+
sprinkling: { canonical: "sprinkling", kind: "approximate", system: "none" },
|
|
272
|
+
sprinklings: { canonical: "sprinkling", kind: "approximate", system: "none" },
|
|
273
|
+
grating: { canonical: "grating", kind: "approximate", system: "none" },
|
|
274
|
+
gratings: { canonical: "grating", kind: "approximate", system: "none" },
|
|
275
|
+
twist: { canonical: "twist", kind: "approximate", system: "none" },
|
|
276
|
+
twists: { canonical: "twist", kind: "approximate", system: "none" },
|
|
277
|
+
smidgen: { canonical: "smidgen", kind: "approximate", system: "none" },
|
|
278
|
+
smidgens: { canonical: "smidgen", kind: "approximate", system: "none" },
|
|
279
|
+
pinch: { canonical: "pinch", kind: "approximate", system: "none", plural: "pinches" },
|
|
280
|
+
pinches: { canonical: "pinch", kind: "approximate", system: "none", plural: "pinches" },
|
|
281
|
+
dash: { canonical: "dash", kind: "approximate", system: "none", plural: "dashes" },
|
|
282
|
+
dashes: { canonical: "dash", kind: "approximate", system: "none", plural: "dashes" },
|
|
283
|
+
splash: { canonical: "splash", kind: "approximate", system: "none", plural: "splashes" },
|
|
284
|
+
splashes: { canonical: "splash", kind: "approximate", system: "none", plural: "splashes" },
|
|
285
|
+
drizzle: { canonical: "drizzle", kind: "approximate", system: "none" },
|
|
286
|
+
drizzles: { canonical: "drizzle", kind: "approximate", system: "none" },
|
|
287
|
+
handful: { canonical: "handful", kind: "approximate", system: "none", plural: "handfuls" },
|
|
288
|
+
handfuls: { canonical: "handful", kind: "approximate", system: "none", plural: "handfuls" },
|
|
289
|
+
drop: { canonical: "drop", kind: "approximate", system: "none" },
|
|
290
|
+
drops: { canonical: "drop", kind: "approximate", system: "none" },
|
|
291
|
+
knob: { canonical: "knob", kind: "approximate", system: "none" },
|
|
292
|
+
knobs: { canonical: "knob", kind: "approximate", system: "none" }
|
|
293
|
+
};
|
|
294
|
+
var VOCABULARY2 = {
|
|
295
|
+
fr: FRENCH_UNITS,
|
|
296
|
+
en: ENGLISH_UNITS
|
|
297
|
+
};
|
|
298
|
+
function normalizeUnitKey(text) {
|
|
299
|
+
return text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\./g, " ").replace(/\((?:s|x|es)\)/g, "").replace(/\s+/g, " ").trim();
|
|
300
|
+
}
|
|
301
|
+
function orderedKeys(vocabulary) {
|
|
302
|
+
return Object.keys(vocabulary).sort(
|
|
303
|
+
(a, b) => b.split(" ").length - a.split(" ").length || b.length - a.length
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
var UNIT_KEYS = {
|
|
307
|
+
fr: orderedKeys(FRENCH_UNITS),
|
|
308
|
+
en: orderedKeys(ENGLISH_UNITS)
|
|
309
|
+
};
|
|
310
|
+
function unitKeys(language) {
|
|
311
|
+
return UNIT_KEYS[language];
|
|
312
|
+
}
|
|
313
|
+
function lookupUnit(text, language) {
|
|
314
|
+
return VOCABULARY2[language][normalizeUnitKey(text)] ?? null;
|
|
315
|
+
}
|
|
316
|
+
var EMBEDDED = {
|
|
317
|
+
fr: buildEmbedded("fr"),
|
|
318
|
+
en: buildEmbedded("en")
|
|
319
|
+
};
|
|
320
|
+
function buildEmbedded(language) {
|
|
321
|
+
const keys = UNIT_KEYS[language].map((key) => key.replace(/ /g, "\\s+"));
|
|
322
|
+
return new RegExp(`\\d[\\d.,/]*\\s*(?:${keys.join("|")})\\b`, "i");
|
|
323
|
+
}
|
|
324
|
+
function hasEmbeddedMeasure(text, language) {
|
|
325
|
+
return EMBEDDED[language].test(text);
|
|
326
|
+
}
|
|
327
|
+
var NOT_A_MEASURE = /* @__PURE__ */ new Set([
|
|
328
|
+
"peu",
|
|
329
|
+
"beaucoup",
|
|
330
|
+
"plus",
|
|
331
|
+
"moins",
|
|
332
|
+
"assez",
|
|
333
|
+
"trop",
|
|
334
|
+
"autant",
|
|
335
|
+
"tant",
|
|
336
|
+
"moitie",
|
|
337
|
+
"quart",
|
|
338
|
+
"tiers",
|
|
339
|
+
"reste",
|
|
340
|
+
"melange",
|
|
341
|
+
"ensemble"
|
|
342
|
+
]);
|
|
343
|
+
function readPartitiveMeasure(text) {
|
|
344
|
+
const match = new RegExp("^\\s*(\\p{L}+)\\s+(?=(?:de|du|des)\\s|d')", "u").exec(text);
|
|
345
|
+
if (!match) return null;
|
|
346
|
+
const word = match[1];
|
|
347
|
+
if (word.length < 3) return null;
|
|
348
|
+
if (NOT_A_MEASURE.has(normalizeUnitKey(word))) return null;
|
|
349
|
+
if (lookupUnit(word, "fr")) return null;
|
|
350
|
+
const canonical = frenchSingular(word);
|
|
351
|
+
return {
|
|
352
|
+
unit: {
|
|
353
|
+
canonical,
|
|
354
|
+
kind: "approximate",
|
|
355
|
+
system: "none",
|
|
356
|
+
plural: frenchPlural(canonical)
|
|
357
|
+
},
|
|
358
|
+
rest: text.slice(match[0].length)
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function readContainerLoad(word) {
|
|
362
|
+
const key = normalizeUnitKey(word);
|
|
363
|
+
if (!/^[a-z]{3,}fuls?$/.test(key)) return null;
|
|
364
|
+
const canonical = key.endsWith("fuls") ? key.slice(0, -1) : key;
|
|
365
|
+
return { canonical, kind: "approximate", system: "none", plural: `${canonical}s` };
|
|
366
|
+
}
|
|
367
|
+
function frenchSingular(word) {
|
|
368
|
+
if (/eaux$/i.test(word)) return word.slice(0, -1);
|
|
369
|
+
if (/aux$/i.test(word)) return `${word.slice(0, -3)}al`;
|
|
370
|
+
if (/[aiou]s$/i.test(word)) return word;
|
|
371
|
+
if (/s$/i.test(word) && word.length > 3) return word.slice(0, -1);
|
|
372
|
+
return word;
|
|
373
|
+
}
|
|
374
|
+
function frenchPlural(word) {
|
|
375
|
+
if (/[sxz]$/i.test(word)) return word;
|
|
376
|
+
if (/eau$/i.test(word)) return `${word}x`;
|
|
377
|
+
if (/al$/i.test(word)) return `${word.slice(0, -2)}aux`;
|
|
378
|
+
return `${word}s`;
|
|
379
|
+
}
|
|
380
|
+
var APPROXIMATE_EQUIVALENT = {
|
|
381
|
+
capful: "commonly taken as about a tablespoon, the size of a bottle cap",
|
|
382
|
+
glug: "commonly taken as about two tablespoons poured free-hand",
|
|
383
|
+
dollop: "commonly taken as about a heaped tablespoon",
|
|
384
|
+
pinch: "commonly taken as about half a teaspoon",
|
|
385
|
+
dash: "commonly taken as about an eighth of a teaspoon",
|
|
386
|
+
splash: "commonly taken as about a tablespoon",
|
|
387
|
+
handful: "commonly taken as about a quarter of a cup",
|
|
388
|
+
knob: "commonly taken as about a tablespoon of butter",
|
|
389
|
+
drizzle: "commonly taken as about a teaspoon poured in a thin line",
|
|
390
|
+
pincee: "commonly taken as about half a teaspoon",
|
|
391
|
+
poignee: "commonly taken as about a quarter of a cup",
|
|
392
|
+
bouchon: "commonly taken as about a tablespoon, the size of a bottle cap",
|
|
393
|
+
goutte: "commonly taken as a single drop",
|
|
394
|
+
filet: "commonly taken as about a teaspoon poured in a thin line",
|
|
395
|
+
noix: "commonly taken as about a tablespoon of butter",
|
|
396
|
+
louche: "commonly taken as about half a cup",
|
|
397
|
+
soupcon: "commonly taken as the smallest amount a spoon tip carries"
|
|
398
|
+
};
|
|
399
|
+
function approximateEquivalent(unit) {
|
|
400
|
+
return APPROXIMATE_EQUIVALENT[normalizeUnitKey(unit.canonical)] ?? null;
|
|
401
|
+
}
|
|
402
|
+
var PROMOTIONS = {
|
|
403
|
+
mg: { to: "g", per: 1e3, language: "en" },
|
|
404
|
+
g: { to: "kg", per: 1e3, language: "en" },
|
|
405
|
+
ml: { to: "l", per: 1e3, language: "en" },
|
|
406
|
+
cl: { to: "l", per: 100, language: "en" },
|
|
407
|
+
dl: { to: "l", per: 10, language: "en" },
|
|
408
|
+
oz: { to: "lb", per: 16, language: "en" },
|
|
409
|
+
ounce: { to: "pound", per: 16, language: "en" },
|
|
410
|
+
"fl oz": { to: "pint", per: 20, language: "en" },
|
|
411
|
+
pint: { to: "quart", per: 2, language: "en" },
|
|
412
|
+
quart: { to: "gallon", per: 4, language: "en" }
|
|
413
|
+
};
|
|
414
|
+
var DEMOTIONS = {
|
|
415
|
+
// Spoons and cups hold a fixed volume, so a share of one is stated in the
|
|
416
|
+
// smaller spoon rather than as a fraction no measuring set carries. The
|
|
417
|
+
// spelling of the step matches the spelling of the unit it comes from, so a
|
|
418
|
+
// line written in abbreviations stays in abbreviations, and a French line
|
|
419
|
+
// stays in French.
|
|
420
|
+
cup: { to: "tablespoon", per: 16, language: "en" },
|
|
421
|
+
tablespoon: { to: "teaspoon", per: 3, language: "en" },
|
|
422
|
+
tbsp: { to: "tsp", per: 3, language: "en" },
|
|
423
|
+
"cuillere a soupe": { to: "cuillere a cafe", per: 3, language: "fr" },
|
|
424
|
+
tasse: { to: "cuillere a soupe", per: 16, language: "fr" },
|
|
425
|
+
kg: { to: "g", per: 1e3, language: "en" },
|
|
426
|
+
g: { to: "mg", per: 1e3, language: "en" },
|
|
427
|
+
l: { to: "cl", per: 100, language: "en" },
|
|
428
|
+
dl: { to: "cl", per: 10, language: "en" },
|
|
429
|
+
cl: { to: "ml", per: 10, language: "en" },
|
|
430
|
+
lb: { to: "oz", per: 16, language: "en" },
|
|
431
|
+
pound: { to: "ounce", per: 16, language: "en" },
|
|
432
|
+
gallon: { to: "quart", per: 4, language: "en" },
|
|
433
|
+
quart: { to: "pint", per: 2, language: "en" },
|
|
434
|
+
pint: { to: "fl oz", per: 20, language: "en" }
|
|
435
|
+
};
|
|
436
|
+
function demoteUnit(unit) {
|
|
437
|
+
const step = DEMOTIONS[normalizeUnitKey(unit.canonical)];
|
|
438
|
+
if (!step) return null;
|
|
439
|
+
const target = lookupUnit(step.to, step.language);
|
|
440
|
+
return target ? { unit: target, per: step.per } : null;
|
|
441
|
+
}
|
|
442
|
+
function isSpoonMeasure(unit) {
|
|
443
|
+
return /^(cup|tablespoon|Tbsp|teaspoon|tsp|cuillère à soupe|cuillère à café|tasse)$/.test(
|
|
444
|
+
unit.canonical
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
function unitDivisibility(unit) {
|
|
448
|
+
return QUARTERED_MEASURE.test(unit.canonical) ? "quarter" : "half";
|
|
449
|
+
}
|
|
450
|
+
var QUARTERED_MEASURE = /\b(pots?|bouteilles?|bottles?|jars?|blocs?|blocks?|gousses?|tranches?|slices?)\b/i;
|
|
451
|
+
function countsBarePieces(unit) {
|
|
452
|
+
return unit.canonical === "ea";
|
|
453
|
+
}
|
|
454
|
+
function chooseReadableUnit(unit, amount) {
|
|
455
|
+
if (unit.kind !== "measured" || !Number.isFinite(amount) || amount <= 0) {
|
|
456
|
+
return { unit, ratio: 1 };
|
|
457
|
+
}
|
|
458
|
+
let current = unit;
|
|
459
|
+
let ratio = 1;
|
|
460
|
+
while (amount * ratio < 1) {
|
|
461
|
+
const step = demoteUnit(current);
|
|
462
|
+
if (!step) break;
|
|
463
|
+
ratio *= step.per;
|
|
464
|
+
current = step.unit;
|
|
465
|
+
}
|
|
466
|
+
const up = PROMOTIONS[normalizeUnitKey(current.canonical)];
|
|
467
|
+
if (up && amount * ratio >= up.per) {
|
|
468
|
+
const target = lookupUnit(up.to, up.language);
|
|
469
|
+
if (target) {
|
|
470
|
+
ratio /= up.per;
|
|
471
|
+
current = target;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { unit: current, ratio };
|
|
475
|
+
}
|
|
476
|
+
function formatUnit(unit, amount, language) {
|
|
477
|
+
if (unit.symbol) return unit.canonical;
|
|
478
|
+
const singular = language === "fr" ? amount < 2 : amount <= 1;
|
|
479
|
+
if (singular) return unit.canonical;
|
|
480
|
+
return unit.plural ?? `${unit.canonical}s`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/recipe/quantity.ts
|
|
484
|
+
var VULGAR_FRACTIONS = {
|
|
485
|
+
"\xBD": 0.5,
|
|
486
|
+
"\u2153": 1 / 3,
|
|
487
|
+
"\u2154": 2 / 3,
|
|
488
|
+
"\xBC": 0.25,
|
|
489
|
+
"\xBE": 0.75,
|
|
490
|
+
"\u2155": 0.2,
|
|
491
|
+
"\u2156": 0.4,
|
|
492
|
+
"\u2157": 0.6,
|
|
493
|
+
"\u2158": 0.8,
|
|
494
|
+
"\u2159": 1 / 6,
|
|
495
|
+
"\u215A": 5 / 6,
|
|
496
|
+
"\u215B": 0.125,
|
|
497
|
+
"\u215C": 0.375,
|
|
498
|
+
"\u215D": 0.625,
|
|
499
|
+
"\u215E": 0.875
|
|
500
|
+
};
|
|
501
|
+
var VULGAR_CLASS = Object.keys(VULGAR_FRACTIONS).join("");
|
|
502
|
+
function parseLeadingQuantity(text, language) {
|
|
503
|
+
const trimmed = text.trimStart();
|
|
504
|
+
const offset = text.length - trimmed.length;
|
|
505
|
+
const mixedGlyph = new RegExp(`^(\\d+)\\s*([${VULGAR_CLASS}])`).exec(trimmed);
|
|
506
|
+
if (mixedGlyph) {
|
|
507
|
+
const whole = Number(mixedGlyph[1]);
|
|
508
|
+
const fraction2 = VULGAR_FRACTIONS[mixedGlyph[2]];
|
|
509
|
+
return { amount: whole + fraction2, length: offset + mixedGlyph[0].length };
|
|
510
|
+
}
|
|
511
|
+
const mixed = /^(\d+)\s+(\d+)\s*\/\s*(\d+)/.exec(trimmed);
|
|
512
|
+
if (mixed) {
|
|
513
|
+
const denominator = Number(mixed[3]);
|
|
514
|
+
if (denominator !== 0) {
|
|
515
|
+
return {
|
|
516
|
+
amount: Number(mixed[1]) + Number(mixed[2]) / denominator,
|
|
517
|
+
length: offset + mixed[0].length
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const fraction = /^(\d+)\s*\/\s*(\d+)/.exec(trimmed);
|
|
522
|
+
if (fraction) {
|
|
523
|
+
const denominator = Number(fraction[2]);
|
|
524
|
+
if (denominator === 0) return null;
|
|
525
|
+
return { amount: Number(fraction[1]) / denominator, length: offset + fraction[0].length };
|
|
526
|
+
}
|
|
527
|
+
const glyph = trimmed[0];
|
|
528
|
+
if (glyph && glyph in VULGAR_FRACTIONS) {
|
|
529
|
+
return { amount: VULGAR_FRACTIONS[glyph], length: offset + 1 };
|
|
530
|
+
}
|
|
531
|
+
const decimal = (language === "fr" ? /^(\d+(?:[.,]\d+)?)/ : /^(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)/).exec(trimmed);
|
|
532
|
+
if (decimal) {
|
|
533
|
+
const written = decimal[1];
|
|
534
|
+
const amount = Number(
|
|
535
|
+
language === "fr" ? written.replace(",", ".") : written.replace(/,/g, "")
|
|
536
|
+
);
|
|
537
|
+
if (Number.isFinite(amount)) return { amount, length: offset + decimal[0].length };
|
|
538
|
+
}
|
|
539
|
+
if (language === "en") {
|
|
540
|
+
const written = parseWrittenFraction(trimmed);
|
|
541
|
+
if (written) return { amount: written.amount, length: offset + written.length };
|
|
542
|
+
}
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
var WRITTEN_NUMERATORS = { a: 1, an: 1, one: 1, two: 2, three: 3 };
|
|
546
|
+
var WRITTEN_DENOMINATORS = {
|
|
547
|
+
half: 2,
|
|
548
|
+
halves: 2,
|
|
549
|
+
third: 3,
|
|
550
|
+
thirds: 3,
|
|
551
|
+
quarter: 4,
|
|
552
|
+
quarters: 4,
|
|
553
|
+
fourth: 4,
|
|
554
|
+
fourths: 4
|
|
555
|
+
};
|
|
556
|
+
function parseWrittenFraction(text) {
|
|
557
|
+
const match = /^(?:(a|an|one|two|three)[\s-]+)?(halves|half|thirds|third|quarters|quarter|fourths|fourth)\b/i.exec(
|
|
558
|
+
text
|
|
559
|
+
);
|
|
560
|
+
if (!match) return null;
|
|
561
|
+
const numerator = match[1] ? WRITTEN_NUMERATORS[match[1].toLowerCase()] : 1;
|
|
562
|
+
const denominator = WRITTEN_DENOMINATORS[match[2].toLowerCase()];
|
|
563
|
+
if (!numerator || !denominator) return null;
|
|
564
|
+
const rest = text.slice(match[0].length).replace(/^\s*of\s+/i, "").trimStart();
|
|
565
|
+
if (!/^an?\s/i.test(rest) && !takeUnit(rest, "en").unit) return null;
|
|
566
|
+
return { amount: numerator / denominator, length: match[0].length };
|
|
567
|
+
}
|
|
568
|
+
function parseLeadingRange(text, language) {
|
|
569
|
+
const low = parseLeadingQuantity(text, language);
|
|
570
|
+
if (!low) return null;
|
|
571
|
+
const after = text.slice(low.length);
|
|
572
|
+
const written = language === "fr" ? /^\s+(à|a|ou)\s+/i : /^\s+(to|or)\s+/i;
|
|
573
|
+
const separator = written.exec(after) ?? /^\s*(–|—|-)\s*/.exec(after);
|
|
574
|
+
if (!separator) return null;
|
|
575
|
+
const high = parseLeadingQuantity(after.slice(separator[0].length), language);
|
|
576
|
+
if (!high || high.amount <= low.amount) return null;
|
|
577
|
+
return {
|
|
578
|
+
amount: low.amount,
|
|
579
|
+
max: high.amount,
|
|
580
|
+
separator: separator[1],
|
|
581
|
+
length: low.length + separator[0].length + high.length
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
var FRENCH_ARTICLES = { un: 1, une: 1, quelques: 3 };
|
|
585
|
+
function takeUnit(text, language) {
|
|
586
|
+
const normalized = normalizeUnitKey(text);
|
|
587
|
+
for (const key of unitKeys(language)) {
|
|
588
|
+
if (normalized !== key && !normalized.startsWith(`${key} `)) continue;
|
|
589
|
+
const unit = lookupUnit(key, language);
|
|
590
|
+
if (!unit) continue;
|
|
591
|
+
const wordCount = key.split(" ").length;
|
|
592
|
+
const words = text.trim().split(/\s+/);
|
|
593
|
+
return { unit, rest: words.slice(wordCount).join(" ") };
|
|
594
|
+
}
|
|
595
|
+
if (language === "en") {
|
|
596
|
+
const words = text.trimStart().split(/\s+/);
|
|
597
|
+
const load = words[0] ? readContainerLoad(words[0]) : null;
|
|
598
|
+
if (load) return { unit: load, rest: words.slice(1).join(" ") };
|
|
599
|
+
}
|
|
600
|
+
return { unit: null, rest: text };
|
|
601
|
+
}
|
|
602
|
+
function takeUnitEitherLanguage(text, language) {
|
|
603
|
+
const first = takeUnit(text, language);
|
|
604
|
+
if (first.unit) return first;
|
|
605
|
+
return takeUnit(text, language === "fr" ? "en" : "fr");
|
|
606
|
+
}
|
|
607
|
+
function detectLanguage(line) {
|
|
608
|
+
const text = line.trim();
|
|
609
|
+
const afterFigures = text.replace(/^[\s\d.,/½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞\-–—]+/u, "").trimStart();
|
|
610
|
+
const afterArticle = afterFigures.replace(/^(?:un|une|quelques|of\s+an?|an?)\s+/iu, "");
|
|
611
|
+
const frenchUnit = takeUnit(afterArticle, "fr").unit !== null;
|
|
612
|
+
const englishUnit = takeUnit(afterArticle, "en").unit !== null;
|
|
613
|
+
return readLanguage(text, { frenchUnit, englishUnit }).language;
|
|
614
|
+
}
|
|
615
|
+
function parseIngredient(line, choice = "auto") {
|
|
616
|
+
const original = line;
|
|
617
|
+
const text = line.trim();
|
|
618
|
+
const language = choice === "auto" ? detectLanguage(text) : choice;
|
|
619
|
+
const empty = {
|
|
620
|
+
original,
|
|
621
|
+
language,
|
|
622
|
+
amount: null,
|
|
623
|
+
amountMax: null,
|
|
624
|
+
rangeSeparator: null,
|
|
625
|
+
unit: null,
|
|
626
|
+
alternates: [],
|
|
627
|
+
alternateStyle: null,
|
|
628
|
+
item: text,
|
|
629
|
+
articleWord: null,
|
|
630
|
+
countMultiplier: null
|
|
631
|
+
};
|
|
632
|
+
const range = parseLeadingRange(text, language);
|
|
633
|
+
const article = range ? null : readArticle(text, language);
|
|
634
|
+
const quantity = range ?? parseLeadingQuantity(text, language) ?? article;
|
|
635
|
+
if (!quantity) return empty;
|
|
636
|
+
let rest = text.slice(quantity.length).trimStart();
|
|
637
|
+
if (language === "en") rest = rest.replace(/^(?:of\s+)?an?\s+/i, "");
|
|
638
|
+
const multiplier = readCountMultiplier(rest);
|
|
639
|
+
if (multiplier) rest = multiplier.rest;
|
|
640
|
+
const times = multiplier?.times ?? 1;
|
|
641
|
+
const leading = takeLeadingUnit(rest, language, quantity === article);
|
|
642
|
+
rest = leading.rest;
|
|
643
|
+
const bracketed = takeAlternates(rest, language);
|
|
644
|
+
rest = bracketed.rest;
|
|
645
|
+
const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest, language);
|
|
646
|
+
if (slashed) rest = slashed.rest;
|
|
647
|
+
return {
|
|
648
|
+
original,
|
|
649
|
+
language,
|
|
650
|
+
amount: quantity.amount * times,
|
|
651
|
+
amountMax: range === null ? null : range.max * times,
|
|
652
|
+
rangeSeparator: range?.separator ?? null,
|
|
653
|
+
unit: leading.unit,
|
|
654
|
+
alternates: slashed ? slashed.measures : bracketed.measures,
|
|
655
|
+
alternateStyle: slashed ? "slash" : bracketed.measures.length > 0 ? "bracket" : null,
|
|
656
|
+
item: stripItemLead(rest, language),
|
|
657
|
+
articleWord: quantity === article ? article?.word ?? null : null,
|
|
658
|
+
countMultiplier: multiplier?.times ?? null
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function readArticle(text, language) {
|
|
662
|
+
const counts = (rest) => takeLeadingUnit(rest, language, true).unit !== null || readCountMultiplier(rest) !== null;
|
|
663
|
+
if (language === "fr") {
|
|
664
|
+
const match = /^\s*(un|une|quelques)\b\s*/i.exec(text);
|
|
665
|
+
if (!match) return null;
|
|
666
|
+
if (!counts(text.slice(match[0].length))) return null;
|
|
667
|
+
const word = match[1];
|
|
668
|
+
return { amount: FRENCH_ARTICLES[word.toLowerCase()], length: match[0].length, word };
|
|
669
|
+
}
|
|
670
|
+
const article = /^an?\s+/i.exec(text);
|
|
671
|
+
if (!article) return null;
|
|
672
|
+
if (!counts(text.slice(article[0].length))) return null;
|
|
673
|
+
return { amount: 1, length: article[0].length, word: article[0].trim() };
|
|
674
|
+
}
|
|
675
|
+
var COUNT_MULTIPLIERS = {
|
|
676
|
+
dozen: 12,
|
|
677
|
+
dozens: 12,
|
|
678
|
+
douzaine: 12,
|
|
679
|
+
douzaines: 12
|
|
680
|
+
};
|
|
681
|
+
function readCountMultiplier(text) {
|
|
682
|
+
const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text);
|
|
683
|
+
if (!match) return null;
|
|
684
|
+
const times = COUNT_MULTIPLIERS[normalizeUnitKey(match[1])];
|
|
685
|
+
if (times === void 0) return null;
|
|
686
|
+
return { times, rest: text.slice(match[0].length) };
|
|
687
|
+
}
|
|
688
|
+
function takeLeadingUnit(text, language, fromArticle) {
|
|
689
|
+
const taken = takeUnit(text, language);
|
|
690
|
+
if (taken.unit) return taken;
|
|
691
|
+
if (language === "fr" && fromArticle) {
|
|
692
|
+
const measure = readPartitiveMeasure(text);
|
|
693
|
+
if (measure) return { unit: measure.unit, rest: measure.rest };
|
|
694
|
+
}
|
|
695
|
+
return taken;
|
|
696
|
+
}
|
|
697
|
+
function stripItemLead(text, language) {
|
|
698
|
+
if (language === "fr") {
|
|
699
|
+
return text.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").replace(/^(?:une|un)\s+/i, "").trim();
|
|
700
|
+
}
|
|
701
|
+
return text.replace(/^of\s+/i, "").replace(/^an?\s+/i, "").trim();
|
|
702
|
+
}
|
|
703
|
+
function takeAlternates(text, language) {
|
|
704
|
+
if (!text.startsWith("(")) return { measures: [], rest: text };
|
|
705
|
+
const close = text.indexOf(")");
|
|
706
|
+
if (close < 0) return { measures: [], rest: text };
|
|
707
|
+
const inside = text.slice(1, close);
|
|
708
|
+
const parts = inside.split("/").map((part) => part.trim());
|
|
709
|
+
const measures = [];
|
|
710
|
+
for (const part of parts) {
|
|
711
|
+
const range = parseLeadingRange(part, language);
|
|
712
|
+
const quantity = range ?? parseLeadingQuantity(part, language);
|
|
713
|
+
if (!quantity) return { measures: [], rest: text };
|
|
714
|
+
const after = takeUnitEitherLanguage(part.slice(quantity.length).trimStart(), language);
|
|
715
|
+
if (!after.unit || after.rest.trim() !== "") return { measures: [], rest: text };
|
|
716
|
+
measures.push({
|
|
717
|
+
amount: quantity.amount,
|
|
718
|
+
amountMax: range?.max ?? null,
|
|
719
|
+
rangeSeparator: range?.separator ?? null,
|
|
720
|
+
unit: after.unit
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
if (measures.length === 0) return { measures: [], rest: text };
|
|
724
|
+
return { measures, rest: text.slice(close + 1).trimStart() };
|
|
725
|
+
}
|
|
726
|
+
function takeSlashAlternates(text, language) {
|
|
727
|
+
const measures = [];
|
|
728
|
+
let rest = text;
|
|
729
|
+
while (rest.startsWith("/")) {
|
|
730
|
+
const after = rest.slice(1).trimStart();
|
|
731
|
+
const range = parseLeadingRange(after, language);
|
|
732
|
+
const quantity = range ?? parseLeadingQuantity(after, language);
|
|
733
|
+
if (!quantity) break;
|
|
734
|
+
const taken = takeUnitEitherLanguage(after.slice(quantity.length).trimStart(), language);
|
|
735
|
+
if (!taken.unit) break;
|
|
736
|
+
measures.push({
|
|
737
|
+
amount: quantity.amount,
|
|
738
|
+
amountMax: range?.max ?? null,
|
|
739
|
+
rangeSeparator: range?.separator ?? null,
|
|
740
|
+
unit: taken.unit
|
|
741
|
+
});
|
|
742
|
+
rest = taken.rest.trimStart();
|
|
743
|
+
}
|
|
744
|
+
return measures.length > 0 ? { measures, rest } : null;
|
|
745
|
+
}
|
|
746
|
+
function formatAmount(amount, language, options = {}) {
|
|
747
|
+
const decimal = (value) => {
|
|
748
|
+
const rounded = value !== 0 && Math.abs(value) < 0.01 ? Number(value.toPrecision(2)) : Math.round(value * 100) / 100;
|
|
749
|
+
const rendered = String(rounded);
|
|
750
|
+
return language === "fr" ? rendered.replace(".", ",") : rendered;
|
|
751
|
+
};
|
|
752
|
+
if (!Number.isFinite(amount)) return "";
|
|
753
|
+
if (Number.isInteger(amount)) return String(amount);
|
|
754
|
+
if (options.fractions === false) return decimal(amount);
|
|
755
|
+
const whole = Math.floor(amount);
|
|
756
|
+
const rest = amount - whole;
|
|
757
|
+
const known = [
|
|
758
|
+
[0.25, "1/4"],
|
|
759
|
+
[1 / 3, "1/3"],
|
|
760
|
+
[0.5, "1/2"],
|
|
761
|
+
[2 / 3, "2/3"],
|
|
762
|
+
[0.75, "3/4"]
|
|
763
|
+
];
|
|
764
|
+
for (const [value, label] of known) {
|
|
765
|
+
if (Math.abs(rest - value) < 0.02) return whole > 0 ? `${whole} ${label}` : label;
|
|
766
|
+
}
|
|
767
|
+
return decimal(amount);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/recipe/scale.ts
|
|
771
|
+
function roundTo(value, step) {
|
|
772
|
+
return Math.round(Math.round(value / step) * step * 100) / 100;
|
|
773
|
+
}
|
|
774
|
+
function roundMeasured(value) {
|
|
775
|
+
if (value >= 100) return roundTo(value, 5);
|
|
776
|
+
if (value >= 10) return roundTo(value, 1);
|
|
777
|
+
if (value >= 1) return roundTo(value, 0.1);
|
|
778
|
+
return Math.round(value * 100) / 100;
|
|
779
|
+
}
|
|
780
|
+
var SMALLEST_USABLE_FRACTION = 0.25;
|
|
781
|
+
var SMALLEST_USABLE = {
|
|
782
|
+
whole: 1,
|
|
783
|
+
half: 0.5,
|
|
784
|
+
quarter: 0.25
|
|
785
|
+
};
|
|
786
|
+
function isHalfStep(value) {
|
|
787
|
+
return Math.abs(value * 2 - Math.round(value * 2)) < 1e-9;
|
|
788
|
+
}
|
|
789
|
+
function trim(value) {
|
|
790
|
+
return Math.round(value * 100) / 100;
|
|
791
|
+
}
|
|
792
|
+
function roundCountable(value, divisibility, ceiling) {
|
|
793
|
+
if (value <= 0) return { value: 0, clamped: false };
|
|
794
|
+
const floor = SMALLEST_USABLE[divisibility];
|
|
795
|
+
if (divisibility !== "whole" && value >= floor && isHalfStep(value)) {
|
|
796
|
+
return { value: trim(value), clamped: false };
|
|
797
|
+
}
|
|
798
|
+
if (divisibility === "whole") {
|
|
799
|
+
if (value < 0.5) return { value: floor, clamped: true };
|
|
800
|
+
return { value: Math.round(value), clamped: false };
|
|
801
|
+
}
|
|
802
|
+
if (value < floor) return { value: floor, clamped: true };
|
|
803
|
+
if (value < 1) {
|
|
804
|
+
const steps = divisibility === "quarter" ? [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1] : [0.5, 1];
|
|
805
|
+
const candidates = steps.filter(
|
|
806
|
+
(candidate) => candidate >= floor && candidate <= Math.max(ceiling, floor)
|
|
807
|
+
);
|
|
808
|
+
let closest = candidates[0];
|
|
809
|
+
for (const candidate of candidates) {
|
|
810
|
+
if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
|
|
811
|
+
}
|
|
812
|
+
return { value: trim(closest), clamped: false };
|
|
813
|
+
}
|
|
814
|
+
return { value: Math.round(value), clamped: false };
|
|
815
|
+
}
|
|
816
|
+
function roundSpoon(value, ceiling) {
|
|
817
|
+
if (value <= 0) return { value: 0, clamped: false };
|
|
818
|
+
if (value < 1) {
|
|
819
|
+
const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(
|
|
820
|
+
(candidate) => candidate <= Math.max(ceiling, SMALLEST_USABLE_FRACTION)
|
|
821
|
+
);
|
|
822
|
+
let closest = candidates[0];
|
|
823
|
+
for (const candidate of candidates) {
|
|
824
|
+
if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
|
|
825
|
+
}
|
|
826
|
+
return { value: trim(closest), clamped: value < SMALLEST_USABLE_FRACTION };
|
|
827
|
+
}
|
|
828
|
+
return { value: roundTo(value, 0.5), clamped: false };
|
|
829
|
+
}
|
|
830
|
+
function stepDownSpoon(unit, reference) {
|
|
831
|
+
let current = unit;
|
|
832
|
+
let ratio = 1;
|
|
833
|
+
while (reference * ratio < 1 && !isHalfStep(reference * ratio)) {
|
|
834
|
+
const step = demoteUnit(current);
|
|
835
|
+
if (!step) break;
|
|
836
|
+
ratio *= step.per;
|
|
837
|
+
current = step.unit;
|
|
838
|
+
}
|
|
839
|
+
return { unit: current, ratio };
|
|
840
|
+
}
|
|
841
|
+
var EXACT_WITHIN = 0.01;
|
|
842
|
+
var EXACT_SHARE = 5e-3;
|
|
843
|
+
function landedExactly(exact, amount) {
|
|
844
|
+
const gap = Math.abs(exact - amount);
|
|
845
|
+
if (gap > EXACT_WITHIN) return false;
|
|
846
|
+
return exact === 0 || gap / Math.abs(exact) <= EXACT_SHARE;
|
|
847
|
+
}
|
|
848
|
+
function scaleMeasure(low, high, unit, factor, divisibility) {
|
|
849
|
+
const raws = high === null ? [low * factor] : [low * factor, high * factor];
|
|
850
|
+
const sources = high === null ? [low] : [low, high];
|
|
851
|
+
const positive = raws.filter((raw) => raw > 0);
|
|
852
|
+
const reference = positive.length > 0 ? Math.min(...positive) : raws[0];
|
|
853
|
+
const inUnit = (target, ratio) => ({
|
|
854
|
+
bounds: raws.map((raw, index) => {
|
|
855
|
+
const exact = raw * ratio;
|
|
856
|
+
const rounded = roundMeasured(exact);
|
|
857
|
+
const usable = rounded === 0 && exact > 0 ? Number(exact.toPrecision(2)) : rounded;
|
|
858
|
+
const ceiling = factor < 1 ? sources[index] * ratio : Number.POSITIVE_INFINITY;
|
|
859
|
+
return {
|
|
860
|
+
amount: Math.min(usable, ceiling),
|
|
861
|
+
exact,
|
|
862
|
+
clamped: false,
|
|
863
|
+
raw
|
|
864
|
+
};
|
|
865
|
+
}),
|
|
866
|
+
unit: target
|
|
867
|
+
});
|
|
868
|
+
if (unit && unit.kind === "measured") {
|
|
869
|
+
const chosen = chooseReadableUnit(unit, reference);
|
|
870
|
+
return inUnit(chosen.unit, chosen.ratio);
|
|
871
|
+
}
|
|
872
|
+
if (unit && isSpoonMeasure(unit)) {
|
|
873
|
+
const stepped = stepDownSpoon(unit, reference);
|
|
874
|
+
if (stepped.ratio !== 1) return inUnit(stepped.unit, stepped.ratio);
|
|
875
|
+
const bounds2 = raws.map((raw, index) => {
|
|
876
|
+
const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
|
|
877
|
+
const rounded = roundSpoon(raw, ceiling);
|
|
878
|
+
return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw };
|
|
879
|
+
});
|
|
880
|
+
return { bounds: bounds2, unit };
|
|
881
|
+
}
|
|
882
|
+
const bounds = raws.map((raw, index) => {
|
|
883
|
+
const ceiling = factor < 1 ? sources[index] : Number.POSITIVE_INFINITY;
|
|
884
|
+
const rounded = roundCountable(raw, divisibility, ceiling);
|
|
885
|
+
return { amount: rounded.value, exact: raw, clamped: rounded.clamped, raw };
|
|
886
|
+
});
|
|
887
|
+
return { bounds, unit };
|
|
888
|
+
}
|
|
889
|
+
var PORTION_SIZED_ITEM = new RegExp(
|
|
890
|
+
"\\b(?:shrimps?|prawns?|langoustines?|mussels?|hazelnuts?|peppercorns?|junipers?|grains?|anise|crevettes?|gambas|moules?|noisettes?|geni\xE8vres?|genievres?|anis)\\b",
|
|
891
|
+
"iu"
|
|
892
|
+
);
|
|
893
|
+
var QUARTERED_ITEM = new RegExp(
|
|
894
|
+
"\\b(?:onions?|shallots?|potatoes|potato|carrots?|apples?|pears?|lemons?|limes?|oranges?|tomato(?:es)?|cucumbers?|courgettes?|zucchinis?|aubergines?|eggplants?|squash(?:es)?|pumpkins?|cabbages?|melons?|watermelons?|peppers?|beets?|turnips?|parsnips?|leeks?|bananas?|mango(?:e?s)?|avocados?|legs? of lamb|lamb legs?|baguettes?|camemberts?|cheeses?|chorizos?|pineapples?|peach(?:es)?|apricots?|milk|chickens?|guinea fowls?|roasts?|oignons?|\xE9chalotes?|echalotes?|pommes? de terre|carottes?|pommes?|poires?|citrons?|tomates?|concombres?|courgettes?|aubergines?|potirons?|choux?|chou|melons?|poivrons?|betteraves?|navets?|panais|poireaux?|bananes?|mangues?|avocats?|past\xE8ques?|pasteques?|gigots?|fromages?|ch\xE8vres?|chevres?|ananas|p\xEAches?|peches?|abricots?|laits?|poulets?|pintades?|reblochons?|b\xFBches?|buches?|r\xF4tis?|rotis?)\\b",
|
|
895
|
+
"iu"
|
|
896
|
+
);
|
|
897
|
+
var HALVED_ITEM = /\b(?:jus|juices?)\b/iu;
|
|
898
|
+
var WHOLE_ITEM = /\b(?:eggs?|yolks?|egg\s+whites?|oeufs?|œufs?|jaunes?\s+d['e]|clous?|zestes?|zests?)\b/iu;
|
|
899
|
+
var HALVED_CUT = /\b(?:breasts?|thighs?|drumsticks?|wings?|cutlets?|cuisses?|ailes?|pilons?|escalopes?|magrets?)\b/iu;
|
|
900
|
+
function cloveDivisibility(unit, item) {
|
|
901
|
+
const counted = unit ? unit.canonical === "clove" : /\bcloves?\b/i.test(item);
|
|
902
|
+
if (!counted) return null;
|
|
903
|
+
return /\bgarlic\b/i.test(item) ? "quarter" : "whole";
|
|
904
|
+
}
|
|
905
|
+
var BLANC_OF = /\bblancs?\s+d(?:e\s|['’])/iu;
|
|
906
|
+
var BLANC_OF_EGG = /\bblancs?\s+d(?:e\s|['’])\s*(?:oeufs?|œufs?)\b/iu;
|
|
907
|
+
function blancDivisibility(item) {
|
|
908
|
+
if (!BLANC_OF.test(item)) return null;
|
|
909
|
+
return BLANC_OF_EGG.test(item) ? "whole" : "half";
|
|
910
|
+
}
|
|
911
|
+
function divisibilityOf(unit, item) {
|
|
912
|
+
const clove = cloveDivisibility(unit, item);
|
|
913
|
+
if (clove) return clove;
|
|
914
|
+
if (unit && !countsBarePieces(unit)) return unitDivisibility(unit);
|
|
915
|
+
const blanc = blancDivisibility(item);
|
|
916
|
+
if (blanc) return blanc;
|
|
917
|
+
if (WHOLE_ITEM.test(item)) return "whole";
|
|
918
|
+
if (PORTION_SIZED_ITEM.test(item)) return "whole";
|
|
919
|
+
if (HALVED_ITEM.test(item)) return "half";
|
|
920
|
+
if (HALVED_CUT.test(item)) return "half";
|
|
921
|
+
if (QUARTERED_MEASURE.test(item)) return "quarter";
|
|
922
|
+
return QUARTERED_ITEM.test(item) ? "quarter" : "half";
|
|
923
|
+
}
|
|
924
|
+
var INVARIABLE_ITEM = /* @__PURE__ */ new Set([
|
|
925
|
+
"anise",
|
|
926
|
+
"ananas",
|
|
927
|
+
"asparagus",
|
|
928
|
+
"bacon",
|
|
929
|
+
"basil",
|
|
930
|
+
"beef",
|
|
931
|
+
"bison",
|
|
932
|
+
"broccoli",
|
|
933
|
+
"butter",
|
|
934
|
+
"celery",
|
|
935
|
+
"cilantro",
|
|
936
|
+
"citrus",
|
|
937
|
+
"cinnamon",
|
|
938
|
+
"cocoa",
|
|
939
|
+
"cod",
|
|
940
|
+
"coriander",
|
|
941
|
+
"corn",
|
|
942
|
+
"cornstarch",
|
|
943
|
+
"couscous",
|
|
944
|
+
"cream",
|
|
945
|
+
"deer",
|
|
946
|
+
"fish",
|
|
947
|
+
"flour",
|
|
948
|
+
"gambas",
|
|
949
|
+
"garlic",
|
|
950
|
+
"ginger",
|
|
951
|
+
"ham",
|
|
952
|
+
"honey",
|
|
953
|
+
"hummus",
|
|
954
|
+
"jus",
|
|
955
|
+
"kale",
|
|
956
|
+
"lamb",
|
|
957
|
+
"macaroni",
|
|
958
|
+
"milk",
|
|
959
|
+
"miso",
|
|
960
|
+
"musk",
|
|
961
|
+
"moose",
|
|
962
|
+
"mutton",
|
|
963
|
+
"nutmeg",
|
|
964
|
+
"oil",
|
|
965
|
+
"okra",
|
|
966
|
+
"oregano",
|
|
967
|
+
"parsley",
|
|
968
|
+
"pasta",
|
|
969
|
+
"pork",
|
|
970
|
+
"quinoa",
|
|
971
|
+
"rice",
|
|
972
|
+
"rosemary",
|
|
973
|
+
"saffron",
|
|
974
|
+
"salmon",
|
|
975
|
+
"salt",
|
|
976
|
+
"shrimp",
|
|
977
|
+
"spinach",
|
|
978
|
+
"squid",
|
|
979
|
+
"sugar",
|
|
980
|
+
"thyme",
|
|
981
|
+
"tofu",
|
|
982
|
+
"trout",
|
|
983
|
+
"tuna",
|
|
984
|
+
"vanilla",
|
|
985
|
+
"veal",
|
|
986
|
+
"venison",
|
|
987
|
+
"vinegar",
|
|
988
|
+
"water",
|
|
989
|
+
"yeast",
|
|
990
|
+
"yogurt"
|
|
991
|
+
]);
|
|
992
|
+
var IRREGULAR_PLURAL = {
|
|
993
|
+
calf: "calves",
|
|
994
|
+
chili: "chilies",
|
|
995
|
+
chilli: "chillies",
|
|
996
|
+
goose: "geese",
|
|
997
|
+
half: "halves",
|
|
998
|
+
knife: "knives",
|
|
999
|
+
leaf: "leaves",
|
|
1000
|
+
loaf: "loaves",
|
|
1001
|
+
mango: "mangoes",
|
|
1002
|
+
potato: "potatoes",
|
|
1003
|
+
shelf: "shelves",
|
|
1004
|
+
tomato: "tomatoes"
|
|
1005
|
+
};
|
|
1006
|
+
var IRREGULAR_SINGULAR = Object.fromEntries(
|
|
1007
|
+
Object.entries(IRREGULAR_PLURAL).map(([one, many]) => [many, one])
|
|
1008
|
+
);
|
|
1009
|
+
function matchCase(source, replacement) {
|
|
1010
|
+
if (source[0] === source[0]?.toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) {
|
|
1011
|
+
return replacement[0].toUpperCase() + replacement.slice(1);
|
|
1012
|
+
}
|
|
1013
|
+
return replacement;
|
|
1014
|
+
}
|
|
1015
|
+
function toEnglishPlural(word) {
|
|
1016
|
+
const key = word.toLowerCase();
|
|
1017
|
+
if (INVARIABLE_ITEM.has(key)) return word;
|
|
1018
|
+
const irregular = IRREGULAR_PLURAL[key];
|
|
1019
|
+
if (irregular) return matchCase(word, irregular);
|
|
1020
|
+
if (/(?:ch|sh|s|x|z)$/i.test(word)) return `${word}es`;
|
|
1021
|
+
if (/[^aeiou]y$/i.test(word)) return `${word.slice(0, -1)}ies`;
|
|
1022
|
+
if (/(?:[^f]f|fe)$/i.test(word)) return `${word.replace(/fe?$/i, "")}ves`;
|
|
1023
|
+
return `${word}s`;
|
|
1024
|
+
}
|
|
1025
|
+
function toEnglishSingular(word) {
|
|
1026
|
+
const key = word.toLowerCase();
|
|
1027
|
+
if (INVARIABLE_ITEM.has(key)) return word;
|
|
1028
|
+
const irregular = IRREGULAR_SINGULAR[key];
|
|
1029
|
+
if (irregular) return matchCase(word, irregular);
|
|
1030
|
+
if (/ies$/i.test(word) && word.length > 4) return `${word.slice(0, -3)}y`;
|
|
1031
|
+
if (/(?:ch|sh|s|x|z)es$/i.test(word)) return word.slice(0, -2);
|
|
1032
|
+
if (/ss$/i.test(word)) return word;
|
|
1033
|
+
if (/s$/i.test(word)) return word.slice(0, -1);
|
|
1034
|
+
return word;
|
|
1035
|
+
}
|
|
1036
|
+
function agreeInEnglish(item, amount) {
|
|
1037
|
+
if (!item) return item;
|
|
1038
|
+
const comma = item.indexOf(",");
|
|
1039
|
+
const head = comma < 0 ? item : item.slice(0, comma);
|
|
1040
|
+
const tail = comma < 0 ? "" : item.slice(comma);
|
|
1041
|
+
const preposition = / of /i.exec(head);
|
|
1042
|
+
if (preposition) {
|
|
1043
|
+
const counted = agreeInEnglish(head.slice(0, preposition.index), amount);
|
|
1044
|
+
return `${counted}${head.slice(preposition.index)}${tail}`;
|
|
1045
|
+
}
|
|
1046
|
+
const words = head.trimEnd().split(" ");
|
|
1047
|
+
const last = words[words.length - 1] ?? "";
|
|
1048
|
+
if (!/^[A-Za-z]+$/.test(last) || last.length <= 2) return item;
|
|
1049
|
+
const wantsPlural = amount > 1;
|
|
1050
|
+
const plural = toEnglishPlural(last);
|
|
1051
|
+
const singular = toEnglishSingular(last);
|
|
1052
|
+
const isPlural = last.toLowerCase() !== singular.toLowerCase();
|
|
1053
|
+
if (wantsPlural && !isPlural) words[words.length - 1] = plural;
|
|
1054
|
+
else if (!wantsPlural && isPlural) words[words.length - 1] = singular;
|
|
1055
|
+
else return item;
|
|
1056
|
+
return `${words.join(" ")}${tail}`;
|
|
1057
|
+
}
|
|
1058
|
+
var INVARIABLE_FRENCH_NOUN = /* @__PURE__ */ new Set([
|
|
1059
|
+
"ananas",
|
|
1060
|
+
"anis",
|
|
1061
|
+
"brebis",
|
|
1062
|
+
"cassis",
|
|
1063
|
+
"colis",
|
|
1064
|
+
"coulis",
|
|
1065
|
+
"couscous",
|
|
1066
|
+
"gambas",
|
|
1067
|
+
"houmous",
|
|
1068
|
+
"jus",
|
|
1069
|
+
"mais",
|
|
1070
|
+
"pastis",
|
|
1071
|
+
"pois",
|
|
1072
|
+
"radis",
|
|
1073
|
+
"ris",
|
|
1074
|
+
"souris",
|
|
1075
|
+
"tamis",
|
|
1076
|
+
"tapas"
|
|
1077
|
+
]);
|
|
1078
|
+
var AGREEABLE_ADJECTIVES = /* @__PURE__ */ new Set([
|
|
1079
|
+
"entier",
|
|
1080
|
+
"entiere",
|
|
1081
|
+
"etoile",
|
|
1082
|
+
"etoilee",
|
|
1083
|
+
"moyen",
|
|
1084
|
+
"moyenne",
|
|
1085
|
+
"petit",
|
|
1086
|
+
"petite",
|
|
1087
|
+
"grand",
|
|
1088
|
+
"grande",
|
|
1089
|
+
"mur",
|
|
1090
|
+
"mure",
|
|
1091
|
+
"vert",
|
|
1092
|
+
"verte",
|
|
1093
|
+
"rouge",
|
|
1094
|
+
"jaune",
|
|
1095
|
+
"noir",
|
|
1096
|
+
"noire",
|
|
1097
|
+
"blanc",
|
|
1098
|
+
"blanche",
|
|
1099
|
+
"rond",
|
|
1100
|
+
"ronde",
|
|
1101
|
+
"hache",
|
|
1102
|
+
"hachee",
|
|
1103
|
+
"coupe",
|
|
1104
|
+
"coupee",
|
|
1105
|
+
"rape",
|
|
1106
|
+
"rapee",
|
|
1107
|
+
"pele",
|
|
1108
|
+
"pelee",
|
|
1109
|
+
"epluche",
|
|
1110
|
+
"epluchee",
|
|
1111
|
+
"denoyaute",
|
|
1112
|
+
"denoyautee",
|
|
1113
|
+
"emince",
|
|
1114
|
+
"emincee"
|
|
1115
|
+
]);
|
|
1116
|
+
function foldWord(word) {
|
|
1117
|
+
return word.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
|
|
1118
|
+
}
|
|
1119
|
+
function agreeTrailingAdjective(word, wantsPlural) {
|
|
1120
|
+
const folded = foldWord(word);
|
|
1121
|
+
const isPlural = folded.endsWith("s");
|
|
1122
|
+
const singular = isPlural ? word.slice(0, -1) : word;
|
|
1123
|
+
if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return null;
|
|
1124
|
+
if (wantsPlural === isPlural) return null;
|
|
1125
|
+
return wantsPlural ? `${word}s` : singular;
|
|
1126
|
+
}
|
|
1127
|
+
function agreeInFrench(item, amount) {
|
|
1128
|
+
if (!item) return item;
|
|
1129
|
+
const words = item.split(" ");
|
|
1130
|
+
const head = words[0] ?? "";
|
|
1131
|
+
if (head.length <= 3) return item;
|
|
1132
|
+
const wantsPlural = amount >= 2;
|
|
1133
|
+
const isPlural = /s$|eaux$|aux$/i.test(head);
|
|
1134
|
+
if (wantsPlural && !isPlural) {
|
|
1135
|
+
if (/[sxz]$/i.test(head)) {
|
|
1136
|
+
} else if (/eau$/i.test(head)) words[0] = `${head}x`;
|
|
1137
|
+
else if (/al$/i.test(head)) words[0] = `${head.slice(0, -2)}aux`;
|
|
1138
|
+
else words[0] = `${head}s`;
|
|
1139
|
+
} else if (!wantsPlural && isPlural) {
|
|
1140
|
+
if (/eaux$/i.test(head)) words[0] = head.slice(0, -1);
|
|
1141
|
+
else if (/aux$/i.test(head)) words[0] = `${head.slice(0, -3)}al`;
|
|
1142
|
+
else if (INVARIABLE_FRENCH_NOUN.has(foldWord(head))) {
|
|
1143
|
+
} else words[0] = head.slice(0, -1);
|
|
1144
|
+
}
|
|
1145
|
+
const last = words.length - 1;
|
|
1146
|
+
if (last > 0) {
|
|
1147
|
+
const adjective = agreeTrailingAdjective(words[last], wantsPlural);
|
|
1148
|
+
if (adjective) words[last] = adjective;
|
|
1149
|
+
}
|
|
1150
|
+
return words.join(" ");
|
|
1151
|
+
}
|
|
1152
|
+
function agreeWithAmount(item, amount, language) {
|
|
1153
|
+
return language === "fr" ? agreeInFrench(item, amount) : agreeInEnglish(item, amount);
|
|
1154
|
+
}
|
|
1155
|
+
var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
|
|
1156
|
+
function joinItem(item, language) {
|
|
1157
|
+
if (!item) return "";
|
|
1158
|
+
if (language === "en") return ` ${item}`;
|
|
1159
|
+
const elides = /^[aeiouàâäéèêëîïôöûü]/i.test(item) || MUTE_H_WORDS.test(item);
|
|
1160
|
+
return elides ? ` d'${item}` : ` de ${item}`;
|
|
1161
|
+
}
|
|
1162
|
+
var BRANCH_SEPARATORS = {
|
|
1163
|
+
en: /\s+or\s+/gi,
|
|
1164
|
+
fr: /\s+ou\s+/gi
|
|
1165
|
+
};
|
|
1166
|
+
function splitBranch(text, parsed) {
|
|
1167
|
+
const itemStart = parsed.item ? text.indexOf(parsed.item) : text.length;
|
|
1168
|
+
if (itemStart < 0) return null;
|
|
1169
|
+
const separator = BRANCH_SEPARATORS[parsed.language];
|
|
1170
|
+
separator.lastIndex = 0;
|
|
1171
|
+
for (let match = separator.exec(text); match; match = separator.exec(text)) {
|
|
1172
|
+
if (match.index < itemStart) continue;
|
|
1173
|
+
const tail = text.slice(match.index + match[0].length);
|
|
1174
|
+
if (parseIngredient(tail, parsed.language).amount === null) continue;
|
|
1175
|
+
return { head: text.slice(0, match.index), separator: match[0], tail };
|
|
1176
|
+
}
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
function scaleIngredient(line, options) {
|
|
1180
|
+
const { factor } = options;
|
|
1181
|
+
if (factor === 1) return passthroughIngredient(line, options.language);
|
|
1182
|
+
const text = line.trim();
|
|
1183
|
+
const parsed = parseIngredient(text, options.language ?? "auto");
|
|
1184
|
+
const branch = splitBranch(text, parsed);
|
|
1185
|
+
if (branch) return scaleBranchedLine(line, branch, { ...options, language: parsed.language });
|
|
1186
|
+
return scaleSingleLine(line, options);
|
|
1187
|
+
}
|
|
1188
|
+
function scaleBranchedLine(line, branch, options) {
|
|
1189
|
+
const head = scaleSingleLine(branch.head, options);
|
|
1190
|
+
if (head.scaling === "unscaled") return { ...head, text: line.trim(), original: line };
|
|
1191
|
+
const tail = scaleAlternative(branch.tail, options);
|
|
1192
|
+
const result = {
|
|
1193
|
+
...head,
|
|
1194
|
+
original: line,
|
|
1195
|
+
text: `${head.text}${branch.separator}${tail.text}`,
|
|
1196
|
+
scaling: "rounded"
|
|
1197
|
+
};
|
|
1198
|
+
const branchNote = tail.rewritten ? "This line offers a choice between two quantities, and each was scaled on its own. How far one stands for the other is the page's own claim." : "This line carries a further quantity after the first one, and only the first was scaled. Read the rest as published.";
|
|
1199
|
+
result.note = head.note ? `${head.note} ${branchNote}` : branchNote;
|
|
1200
|
+
return result;
|
|
1201
|
+
}
|
|
1202
|
+
function scaleAlternative(tail, options) {
|
|
1203
|
+
const parsed = parseIngredient(tail, options.language ?? "auto");
|
|
1204
|
+
const published = tail.trim();
|
|
1205
|
+
if (parsed.amount === null) return { text: published, rewritten: false };
|
|
1206
|
+
const largest = (parsed.amountMax ?? parsed.amount) * options.factor;
|
|
1207
|
+
if (largest < 1) return { text: published, rewritten: false };
|
|
1208
|
+
return { text: scaleIngredient(tail, options).text, rewritten: true };
|
|
1209
|
+
}
|
|
1210
|
+
function scaleSingleLine(line, options) {
|
|
1211
|
+
const { factor } = options;
|
|
1212
|
+
const parsed = parseIngredient(line, options.language ?? "auto");
|
|
1213
|
+
const language = parsed.language;
|
|
1214
|
+
if (parsed.amount === null) {
|
|
1215
|
+
return {
|
|
1216
|
+
text: parsed.original,
|
|
1217
|
+
original: parsed.original,
|
|
1218
|
+
scaling: "unscaled",
|
|
1219
|
+
amount: null,
|
|
1220
|
+
amountMax: null,
|
|
1221
|
+
unit: null,
|
|
1222
|
+
language,
|
|
1223
|
+
note: "No quantity given; adjust to taste."
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
const divisibility = divisibilityOf(parsed.unit, parsed.item);
|
|
1227
|
+
const primary = scaleMeasure(parsed.amount, parsed.amountMax, parsed.unit, factor, divisibility);
|
|
1228
|
+
const alternates = parsed.alternates.map((measure) => renderMeasure(measure, factor, language));
|
|
1229
|
+
const primaryBounds = primary.bounds;
|
|
1230
|
+
const alternateBounds = alternates.flatMap((entry) => entry.bounds);
|
|
1231
|
+
const movedPrimary = primaryBounds.some((b) => !landedExactly(b.exact, b.amount));
|
|
1232
|
+
const movedAlternate = alternateBounds.some((b) => !landedExactly(b.exact, b.amount));
|
|
1233
|
+
const clamped = [...primaryBounds, ...alternateBounds].find((bound) => bound.clamped) ?? null;
|
|
1234
|
+
const restated = parsed.alternateStyle === "slash";
|
|
1235
|
+
const low = primaryBounds[0];
|
|
1236
|
+
const high = primaryBounds[1] ?? null;
|
|
1237
|
+
const unit = primary.unit;
|
|
1238
|
+
const shown = high?.amount ?? low.amount;
|
|
1239
|
+
const asText = (value) => formatAmount(value, language, { fractions: unit?.kind !== "measured" });
|
|
1240
|
+
const collapsed = high !== null && high.amount === low.amount;
|
|
1241
|
+
const amountText = renderRange(
|
|
1242
|
+
asText(low.amount),
|
|
1243
|
+
high === null || collapsed ? null : asText(high.amount),
|
|
1244
|
+
parsed.rangeSeparator
|
|
1245
|
+
);
|
|
1246
|
+
const named = unit && !countsBarePieces(unit) ? unit : null;
|
|
1247
|
+
const unitLabel = named ? ` ${formatUnit(named, shown, language)}` : "";
|
|
1248
|
+
const alternateTexts = alternates.map((entry) => entry.text);
|
|
1249
|
+
const altLabel = alternates.length === 0 ? "" : parsed.alternateStyle === "slash" ? ` / ${alternateTexts.join(" / ")}` : ` (${alternateTexts.join(" / ")})`;
|
|
1250
|
+
const counted = agreeWithAmount(parsed.item, shown, language);
|
|
1251
|
+
const itemLabel = named ? joinItem(parsed.item, language) : counted ? ` ${counted}` : "";
|
|
1252
|
+
const result = {
|
|
1253
|
+
text: `${amountText}${unitLabel}${altLabel}${itemLabel}`.trim(),
|
|
1254
|
+
original: parsed.original,
|
|
1255
|
+
scaling: movedPrimary || movedAlternate || restated ? "rounded" : "scaled",
|
|
1256
|
+
amount: low.amount,
|
|
1257
|
+
amountMax: collapsed ? null : high?.amount ?? null,
|
|
1258
|
+
unit: named?.canonical ?? null,
|
|
1259
|
+
language
|
|
1260
|
+
};
|
|
1261
|
+
const asPublished = (value, source) => `${formatAmount(value, language, { fractions: false })}${source ? ` ${formatUnit(source, value, language)}` : ""}`;
|
|
1262
|
+
const sentences = [];
|
|
1263
|
+
if (clamped) {
|
|
1264
|
+
sentences.push(
|
|
1265
|
+
`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.`
|
|
1266
|
+
);
|
|
1267
|
+
} else if (movedPrimary) {
|
|
1268
|
+
const moved = primaryBounds.filter((bound) => !landedExactly(bound.exact, bound.amount));
|
|
1269
|
+
sentences.push(
|
|
1270
|
+
moved.map(
|
|
1271
|
+
(bound) => `Rounded ${bound.amount > bound.exact ? "up" : "down"} from ${asPublished(bound.raw, parsed.unit)}.`
|
|
1272
|
+
).join(" ")
|
|
1273
|
+
);
|
|
1274
|
+
} else if (movedAlternate) {
|
|
1275
|
+
sentences.push(
|
|
1276
|
+
`The amount is exact; the equivalent ${restated ? "beside it" : "in brackets"} was rounded to stay readable.`
|
|
1277
|
+
);
|
|
1278
|
+
} else if (restated) {
|
|
1279
|
+
sentences.push(
|
|
1280
|
+
"This line states one quantity twice, and both readings were scaled. They agree as closely as the page wrote them, and no closer."
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
if (hasEmbeddedMeasure(parsed.item, language)) {
|
|
1284
|
+
sentences.push(
|
|
1285
|
+
"This line carries a further quantity after the first one, and only the first was scaled. Read the rest as published."
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
if (collapsed) {
|
|
1289
|
+
sentences.push("The page gave a range, and at this size both ends come to the same amount.");
|
|
1290
|
+
}
|
|
1291
|
+
if (unit?.kind === "measured" && low.amount > 0 && low.amount < 0.05) {
|
|
1292
|
+
sentences.push(
|
|
1293
|
+
"This is smaller than a kitchen scale resolves. Make a larger batch, or measure it by eye."
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
if (sentences.length > 0) result.note = sentences.join(" ");
|
|
1297
|
+
if (parsed.unit && parsed.unit.kind === "approximate") {
|
|
1298
|
+
result.note = withApproximateNote(parsed.unit, result.note);
|
|
1299
|
+
}
|
|
1300
|
+
if (parsed.articleWord) {
|
|
1301
|
+
const stood = (parsed.amount ?? 0) / (parsed.countMultiplier ?? 1);
|
|
1302
|
+
const read = `"${parsed.articleWord}" read as ${formatAmount(stood, language)}.`;
|
|
1303
|
+
result.note = result.note ? `${read} ${result.note}` : read;
|
|
1304
|
+
}
|
|
1305
|
+
return result;
|
|
1306
|
+
}
|
|
1307
|
+
function withApproximateNote(unit, existing) {
|
|
1308
|
+
const equivalence = approximateEquivalent(unit);
|
|
1309
|
+
const sentence = `A ${unit.canonical} is an approximate measure${equivalence ? `, ${equivalence}` : ""}. The count was scaled and the size of one is the cook's.`;
|
|
1310
|
+
return existing ? `${existing} ${sentence}` : sentence;
|
|
1311
|
+
}
|
|
1312
|
+
function renderMeasure(measure, factor, language) {
|
|
1313
|
+
const scaled = scaleMeasure(
|
|
1314
|
+
measure.amount,
|
|
1315
|
+
measure.amountMax,
|
|
1316
|
+
measure.unit,
|
|
1317
|
+
factor,
|
|
1318
|
+
divisibilityOf(measure.unit, "")
|
|
1319
|
+
);
|
|
1320
|
+
const low = scaled.bounds[0];
|
|
1321
|
+
const high = scaled.bounds[1] ?? null;
|
|
1322
|
+
const unit = scaled.unit;
|
|
1323
|
+
const shown = high?.amount ?? low.amount;
|
|
1324
|
+
const asText = (value) => formatAmount(value, language, { fractions: unit?.kind !== "measured" });
|
|
1325
|
+
return {
|
|
1326
|
+
text: `${renderRange(
|
|
1327
|
+
asText(low.amount),
|
|
1328
|
+
high === null ? null : asText(high.amount),
|
|
1329
|
+
measure.rangeSeparator
|
|
1330
|
+
)}${unit ? ` ${formatUnit(unit, shown, language)}` : ""}`,
|
|
1331
|
+
bounds: scaled.bounds
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
function renderRange(low, high, separator) {
|
|
1335
|
+
if (high === null || separator === null) return low;
|
|
1336
|
+
return /^[-–—]$/.test(separator) ? `${low}${separator}${high}` : `${low} ${separator} ${high}`;
|
|
1337
|
+
}
|
|
1338
|
+
function scaleIngredients(lines, options) {
|
|
1339
|
+
return lines.map((line) => scaleIngredient(line, options));
|
|
1340
|
+
}
|
|
1341
|
+
function passthroughIngredient(line, language = "auto") {
|
|
1342
|
+
const parsed = parseIngredient(line, language);
|
|
1343
|
+
const result = {
|
|
1344
|
+
text: parsed.original,
|
|
1345
|
+
original: parsed.original,
|
|
1346
|
+
scaling: parsed.amount === null ? "unscaled" : "scaled",
|
|
1347
|
+
amount: parsed.amount,
|
|
1348
|
+
amountMax: parsed.amountMax,
|
|
1349
|
+
unit: parsed.unit?.canonical ?? null,
|
|
1350
|
+
language: parsed.language
|
|
1351
|
+
};
|
|
1352
|
+
if (parsed.amount === null) result.note = "No quantity given; adjust to taste.";
|
|
1353
|
+
else if (parsed.unit?.kind === "approximate") {
|
|
1354
|
+
result.note = withApproximateNote(parsed.unit, void 0);
|
|
1355
|
+
}
|
|
1356
|
+
return result;
|
|
1357
|
+
}
|
|
1358
|
+
function passthroughIngredients(lines, language = "auto") {
|
|
1359
|
+
return lines.map((line) => passthroughIngredient(line, language));
|
|
1360
|
+
}
|
|
1361
|
+
export {
|
|
1362
|
+
passthroughIngredient,
|
|
1363
|
+
passthroughIngredients,
|
|
1364
|
+
scaleIngredient,
|
|
1365
|
+
scaleIngredients
|
|
1366
|
+
};
|
|
1367
|
+
//# sourceMappingURL=scale.js.map
|