@recipe-book/core 0.0.3 → 0.1.1
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 +116 -0
- package/LICENSE +21 -0
- package/README.md +50 -7
- package/dist/chunk-D5CV3UF7.cjs +2754 -0
- package/dist/chunk-D5CV3UF7.cjs.map +1 -0
- package/dist/errors.js +76 -0
- package/dist/errors.js.map +1 -0
- package/dist/format/duration.js +36 -0
- package/dist/format/duration.js.map +1 -0
- package/dist/format/ingredient.js +49 -0
- package/dist/format/ingredient.js.map +1 -0
- package/dist/format/number.js +120 -0
- package/dist/format/number.js.map +1 -0
- package/dist/format/options.js +31 -0
- package/dist/format/options.js.map +1 -0
- package/dist/format/quantity.js +144 -0
- package/dist/format/quantity.js.map +1 -0
- package/dist/format/servings.js +68 -0
- package/dist/format/servings.js.map +1 -0
- package/dist/format/temperature.js +35 -0
- package/dist/format/temperature.js.map +1 -0
- package/dist/index.cjs +476 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +994 -0
- package/dist/index.d.ts +994 -5
- package/dist/index.js +48 -1
- package/dist/index.js.map +1 -0
- package/dist/ingredient/parse.js +412 -0
- package/dist/ingredient/parse.js.map +1 -0
- package/dist/issues.js +26 -0
- package/dist/issues.js.map +1 -0
- package/dist/quantity/amount.js +78 -0
- package/dist/quantity/amount.js.map +1 -0
- package/dist/quantity/parse.js +255 -0
- package/dist/quantity/parse.js.map +1 -0
- package/dist/quantity/scale.js +23 -0
- package/dist/quantity/scale.js.map +1 -0
- package/dist/recipe/duration.js +28 -0
- package/dist/recipe/duration.js.map +1 -0
- package/dist/recipe/ids.js +68 -0
- package/dist/recipe/ids.js.map +1 -0
- package/dist/recipe/infer.js +24 -0
- package/dist/recipe/infer.js.map +1 -0
- package/dist/recipe/nutrient-units.js +18 -0
- package/dist/recipe/nutrient-units.js.map +1 -0
- package/dist/recipe/nutrition.js +26 -0
- package/dist/recipe/nutrition.js.map +1 -0
- package/dist/recipe/parse.js +854 -0
- package/dist/recipe/parse.js.map +1 -0
- package/dist/recipe/schema.js +32 -0
- package/dist/recipe/schema.js.map +1 -0
- package/dist/recipe/url.js +9 -0
- package/dist/recipe/url.js.map +1 -0
- package/dist/resolve.js +120 -0
- package/dist/resolve.js.map +1 -0
- package/dist/schema-org/find.js +158 -0
- package/dist/schema-org/find.js.map +1 -0
- package/dist/schema-org/from-json-ld.js +647 -0
- package/dist/schema-org/from-json-ld.js.map +1 -0
- package/dist/schema-org/nutrients.js +19 -0
- package/dist/schema-org/nutrients.js.map +1 -0
- package/dist/schema-org/text.js +125 -0
- package/dist/schema-org/text.js.map +1 -0
- package/dist/schema-org/to-json-ld.js +151 -0
- package/dist/schema-org/to-json-ld.js.map +1 -0
- package/dist/schema-org.cjs +1062 -0
- package/dist/schema-org.cjs.map +1 -0
- package/dist/schema-org.d.cts +223 -0
- package/dist/schema-org.d.ts +223 -0
- package/dist/schema-org.js +10 -0
- package/dist/schema-org.js.map +1 -0
- package/dist/steps/segment.js +45 -0
- package/dist/steps/segment.js.map +1 -0
- package/dist/steps/temperature.js +174 -0
- package/dist/steps/temperature.js.map +1 -0
- package/dist/suggest.js +50 -0
- package/dist/suggest.js.map +1 -0
- package/dist/types-BknM8KOd.d.cts +628 -0
- package/dist/types-BknM8KOd.d.ts +628 -0
- package/dist/units/convert.js +79 -0
- package/dist/units/convert.js.map +1 -0
- package/dist/units/lookup.js +86 -0
- package/dist/units/lookup.js.map +1 -0
- package/dist/units/registry.js +183 -0
- package/dist/units/registry.js.map +1 -0
- package/dist/units/system.js +91 -0
- package/dist/units/system.js.map +1 -0
- package/dist/units/temperature.js +40 -0
- package/dist/units/temperature.js.map +1 -0
- package/dist/validation.js +90 -0
- package/dist/validation.js.map +1 -0
- package/package.json +53 -8
- package/schema-org/package.json +5 -0
- package/dist/chunk-22RBQQWH.mjs +0 -1
- package/dist/chunk-3CSP4UZG.mjs +0 -1
- package/dist/chunk-3FFMHRB7.mjs +0 -26
- package/dist/chunk-4EB3HG6B.mjs +0 -50
- package/dist/chunk-4EHNJLGI.mjs +0 -50
- package/dist/chunk-4IA5EEKI.mjs +0 -81
- package/dist/chunk-4TATADPT.mjs +0 -51
- package/dist/chunk-6GI5KLI4.mjs +0 -22
- package/dist/chunk-6QJZOZ6W.mjs +0 -49
- package/dist/chunk-6YYQ4RYZ.mjs +0 -26
- package/dist/chunk-7BBUDI2L.mjs +0 -0
- package/dist/chunk-7KEMNCV7.mjs +0 -26
- package/dist/chunk-7MMJZZ7Z.mjs +0 -75
- package/dist/chunk-BES4V7DM.mjs +0 -26
- package/dist/chunk-BRDXRIAH.mjs +0 -50
- package/dist/chunk-C45UD45A.mjs +0 -1
- package/dist/chunk-CBLJXA6P.mjs +0 -1
- package/dist/chunk-CFR2ZBPB.mjs +0 -77
- package/dist/chunk-DYMNVFL2.mjs +0 -78
- package/dist/chunk-F6AQKXJU.mjs +0 -51
- package/dist/chunk-FW4M2R43.mjs +0 -51
- package/dist/chunk-GCCEDUST.mjs +0 -75
- package/dist/chunk-GE7KBYZR.mjs +0 -26
- package/dist/chunk-GN7ZDGYC.mjs +0 -51
- package/dist/chunk-GOO2SCQX.mjs +0 -75
- package/dist/chunk-HAVKBQLH.mjs +0 -51
- package/dist/chunk-HCBIPANU.mjs +0 -50
- package/dist/chunk-IEBAGBS7.mjs +0 -51
- package/dist/chunk-IQ6WN2V4.mjs +0 -75
- package/dist/chunk-JB4Z2FR7.mjs +0 -77
- package/dist/chunk-JMTQOTBM.mjs +0 -75
- package/dist/chunk-JRO6JUES.mjs +0 -51
- package/dist/chunk-KH3SRXET.mjs +0 -52
- package/dist/chunk-KNZMCAQZ.mjs +0 -51
- package/dist/chunk-KXFUCO3R.mjs +0 -1
- package/dist/chunk-LEBGCVCJ.mjs +0 -75
- package/dist/chunk-MAHWFCIL.mjs +0 -51
- package/dist/chunk-MPH76SLC.mjs +0 -51
- package/dist/chunk-MQGMFBJQ.mjs +0 -26
- package/dist/chunk-NMIVG7T4.mjs +0 -6
- package/dist/chunk-Q3TT3AZ4.mjs +0 -26
- package/dist/chunk-QY5HJQIL.mjs +0 -51
- package/dist/chunk-RCPLSNKM.mjs +0 -1
- package/dist/chunk-SR2KFBT3.mjs +0 -1
- package/dist/chunk-ULDLBAEG.mjs +0 -1
- package/dist/chunk-UQ3EUYFR.mjs +0 -1
- package/dist/chunk-V26OCCTL.mjs +0 -82
- package/dist/chunk-WBQAMGXK.mjs +0 -0
- package/dist/chunk-WSW6FMUL.mjs +0 -51
- package/dist/chunk-WU55SBIV.mjs +0 -75
- package/dist/chunk-XINK45P3.mjs +0 -51
- package/dist/chunk-XNOHIWI3.mjs +0 -1
- package/dist/chunk-XV4QN4LA.mjs +0 -6
- package/dist/chunk-YHBXEVUX.mjs +0 -51
- package/dist/chunk-YSZSCEI7.mjs +0 -75
- package/dist/chunk-YY3SUQ7P.mjs +0 -16
- package/dist/chunk-YZB3IG47.mjs +0 -80
- package/dist/chunk-ZPZT4LMQ.mjs +0 -25
- package/dist/const/conversion.d.mts +0 -7
- package/dist/const/conversion.d.ts +0 -7
- package/dist/const/conversion.js +0 -1
- package/dist/const/conversion.mjs +0 -1
- package/dist/const/unit.d.mts +0 -119
- package/dist/const/unit.d.ts +0 -119
- package/dist/const/unit.js +0 -1
- package/dist/const/unit.mjs +0 -1
- package/dist/index.d.mts +0 -5
- package/dist/index.mjs +0 -1
- package/dist/lib/assert.d.mts +0 -18
- package/dist/lib/assert.d.ts +0 -18
- package/dist/lib/assert.js +0 -1
- package/dist/lib/assert.mjs +0 -1
- package/dist/lib/ingredient.d.mts +0 -9
- package/dist/lib/ingredient.d.ts +0 -9
- package/dist/lib/ingredient.js +0 -1
- package/dist/lib/ingredient.mjs +0 -1
- package/dist/lib/recipe.d.mts +0 -12
- package/dist/lib/recipe.d.ts +0 -12
- package/dist/lib/recipe.js +0 -1
- package/dist/lib/recipe.mjs +0 -1
- package/dist/lib/runtime.d.mts +0 -3
- package/dist/lib/runtime.d.ts +0 -3
- package/dist/lib/runtime.js +0 -1
- package/dist/lib/runtime.mjs +0 -1
- package/dist/lib/unit.d.mts +0 -19
- package/dist/lib/unit.d.ts +0 -19
- package/dist/lib/unit.js +0 -1
- package/dist/lib/unit.mjs +0 -1
- package/dist/types.d.mts +0 -36
- package/dist/types.d.ts +0 -36
- package/dist/types.js +0 -1
- package/dist/types.mjs +0 -1
|
@@ -0,0 +1,2754 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
var DOCS_ORIGIN = "https://recipe-book.siraj.page";
|
|
5
|
+
var ERROR_CODES = /* @__PURE__ */ new Set([
|
|
6
|
+
"invalid_recipe",
|
|
7
|
+
"invalid_quantity",
|
|
8
|
+
"invalid_ingredient",
|
|
9
|
+
"invalid_argument",
|
|
10
|
+
"incompatible_units",
|
|
11
|
+
"unknown_unit",
|
|
12
|
+
"missing_parent"
|
|
13
|
+
]);
|
|
14
|
+
var ERROR_NAMES = /* @__PURE__ */ new Set([
|
|
15
|
+
"RecipeBookError",
|
|
16
|
+
"RecipeValidationError"
|
|
17
|
+
]);
|
|
18
|
+
var RecipeBookError = class extends Error {
|
|
19
|
+
constructor(code, message, options) {
|
|
20
|
+
const docsUrl = `${DOCS_ORIGIN}/errors/${code}`;
|
|
21
|
+
super(`${message}
|
|
22
|
+
See ${docsUrl}`, options);
|
|
23
|
+
this.name = "RecipeBookError";
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.docsUrl = docsUrl;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
function isRecipeBookError(error2) {
|
|
29
|
+
if (typeof error2 !== "object" || error2 === null) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const { name, code } = error2;
|
|
34
|
+
return ERROR_NAMES.has(name) && ERROR_CODES.has(code);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
var MAX_ECHOED = 60;
|
|
40
|
+
function describeValue(value) {
|
|
41
|
+
if (typeof value === "string") {
|
|
42
|
+
return quote(value);
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === "function") {
|
|
45
|
+
return "a function";
|
|
46
|
+
}
|
|
47
|
+
if (value === null || typeof value !== "object") {
|
|
48
|
+
return quote(String(value)).slice(1, -1);
|
|
49
|
+
}
|
|
50
|
+
return Array.isArray(value) ? "an array" : "an object";
|
|
51
|
+
}
|
|
52
|
+
var UNESCAPED_BY_JSON = /[\x80-\x9f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g;
|
|
53
|
+
function quote(text) {
|
|
54
|
+
return JSON.stringify(truncate(text)).replace(
|
|
55
|
+
UNESCAPED_BY_JSON,
|
|
56
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
function truncate(text) {
|
|
60
|
+
if (text.length <= MAX_ECHOED) {
|
|
61
|
+
return text;
|
|
62
|
+
}
|
|
63
|
+
let end = MAX_ECHOED - 1;
|
|
64
|
+
const last = text.charCodeAt(end - 1);
|
|
65
|
+
if (last >= 55296 && last <= 56319) {
|
|
66
|
+
end--;
|
|
67
|
+
}
|
|
68
|
+
return `${text.slice(0, end)}\u2026`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/issues.ts
|
|
72
|
+
function warning(issues, code, path, message, suggestion) {
|
|
73
|
+
issues.push(issue("warning", code, path, message, suggestion));
|
|
74
|
+
}
|
|
75
|
+
function issue(severity, code, path, message, suggestion) {
|
|
76
|
+
return {
|
|
77
|
+
code,
|
|
78
|
+
severity,
|
|
79
|
+
path,
|
|
80
|
+
message,
|
|
81
|
+
...suggestion !== void 0 && { suggestion }
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function isRecord(value) {
|
|
85
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
function hasOwn(record, key) {
|
|
88
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/units/registry.ts
|
|
92
|
+
var TSP_ML = 4.92892159375;
|
|
93
|
+
var OZ_G = 28.349523125;
|
|
94
|
+
function definitions() {
|
|
95
|
+
return {
|
|
96
|
+
mg: {
|
|
97
|
+
dimension: "mass",
|
|
98
|
+
systems: ["metric"],
|
|
99
|
+
toBase: 1e-3,
|
|
100
|
+
aliases: ["milligram", "milligrams", "milligramme", "milligrammes"],
|
|
101
|
+
labels: { short: invariable("mg"), long: label("milligram") }
|
|
102
|
+
},
|
|
103
|
+
g: {
|
|
104
|
+
dimension: "mass",
|
|
105
|
+
systems: ["metric"],
|
|
106
|
+
toBase: 1,
|
|
107
|
+
aliases: ["gram", "grams", "gramme", "grammes"],
|
|
108
|
+
labels: { short: invariable("g"), long: label("gram") }
|
|
109
|
+
},
|
|
110
|
+
kg: {
|
|
111
|
+
dimension: "mass",
|
|
112
|
+
systems: ["metric"],
|
|
113
|
+
toBase: 1e3,
|
|
114
|
+
aliases: [
|
|
115
|
+
"kgs",
|
|
116
|
+
"kilo",
|
|
117
|
+
"kilos",
|
|
118
|
+
"kilogram",
|
|
119
|
+
"kilograms",
|
|
120
|
+
"kilogramme",
|
|
121
|
+
"kilogrammes"
|
|
122
|
+
],
|
|
123
|
+
labels: { short: invariable("kg"), long: label("kilogram") }
|
|
124
|
+
},
|
|
125
|
+
oz: {
|
|
126
|
+
dimension: "mass",
|
|
127
|
+
systems: ["us"],
|
|
128
|
+
toBase: OZ_G,
|
|
129
|
+
aliases: ["ounce", "ounces"],
|
|
130
|
+
labels: { short: invariable("oz"), long: label("ounce") }
|
|
131
|
+
},
|
|
132
|
+
lb: {
|
|
133
|
+
dimension: "mass",
|
|
134
|
+
systems: ["us"],
|
|
135
|
+
toBase: 16 * OZ_G,
|
|
136
|
+
aliases: ["lbs", "pound", "pounds"],
|
|
137
|
+
labels: { short: invariable("lb"), long: label("pound") }
|
|
138
|
+
},
|
|
139
|
+
ml: {
|
|
140
|
+
dimension: "volume",
|
|
141
|
+
systems: ["metric"],
|
|
142
|
+
toBase: 1,
|
|
143
|
+
aliases: ["mL", "milliliter", "milliliters", "millilitre", "millilitres"],
|
|
144
|
+
labels: { short: invariable("mL"), long: label("milliliter") }
|
|
145
|
+
},
|
|
146
|
+
cl: {
|
|
147
|
+
dimension: "volume",
|
|
148
|
+
systems: ["metric"],
|
|
149
|
+
toBase: 10,
|
|
150
|
+
aliases: ["cL", "centiliter", "centiliters", "centilitre", "centilitres"],
|
|
151
|
+
labels: { short: invariable("cL"), long: label("centiliter") }
|
|
152
|
+
},
|
|
153
|
+
dl: {
|
|
154
|
+
dimension: "volume",
|
|
155
|
+
systems: ["metric"],
|
|
156
|
+
toBase: 100,
|
|
157
|
+
aliases: ["dL", "deciliter", "deciliters", "decilitre", "decilitres"],
|
|
158
|
+
labels: { short: invariable("dL"), long: label("deciliter") }
|
|
159
|
+
},
|
|
160
|
+
l: {
|
|
161
|
+
dimension: "volume",
|
|
162
|
+
systems: ["metric"],
|
|
163
|
+
toBase: 1e3,
|
|
164
|
+
aliases: ["L", "liter", "liters", "litre", "litres"],
|
|
165
|
+
labels: { short: invariable("L"), long: label("liter") }
|
|
166
|
+
},
|
|
167
|
+
tsp: {
|
|
168
|
+
dimension: "volume",
|
|
169
|
+
systems: ["metric", "us"],
|
|
170
|
+
toBase: TSP_ML,
|
|
171
|
+
aliases: ["t", "tsps", "teaspoon", "teaspoons"],
|
|
172
|
+
labels: { short: invariable("tsp"), long: label("teaspoon") }
|
|
173
|
+
},
|
|
174
|
+
tbsp: {
|
|
175
|
+
dimension: "volume",
|
|
176
|
+
systems: ["metric", "us"],
|
|
177
|
+
toBase: 3 * TSP_ML,
|
|
178
|
+
aliases: ["T", "tbs", "tbsps", "tablespoon", "tablespoons"],
|
|
179
|
+
labels: { short: invariable("tbsp"), long: label("tablespoon") }
|
|
180
|
+
},
|
|
181
|
+
"fl-oz": {
|
|
182
|
+
dimension: "volume",
|
|
183
|
+
systems: ["us"],
|
|
184
|
+
toBase: 6 * TSP_ML,
|
|
185
|
+
aliases: ["fl oz", "fluid ounce", "fluid ounces"],
|
|
186
|
+
labels: { short: invariable("fl oz"), long: label("fluid ounce") }
|
|
187
|
+
},
|
|
188
|
+
cup: {
|
|
189
|
+
dimension: "volume",
|
|
190
|
+
systems: ["us"],
|
|
191
|
+
toBase: 48 * TSP_ML,
|
|
192
|
+
aliases: ["cups", "c"],
|
|
193
|
+
labels: { short: label("cup"), long: label("cup") }
|
|
194
|
+
},
|
|
195
|
+
pint: {
|
|
196
|
+
dimension: "volume",
|
|
197
|
+
systems: ["us"],
|
|
198
|
+
toBase: 96 * TSP_ML,
|
|
199
|
+
aliases: ["pt", "pts", "pints"],
|
|
200
|
+
labels: { short: invariable("pt"), long: label("pint") }
|
|
201
|
+
},
|
|
202
|
+
quart: {
|
|
203
|
+
dimension: "volume",
|
|
204
|
+
systems: ["us"],
|
|
205
|
+
toBase: 192 * TSP_ML,
|
|
206
|
+
aliases: ["qt", "qts", "quarts"],
|
|
207
|
+
labels: { short: invariable("qt"), long: label("quart") }
|
|
208
|
+
},
|
|
209
|
+
gallon: {
|
|
210
|
+
dimension: "volume",
|
|
211
|
+
systems: ["us"],
|
|
212
|
+
toBase: 768 * TSP_ML,
|
|
213
|
+
aliases: ["gal", "gals", "gallons"],
|
|
214
|
+
labels: { short: invariable("gal"), long: label("gallon") }
|
|
215
|
+
},
|
|
216
|
+
pinch: count("pinch", ["pinches"]),
|
|
217
|
+
dash: count("dash", ["dashes"]),
|
|
218
|
+
clove: count("clove", ["cloves"]),
|
|
219
|
+
slice: count("slice", ["slices"]),
|
|
220
|
+
piece: count("piece", ["pieces", "pc", "pcs"]),
|
|
221
|
+
can: count("can", ["cans"]),
|
|
222
|
+
package: count("package", ["packages", "pkg", "pkgs"]),
|
|
223
|
+
stick: count("stick", ["sticks"]),
|
|
224
|
+
sprig: count("sprig", ["sprigs"]),
|
|
225
|
+
bunch: count("bunch", ["bunches"]),
|
|
226
|
+
head: count("head", ["heads"])
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function label(one, other = `${one}s`) {
|
|
230
|
+
return { one, other };
|
|
231
|
+
}
|
|
232
|
+
function invariable(one) {
|
|
233
|
+
return { one, other: one };
|
|
234
|
+
}
|
|
235
|
+
function count(one, aliases) {
|
|
236
|
+
return {
|
|
237
|
+
dimension: "count",
|
|
238
|
+
systems: ["metric", "us"],
|
|
239
|
+
aliases,
|
|
240
|
+
labels: { short: label(one, aliases[0]), long: label(one, aliases[0]) }
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
var units = /* @__PURE__ */ buildRegistry();
|
|
244
|
+
function lookupUnit(id) {
|
|
245
|
+
return typeof id === "string" && hasOwn(units, id) ? units[id] : void 0;
|
|
246
|
+
}
|
|
247
|
+
function buildRegistry() {
|
|
248
|
+
return Object.freeze(
|
|
249
|
+
Object.fromEntries(
|
|
250
|
+
Object.entries(definitions()).map(
|
|
251
|
+
([id, spec]) => [
|
|
252
|
+
id,
|
|
253
|
+
Object.freeze({
|
|
254
|
+
id,
|
|
255
|
+
...spec,
|
|
256
|
+
systems: Object.freeze(spec.systems),
|
|
257
|
+
aliases: Object.freeze([id, ...spec.aliases]),
|
|
258
|
+
labels: Object.freeze({
|
|
259
|
+
short: Object.freeze(spec.labels.short),
|
|
260
|
+
long: Object.freeze(spec.labels.long)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/suggest.ts
|
|
270
|
+
function closestMatch(text, candidates) {
|
|
271
|
+
const input = text.trim().toLowerCase();
|
|
272
|
+
const maxDistance = input.length < 3 ? 0 : input.length < 6 ? 1 : 2;
|
|
273
|
+
let best;
|
|
274
|
+
let bestDistance = Infinity;
|
|
275
|
+
let bestGap = 0;
|
|
276
|
+
for (const candidate of candidates) {
|
|
277
|
+
const lengthGap = Math.abs(candidate.length - input.length);
|
|
278
|
+
if (lengthGap > maxDistance) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const distance = editDistance(input, candidate.toLowerCase());
|
|
282
|
+
if (distance <= maxDistance && (distance < bestDistance || distance === bestDistance && lengthGap < bestGap)) {
|
|
283
|
+
best = candidate;
|
|
284
|
+
bestDistance = distance;
|
|
285
|
+
bestGap = lengthGap;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return best;
|
|
289
|
+
}
|
|
290
|
+
function editDistance(a, b) {
|
|
291
|
+
let twoAbove = [];
|
|
292
|
+
let above = [0];
|
|
293
|
+
for (let j = 1; j <= b.length; j++) {
|
|
294
|
+
above[j] = j;
|
|
295
|
+
}
|
|
296
|
+
for (let i = 1; i <= a.length; i++) {
|
|
297
|
+
const row = [i];
|
|
298
|
+
for (let j = 1; j <= b.length; j++) {
|
|
299
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
300
|
+
let distance = Math.min(
|
|
301
|
+
above[j] + 1,
|
|
302
|
+
row[j - 1] + 1,
|
|
303
|
+
above[j - 1] + cost
|
|
304
|
+
);
|
|
305
|
+
if (a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
306
|
+
distance = Math.min(distance, twoAbove[j - 2] + 0.5);
|
|
307
|
+
}
|
|
308
|
+
row[j] = distance;
|
|
309
|
+
}
|
|
310
|
+
twoAbove = above;
|
|
311
|
+
above = row;
|
|
312
|
+
}
|
|
313
|
+
return above[b.length];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/units/lookup.ts
|
|
317
|
+
var aliasIndex;
|
|
318
|
+
var CASE_SENSITIVE = /^[Ttc]$/;
|
|
319
|
+
function foldKey(key) {
|
|
320
|
+
const lower = key.toLowerCase();
|
|
321
|
+
return lower === "t" || lower === "c" ? key : lower;
|
|
322
|
+
}
|
|
323
|
+
function findUnit(text) {
|
|
324
|
+
if (typeof text !== "string") {
|
|
325
|
+
return void 0;
|
|
326
|
+
}
|
|
327
|
+
const key = unitKey(text);
|
|
328
|
+
if (!aliasIndex) {
|
|
329
|
+
const index = /* @__PURE__ */ new Map();
|
|
330
|
+
for (const unit of Object.values(units)) {
|
|
331
|
+
for (const alias of unit.aliases) {
|
|
332
|
+
index.set(foldKey(unitKey(alias)), unit.id);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
aliasIndex = index;
|
|
336
|
+
}
|
|
337
|
+
return aliasIndex.get(key) ?? aliasIndex.get(foldKey(key));
|
|
338
|
+
}
|
|
339
|
+
function unitKey(text) {
|
|
340
|
+
return text.trim().replace(/\s+/g, " ").replace(/(\p{L}) ?\.(?= |$)/gu, "$1");
|
|
341
|
+
}
|
|
342
|
+
function getUnit(id) {
|
|
343
|
+
if (!lookupUnit(id)) {
|
|
344
|
+
throw unknownUnitError(id);
|
|
345
|
+
}
|
|
346
|
+
return units[id];
|
|
347
|
+
}
|
|
348
|
+
function unknownUnitError(value) {
|
|
349
|
+
if (typeof value !== "string") {
|
|
350
|
+
return new RecipeBookError(
|
|
351
|
+
"unknown_unit",
|
|
352
|
+
`Expected a unit id such as "g", "ml", or "cup", received ${describeValue(value)}.`
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const name = value.toLowerCase();
|
|
356
|
+
if (hasOwn(SYSTEM_NAMES, name)) {
|
|
357
|
+
return new RecipeBookError(
|
|
358
|
+
"unknown_unit",
|
|
359
|
+
`${quote(value)} is a measurement system, not a unit. To convert a quantity into a system, use toSystem(quantity, "${SYSTEM_NAMES[name]}").`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
const id = findUnit(value);
|
|
363
|
+
if (id) {
|
|
364
|
+
return new RecipeBookError(
|
|
365
|
+
"unknown_unit",
|
|
366
|
+
`${quote(value)} is not a unit id. Use the id "${id}"; findUnit(${quote(value)}) converts author text to ids.`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
const suggestion = suggestUnit(value);
|
|
370
|
+
return new RecipeBookError(
|
|
371
|
+
"unknown_unit",
|
|
372
|
+
suggestion ? (
|
|
373
|
+
// The caller wants an id, so "cupz" suggests "cup" rather than the alias "cups".
|
|
374
|
+
`Unknown unit ${quote(value)}. Did you mean "${findUnit(suggestion)}"?`
|
|
375
|
+
) : `Unknown unit ${quote(value)}. Use a unit id such as "g", "ml", or "cup".`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
var SYSTEM_NAMES = {
|
|
379
|
+
metric: "metric",
|
|
380
|
+
us: "us"
|
|
381
|
+
};
|
|
382
|
+
function suggestUnit(text) {
|
|
383
|
+
return closestMatch(
|
|
384
|
+
unitKey(text).replace(/(^|\P{P})\p{P}+$/u, "$1"),
|
|
385
|
+
Object.values(units).flatMap((unit) => unit.aliases).filter((alias) => !CASE_SENSITIVE.test(alias))
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/validation.ts
|
|
390
|
+
var RecipeValidationError = class extends RecipeBookError {
|
|
391
|
+
/**
|
|
392
|
+
* @param subject - What was validated, shown as given at the start of the
|
|
393
|
+
* message, e.g. `'"Buttermilk pancakes"'`. Recipe Book's own errors quote
|
|
394
|
+
* the recipe's name or the input text like a JSON string, cut with "…"
|
|
395
|
+
* when longer than 60 UTF-16 code units.
|
|
396
|
+
*/
|
|
397
|
+
constructor(code, issues, subject = code === "invalid_recipe" ? "Recipe" : code === "invalid_quantity" ? "Quantity" : "Ingredient") {
|
|
398
|
+
super(code, summarize(subject, issues));
|
|
399
|
+
Object.assign(this, { name: "RecipeValidationError" });
|
|
400
|
+
this.issues = issues;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
var MAX_LISTED = 10;
|
|
404
|
+
var IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
405
|
+
function summarize(subject, issues) {
|
|
406
|
+
const count2 = issues.length;
|
|
407
|
+
const header = `${subject} has ${count2} ${count2 === 1 ? "problem" : "problems"}:`;
|
|
408
|
+
const listed = issues.slice(0, MAX_LISTED);
|
|
409
|
+
const paths = listed.map((issue2) => formatPath(issue2.path));
|
|
410
|
+
const width = Math.max(0, ...paths.map((path) => path.length));
|
|
411
|
+
const lines = listed.map(
|
|
412
|
+
(issue2, i) => ` ${issue2.severity === "error" ? "\u2716" : "\u26A0"} ${paths[i].padEnd(width)} ${issue2.message}`
|
|
413
|
+
);
|
|
414
|
+
if (count2 > listed.length) {
|
|
415
|
+
lines.push(
|
|
416
|
+
` \u2026and ${count2 - listed.length} more. error.issues lists them all.`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
return [header, ...lines].join("\n");
|
|
420
|
+
}
|
|
421
|
+
function formatPath(path) {
|
|
422
|
+
if (path.length === 0) {
|
|
423
|
+
return "(root)";
|
|
424
|
+
}
|
|
425
|
+
return path.reduce(
|
|
426
|
+
(text, segment) => typeof segment === "number" ? `${text}[${segment}]` : IDENTIFIER.test(segment) ? text ? `${text}.${truncate(segment)}` : truncate(segment) : `${text}[${quote(segment)}]`,
|
|
427
|
+
""
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
function error(issues, code, path, message, suggestion) {
|
|
431
|
+
issues.push(issue("error", code, path, message, suggestion));
|
|
432
|
+
return void 0;
|
|
433
|
+
}
|
|
434
|
+
function toResult(code, data, issues, subject) {
|
|
435
|
+
if (data === void 0 || issues.some((issue2) => issue2.severity === "error")) {
|
|
436
|
+
return {
|
|
437
|
+
success: false,
|
|
438
|
+
error: new RecipeValidationError(code, issues, subject)
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
return { success: true, data, warnings: issues };
|
|
442
|
+
}
|
|
443
|
+
function asRecord(value) {
|
|
444
|
+
return isRecord(value) ? Object.assign(/* @__PURE__ */ Object.create(null), value) : void 0;
|
|
445
|
+
}
|
|
446
|
+
function checkFields(issues, record, fields, path) {
|
|
447
|
+
for (const key of Object.keys(record)) {
|
|
448
|
+
if (hasOwn(fields, key)) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const suggestion = closestMatch(
|
|
452
|
+
key.replace(/Content$/, ""),
|
|
453
|
+
Object.keys(fields)
|
|
454
|
+
);
|
|
455
|
+
warning(
|
|
456
|
+
issues,
|
|
457
|
+
"unknown_field",
|
|
458
|
+
[...path, key],
|
|
459
|
+
suggestion ? `Unknown field. Did you mean "${suggestion}"?` : "Unknown field. It is ignored.",
|
|
460
|
+
suggestion
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/quantity/amount.ts
|
|
466
|
+
function mapAmount(amount, fn) {
|
|
467
|
+
return typeof amount === "number" ? fn(amount) : { min: fn(amount.min), max: fn(amount.max) };
|
|
468
|
+
}
|
|
469
|
+
function amountEnds(amount) {
|
|
470
|
+
return typeof amount === "number" ? [amount] : [amount.min, amount.max];
|
|
471
|
+
}
|
|
472
|
+
function checkQuantity(fn, quantity) {
|
|
473
|
+
if (typeof quantity !== "object" || quantity === null || !("amount" in quantity)) {
|
|
474
|
+
throw invalidArgument(
|
|
475
|
+
`${fn}: expected a quantity such as { amount: 2, unit: "cup" }, received ${describeValue(quantity)}.`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
if (quantity.max !== void 0) {
|
|
479
|
+
throw invalidArgument(
|
|
480
|
+
`${fn}: a quantity has no max; use amount: { min, max }, or formatServings or formatYield.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
const { amount } = quantity;
|
|
484
|
+
if (typeof amount === "number") {
|
|
485
|
+
if (!isPositive(amount)) {
|
|
486
|
+
throw invalidArgument(
|
|
487
|
+
`${fn}: amount must be a finite number greater than 0, received ${describeValue(amount)}.`
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return amount;
|
|
491
|
+
}
|
|
492
|
+
if (typeof amount === "object" && amount !== null && "min" in amount && "max" in amount) {
|
|
493
|
+
const { min, max } = amount;
|
|
494
|
+
if (!isPositive(min) || !isPositive(max)) {
|
|
495
|
+
throw invalidArgument(
|
|
496
|
+
`${fn}: range ends must be finite numbers greater than 0, received { min: ${describeValue(min)}, max: ${describeValue(max)} }.`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
if (min >= max) {
|
|
500
|
+
throw invalidArgument(
|
|
501
|
+
`${fn}: range minimum (${min}) must be less than maximum (${max}). Use a plain number when both ends are equal.`
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
return { min, max };
|
|
505
|
+
}
|
|
506
|
+
throw invalidArgument(
|
|
507
|
+
`${fn}: amount must be a number or a { min, max } range, received ${describeValue(amount)}.`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
function checkResult(fn, amount) {
|
|
511
|
+
for (const value of amountEnds(amount)) {
|
|
512
|
+
if (!isPositive(value)) {
|
|
513
|
+
throw invalidArgument(
|
|
514
|
+
`${fn}: the result must be a finite number greater than 0, but it is ${describeValue(value)}.`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return amount;
|
|
519
|
+
}
|
|
520
|
+
function checkFactor(fn, name, value) {
|
|
521
|
+
if (!isPositive(value)) {
|
|
522
|
+
throw invalidArgument(
|
|
523
|
+
`${fn}: ${name} must be a finite number greater than 0, received ${describeValue(value)}.`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function isPositive(value) {
|
|
528
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
529
|
+
}
|
|
530
|
+
function invalidArgument(message) {
|
|
531
|
+
return new RecipeBookError("invalid_argument", message);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/quantity/parse.ts
|
|
535
|
+
var VULGAR = "[\xBC-\xBE\u2150-\u215E]";
|
|
536
|
+
var ARTICLE = /^an?\s+(?=\D)/i;
|
|
537
|
+
var amountPattern;
|
|
538
|
+
function parseQuantity(text) {
|
|
539
|
+
const issues = [];
|
|
540
|
+
if (typeof text !== "string") {
|
|
541
|
+
error(
|
|
542
|
+
issues,
|
|
543
|
+
"invalid_type",
|
|
544
|
+
[],
|
|
545
|
+
`Expected quantity text such as "1 \xBD cups", received ${describeValue(text)}.`
|
|
546
|
+
);
|
|
547
|
+
return toResult("invalid_quantity", void 0, issues);
|
|
548
|
+
}
|
|
549
|
+
const quantity = readQuantityText(issues, text, []);
|
|
550
|
+
return toResult("invalid_quantity", quantity, issues, quote(text));
|
|
551
|
+
}
|
|
552
|
+
function readQuantity(issues, value, path) {
|
|
553
|
+
if (typeof value === "string") {
|
|
554
|
+
const quantity = readQuantityText(issues, value, path);
|
|
555
|
+
return quantity && readTextAmount(issues, quantity.amount, path) ? quantity : void 0;
|
|
556
|
+
}
|
|
557
|
+
if (typeof value === "number") {
|
|
558
|
+
const amount = readPositive(issues, value, path);
|
|
559
|
+
return amount === void 0 ? void 0 : { amount };
|
|
560
|
+
}
|
|
561
|
+
const record = asRecord(value);
|
|
562
|
+
if (record) {
|
|
563
|
+
checkFields(issues, record, { amount: true, unit: true }, path);
|
|
564
|
+
const amount = readAmount(issues, record.amount, [...path, "amount"]);
|
|
565
|
+
const { unit } = record;
|
|
566
|
+
if (unit === void 0) {
|
|
567
|
+
return amount === void 0 ? void 0 : { amount };
|
|
568
|
+
}
|
|
569
|
+
if (typeof unit !== "string") {
|
|
570
|
+
return error(
|
|
571
|
+
issues,
|
|
572
|
+
"invalid_type",
|
|
573
|
+
[...path, "unit"],
|
|
574
|
+
`Expected a unit such as "g" or "cup", received ${describeValue(unit)}.`
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
const id = findUnit(unit);
|
|
578
|
+
if (!id) {
|
|
579
|
+
const problem = unknownUnit(
|
|
580
|
+
unit,
|
|
581
|
+
amount === void 0 ? void 0 : typeof amount === "number" ? String(amount) : `{ amount: { min: ${amount.min}, max: ${amount.max} } }`
|
|
582
|
+
);
|
|
583
|
+
return error(
|
|
584
|
+
issues,
|
|
585
|
+
problem.code,
|
|
586
|
+
[...path, "unit"],
|
|
587
|
+
problem.message,
|
|
588
|
+
problem.suggestion
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
return amount === void 0 ? void 0 : { amount, unit: id };
|
|
592
|
+
}
|
|
593
|
+
return error(
|
|
594
|
+
issues,
|
|
595
|
+
"invalid_type",
|
|
596
|
+
path,
|
|
597
|
+
`Expected a quantity such as "2 cups", 2, or { amount: 2, unit: "cup" }, received ${describeValue(value)}.`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
function readQuantityText(issues, text, path) {
|
|
601
|
+
const result = parseText(text.replace(/\s+/g, " ").trim());
|
|
602
|
+
if ("code" in result) {
|
|
603
|
+
return error(issues, result.code, path, result.message, result.suggestion);
|
|
604
|
+
}
|
|
605
|
+
return result;
|
|
606
|
+
}
|
|
607
|
+
function parseText(text) {
|
|
608
|
+
const match = matchAmount(text);
|
|
609
|
+
if (!match) {
|
|
610
|
+
const unit2 = findUnit(text);
|
|
611
|
+
return unit2 ? {
|
|
612
|
+
code: "invalid_quantity",
|
|
613
|
+
message: `${quote(text)} has no amount. Write a full quantity, e.g. "1 ${units[unit2].labels.short.one}".`
|
|
614
|
+
} : unreadable(text);
|
|
615
|
+
}
|
|
616
|
+
if ("code" in match) {
|
|
617
|
+
return match;
|
|
618
|
+
}
|
|
619
|
+
const { amount, amountText, rest: unitText } = match;
|
|
620
|
+
if (unitText === "") {
|
|
621
|
+
return { amount };
|
|
622
|
+
}
|
|
623
|
+
if (/\d/.test(unitText) || !/\p{L}/u.test(unitText)) {
|
|
624
|
+
return unreadable(text);
|
|
625
|
+
}
|
|
626
|
+
const unit = findUnit(unitText);
|
|
627
|
+
if (unit) {
|
|
628
|
+
return { amount, unit };
|
|
629
|
+
}
|
|
630
|
+
return unknownUnit(
|
|
631
|
+
unitText,
|
|
632
|
+
amountText.length <= MAX_ECHOED && /^\d+(?:\.\d+)?$/.test(amountText) ? amountText : quote(amountText)
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
function matchAmount(text, allowZero) {
|
|
636
|
+
if (/\d,\d/.test(text)) {
|
|
637
|
+
return {
|
|
638
|
+
code: "invalid_quantity",
|
|
639
|
+
message: `Couldn't read ${quote(text)}. Write decimals with a period, e.g. "1.5", and leave out thousands separators.`
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
const article = ARTICLE.exec(text);
|
|
643
|
+
if (article) {
|
|
644
|
+
return { amount: 1, amountText: "1", rest: text.slice(article[0].length) };
|
|
645
|
+
}
|
|
646
|
+
if (!amountPattern) {
|
|
647
|
+
const number = String.raw`(?:\d+\s+)?\d+\s*[/⁄]\s*\d+|(?:\d+\s*)?${VULGAR}|\d+(?:\.\d+)?|\.\d+`;
|
|
648
|
+
amountPattern = new RegExp(
|
|
649
|
+
String.raw`^(${number})(?:(?:\s*[-–—]\s*|\s+to\s+)(${number}))?`,
|
|
650
|
+
"i"
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
const match = amountPattern.exec(text);
|
|
654
|
+
if (!match) {
|
|
655
|
+
return void 0;
|
|
656
|
+
}
|
|
657
|
+
const [matched, lowText = "", highText] = match;
|
|
658
|
+
const low = toNumber(lowText);
|
|
659
|
+
const high = highText === void 0 ? low : toNumber(highText);
|
|
660
|
+
if (low === Infinity || high === Infinity) {
|
|
661
|
+
return {
|
|
662
|
+
code: "invalid_amount",
|
|
663
|
+
message: `${quote(text)} has an amount too large to read. Check it for extra digits.`
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
if (!(isPositive(low) || allowZero && low === 0) || !(isPositive(high) || allowZero && high === 0)) {
|
|
667
|
+
return {
|
|
668
|
+
code: "invalid_amount",
|
|
669
|
+
message: `Expected a positive amount, received ${quote(text)}.`
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (low > high) {
|
|
673
|
+
const mixedHint = /^\d+$/.test(lowText) && high < 1 ? ` To write a mixed number, use ${quote(`${lowText} ${highText}`)}.` : "";
|
|
674
|
+
return {
|
|
675
|
+
code: "invalid_range",
|
|
676
|
+
message: `Range minimum (${truncateNumber(lowText)}) must be less than maximum (${truncateNumber(highText)}).${mixedHint}`
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
amount: low === high ? low : { min: low, max: high },
|
|
681
|
+
amountText: matched,
|
|
682
|
+
rest: text.slice(matched.length).trim()
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
function unknownUnit(unitText, quantity) {
|
|
686
|
+
const suggestion = suggestUnit(unitText);
|
|
687
|
+
const move = quantity === void 0 ? "move the word into the name" : `move the word into the name: { name: ${quote(unitText)}, quantity: ${quantity} }`;
|
|
688
|
+
return {
|
|
689
|
+
code: "unknown_unit",
|
|
690
|
+
message: suggestion ? `Unknown unit ${quote(unitText)}. Did you mean "${suggestion}"?` : `Unknown unit ${quote(unitText)}. Use a known unit, or ${move}.`,
|
|
691
|
+
...suggestion && { suggestion }
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
function truncateNumber(text) {
|
|
695
|
+
return text.length > 20 ? `${text.slice(0, 19)}\u2026` : text;
|
|
696
|
+
}
|
|
697
|
+
function unreadable(text) {
|
|
698
|
+
return {
|
|
699
|
+
code: "invalid_quantity",
|
|
700
|
+
message: text === "" ? 'Expected a quantity such as "1 \xBD cups", received an empty string.' : `Couldn't read ${quote(text)}. Use a number or fraction, e.g. "1 \xBD cups".`
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
var fractions = /* @__PURE__ */ new Map();
|
|
704
|
+
function toNumber(text) {
|
|
705
|
+
const ascii = /^(?:(\d+)\s+)?(\d+)\s*[/⁄]\s*(\d+)$/.exec(text);
|
|
706
|
+
if (ascii) {
|
|
707
|
+
const [, whole = "0", numerator, denominator] = ascii;
|
|
708
|
+
return Number(whole) + Number(numerator) / (Number(denominator) || NaN);
|
|
709
|
+
}
|
|
710
|
+
const last = text.slice(-1);
|
|
711
|
+
if (last > "9") {
|
|
712
|
+
let fraction = fractions.get(last);
|
|
713
|
+
if (fraction === void 0) {
|
|
714
|
+
const [numerator, denominator] = last.normalize("NFKD").split("\u2044");
|
|
715
|
+
fraction = Number(numerator) / Number(denominator);
|
|
716
|
+
fractions.set(last, fraction);
|
|
717
|
+
}
|
|
718
|
+
return Number(text.slice(0, -1)) + fraction;
|
|
719
|
+
}
|
|
720
|
+
return Number(text);
|
|
721
|
+
}
|
|
722
|
+
function readAmount(issues, value, path) {
|
|
723
|
+
if (value === void 0) {
|
|
724
|
+
return error(
|
|
725
|
+
issues,
|
|
726
|
+
"missing_field",
|
|
727
|
+
path,
|
|
728
|
+
'Add an amount, e.g. { amount: 2, unit: "cup" }.'
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
if (typeof value === "number") {
|
|
732
|
+
return readPositive(issues, value, path);
|
|
733
|
+
}
|
|
734
|
+
const range = asRecord(value);
|
|
735
|
+
if (range && "min" in range && "max" in range) {
|
|
736
|
+
checkFields(issues, range, { min: true, max: true }, path);
|
|
737
|
+
const min = readPositive(issues, range.min, [...path, "min"]);
|
|
738
|
+
const max = readPositive(issues, range.max, [...path, "max"]);
|
|
739
|
+
if (min === void 0 || max === void 0) {
|
|
740
|
+
return void 0;
|
|
741
|
+
}
|
|
742
|
+
if (min > max) {
|
|
743
|
+
return error(
|
|
744
|
+
issues,
|
|
745
|
+
"invalid_range",
|
|
746
|
+
path,
|
|
747
|
+
`Range minimum (${min}) must be less than maximum (${max}).`
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
return min === max ? min : { min, max };
|
|
751
|
+
}
|
|
752
|
+
return error(
|
|
753
|
+
issues,
|
|
754
|
+
"invalid_type",
|
|
755
|
+
path,
|
|
756
|
+
`Expected a number or { min, max }, received ${describeValue(value)}.`
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
function readPositive(issues, value, path) {
|
|
760
|
+
if (isPositive(value) && value >= 1e-300 && value <= 1e290) {
|
|
761
|
+
return value;
|
|
762
|
+
}
|
|
763
|
+
return error(
|
|
764
|
+
issues,
|
|
765
|
+
typeof value === "number" ? "invalid_amount" : "invalid_type",
|
|
766
|
+
path,
|
|
767
|
+
isPositive(value) ? `Expected a number from 1e-300 to 1e290, received ${describeValue(value)}.` : `Expected a positive number, received ${describeValue(value)}.`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
function readTextAmount(issues, amount, path) {
|
|
771
|
+
return (typeof amount === "number" ? [amount] : [amount.min, amount.max]).every((end) => readPositive(issues, end, path) !== void 0);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/recipe/duration.ts
|
|
775
|
+
var ISO_DURATION = /^P(?!$)(?:(\d+(?:[.,]\d+)?)W)?(?:(\d+(?:[.,]\d+)?)D)?(?:T(?=\d)(?:(\d+(?:[.,]\d+)?)H)?(?:(\d+(?:[.,]\d+)?)M)?(?:(\d+(?:[.,]\d+)?)S)?)?$/i;
|
|
776
|
+
var SECONDS = [604800, 86400, 3600, 60, 1];
|
|
777
|
+
function parseIsoDuration(text) {
|
|
778
|
+
const match = ISO_DURATION.exec(text.trim());
|
|
779
|
+
if (!match) {
|
|
780
|
+
return void 0;
|
|
781
|
+
}
|
|
782
|
+
let seconds = 0;
|
|
783
|
+
let fractional = false;
|
|
784
|
+
let group = 0;
|
|
785
|
+
for (const unitSeconds of SECONDS) {
|
|
786
|
+
const part = match[++group];
|
|
787
|
+
if (part === void 0) {
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (fractional) {
|
|
791
|
+
return void 0;
|
|
792
|
+
}
|
|
793
|
+
fractional = /[.,]/.test(part);
|
|
794
|
+
seconds += Number(part.replace(",", ".")) * unitSeconds;
|
|
795
|
+
}
|
|
796
|
+
return Number.isFinite(seconds) ? seconds / 60 : void 0;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/recipe/ids.ts
|
|
800
|
+
function slugify(text) {
|
|
801
|
+
return text.normalize("NFKD").replace(/(\p{Script=Latin})\p{M}+/gu, "$1").toLowerCase().normalize("NFC").replace(/(^|[^\p{L}\p{M}])\p{M}+/gu, "$1").replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
|
|
802
|
+
}
|
|
803
|
+
function assignIds(requests, pending) {
|
|
804
|
+
const used = /* @__PURE__ */ new Set();
|
|
805
|
+
const nextSuffix = /* @__PURE__ */ new Map();
|
|
806
|
+
const ids = requests.map(({ authored }) => {
|
|
807
|
+
if (authored === void 0 || used.has(authored)) {
|
|
808
|
+
return void 0;
|
|
809
|
+
}
|
|
810
|
+
used.add(authored);
|
|
811
|
+
return authored;
|
|
812
|
+
});
|
|
813
|
+
return requests.map((request, i) => {
|
|
814
|
+
const claimed = ids[i];
|
|
815
|
+
if (claimed !== void 0) {
|
|
816
|
+
return claimed;
|
|
817
|
+
}
|
|
818
|
+
const base = request.authored ?? request.derived;
|
|
819
|
+
let id = base;
|
|
820
|
+
if (used.has(id)) {
|
|
821
|
+
let n = nextSuffix.get(base) ?? 2;
|
|
822
|
+
while (used.has(id = `${base}-${n}`)) {
|
|
823
|
+
n++;
|
|
824
|
+
}
|
|
825
|
+
nextSuffix.set(base, n + 1);
|
|
826
|
+
}
|
|
827
|
+
used.add(id);
|
|
828
|
+
if (request.authored !== void 0) {
|
|
829
|
+
pending.push({
|
|
830
|
+
position: request.position,
|
|
831
|
+
issue: issue(
|
|
832
|
+
"warning",
|
|
833
|
+
"duplicate_id",
|
|
834
|
+
[...request.path, "id"],
|
|
835
|
+
`${quote(request.authored)} is already used; renamed to ${quote(id)}.`
|
|
836
|
+
)
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
return id;
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
function insertPending(issues, pending) {
|
|
843
|
+
if (pending.length === 0) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const sorted = [...pending].sort((a, b) => a.position - b.position);
|
|
847
|
+
const original = issues.splice(0);
|
|
848
|
+
let next = 0;
|
|
849
|
+
for (const { position, issue: issue2 } of sorted) {
|
|
850
|
+
while (next < position) {
|
|
851
|
+
issues.push(original[next++]);
|
|
852
|
+
}
|
|
853
|
+
issues.push(issue2);
|
|
854
|
+
}
|
|
855
|
+
while (next < original.length) {
|
|
856
|
+
issues.push(original[next++]);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/recipe/infer.ts
|
|
861
|
+
function inferSystem(sections) {
|
|
862
|
+
const counts = { metric: 0, us: 0 };
|
|
863
|
+
let first;
|
|
864
|
+
for (const { items } of sections) {
|
|
865
|
+
for (const { quantity } of items) {
|
|
866
|
+
const systems = quantity?.unit ? units[quantity.unit].systems : [];
|
|
867
|
+
if (systems.length === 1) {
|
|
868
|
+
const system = systems[0];
|
|
869
|
+
counts[system]++;
|
|
870
|
+
first ?? (first = system);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (counts.metric !== counts.us) {
|
|
875
|
+
return counts.metric > counts.us ? "metric" : "us";
|
|
876
|
+
}
|
|
877
|
+
return first;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// src/recipe/nutrient-units.ts
|
|
881
|
+
var NUTRIENT_UNITS = {
|
|
882
|
+
calories: "kcal",
|
|
883
|
+
fat: "g",
|
|
884
|
+
saturatedFat: "g",
|
|
885
|
+
transFat: "g",
|
|
886
|
+
unsaturatedFat: "g",
|
|
887
|
+
carbohydrates: "g",
|
|
888
|
+
sugar: "g",
|
|
889
|
+
fiber: "g",
|
|
890
|
+
protein: "g",
|
|
891
|
+
sodium: "mg",
|
|
892
|
+
cholesterol: "mg"
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
// src/recipe/nutrition.ts
|
|
896
|
+
function parseNutrient(text, unit) {
|
|
897
|
+
const match = matchAmount(text.replace(/\s+/g, " "), true);
|
|
898
|
+
if (match && "code" in match) {
|
|
899
|
+
return match.code === "invalid_quantity" ? match : void 0;
|
|
900
|
+
}
|
|
901
|
+
const amount = match && typeof match.amount === "number" ? convert(match.amount, match.rest, unit) : void 0;
|
|
902
|
+
return Number.isFinite(amount) ? amount : void 0;
|
|
903
|
+
}
|
|
904
|
+
function convert(amount, unitText, unit) {
|
|
905
|
+
if (unitText === "") {
|
|
906
|
+
return amount;
|
|
907
|
+
}
|
|
908
|
+
if (unit === "kcal") {
|
|
909
|
+
return /^(?:k?cals?|(?:kilo)?calories?)\.?$/i.test(unitText) ? amount : /^(?:kj|kilojoules?)\.?$/i.test(unitText) ? amount / 4.184 : void 0;
|
|
910
|
+
}
|
|
911
|
+
const mass = lookupUnit(findUnit(unitText));
|
|
912
|
+
return mass?.dimension === "mass" && mass.toBase !== void 0 ? amount * (unit === "mg" ? mass.toBase * 1e3 : mass.toBase) : void 0;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// src/recipe/url.ts
|
|
916
|
+
function disallowedScheme(url) {
|
|
917
|
+
const scheme = /^[\0- ]*[a-z][\d+.a-z\t\n\r-]*:/i.exec(url)?.[0].replace(/[\0- ]+/g, "").toLowerCase();
|
|
918
|
+
return scheme === "http:" || scheme === "https:" ? void 0 : scheme;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// src/recipe/parse.ts
|
|
922
|
+
var RECIPE_FIELDS = {
|
|
923
|
+
id: true,
|
|
924
|
+
name: true,
|
|
925
|
+
description: true,
|
|
926
|
+
image: true,
|
|
927
|
+
servings: true,
|
|
928
|
+
yield: true,
|
|
929
|
+
system: true,
|
|
930
|
+
times: true,
|
|
931
|
+
ingredients: true,
|
|
932
|
+
steps: true,
|
|
933
|
+
notes: true,
|
|
934
|
+
equipment: true,
|
|
935
|
+
nutrition: true
|
|
936
|
+
};
|
|
937
|
+
var INGREDIENT_FIELDS = {
|
|
938
|
+
id: true,
|
|
939
|
+
name: true,
|
|
940
|
+
plural: true,
|
|
941
|
+
quantity: true,
|
|
942
|
+
equivalents: true,
|
|
943
|
+
note: true,
|
|
944
|
+
url: true,
|
|
945
|
+
optional: true
|
|
946
|
+
};
|
|
947
|
+
var SECTION_FIELDS = {
|
|
948
|
+
id: true,
|
|
949
|
+
title: true,
|
|
950
|
+
items: true
|
|
951
|
+
};
|
|
952
|
+
var STEP_FIELDS = {
|
|
953
|
+
id: true,
|
|
954
|
+
text: true,
|
|
955
|
+
name: true,
|
|
956
|
+
image: true
|
|
957
|
+
};
|
|
958
|
+
var SERVINGS_FIELDS = {
|
|
959
|
+
amount: true,
|
|
960
|
+
max: true
|
|
961
|
+
};
|
|
962
|
+
var YIELD_FIELDS = {
|
|
963
|
+
amount: true,
|
|
964
|
+
max: true,
|
|
965
|
+
unit: true,
|
|
966
|
+
plural: true
|
|
967
|
+
};
|
|
968
|
+
var TIMES_FIELDS = {
|
|
969
|
+
prep: true,
|
|
970
|
+
cook: true,
|
|
971
|
+
total: true,
|
|
972
|
+
custom: true
|
|
973
|
+
};
|
|
974
|
+
var CUSTOM_TIME_FIELDS = {
|
|
975
|
+
id: true,
|
|
976
|
+
label: true,
|
|
977
|
+
minutes: true
|
|
978
|
+
};
|
|
979
|
+
var NOTE_FIELDS = {
|
|
980
|
+
id: true,
|
|
981
|
+
title: true,
|
|
982
|
+
text: true
|
|
983
|
+
};
|
|
984
|
+
var EQUIPMENT_FIELDS = {
|
|
985
|
+
id: true,
|
|
986
|
+
name: true,
|
|
987
|
+
plural: true,
|
|
988
|
+
quantity: true,
|
|
989
|
+
url: true,
|
|
990
|
+
note: true
|
|
991
|
+
};
|
|
992
|
+
var NUTRIENT_EXAMPLES = {
|
|
993
|
+
kcal: 'kcal, e.g. 250, "250 kcal", or "1046 kJ"',
|
|
994
|
+
g: 'grams, e.g. 12 or "12 g"',
|
|
995
|
+
mg: 'milligrams, e.g. 300, "300 mg", or "0.3 g"'
|
|
996
|
+
};
|
|
997
|
+
function parseRecipe(input, options) {
|
|
998
|
+
return parseRecipeWith(
|
|
999
|
+
input,
|
|
1000
|
+
readLineOption("parseRecipe", options) ?? "pass parseIngredientLine: parseRecipe(input, { parseIngredientLine })"
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
function parseRecipeWith(input, lines) {
|
|
1004
|
+
const issues = [];
|
|
1005
|
+
const recipe = readRecipe(issues, input, lines);
|
|
1006
|
+
const label2 = recipe && quote(recipe.name);
|
|
1007
|
+
return toResult("invalid_recipe", recipe, issues, label2);
|
|
1008
|
+
}
|
|
1009
|
+
function readLineOption(fn, options) {
|
|
1010
|
+
const reader = asRecord(options)?.parseIngredientLine;
|
|
1011
|
+
if (reader !== void 0 && typeof reader !== "function") {
|
|
1012
|
+
throw new RecipeBookError(
|
|
1013
|
+
"invalid_argument",
|
|
1014
|
+
`${fn}: parseIngredientLine must be a function such as parseIngredientLine, received ${describeValue(reader)}.`
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
return reader;
|
|
1018
|
+
}
|
|
1019
|
+
function readRecipe(issues, value, lines) {
|
|
1020
|
+
const input = asRecord(value);
|
|
1021
|
+
if (!input) {
|
|
1022
|
+
return error(
|
|
1023
|
+
issues,
|
|
1024
|
+
"invalid_type",
|
|
1025
|
+
[],
|
|
1026
|
+
`Expected a recipe object such as { name, ingredients, steps }, received ${describeValue(value)}.`
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
const pending = [];
|
|
1030
|
+
const id = readText(issues, input.id, ["id"]);
|
|
1031
|
+
const name = readText(
|
|
1032
|
+
issues,
|
|
1033
|
+
input.name,
|
|
1034
|
+
["name"],
|
|
1035
|
+
"Add a name for the recipe."
|
|
1036
|
+
);
|
|
1037
|
+
const description = readText(issues, input.description, ["description"]);
|
|
1038
|
+
const image = readUrl(issues, input.image, ["image"]);
|
|
1039
|
+
const servings = readServings(issues, input.servings, ["servings"]);
|
|
1040
|
+
const recipeYield = readYield(issues, input.yield, ["yield"]);
|
|
1041
|
+
const authoredSystem = readSystem(issues, input.system, ["system"]);
|
|
1042
|
+
const times = readTimes(issues, pending, input.times, ["times"]);
|
|
1043
|
+
const ingredients = readList(
|
|
1044
|
+
issues,
|
|
1045
|
+
pending,
|
|
1046
|
+
input.ingredients,
|
|
1047
|
+
["ingredients"],
|
|
1048
|
+
"ingredients",
|
|
1049
|
+
(issues2, value2, path) => readIngredient(issues2, value2, path, lines)
|
|
1050
|
+
);
|
|
1051
|
+
const steps = readList(
|
|
1052
|
+
issues,
|
|
1053
|
+
pending,
|
|
1054
|
+
input.steps,
|
|
1055
|
+
["steps"],
|
|
1056
|
+
"steps",
|
|
1057
|
+
readStep
|
|
1058
|
+
);
|
|
1059
|
+
const notes = readItems(
|
|
1060
|
+
issues,
|
|
1061
|
+
pending,
|
|
1062
|
+
input.notes,
|
|
1063
|
+
["notes"],
|
|
1064
|
+
"notes",
|
|
1065
|
+
readNote
|
|
1066
|
+
);
|
|
1067
|
+
const equipment = readItems(
|
|
1068
|
+
issues,
|
|
1069
|
+
pending,
|
|
1070
|
+
input.equipment,
|
|
1071
|
+
["equipment"],
|
|
1072
|
+
"equipment",
|
|
1073
|
+
readEquipment
|
|
1074
|
+
);
|
|
1075
|
+
const nutrition = readNutrition(issues, input.nutrition, ["nutrition"]);
|
|
1076
|
+
checkFields(issues, input, RECIPE_FIELDS, []);
|
|
1077
|
+
insertPending(issues, pending);
|
|
1078
|
+
if (name === void 0) {
|
|
1079
|
+
return void 0;
|
|
1080
|
+
}
|
|
1081
|
+
const system = authoredSystem ?? inferSystem(ingredients);
|
|
1082
|
+
return defined({
|
|
1083
|
+
id: id ?? (slugify(name) || "recipe"),
|
|
1084
|
+
name,
|
|
1085
|
+
description,
|
|
1086
|
+
image,
|
|
1087
|
+
servings,
|
|
1088
|
+
yield: recipeYield,
|
|
1089
|
+
system,
|
|
1090
|
+
times,
|
|
1091
|
+
ingredients,
|
|
1092
|
+
steps,
|
|
1093
|
+
notes,
|
|
1094
|
+
equipment,
|
|
1095
|
+
nutrition
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
function readList(issues, pending, value, path, noun, readItem) {
|
|
1099
|
+
const sections = [];
|
|
1100
|
+
let loose;
|
|
1101
|
+
let count2 = 0;
|
|
1102
|
+
const read = (entry, entryPath) => readItem(issues, entry, entryPath, ++count2);
|
|
1103
|
+
const visit = (entry, i) => {
|
|
1104
|
+
const entryPath = [...path, i];
|
|
1105
|
+
const section = asSection(entry);
|
|
1106
|
+
if (section) {
|
|
1107
|
+
loose = void 0;
|
|
1108
|
+
sections.push(readSection(issues, section, entryPath, noun, read));
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (!loose) {
|
|
1112
|
+
loose = {
|
|
1113
|
+
authored: void 0,
|
|
1114
|
+
derived: noun,
|
|
1115
|
+
path: entryPath,
|
|
1116
|
+
position: issues.length,
|
|
1117
|
+
title: void 0,
|
|
1118
|
+
items: []
|
|
1119
|
+
};
|
|
1120
|
+
sections.push(loose);
|
|
1121
|
+
}
|
|
1122
|
+
const item = read(entry, entryPath);
|
|
1123
|
+
if (item) {
|
|
1124
|
+
loose.items.push(item);
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
if (value !== void 0 && !readArray(issues, value, path, noun, visit)) {
|
|
1128
|
+
return [];
|
|
1129
|
+
}
|
|
1130
|
+
if (sections.length === 0) {
|
|
1131
|
+
warning(issues, "empty_list", path, `Recipe has no ${noun}.`);
|
|
1132
|
+
return [];
|
|
1133
|
+
}
|
|
1134
|
+
const sectionIds = assignIds(sections, pending);
|
|
1135
|
+
const itemIds = assignIds(
|
|
1136
|
+
sections.flatMap((section) => section.items),
|
|
1137
|
+
pending
|
|
1138
|
+
);
|
|
1139
|
+
let next = 0;
|
|
1140
|
+
return sections.map(
|
|
1141
|
+
(section, i) => defined({
|
|
1142
|
+
id: sectionIds[i],
|
|
1143
|
+
title: section.title,
|
|
1144
|
+
items: section.items.map((item) => item.build(itemIds[next++]))
|
|
1145
|
+
})
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
function asSection(entry) {
|
|
1149
|
+
const record = asRecord(entry);
|
|
1150
|
+
return record && "items" in record ? record : void 0;
|
|
1151
|
+
}
|
|
1152
|
+
function readSection(issues, record, path, noun, read) {
|
|
1153
|
+
const position = issues.length;
|
|
1154
|
+
const authored = readText(issues, record.id, [...path, "id"]);
|
|
1155
|
+
const title = readText(issues, record.title, [...path, "title"]);
|
|
1156
|
+
const items = [];
|
|
1157
|
+
const itemsPath = [...path, "items"];
|
|
1158
|
+
let visited = 0;
|
|
1159
|
+
const entries = readArray(
|
|
1160
|
+
issues,
|
|
1161
|
+
record.items,
|
|
1162
|
+
itemsPath,
|
|
1163
|
+
noun,
|
|
1164
|
+
(entry, j) => {
|
|
1165
|
+
visited++;
|
|
1166
|
+
const itemPath = [...itemsPath, j];
|
|
1167
|
+
if (asSection(entry)) {
|
|
1168
|
+
error(
|
|
1169
|
+
issues,
|
|
1170
|
+
"invalid_type",
|
|
1171
|
+
itemPath,
|
|
1172
|
+
`Sections can't be nested. Make this a top-level section in ${noun}.`
|
|
1173
|
+
);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
const item = read(entry, itemPath);
|
|
1177
|
+
if (item) {
|
|
1178
|
+
items.push(item);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
);
|
|
1182
|
+
if (entries && visited === 0) {
|
|
1183
|
+
warning(issues, "empty_list", itemsPath, `Section has no ${noun}.`);
|
|
1184
|
+
}
|
|
1185
|
+
checkFields(issues, record, SECTION_FIELDS, path);
|
|
1186
|
+
return {
|
|
1187
|
+
authored,
|
|
1188
|
+
derived: title && slugify(title) || noun,
|
|
1189
|
+
path,
|
|
1190
|
+
position,
|
|
1191
|
+
title,
|
|
1192
|
+
items
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
function readItems(issues, pending, value, path, noun, readItem) {
|
|
1196
|
+
let n = 0;
|
|
1197
|
+
const drafts = value === void 0 ? [] : readArray(
|
|
1198
|
+
issues,
|
|
1199
|
+
value,
|
|
1200
|
+
path,
|
|
1201
|
+
noun,
|
|
1202
|
+
(entry, i) => readItem(issues, entry, [...path, i], ++n)
|
|
1203
|
+
);
|
|
1204
|
+
if (!drafts) {
|
|
1205
|
+
return [];
|
|
1206
|
+
}
|
|
1207
|
+
const ids = assignIds(drafts, pending);
|
|
1208
|
+
return drafts.map((draft, i) => draft.build(ids[i]));
|
|
1209
|
+
}
|
|
1210
|
+
function readArray(issues, value, path, noun, read) {
|
|
1211
|
+
if (!Array.isArray(value)) {
|
|
1212
|
+
return error(
|
|
1213
|
+
issues,
|
|
1214
|
+
"invalid_type",
|
|
1215
|
+
path,
|
|
1216
|
+
`Expected a list of ${noun}, received ${describeValue(value)}.`
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
const kept = [];
|
|
1220
|
+
for (let i = 0, n = value.length; i < n; i++) {
|
|
1221
|
+
if (i in value) {
|
|
1222
|
+
const entry = value[i];
|
|
1223
|
+
const result = hasOwn(value, i) && read(entry, i);
|
|
1224
|
+
if (result) {
|
|
1225
|
+
kept.push(result);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return kept;
|
|
1230
|
+
}
|
|
1231
|
+
function readIngredient(issues, value, path, lines) {
|
|
1232
|
+
if (typeof value === "string" && typeof lines === "function") {
|
|
1233
|
+
const result = lines(value);
|
|
1234
|
+
for (const issue2 of result.success ? result.warnings : result.error.issues) {
|
|
1235
|
+
issues.push({ ...issue2, path: [...path, ...issue2.path] });
|
|
1236
|
+
}
|
|
1237
|
+
return result.success ? readIngredient(issues, result.data, path) : void 0;
|
|
1238
|
+
}
|
|
1239
|
+
const record = asRecord(value);
|
|
1240
|
+
if (!record) {
|
|
1241
|
+
return error(
|
|
1242
|
+
issues,
|
|
1243
|
+
"invalid_type",
|
|
1244
|
+
path,
|
|
1245
|
+
`Expected an ingredient such as ${typeof lines === "function" ? '"2 cups flour" or ' : ""}{ name: "flour", quantity: "2 cups" }, received ${describeValue(value)}.${typeof value === "string" && typeof lines === "string" ? ` To read ingredient lines, ${lines}.` : ""}`
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
const position = issues.length;
|
|
1249
|
+
const authored = readText(issues, record.id, [...path, "id"]);
|
|
1250
|
+
const name = readText(
|
|
1251
|
+
issues,
|
|
1252
|
+
record.name,
|
|
1253
|
+
[...path, "name"],
|
|
1254
|
+
'Add a name, e.g. name: "flour".'
|
|
1255
|
+
);
|
|
1256
|
+
const plural = readText(issues, record.plural, [...path, "plural"]);
|
|
1257
|
+
const quantity = record.quantity === void 0 ? void 0 : readQuantity(issues, record.quantity, [...path, "quantity"]);
|
|
1258
|
+
const equivalents = readEquivalents(issues, record.equivalents, [
|
|
1259
|
+
...path,
|
|
1260
|
+
"equivalents"
|
|
1261
|
+
]);
|
|
1262
|
+
const note = readText(issues, record.note, [...path, "note"]);
|
|
1263
|
+
const url = readUrl(issues, record.url, [...path, "url"]);
|
|
1264
|
+
const optional = readBoolean(issues, record.optional, [...path, "optional"]);
|
|
1265
|
+
if (equivalents.length > 0 && record.quantity === void 0) {
|
|
1266
|
+
error(
|
|
1267
|
+
issues,
|
|
1268
|
+
"missing_field",
|
|
1269
|
+
[...path, "quantity"],
|
|
1270
|
+
"Add a quantity. Equivalents are alternatives to it, so they need one."
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
checkFields(issues, record, INGREDIENT_FIELDS, path);
|
|
1274
|
+
if (name === void 0) {
|
|
1275
|
+
return void 0;
|
|
1276
|
+
}
|
|
1277
|
+
return {
|
|
1278
|
+
authored,
|
|
1279
|
+
derived: slugify(name) || "ingredient",
|
|
1280
|
+
path,
|
|
1281
|
+
position,
|
|
1282
|
+
build: (id) => defined({ id, name, plural, quantity, equivalents, note, url, optional })
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
function readEquivalents(issues, value, path) {
|
|
1286
|
+
return value !== void 0 && readArray(
|
|
1287
|
+
issues,
|
|
1288
|
+
value,
|
|
1289
|
+
path,
|
|
1290
|
+
'quantities, e.g. ["190 g"]',
|
|
1291
|
+
(entry, i) => readQuantity(issues, entry, [...path, i])
|
|
1292
|
+
) || [];
|
|
1293
|
+
}
|
|
1294
|
+
function readStep(issues, value, path, n) {
|
|
1295
|
+
const isText = typeof value === "string";
|
|
1296
|
+
const record = asRecord(isText ? { text: value } : value);
|
|
1297
|
+
if (!record) {
|
|
1298
|
+
return error(
|
|
1299
|
+
issues,
|
|
1300
|
+
"invalid_type",
|
|
1301
|
+
path,
|
|
1302
|
+
`Expected a string or { text }, received ${describeValue(value)}.`
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1305
|
+
const position = issues.length;
|
|
1306
|
+
const authored = readText(issues, record.id, [...path, "id"]);
|
|
1307
|
+
const text = readText(
|
|
1308
|
+
issues,
|
|
1309
|
+
record.text,
|
|
1310
|
+
isText ? path : [...path, "text"],
|
|
1311
|
+
"Add the step text."
|
|
1312
|
+
);
|
|
1313
|
+
const name = readText(issues, record.name, [...path, "name"]);
|
|
1314
|
+
const image = readUrl(issues, record.image, [...path, "image"]);
|
|
1315
|
+
checkFields(issues, record, STEP_FIELDS, path);
|
|
1316
|
+
if (text === void 0) {
|
|
1317
|
+
return void 0;
|
|
1318
|
+
}
|
|
1319
|
+
return {
|
|
1320
|
+
authored,
|
|
1321
|
+
derived: name && slugify(name) || `step-${n}`,
|
|
1322
|
+
path,
|
|
1323
|
+
position,
|
|
1324
|
+
build: (id) => defined({ id, text, name, image })
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
function readNote(issues, value, path, n) {
|
|
1328
|
+
const isText = typeof value === "string";
|
|
1329
|
+
const record = asRecord(isText ? { text: value } : value);
|
|
1330
|
+
if (!record) {
|
|
1331
|
+
return error(
|
|
1332
|
+
issues,
|
|
1333
|
+
"invalid_type",
|
|
1334
|
+
path,
|
|
1335
|
+
`Expected a string or { text }, received ${describeValue(value)}.`
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
const position = issues.length;
|
|
1339
|
+
const authored = readText(issues, record.id, [...path, "id"]);
|
|
1340
|
+
const title = readText(issues, record.title, [...path, "title"]);
|
|
1341
|
+
const text = readText(
|
|
1342
|
+
issues,
|
|
1343
|
+
record.text,
|
|
1344
|
+
isText ? path : [...path, "text"],
|
|
1345
|
+
"Add the note text."
|
|
1346
|
+
);
|
|
1347
|
+
checkFields(issues, record, NOTE_FIELDS, path);
|
|
1348
|
+
if (text === void 0) {
|
|
1349
|
+
return void 0;
|
|
1350
|
+
}
|
|
1351
|
+
return {
|
|
1352
|
+
authored,
|
|
1353
|
+
derived: title && slugify(title) || `note-${n}`,
|
|
1354
|
+
path,
|
|
1355
|
+
position,
|
|
1356
|
+
build: (id) => defined({ id, title, text })
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
function readEquipment(issues, value, path) {
|
|
1360
|
+
const isText = typeof value === "string";
|
|
1361
|
+
const record = asRecord(isText ? { name: value } : value);
|
|
1362
|
+
if (!record) {
|
|
1363
|
+
return error(
|
|
1364
|
+
issues,
|
|
1365
|
+
"invalid_type",
|
|
1366
|
+
path,
|
|
1367
|
+
`Expected a string or { name }, received ${describeValue(value)}.`
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
const position = issues.length;
|
|
1371
|
+
const authored = readText(issues, record.id, [...path, "id"]);
|
|
1372
|
+
const name = readText(
|
|
1373
|
+
issues,
|
|
1374
|
+
record.name,
|
|
1375
|
+
isText ? path : [...path, "name"],
|
|
1376
|
+
`Add a name, e.g. ${isText ? "" : "name: "}"whisk".`
|
|
1377
|
+
);
|
|
1378
|
+
const plural = readText(issues, record.plural, [...path, "plural"]);
|
|
1379
|
+
const quantity = record.quantity === void 0 ? void 0 : readCount(issues, record.quantity, [...path, "quantity"]);
|
|
1380
|
+
const url = readUrl(issues, record.url, [...path, "url"]);
|
|
1381
|
+
const note = readText(issues, record.note, [...path, "note"]);
|
|
1382
|
+
checkFields(issues, record, EQUIPMENT_FIELDS, path);
|
|
1383
|
+
if (name === void 0) {
|
|
1384
|
+
return void 0;
|
|
1385
|
+
}
|
|
1386
|
+
return {
|
|
1387
|
+
authored,
|
|
1388
|
+
derived: slugify(name) || "equipment",
|
|
1389
|
+
path,
|
|
1390
|
+
position,
|
|
1391
|
+
build: (id) => defined({ id, name, plural, quantity, url, note })
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
function defined(fields) {
|
|
1395
|
+
const copy = {};
|
|
1396
|
+
for (const key in fields) {
|
|
1397
|
+
if (hasOwn(fields, key) && fields[key] !== void 0) {
|
|
1398
|
+
copy[key] = fields[key];
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return copy;
|
|
1402
|
+
}
|
|
1403
|
+
function readCount(issues, value, path) {
|
|
1404
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
|
|
1405
|
+
return value;
|
|
1406
|
+
}
|
|
1407
|
+
return error(
|
|
1408
|
+
issues,
|
|
1409
|
+
typeof value === "number" ? "invalid_amount" : "invalid_type",
|
|
1410
|
+
path,
|
|
1411
|
+
`Expected a whole number greater than 0, received ${describeValue(value)}.`
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
function readServings(issues, value, path) {
|
|
1415
|
+
if (value === void 0) {
|
|
1416
|
+
return void 0;
|
|
1417
|
+
}
|
|
1418
|
+
if (typeof value === "number") {
|
|
1419
|
+
const amount = readPositive(issues, value, path);
|
|
1420
|
+
return amount === void 0 ? void 0 : { amount };
|
|
1421
|
+
}
|
|
1422
|
+
const expected = `Expected a number of servings, e.g. 4 or "4\u20136", received ${describeValue(value)}.`;
|
|
1423
|
+
if (typeof value === "string") {
|
|
1424
|
+
const match = readRangeText(issues, value, path, expected);
|
|
1425
|
+
if (match?.rest) {
|
|
1426
|
+
return error(issues, "invalid_quantity", path, expected);
|
|
1427
|
+
}
|
|
1428
|
+
return match?.range;
|
|
1429
|
+
}
|
|
1430
|
+
const record = asRecord(value);
|
|
1431
|
+
if (!record) {
|
|
1432
|
+
return error(issues, "invalid_type", path, expected);
|
|
1433
|
+
}
|
|
1434
|
+
const range = readRange(
|
|
1435
|
+
issues,
|
|
1436
|
+
record,
|
|
1437
|
+
path,
|
|
1438
|
+
"Add an amount, e.g. { amount: 4, max: 6 }."
|
|
1439
|
+
);
|
|
1440
|
+
checkFields(issues, record, SERVINGS_FIELDS, path);
|
|
1441
|
+
return range;
|
|
1442
|
+
}
|
|
1443
|
+
function readYield(issues, value, path) {
|
|
1444
|
+
if (value === void 0) {
|
|
1445
|
+
return void 0;
|
|
1446
|
+
}
|
|
1447
|
+
const isText = typeof value === "string";
|
|
1448
|
+
const expected = `Expected a batch such as "24 cookies" or ${isText ? '"3\u20134 loaves"' : "{ amount, unit }"}, received ${describeValue(value)}.`;
|
|
1449
|
+
if (isText) {
|
|
1450
|
+
const match = readRangeText(issues, value, path, expected);
|
|
1451
|
+
if (!match) {
|
|
1452
|
+
return void 0;
|
|
1453
|
+
}
|
|
1454
|
+
const { range: range2, rest } = match;
|
|
1455
|
+
if (!rest) {
|
|
1456
|
+
return error(
|
|
1457
|
+
issues,
|
|
1458
|
+
"missing_field",
|
|
1459
|
+
path,
|
|
1460
|
+
`Add what the recipe makes, e.g. "24 cookies".${servingsHint(range2)}`
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
if (!/\p{L}/u.test(rest) || /^(?:[-–—&]|(?:[\d.,/\p{No}]+|or|to|x|and)(?:\s|$))/iu.test(rest)) {
|
|
1464
|
+
const suggestion = suggestJoinedYield(value, range2, rest);
|
|
1465
|
+
return error(
|
|
1466
|
+
issues,
|
|
1467
|
+
"invalid_quantity",
|
|
1468
|
+
path,
|
|
1469
|
+
suggestion ? `${expected} Did you mean "${suggestion}"?` : expected,
|
|
1470
|
+
suggestion
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
return { ...range2, unit: rest };
|
|
1474
|
+
}
|
|
1475
|
+
const record = asRecord(value);
|
|
1476
|
+
if (!record) {
|
|
1477
|
+
return error(
|
|
1478
|
+
issues,
|
|
1479
|
+
"invalid_type",
|
|
1480
|
+
path,
|
|
1481
|
+
`${expected}${isPositive(value) ? servingsHint({ amount: value }) : ""}`
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
const range = readRange(
|
|
1485
|
+
issues,
|
|
1486
|
+
record,
|
|
1487
|
+
path,
|
|
1488
|
+
'Add an amount, e.g. { amount: 24, unit: "cookie" }.'
|
|
1489
|
+
);
|
|
1490
|
+
const unit = readText(
|
|
1491
|
+
issues,
|
|
1492
|
+
record.unit,
|
|
1493
|
+
[...path, "unit"],
|
|
1494
|
+
`Add a unit, e.g. { amount: 24, unit: "cookie" }.${servingsHint(range)}`
|
|
1495
|
+
);
|
|
1496
|
+
const plural = readText(issues, record.plural, [...path, "plural"]);
|
|
1497
|
+
checkFields(issues, record, YIELD_FIELDS, path);
|
|
1498
|
+
if (range === void 0 || unit === void 0) {
|
|
1499
|
+
return void 0;
|
|
1500
|
+
}
|
|
1501
|
+
return defined({ ...range, unit, plural });
|
|
1502
|
+
}
|
|
1503
|
+
function suggestJoinedYield(text, { amount }, rest) {
|
|
1504
|
+
const joiner = /^(?:and|&)\s*/i.exec(rest);
|
|
1505
|
+
const next = joiner && matchAmount(rest.slice(joiner[0].length));
|
|
1506
|
+
if (!next || "code" in next || typeof next.amount !== "number") {
|
|
1507
|
+
return void 0;
|
|
1508
|
+
}
|
|
1509
|
+
const isFraction = next.amount < 1;
|
|
1510
|
+
if (!isFraction && !Number.isInteger(next.amount)) {
|
|
1511
|
+
return void 0;
|
|
1512
|
+
}
|
|
1513
|
+
const head = text.replace(/\s+/g, " ").trim().slice(0, -rest.length);
|
|
1514
|
+
const suggestion = `${head.trim()}${isFraction ? " " : "-"}${next.amountText} ${next.rest}`;
|
|
1515
|
+
const parsed = readYield([], suggestion, []);
|
|
1516
|
+
return parsed && (isFraction ? parsed.amount === amount + next.amount && parsed.max === void 0 : parsed.amount === amount && parsed.max === next.amount) ? suggestion : void 0;
|
|
1517
|
+
}
|
|
1518
|
+
function readRangeText(issues, text, path, expected) {
|
|
1519
|
+
const match = matchAmount(text.replace(/\s+/g, " ").trim());
|
|
1520
|
+
if (!match) {
|
|
1521
|
+
return error(issues, "invalid_quantity", path, expected);
|
|
1522
|
+
}
|
|
1523
|
+
if ("code" in match) {
|
|
1524
|
+
return error(issues, match.code, path, match.message);
|
|
1525
|
+
}
|
|
1526
|
+
const { amount, rest } = match;
|
|
1527
|
+
const range = typeof amount === "number" ? { amount } : { amount: amount.min, max: amount.max };
|
|
1528
|
+
if (readPositive(issues, range.amount, path) === void 0 || range.max !== void 0 && readPositive(issues, range.max, path) === void 0) {
|
|
1529
|
+
return void 0;
|
|
1530
|
+
}
|
|
1531
|
+
return { range, rest };
|
|
1532
|
+
}
|
|
1533
|
+
function readRange(issues, record, path, missingAmount) {
|
|
1534
|
+
const value = record.amount;
|
|
1535
|
+
const amount = value === void 0 ? error(issues, "missing_field", [...path, "amount"], missingAmount) : isRecord(value) && hasOwn(value, "min") && hasOwn(value, "max") ? error(
|
|
1536
|
+
issues,
|
|
1537
|
+
"invalid_type",
|
|
1538
|
+
[...path, "amount"],
|
|
1539
|
+
"Expected a positive number, received an object. Write a range as { amount: 4, max: 6 }."
|
|
1540
|
+
) : readPositive(issues, value, [...path, "amount"]);
|
|
1541
|
+
const max = record.max === void 0 ? void 0 : readPositive(issues, record.max, [...path, "max"]);
|
|
1542
|
+
if (amount === void 0) {
|
|
1543
|
+
return void 0;
|
|
1544
|
+
}
|
|
1545
|
+
if (max !== void 0 && max < amount) {
|
|
1546
|
+
return error(
|
|
1547
|
+
issues,
|
|
1548
|
+
"invalid_range",
|
|
1549
|
+
[...path, "max"],
|
|
1550
|
+
`Expected a maximum greater than the amount (${amount}), received ${max}.`
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
return max === void 0 || max === amount ? { amount } : { amount, max };
|
|
1554
|
+
}
|
|
1555
|
+
function servingsHint(servings) {
|
|
1556
|
+
if (!servings) {
|
|
1557
|
+
return "";
|
|
1558
|
+
}
|
|
1559
|
+
const { amount, max } = servings;
|
|
1560
|
+
return ` For a number of servings, use servings: ${max === void 0 ? amount : quote(`${amount}\u2013${max}`)}.`;
|
|
1561
|
+
}
|
|
1562
|
+
function readSystem(issues, value, path) {
|
|
1563
|
+
if (value === void 0 || value === "metric" || value === "us") {
|
|
1564
|
+
return value;
|
|
1565
|
+
}
|
|
1566
|
+
const suggestion = value === "imperial" ? "us" : void 0;
|
|
1567
|
+
return error(
|
|
1568
|
+
issues,
|
|
1569
|
+
"invalid_type",
|
|
1570
|
+
path,
|
|
1571
|
+
`Expected "metric" or "us", received ${describeValue(value)}.${suggestion ? ` Did you mean "${suggestion}"?` : ""}`,
|
|
1572
|
+
suggestion
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
function readTimes(issues, pending, value, path) {
|
|
1576
|
+
if (value === void 0) {
|
|
1577
|
+
return { custom: [] };
|
|
1578
|
+
}
|
|
1579
|
+
const record = asRecord(value);
|
|
1580
|
+
if (!record) {
|
|
1581
|
+
error(
|
|
1582
|
+
issues,
|
|
1583
|
+
"invalid_type",
|
|
1584
|
+
path,
|
|
1585
|
+
`Expected { prep, cook, total, custom }, received ${describeValue(value)}.`
|
|
1586
|
+
);
|
|
1587
|
+
return { custom: [] };
|
|
1588
|
+
}
|
|
1589
|
+
const prep = readDuration(issues, record.prep, [...path, "prep"]);
|
|
1590
|
+
const cook = readDuration(issues, record.cook, [...path, "cook"]);
|
|
1591
|
+
const total = readDuration(issues, record.total, [...path, "total"]);
|
|
1592
|
+
const custom = readItems(
|
|
1593
|
+
issues,
|
|
1594
|
+
pending,
|
|
1595
|
+
record.custom,
|
|
1596
|
+
[...path, "custom"],
|
|
1597
|
+
"custom times",
|
|
1598
|
+
readCustomTime
|
|
1599
|
+
);
|
|
1600
|
+
checkFields(issues, record, TIMES_FIELDS, path);
|
|
1601
|
+
return defined({ prep, cook, total, custom });
|
|
1602
|
+
}
|
|
1603
|
+
function readCustomTime(issues, value, path) {
|
|
1604
|
+
const record = asRecord(value);
|
|
1605
|
+
if (!record) {
|
|
1606
|
+
return error(
|
|
1607
|
+
issues,
|
|
1608
|
+
"invalid_type",
|
|
1609
|
+
path,
|
|
1610
|
+
`Expected { label, minutes }, e.g. { label: "Chill", minutes: 60 }, received ${describeValue(value)}.`
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
const position = issues.length;
|
|
1614
|
+
const authored = readText(issues, record.id, [...path, "id"]);
|
|
1615
|
+
const label2 = readText(
|
|
1616
|
+
issues,
|
|
1617
|
+
record.label,
|
|
1618
|
+
[...path, "label"],
|
|
1619
|
+
'Add a label, e.g. label: "Chill".'
|
|
1620
|
+
);
|
|
1621
|
+
const minutes = record.minutes === void 0 ? error(
|
|
1622
|
+
issues,
|
|
1623
|
+
"missing_field",
|
|
1624
|
+
[...path, "minutes"],
|
|
1625
|
+
'Add the time, e.g. minutes: 60 or minutes: "PT1H".'
|
|
1626
|
+
) : readDuration(issues, record.minutes, [...path, "minutes"]);
|
|
1627
|
+
checkFields(issues, record, CUSTOM_TIME_FIELDS, path);
|
|
1628
|
+
if (label2 === void 0 || minutes === void 0) {
|
|
1629
|
+
return void 0;
|
|
1630
|
+
}
|
|
1631
|
+
return {
|
|
1632
|
+
authored,
|
|
1633
|
+
derived: slugify(label2) || "time",
|
|
1634
|
+
path,
|
|
1635
|
+
position,
|
|
1636
|
+
build: (id) => ({ id, label: label2, minutes })
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
function readDuration(issues, value, path) {
|
|
1640
|
+
if (value === void 0) {
|
|
1641
|
+
return void 0;
|
|
1642
|
+
}
|
|
1643
|
+
const minutes = typeof value === "number" ? nonNegative(value) : typeof value === "string" ? parseIsoDuration(value) : void 0;
|
|
1644
|
+
if (minutes === void 0) {
|
|
1645
|
+
return error(
|
|
1646
|
+
issues,
|
|
1647
|
+
"invalid_duration",
|
|
1648
|
+
path,
|
|
1649
|
+
`Expected minutes or an ISO 8601 duration like "PT30M", received ${describeValue(value)}.`
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
return minutes;
|
|
1653
|
+
}
|
|
1654
|
+
function readNutrition(issues, value, path) {
|
|
1655
|
+
if (value === void 0) {
|
|
1656
|
+
return void 0;
|
|
1657
|
+
}
|
|
1658
|
+
const record = asRecord(value);
|
|
1659
|
+
if (!record) {
|
|
1660
|
+
return error(
|
|
1661
|
+
issues,
|
|
1662
|
+
"invalid_type",
|
|
1663
|
+
path,
|
|
1664
|
+
`Expected nutrition for one serving, e.g. { calories: 250, fat: "12 g" }, received ${describeValue(value)}.`
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
const nutrition = {};
|
|
1668
|
+
const servingSize = readText(issues, record.servingSize, [
|
|
1669
|
+
...path,
|
|
1670
|
+
"servingSize"
|
|
1671
|
+
]);
|
|
1672
|
+
if (servingSize !== void 0) {
|
|
1673
|
+
nutrition.servingSize = servingSize;
|
|
1674
|
+
}
|
|
1675
|
+
for (const key of Object.keys(NUTRIENT_UNITS)) {
|
|
1676
|
+
const amount = readNutrient(
|
|
1677
|
+
issues,
|
|
1678
|
+
record[key],
|
|
1679
|
+
[...path, key],
|
|
1680
|
+
NUTRIENT_UNITS[key]
|
|
1681
|
+
);
|
|
1682
|
+
if (amount !== void 0) {
|
|
1683
|
+
nutrition[key] = amount;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
checkFields(issues, record, { servingSize: true, ...NUTRIENT_UNITS }, path);
|
|
1687
|
+
return Object.keys(nutrition).length > 0 ? nutrition : void 0;
|
|
1688
|
+
}
|
|
1689
|
+
function readNutrient(issues, value, path, unit) {
|
|
1690
|
+
const text = typeof value === "string" ? value.trim() : void 0;
|
|
1691
|
+
if (value === void 0 || text === "") {
|
|
1692
|
+
return void 0;
|
|
1693
|
+
}
|
|
1694
|
+
const amount = typeof value === "number" ? nonNegative(value) : text === void 0 ? void 0 : parseNutrient(text, unit);
|
|
1695
|
+
if (typeof amount === "number") {
|
|
1696
|
+
return amount;
|
|
1697
|
+
}
|
|
1698
|
+
return error(
|
|
1699
|
+
issues,
|
|
1700
|
+
typeof value === "number" ? "invalid_amount" : text === void 0 ? "invalid_type" : "invalid_quantity",
|
|
1701
|
+
path,
|
|
1702
|
+
amount?.message ?? `Expected ${NUTRIENT_EXAMPLES[unit]}, received ${describeValue(value)}.`
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
function nonNegative(value) {
|
|
1706
|
+
return Number.isFinite(value) && value >= 0 ? Math.abs(value) : void 0;
|
|
1707
|
+
}
|
|
1708
|
+
function readText(issues, value, path, requiredMessage) {
|
|
1709
|
+
if (value !== void 0 && typeof value !== "string") {
|
|
1710
|
+
return error(
|
|
1711
|
+
issues,
|
|
1712
|
+
"invalid_type",
|
|
1713
|
+
path,
|
|
1714
|
+
`Expected text, received ${describeValue(value)}.`
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
const text = value?.trim();
|
|
1718
|
+
if (!text) {
|
|
1719
|
+
return requiredMessage === void 0 ? void 0 : error(issues, "missing_field", path, requiredMessage);
|
|
1720
|
+
}
|
|
1721
|
+
return text;
|
|
1722
|
+
}
|
|
1723
|
+
function readUrl(issues, value, path) {
|
|
1724
|
+
const url = readText(issues, value, path);
|
|
1725
|
+
const scheme = url === void 0 ? void 0 : disallowedScheme(url);
|
|
1726
|
+
return scheme === void 0 ? url : error(
|
|
1727
|
+
issues,
|
|
1728
|
+
"invalid_url",
|
|
1729
|
+
path,
|
|
1730
|
+
`Expected an http or https URL, or a relative one, received ${quote(`${scheme}\u2026`)}.`
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
function readBoolean(issues, value, path) {
|
|
1734
|
+
if (value === void 0 || typeof value === "boolean") {
|
|
1735
|
+
return value ?? false;
|
|
1736
|
+
}
|
|
1737
|
+
error(
|
|
1738
|
+
issues,
|
|
1739
|
+
"invalid_type",
|
|
1740
|
+
path,
|
|
1741
|
+
`Expected true or false, received ${describeValue(value)}.`
|
|
1742
|
+
);
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// src/ingredient/parse.ts
|
|
1747
|
+
var NUMBER_WORDS = "one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve";
|
|
1748
|
+
var DASH = "[ \u2013\u2014-]";
|
|
1749
|
+
var OPTIONAL = /(?:\(optional\)|optional\b ?:?) ?/iy;
|
|
1750
|
+
var ABOUT = /(about|approx(?:imately|\.)?) /iy;
|
|
1751
|
+
var PHRASE = /((?:an? )?(?:handful|few|couple|little|splash|drizzle|knob|squeeze|sprinkle|dollop))(?: of)? /iy;
|
|
1752
|
+
var HYPHENATED_MIXED = /^(\d+)-(?=(\d+)[/⁄](\d+)(?![\d/⁄]))/;
|
|
1753
|
+
var AMOUNT_CHARS = /(?:an? )?(?:[\d .,/⁄¼-¾⅐-⅞–—-]|to )*/iy;
|
|
1754
|
+
var TIMES = / ?[x×] ?(?=\d)/iy;
|
|
1755
|
+
var UNREAD = / ?(?:(?:or|to|and|plus|half)(?![\p{L}\p{M}\d_])|[-–—/+&]|\d+(?:\.\d+)?(?= |$))/iuy;
|
|
1756
|
+
var UNREAD_AFTER_UNIT = / (?:or|to|[-–—&])(?= )|[-–—+&](?=\d)/iy;
|
|
1757
|
+
var PAREN = / ?\(([^()]*)\)/y;
|
|
1758
|
+
var PARENS = / ?\(([^()]*)\)/g;
|
|
1759
|
+
var MODIFIER = / ?(heaping|heaped|level|scant|rounded|generous|good|small|medium|large|big)(?= )/iy;
|
|
1760
|
+
var JOIN = /(?: (plus|and|\+)(?![\p{L}\p{M}]))? ?/iuy;
|
|
1761
|
+
var SEPARATOR = / ?[;/] ?/y;
|
|
1762
|
+
var OF = / ?of(?: (?:an?|the))?(?= |$)/iy;
|
|
1763
|
+
var NAMED = /(?!(?:for|to|an?|yields?|total)(?![\p{L}\p{M}\d_])|(?:makes|serv(?:es|ings?)(?: size)?|protein|carbs|carbohydrates?|fiber|fibre|sodium|cholesterol|saturated fat|fat) ?:)([^\d:]+):/iuy;
|
|
1764
|
+
var PHRASE_NOTE = / (?=to taste|as needed|as required|plus more|plus extra|or more|or to taste|for (?:serving|garnish|dusting|greasing|frying|drizzling|decoration))/i;
|
|
1765
|
+
var OPTIONAL_NOTE = /(?:^|,) ?optional(?= ?(?:,|$))/gi;
|
|
1766
|
+
var OPTIONAL_PAREN = /^optional\b[,;:]? ?/i;
|
|
1767
|
+
var CONTAINER = /^(?:stalk|rib|ear|sheet|loa(?:f|ves)|knob|strip|bag|box|jar|bottle|packet|envelope|tin|block|ball|handful)(?:e?s)?$/i;
|
|
1768
|
+
var BONELESS = /(?:^|[^a-z])(?:bone|skin)less$/i;
|
|
1769
|
+
var patterns;
|
|
1770
|
+
function getPatterns() {
|
|
1771
|
+
return patterns ?? (patterns = compilePatterns());
|
|
1772
|
+
}
|
|
1773
|
+
function compilePatterns() {
|
|
1774
|
+
const word = String.raw`([a-z]+(?:-[a-z]+)?)\.?`;
|
|
1775
|
+
const end = String.raw`(?=[ ,;()/]|[-–—+&]\d|$)`;
|
|
1776
|
+
return {
|
|
1777
|
+
count: new RegExp(`(an?|${NUMBER_WORDS}) (?=\\S)`, "iy"),
|
|
1778
|
+
unit: new RegExp(`${DASH}?${word}${end}`, "iy"),
|
|
1779
|
+
twoWordUnit: new RegExp(`${DASH}?${word} ${word}${end}`, "iy")
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
function parseIngredientLine(text) {
|
|
1783
|
+
const issues = [];
|
|
1784
|
+
if (typeof text !== "string") {
|
|
1785
|
+
error(
|
|
1786
|
+
issues,
|
|
1787
|
+
"invalid_type",
|
|
1788
|
+
[],
|
|
1789
|
+
`Expected ingredient text such as "2 cups flour", received ${describeValue(text)}.`
|
|
1790
|
+
);
|
|
1791
|
+
return toResult("invalid_ingredient", void 0, issues);
|
|
1792
|
+
}
|
|
1793
|
+
const line = text.replace(/[\s\u200b]+/g, " ").trim();
|
|
1794
|
+
if (line === "") {
|
|
1795
|
+
error(
|
|
1796
|
+
issues,
|
|
1797
|
+
"missing_field",
|
|
1798
|
+
[],
|
|
1799
|
+
'Add the ingredient text, e.g. "2 cups flour".'
|
|
1800
|
+
);
|
|
1801
|
+
return toResult(
|
|
1802
|
+
"invalid_ingredient",
|
|
1803
|
+
void 0,
|
|
1804
|
+
issues,
|
|
1805
|
+
quote(text)
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
let ingredient = readLine(line);
|
|
1809
|
+
if (isProblem(ingredient)) {
|
|
1810
|
+
warning(
|
|
1811
|
+
issues,
|
|
1812
|
+
ingredient.code,
|
|
1813
|
+
[],
|
|
1814
|
+
`${ingredient.message} The whole line is kept as the name.`
|
|
1815
|
+
);
|
|
1816
|
+
ingredient = { name: line };
|
|
1817
|
+
}
|
|
1818
|
+
return toResult("invalid_ingredient", ingredient, issues);
|
|
1819
|
+
}
|
|
1820
|
+
function readLine(line) {
|
|
1821
|
+
const p = getPatterns();
|
|
1822
|
+
const notes = [];
|
|
1823
|
+
const equivalents = [];
|
|
1824
|
+
let optional = false;
|
|
1825
|
+
let quantity;
|
|
1826
|
+
let amount;
|
|
1827
|
+
let unitText = "";
|
|
1828
|
+
let i = 0;
|
|
1829
|
+
let match;
|
|
1830
|
+
const eat = (pattern) => {
|
|
1831
|
+
const found = exec(pattern, line, i);
|
|
1832
|
+
if (found) {
|
|
1833
|
+
i = pattern.lastIndex;
|
|
1834
|
+
}
|
|
1835
|
+
return found;
|
|
1836
|
+
};
|
|
1837
|
+
const addNote = (text) => {
|
|
1838
|
+
const note = /[^ ,](?:.*[^ ,])?/s.exec(text);
|
|
1839
|
+
if (note) {
|
|
1840
|
+
notes.push(note[0]);
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
const readParen = (content, keep) => {
|
|
1844
|
+
const text = content.replace(OPTIONAL_PAREN, () => {
|
|
1845
|
+
optional = true;
|
|
1846
|
+
return "";
|
|
1847
|
+
});
|
|
1848
|
+
const measures = text ? readMeasures(text) : void 0;
|
|
1849
|
+
if (measures) {
|
|
1850
|
+
quantity ?? (quantity = measures.shift());
|
|
1851
|
+
for (const measure of measures) {
|
|
1852
|
+
equivalents.push(measure);
|
|
1853
|
+
}
|
|
1854
|
+
return "";
|
|
1855
|
+
}
|
|
1856
|
+
if (keep && text) {
|
|
1857
|
+
return ` (${text})`;
|
|
1858
|
+
}
|
|
1859
|
+
addNote(text);
|
|
1860
|
+
return "";
|
|
1861
|
+
};
|
|
1862
|
+
if (eat(OPTIONAL)) {
|
|
1863
|
+
optional = true;
|
|
1864
|
+
}
|
|
1865
|
+
match = exec(ABOUT, line, i);
|
|
1866
|
+
if (match && readAmount2(line, ABOUT.lastIndex)) {
|
|
1867
|
+
addNote(match[1]);
|
|
1868
|
+
i = ABOUT.lastIndex;
|
|
1869
|
+
}
|
|
1870
|
+
if (match = eat(PHRASE)) {
|
|
1871
|
+
addNote(match[1]);
|
|
1872
|
+
} else if (match = eat(p.count)) {
|
|
1873
|
+
amount = NUMBER_WORDS.split("|").indexOf(match[1].toLowerCase()) + 1 || 1;
|
|
1874
|
+
} else {
|
|
1875
|
+
const found = readAmount2(line, i, true);
|
|
1876
|
+
if (isProblem(found)) {
|
|
1877
|
+
return found;
|
|
1878
|
+
}
|
|
1879
|
+
if (found) {
|
|
1880
|
+
[amount, i] = found;
|
|
1881
|
+
} else {
|
|
1882
|
+
const modifier = exec(MODIFIER, line, i);
|
|
1883
|
+
const unit = readUnit(line, modifier ? MODIFIER.lastIndex : i);
|
|
1884
|
+
if (unit && exec(OF, line, unit[1])) {
|
|
1885
|
+
if (modifier) {
|
|
1886
|
+
addNote(modifier[1]);
|
|
1887
|
+
}
|
|
1888
|
+
quantity = { amount: 1, unit: unit[0] };
|
|
1889
|
+
i = unit[1];
|
|
1890
|
+
} else if (match = exec(NAMED, line, i)) {
|
|
1891
|
+
const measure = readMeasure(line, NAMED.lastIndex, false);
|
|
1892
|
+
if (measure && (line[measure[1]] ?? ",") === ",") {
|
|
1893
|
+
quantity = measure[0];
|
|
1894
|
+
line = match[1] + line.slice(measure[1]);
|
|
1895
|
+
i = 0;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
if (amount !== void 0) {
|
|
1901
|
+
let each;
|
|
1902
|
+
eat(TIMES);
|
|
1903
|
+
if (match = eat(PAREN)) {
|
|
1904
|
+
each = readMeasures(match[1]);
|
|
1905
|
+
if (!each) {
|
|
1906
|
+
readParen(match[1], false);
|
|
1907
|
+
}
|
|
1908
|
+
} else {
|
|
1909
|
+
const measure = readMeasure(line, i, false);
|
|
1910
|
+
if (measure) {
|
|
1911
|
+
each = [measure[0]];
|
|
1912
|
+
i = measure[1];
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
const start = i;
|
|
1916
|
+
const modifier = eat(MODIFIER);
|
|
1917
|
+
const unit = readUnit(line, i);
|
|
1918
|
+
if (unit) {
|
|
1919
|
+
if (modifier) {
|
|
1920
|
+
addNote(modifier[1]);
|
|
1921
|
+
}
|
|
1922
|
+
unitText = line.slice(i, unit[1]);
|
|
1923
|
+
quantity = { amount, unit: unit[0] };
|
|
1924
|
+
i = unit[1];
|
|
1925
|
+
const size = !each && units[unit[0]].dimension === "count" && readMeasure(line, i, false);
|
|
1926
|
+
if (size) {
|
|
1927
|
+
equivalents.push(size[0]);
|
|
1928
|
+
i = size[1];
|
|
1929
|
+
} else if (!each) {
|
|
1930
|
+
const extended = extend(line, [quantity, i]);
|
|
1931
|
+
if (isProblem(extended)) {
|
|
1932
|
+
return extended;
|
|
1933
|
+
}
|
|
1934
|
+
[quantity, i] = extended;
|
|
1935
|
+
}
|
|
1936
|
+
while (match = eat(SEPARATOR)) {
|
|
1937
|
+
const measure = readMeasure(line, i, true);
|
|
1938
|
+
if (!measure) {
|
|
1939
|
+
return noMeasure(quote(match[0].trim()), "100 g/3\xBD oz");
|
|
1940
|
+
}
|
|
1941
|
+
equivalents.push(measure[0]);
|
|
1942
|
+
i = measure[1];
|
|
1943
|
+
}
|
|
1944
|
+
} else {
|
|
1945
|
+
i = start;
|
|
1946
|
+
quantity = { amount };
|
|
1947
|
+
}
|
|
1948
|
+
if (exec(unit ? UNREAD_AFTER_UNIT : UNREAD, line, i)) {
|
|
1949
|
+
return {
|
|
1950
|
+
code: "invalid_quantity",
|
|
1951
|
+
message: `Couldn't read the quantity in ${quote(line)}. Write one amount or range before the unit, e.g. "2-3 eggs" or "2 \xBD cups".`
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
for (const item of each ?? []) {
|
|
1955
|
+
const total = combine(amount, item.amount, (a, b) => a * b);
|
|
1956
|
+
if (isProblem(total)) {
|
|
1957
|
+
return total;
|
|
1958
|
+
}
|
|
1959
|
+
equivalents.push({ ...item, amount: total });
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
const of = quantity !== void 0 && eat(OF);
|
|
1963
|
+
const rest = line.slice(i).trim();
|
|
1964
|
+
const comma = topLevelComma(rest);
|
|
1965
|
+
let name = rest.slice(0, comma).replace(PARENS, (_, content) => readParen(content, false));
|
|
1966
|
+
const phrase = PHRASE_NOTE.exec(name);
|
|
1967
|
+
if (phrase) {
|
|
1968
|
+
addNote(name.slice(phrase.index));
|
|
1969
|
+
name = name.slice(0, phrase.index);
|
|
1970
|
+
}
|
|
1971
|
+
addNote(
|
|
1972
|
+
rest.slice(comma + 1).replace(PARENS, (_, content) => readParen(content, true)).replace(OPTIONAL_NOTE, () => {
|
|
1973
|
+
optional = true;
|
|
1974
|
+
return "";
|
|
1975
|
+
})
|
|
1976
|
+
);
|
|
1977
|
+
name = name.trim();
|
|
1978
|
+
if (!name) {
|
|
1979
|
+
if (of || !unitText || !quantity?.unit || units[quantity.unit].dimension !== "count") {
|
|
1980
|
+
return {
|
|
1981
|
+
code: "missing_field",
|
|
1982
|
+
message: 'Add a name after the quantity, e.g. "2 cups flour".'
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
name = unitText.replace(/^[ –—-]/, "");
|
|
1986
|
+
quantity = { amount: quantity.amount };
|
|
1987
|
+
}
|
|
1988
|
+
let plural;
|
|
1989
|
+
if (quantity && !quantity.unit) {
|
|
1990
|
+
const many = typeof quantity.amount !== "number" || quantity.amount > 1;
|
|
1991
|
+
[name, plural] = countForms(name, many);
|
|
1992
|
+
}
|
|
1993
|
+
return {
|
|
1994
|
+
name,
|
|
1995
|
+
...plural !== void 0 && { plural },
|
|
1996
|
+
...quantity && { quantity },
|
|
1997
|
+
...equivalents.length > 0 && { equivalents },
|
|
1998
|
+
...notes.length > 0 && { note: notes.join(", ") },
|
|
1999
|
+
...optional && { optional }
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
function exec(pattern, text, start) {
|
|
2003
|
+
pattern.lastIndex = start;
|
|
2004
|
+
return pattern.exec(text);
|
|
2005
|
+
}
|
|
2006
|
+
function readAmount2(text, start, whole) {
|
|
2007
|
+
exec(AMOUNT_CHARS, text, start);
|
|
2008
|
+
const rest = text.slice(start, whole ? void 0 : AMOUNT_CHARS.lastIndex + 1).replace(
|
|
2009
|
+
HYPHENATED_MIXED,
|
|
2010
|
+
(dash, whole2, numerator, denominator) => Number(numerator) < Number(denominator) ? `${whole2} ` : dash
|
|
2011
|
+
);
|
|
2012
|
+
const comma = rest.search(/\d,\d/);
|
|
2013
|
+
const head = comma < 0 ? rest : rest.slice(0, comma).trimEnd();
|
|
2014
|
+
const match = matchAmount(head);
|
|
2015
|
+
if (comma >= 0 && (match ? !("code" in match) && !match.rest : !head)) {
|
|
2016
|
+
return matchAmount(rest);
|
|
2017
|
+
}
|
|
2018
|
+
if (!match || "code" in match) {
|
|
2019
|
+
return match;
|
|
2020
|
+
}
|
|
2021
|
+
return [match.amount, start + head.length - match.rest.length];
|
|
2022
|
+
}
|
|
2023
|
+
function isProblem(value) {
|
|
2024
|
+
return typeof value === "object" && value !== null && "code" in value;
|
|
2025
|
+
}
|
|
2026
|
+
function readUnit(text, start) {
|
|
2027
|
+
const { twoWordUnit, unit } = getPatterns();
|
|
2028
|
+
for (const pattern of [twoWordUnit, unit]) {
|
|
2029
|
+
const match = exec(pattern, text, start);
|
|
2030
|
+
const id = match && findUnit(match.slice(1).join(" "));
|
|
2031
|
+
if (id) {
|
|
2032
|
+
return [id, pattern.lastIndex];
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return void 0;
|
|
2036
|
+
}
|
|
2037
|
+
function readMeasure(text, start, compound) {
|
|
2038
|
+
const amount = readAmount2(text, text[start] === " " ? start + 1 : start);
|
|
2039
|
+
if (!amount || isProblem(amount)) {
|
|
2040
|
+
return void 0;
|
|
2041
|
+
}
|
|
2042
|
+
const unit = readUnit(text, amount[1]);
|
|
2043
|
+
if (!unit) {
|
|
2044
|
+
return void 0;
|
|
2045
|
+
}
|
|
2046
|
+
const measure = [
|
|
2047
|
+
{ amount: amount[0], unit: unit[0] },
|
|
2048
|
+
unit[1]
|
|
2049
|
+
];
|
|
2050
|
+
const extended = compound ? extend(text, measure) : measure;
|
|
2051
|
+
return isProblem(extended) ? void 0 : extended;
|
|
2052
|
+
}
|
|
2053
|
+
function extend(text, measure) {
|
|
2054
|
+
const [{ amount, unit }, end] = measure;
|
|
2055
|
+
const connector = exec(JOIN, text, end)[1];
|
|
2056
|
+
const extra = readMeasure(text, JOIN.lastIndex, false);
|
|
2057
|
+
const from = units[unit];
|
|
2058
|
+
const to = extra && units[extra[0].unit];
|
|
2059
|
+
if (!from.toBase || !to?.toBase || from.dimension !== to.dimension) {
|
|
2060
|
+
return connector ? noMeasure(
|
|
2061
|
+
`${quote(connector)} in the same kind of unit`,
|
|
2062
|
+
"1 cup plus 2 tbsp"
|
|
2063
|
+
) : measure;
|
|
2064
|
+
}
|
|
2065
|
+
const ratio = to.toBase / from.toBase;
|
|
2066
|
+
const total = combine(amount, extra[0].amount, (a, b) => a + b * ratio);
|
|
2067
|
+
return isProblem(total) ? total : [{ amount: total, unit }, extra[1]];
|
|
2068
|
+
}
|
|
2069
|
+
function noMeasure(after, example) {
|
|
2070
|
+
return {
|
|
2071
|
+
code: "invalid_quantity",
|
|
2072
|
+
message: `Expected a measure after ${after}, e.g. "${example}".`
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
function readMeasures(text) {
|
|
2076
|
+
const measures = [];
|
|
2077
|
+
let i = 0;
|
|
2078
|
+
for (; ; ) {
|
|
2079
|
+
const measure = readMeasure(text, i, true);
|
|
2080
|
+
if (!measure) {
|
|
2081
|
+
return void 0;
|
|
2082
|
+
}
|
|
2083
|
+
measures.push(measure[0]);
|
|
2084
|
+
i = measure[1];
|
|
2085
|
+
if (i === text.length) {
|
|
2086
|
+
return measures;
|
|
2087
|
+
}
|
|
2088
|
+
if (!exec(SEPARATOR, text, i)) {
|
|
2089
|
+
return void 0;
|
|
2090
|
+
}
|
|
2091
|
+
i = SEPARATOR.lastIndex;
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
function combine(a, b, op) {
|
|
2095
|
+
const [aMin, aMax] = typeof a === "number" ? [a, a] : [a.min, a.max];
|
|
2096
|
+
const [bMin, bMax] = typeof b === "number" ? [b, b] : [b.min, b.max];
|
|
2097
|
+
const min = op(aMin, bMin);
|
|
2098
|
+
const max = op(aMax, bMax);
|
|
2099
|
+
if (!isPositive(min) || !isPositive(max)) {
|
|
2100
|
+
return {
|
|
2101
|
+
code: "invalid_amount",
|
|
2102
|
+
message: 'The total amount is out of range. Write it as one amount, e.g. "800 g".'
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
return min < max ? { min, max } : min;
|
|
2106
|
+
}
|
|
2107
|
+
function topLevelComma(text) {
|
|
2108
|
+
let depth = 0;
|
|
2109
|
+
for (let j = 0; j < text.length; j++) {
|
|
2110
|
+
const char = text[j];
|
|
2111
|
+
if (char === "(") {
|
|
2112
|
+
depth++;
|
|
2113
|
+
} else if (char === ")") {
|
|
2114
|
+
depth = Math.max(0, depth - 1);
|
|
2115
|
+
} else if (char === "," && depth === 0 && !BONELESS.test(text.slice(0, j).slice(-9))) {
|
|
2116
|
+
return j;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
return text.length;
|
|
2120
|
+
}
|
|
2121
|
+
function countForms(name, many) {
|
|
2122
|
+
const words = name.split(" ");
|
|
2123
|
+
let k = words.indexOf("of") - 1;
|
|
2124
|
+
if (k < 0) {
|
|
2125
|
+
k = CONTAINER.test(words[0]) ? 0 : words.length - 1;
|
|
2126
|
+
}
|
|
2127
|
+
const word = words[k];
|
|
2128
|
+
const other = many ? word.replace(
|
|
2129
|
+
/(?:(lea|hal|loa)ves|([rtdv])ies|(o|ch|sh|x|ss|chill?i)es|([^su])s)$/i,
|
|
2130
|
+
(_, f, y, es, s) => f ? `${f}f` : y ? `${y}y` : es ?? s
|
|
2131
|
+
) : word.replace(
|
|
2132
|
+
/(?:(lea|hal|loa)f|([^aeiou])y|(ch|sh|x|z|tomato|potato|mango|chill?i)|[a-rt-z])$/i,
|
|
2133
|
+
(match, f, y, es) => f ? `${f}ves` : y ? `${y}ies` : es ? `${es}es` : `${match}s`
|
|
2134
|
+
);
|
|
2135
|
+
if (other === word) {
|
|
2136
|
+
return [name, void 0];
|
|
2137
|
+
}
|
|
2138
|
+
words[k] = other;
|
|
2139
|
+
const swapped = words.join(" ");
|
|
2140
|
+
return many ? [swapped, name] : [name, swapped];
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// src/quantity/scale.ts
|
|
2144
|
+
function scaleQuantity(quantity, factor) {
|
|
2145
|
+
const amount = checkQuantity("scaleQuantity", quantity);
|
|
2146
|
+
checkFactor("scaleQuantity", "factor", factor);
|
|
2147
|
+
const result = multiply({ amount, unit: quantity.unit }, factor);
|
|
2148
|
+
checkResult("scaleQuantity", result.amount);
|
|
2149
|
+
return result;
|
|
2150
|
+
}
|
|
2151
|
+
function multiply(quantity, factor) {
|
|
2152
|
+
const amount = mapAmount(quantity.amount, (value) => value * factor);
|
|
2153
|
+
return quantity.unit === void 0 ? { amount } : { amount, unit: quantity.unit };
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// src/units/system.ts
|
|
2157
|
+
var STEP_TOLERANCE = 0.05;
|
|
2158
|
+
var FLOAT_TOLERANCE = 1e-9;
|
|
2159
|
+
var LADDERS = {
|
|
2160
|
+
metric: {
|
|
2161
|
+
mass: [{ unit: "kg", min: 1 }, { unit: "g" }],
|
|
2162
|
+
volume: [{ unit: "l", min: 1 }, { unit: "ml" }]
|
|
2163
|
+
},
|
|
2164
|
+
us: {
|
|
2165
|
+
mass: [{ unit: "lb", min: 1 }, { unit: "oz" }],
|
|
2166
|
+
volume: [
|
|
2167
|
+
{ unit: "cup", min: 0.25, steps: [4, 3], stepsBelow: 1 },
|
|
2168
|
+
{ unit: "tbsp", min: 1, steps: [2] },
|
|
2169
|
+
{ unit: "tsp" }
|
|
2170
|
+
]
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
function inSystem(quantity, system) {
|
|
2174
|
+
const source = lookupUnit(quantity.unit);
|
|
2175
|
+
if (source?.toBase === void 0 || source.dimension === "count" || source.systems.includes(system)) {
|
|
2176
|
+
return quantity;
|
|
2177
|
+
}
|
|
2178
|
+
return onLadder(
|
|
2179
|
+
quantity,
|
|
2180
|
+
source.toBase,
|
|
2181
|
+
LADDERS[system][source.dimension],
|
|
2182
|
+
system
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
function rebalance(quantity, system) {
|
|
2186
|
+
const source = lookupUnit(quantity.unit);
|
|
2187
|
+
if (source?.toBase === void 0 || source.dimension === "count" || !source.systems.includes(system)) {
|
|
2188
|
+
return quantity;
|
|
2189
|
+
}
|
|
2190
|
+
for (const owner of source.systems) {
|
|
2191
|
+
const ladder = LADDERS[owner][source.dimension];
|
|
2192
|
+
if (ladder.some((rung) => rung.unit === source.id)) {
|
|
2193
|
+
return onLadder(quantity, source.toBase, ladder, system);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return quantity;
|
|
2197
|
+
}
|
|
2198
|
+
function isSystem(value) {
|
|
2199
|
+
return value === "metric" || value === "us";
|
|
2200
|
+
}
|
|
2201
|
+
function checkSystem(fn, system) {
|
|
2202
|
+
if (!isSystem(system)) {
|
|
2203
|
+
throw new RecipeBookError(
|
|
2204
|
+
"invalid_argument",
|
|
2205
|
+
`${fn}: system must be "metric" or "us", received ${describeValue(system)}.`
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
function onLadder(quantity, fromBase, ladder, system) {
|
|
2210
|
+
const base = mapAmount(quantity.amount, (value) => value * fromBase);
|
|
2211
|
+
for (const rung of ladder) {
|
|
2212
|
+
const unit = lookupUnit(rung.unit);
|
|
2213
|
+
if (!unit.systems.includes(system)) {
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
2216
|
+
const amount = mapAmount(base, (value) => value / unit.toBase);
|
|
2217
|
+
const ends = amountEnds(amount);
|
|
2218
|
+
const low = ends[0];
|
|
2219
|
+
const tolerance = rung.steps ? STEP_TOLERANCE : FLOAT_TOLERANCE;
|
|
2220
|
+
const meetsMin = rung.min === void 0 || low >= rung.min * (1 - tolerance);
|
|
2221
|
+
const fitsSteps = !rung.steps || ends.every(
|
|
2222
|
+
(value) => rung.stepsBelow !== void 0 && value >= rung.stepsBelow || rung.steps.some(
|
|
2223
|
+
(denominator) => nearMultiple(value, 1 / denominator)
|
|
2224
|
+
)
|
|
2225
|
+
);
|
|
2226
|
+
if (meetsMin && fitsSteps) {
|
|
2227
|
+
return unit.id === quantity.unit ? quantity : { amount, unit: unit.id };
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
return quantity;
|
|
2231
|
+
}
|
|
2232
|
+
function nearMultiple(value, step) {
|
|
2233
|
+
return Math.abs(value - Math.round(value / step) * step) <= STEP_TOLERANCE * value;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// src/resolve.ts
|
|
2237
|
+
function resolveRecipe(recipe, options) {
|
|
2238
|
+
const {
|
|
2239
|
+
servings,
|
|
2240
|
+
scale: requestedScale,
|
|
2241
|
+
system: requestedSystem,
|
|
2242
|
+
rebalance: rebalance2
|
|
2243
|
+
} = options ?? {};
|
|
2244
|
+
const servingsIsBase = isPositive(recipe.servings?.amount);
|
|
2245
|
+
const base = [recipe.servings?.amount, recipe.yield?.amount].find(isPositive) ?? 1;
|
|
2246
|
+
const ratio = isServingsFor(servings, base) ? Math.min(Math.max(servings / base, 1e-6), 1e6) : Number.NaN;
|
|
2247
|
+
const exactServings = isScale(requestedScale) || !isScale(ratio) || isUnity(ratio) ? void 0 : servings;
|
|
2248
|
+
const scale = isScale(requestedScale) ? requestedScale : isScale(ratio) ? ratio : 1;
|
|
2249
|
+
const system = [requestedSystem, recipe.system].find(isSystem) ?? "metric";
|
|
2250
|
+
const shouldRebalance = rebalance2 !== false;
|
|
2251
|
+
let number = 0;
|
|
2252
|
+
function resize(range, isBase) {
|
|
2253
|
+
if (exactServings !== void 0 && isBase) {
|
|
2254
|
+
return withRange(range, exactServings);
|
|
2255
|
+
}
|
|
2256
|
+
return isUnity(scale) ? range : withRange(range, range.amount * scale, (range.max ?? 0) * scale);
|
|
2257
|
+
}
|
|
2258
|
+
return {
|
|
2259
|
+
recipe,
|
|
2260
|
+
scale,
|
|
2261
|
+
...recipe.servings && {
|
|
2262
|
+
servings: resize(recipe.servings, servingsIsBase)
|
|
2263
|
+
},
|
|
2264
|
+
...recipe.yield && { yield: resize(recipe.yield, !servingsIsBase) },
|
|
2265
|
+
system,
|
|
2266
|
+
canConvert: recipe.ingredients.some(
|
|
2267
|
+
(section) => section.items.some(
|
|
2268
|
+
(item) => lookupUnit(item.quantity?.unit)?.systems.length === 1
|
|
2269
|
+
)
|
|
2270
|
+
),
|
|
2271
|
+
ingredients: recipe.ingredients.map((section) => ({
|
|
2272
|
+
id: section.id,
|
|
2273
|
+
...section.title !== void 0 && { title: section.title },
|
|
2274
|
+
items: section.items.map(
|
|
2275
|
+
(ingredient) => resolveLine(ingredient, scale, system, shouldRebalance)
|
|
2276
|
+
)
|
|
2277
|
+
})),
|
|
2278
|
+
steps: recipe.steps.map((section) => ({
|
|
2279
|
+
id: section.id,
|
|
2280
|
+
...section.title !== void 0 && { title: section.title },
|
|
2281
|
+
items: section.items.map((step) => ({ ...step, number: ++number }))
|
|
2282
|
+
}))
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
function resolveIngredient(ingredient, options) {
|
|
2286
|
+
checkFactor("resolveIngredient", "scale", options?.scale);
|
|
2287
|
+
checkSystem("resolveIngredient", options.system);
|
|
2288
|
+
return resolveLine(
|
|
2289
|
+
ingredient,
|
|
2290
|
+
options.scale,
|
|
2291
|
+
options.system,
|
|
2292
|
+
options.rebalance !== false
|
|
2293
|
+
);
|
|
2294
|
+
}
|
|
2295
|
+
function resolveLine(ingredient, scale, system, shouldRebalance) {
|
|
2296
|
+
const { id, quantity } = ingredient;
|
|
2297
|
+
if (quantity === void 0) {
|
|
2298
|
+
return { id, ingredient, origin: "authored" };
|
|
2299
|
+
}
|
|
2300
|
+
const candidates = [quantity, ...ingredient.equivalents];
|
|
2301
|
+
const native = candidates.findIndex(
|
|
2302
|
+
(candidate) => isNative(candidate, system)
|
|
2303
|
+
);
|
|
2304
|
+
const chosen = candidates[native] ?? quantity;
|
|
2305
|
+
const scaled = isUnity(scale) ? chosen : multiply(chosen, scale);
|
|
2306
|
+
if (native < 0) {
|
|
2307
|
+
return {
|
|
2308
|
+
id,
|
|
2309
|
+
ingredient,
|
|
2310
|
+
quantity: withEnds(inSystem(scaled, system)),
|
|
2311
|
+
origin: "converted"
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
return {
|
|
2315
|
+
id,
|
|
2316
|
+
ingredient,
|
|
2317
|
+
quantity: withEnds(
|
|
2318
|
+
shouldRebalance && !isUnity(scale) ? rebalance(scaled, system) : scaled
|
|
2319
|
+
),
|
|
2320
|
+
origin: native === 0 ? "authored" : "equivalent"
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
function withRange(range, amount, max = 0) {
|
|
2324
|
+
const { max: authoredMax, ...rest } = range;
|
|
2325
|
+
return { ...rest, amount, ...max > amount && { max } };
|
|
2326
|
+
}
|
|
2327
|
+
function withEnds(quantity) {
|
|
2328
|
+
const { amount } = quantity;
|
|
2329
|
+
return typeof amount === "number" || amount.min < amount.max ? quantity : { ...quantity, amount: amount.min };
|
|
2330
|
+
}
|
|
2331
|
+
function isServingsFor(servings, base) {
|
|
2332
|
+
const [mantissa, exponent] = base.toExponential().split("e");
|
|
2333
|
+
const shift = (places) => Number(`${mantissa}e${Number(exponent) + places}`);
|
|
2334
|
+
return isPositive(servings) && servings >= shift(-6) && servings <= shift(6);
|
|
2335
|
+
}
|
|
2336
|
+
function isScale(value) {
|
|
2337
|
+
return typeof value === "number" && value >= 1e-6 && value <= 1e6;
|
|
2338
|
+
}
|
|
2339
|
+
function isUnity(scale) {
|
|
2340
|
+
return Math.abs(scale - 1) <= 1e-9;
|
|
2341
|
+
}
|
|
2342
|
+
function isNative(quantity, system) {
|
|
2343
|
+
const unit = lookupUnit(quantity.unit);
|
|
2344
|
+
return unit === void 0 || unit.systems.includes(system);
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// src/format/number.ts
|
|
2348
|
+
var POWERS = [
|
|
2349
|
+
1,
|
|
2350
|
+
10,
|
|
2351
|
+
100,
|
|
2352
|
+
1e3,
|
|
2353
|
+
1e4,
|
|
2354
|
+
1e5,
|
|
2355
|
+
1e6,
|
|
2356
|
+
1e7,
|
|
2357
|
+
1e8,
|
|
2358
|
+
1e9,
|
|
2359
|
+
1e10,
|
|
2360
|
+
1e11,
|
|
2361
|
+
1e12,
|
|
2362
|
+
1e13,
|
|
2363
|
+
1e14,
|
|
2364
|
+
1e15
|
|
2365
|
+
];
|
|
2366
|
+
var MAX_DIGITS = 15;
|
|
2367
|
+
var NBSP = "\xA0";
|
|
2368
|
+
function round(value, digits) {
|
|
2369
|
+
const power = POWERS[digits];
|
|
2370
|
+
const magnitude = Math.abs(value);
|
|
2371
|
+
const scaled = magnitude * power;
|
|
2372
|
+
const rounded = scaled < 1e15 ? Math.round(Number(scaled.toPrecision(15))) / power : Math.round(magnitude);
|
|
2373
|
+
return value < 0 && rounded !== 0 ? -rounded : rounded;
|
|
2374
|
+
}
|
|
2375
|
+
function roundAboveZero(value, digits) {
|
|
2376
|
+
for (let used = digits; used <= MAX_DIGITS; used++) {
|
|
2377
|
+
const rounded = round(value, used);
|
|
2378
|
+
if (rounded > 0) {
|
|
2379
|
+
return [rounded, used];
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
return [1e-15, MAX_DIGITS];
|
|
2383
|
+
}
|
|
2384
|
+
var writers = /* @__PURE__ */ new Map();
|
|
2385
|
+
var MAX_WRITERS = 32;
|
|
2386
|
+
function numberWriter(fn, locale = "en-US") {
|
|
2387
|
+
if (typeof locale !== "string") {
|
|
2388
|
+
throw invalidLocale(fn, locale);
|
|
2389
|
+
}
|
|
2390
|
+
if (/^en(?:-(?:[a-z]{2}|\d{3}))?$/i.test(locale) || typeof Intl === "undefined") {
|
|
2391
|
+
return englishNumber;
|
|
2392
|
+
}
|
|
2393
|
+
let writer = writers.get(locale);
|
|
2394
|
+
if (!writer) {
|
|
2395
|
+
writer = intlNumber(fn, locale);
|
|
2396
|
+
if (writer !== englishNumber) {
|
|
2397
|
+
if (writers.size >= MAX_WRITERS) {
|
|
2398
|
+
writers.clear();
|
|
2399
|
+
}
|
|
2400
|
+
writers.set(locale, writer);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
return writer;
|
|
2404
|
+
}
|
|
2405
|
+
function pluralForm(value) {
|
|
2406
|
+
return Math.abs(value) === 1 ? "one" : "other";
|
|
2407
|
+
}
|
|
2408
|
+
function intlNumber(fn, locale) {
|
|
2409
|
+
let supported;
|
|
2410
|
+
try {
|
|
2411
|
+
supported = Intl.NumberFormat.supportedLocalesOf(locale);
|
|
2412
|
+
} catch {
|
|
2413
|
+
throw invalidLocale(fn, locale);
|
|
2414
|
+
}
|
|
2415
|
+
if (supported.length === 0) {
|
|
2416
|
+
return englishNumber;
|
|
2417
|
+
}
|
|
2418
|
+
const formats = [];
|
|
2419
|
+
return (value, digits) => {
|
|
2420
|
+
let format = formats[digits];
|
|
2421
|
+
if (!format) {
|
|
2422
|
+
format = new Intl.NumberFormat(locale, {
|
|
2423
|
+
useGrouping: false,
|
|
2424
|
+
maximumFractionDigits: digits
|
|
2425
|
+
});
|
|
2426
|
+
formats[digits] = format;
|
|
2427
|
+
}
|
|
2428
|
+
return format.format(value);
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
function englishNumber(value, digits) {
|
|
2432
|
+
const magnitude = Math.abs(value);
|
|
2433
|
+
const units2 = Math.round(magnitude * POWERS[digits]);
|
|
2434
|
+
const sign = value < 0 ? "-" : "";
|
|
2435
|
+
if (digits === 0 || units2 > Number.MAX_SAFE_INTEGER) {
|
|
2436
|
+
return sign + integerText(magnitude);
|
|
2437
|
+
}
|
|
2438
|
+
const text = String(units2).padStart(digits + 1, "0");
|
|
2439
|
+
const whole = text.slice(0, -digits);
|
|
2440
|
+
const fraction = text.slice(-digits).replace(/0+$/, "");
|
|
2441
|
+
return sign + (fraction ? `${whole}.${fraction}` : whole);
|
|
2442
|
+
}
|
|
2443
|
+
function integerText(value) {
|
|
2444
|
+
const text = String(value);
|
|
2445
|
+
const match = /^(\d)(?:\.(\d+))?e\+(\d+)$/.exec(text);
|
|
2446
|
+
if (!match) {
|
|
2447
|
+
return text;
|
|
2448
|
+
}
|
|
2449
|
+
const [, head, tail = "", exponent] = match;
|
|
2450
|
+
return head + tail.padEnd(Number(exponent), "0");
|
|
2451
|
+
}
|
|
2452
|
+
function invalidLocale(fn, locale) {
|
|
2453
|
+
return new RecipeBookError(
|
|
2454
|
+
"invalid_argument",
|
|
2455
|
+
`${fn}: locale must be a language tag that Intl accepts, such as "en-US", received ${describeValue(locale)}.`
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// src/format/options.ts
|
|
2460
|
+
function readSettings(fn, options) {
|
|
2461
|
+
return {
|
|
2462
|
+
number: numberWriter(fn, options?.locale),
|
|
2463
|
+
unitDisplay: option(fn, "unitDisplay", options?.unitDisplay, [
|
|
2464
|
+
"short",
|
|
2465
|
+
"long"
|
|
2466
|
+
])
|
|
2467
|
+
};
|
|
2468
|
+
}
|
|
2469
|
+
function option(fn, name, value, allowed) {
|
|
2470
|
+
if (value === void 0) {
|
|
2471
|
+
return allowed[0];
|
|
2472
|
+
}
|
|
2473
|
+
if (allowed.includes(value)) {
|
|
2474
|
+
return value;
|
|
2475
|
+
}
|
|
2476
|
+
const choices = allowed.map((choice) => JSON.stringify(choice));
|
|
2477
|
+
const last = choices.pop();
|
|
2478
|
+
throw new RecipeBookError(
|
|
2479
|
+
"invalid_argument",
|
|
2480
|
+
`${fn}: ${name} must be ${choices.join(", ")}${choices.length > 1 ? "," : ""} or ${last}, received ${describeValue(value)}.`
|
|
2481
|
+
);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// src/format/quantity.ts
|
|
2485
|
+
var EIGHTHS_AND_THIRDS = [
|
|
2486
|
+
[1, 8, "\u215B"],
|
|
2487
|
+
[1, 4, "\xBC"],
|
|
2488
|
+
[1, 3, "\u2153"],
|
|
2489
|
+
[3, 8, "\u215C"],
|
|
2490
|
+
[1, 2, "\xBD"],
|
|
2491
|
+
[5, 8, "\u215D"],
|
|
2492
|
+
[2, 3, "\u2154"],
|
|
2493
|
+
[3, 4, "\xBE"],
|
|
2494
|
+
[7, 8, "\u215E"],
|
|
2495
|
+
[1, 1, ""]
|
|
2496
|
+
];
|
|
2497
|
+
var QUARTERS = [
|
|
2498
|
+
[1, 4, "\xBC"],
|
|
2499
|
+
[1, 2, "\xBD"],
|
|
2500
|
+
[3, 4, "\xBE"],
|
|
2501
|
+
[1, 1, ""]
|
|
2502
|
+
];
|
|
2503
|
+
function formatQuantity(quantity, options) {
|
|
2504
|
+
const fn = "formatQuantity";
|
|
2505
|
+
return joinParts(
|
|
2506
|
+
quantityParts(fn, quantity, readQuantitySettings(fn, options)).parts
|
|
2507
|
+
);
|
|
2508
|
+
}
|
|
2509
|
+
function formatQuantityToParts(quantity, options) {
|
|
2510
|
+
const fn = "formatQuantityToParts";
|
|
2511
|
+
return quantityParts(fn, quantity, readQuantitySettings(fn, options)).parts;
|
|
2512
|
+
}
|
|
2513
|
+
function readQuantitySettings(fn, options) {
|
|
2514
|
+
return {
|
|
2515
|
+
...readSettings(fn, options),
|
|
2516
|
+
fractions: option(fn, "fractions", options?.fractions, [true, false]),
|
|
2517
|
+
rounding: option(fn, "rounding", options?.rounding, ["kitchen", "none"])
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
function quantityParts(fn, quantity, settings) {
|
|
2521
|
+
const amount = checkQuantity(fn, quantity);
|
|
2522
|
+
const unit = quantity.unit === void 0 ? void 0 : formatUnit(fn, quantity.unit);
|
|
2523
|
+
const [low, high = low] = amountEnds(amount).map(
|
|
2524
|
+
(value) => showAmount(value, settings, unit && unitSteps(unit, value))
|
|
2525
|
+
);
|
|
2526
|
+
const isRange = high.text !== low.text;
|
|
2527
|
+
const plural = isRange ? "other" : low.plural;
|
|
2528
|
+
const parts = [{ type: "amount", value: low.text }];
|
|
2529
|
+
if (isRange) {
|
|
2530
|
+
parts.push(
|
|
2531
|
+
{ type: "rangeSeparator", value: "\u2013" },
|
|
2532
|
+
{ type: "amount", value: high.text }
|
|
2533
|
+
);
|
|
2534
|
+
}
|
|
2535
|
+
if (unit) {
|
|
2536
|
+
parts.push(
|
|
2537
|
+
{ type: "literal", value: NBSP },
|
|
2538
|
+
{ type: "unit", value: unit.labels[settings.unitDisplay][plural] }
|
|
2539
|
+
);
|
|
2540
|
+
}
|
|
2541
|
+
return { parts, plural };
|
|
2542
|
+
}
|
|
2543
|
+
function formatUnit(fn, id) {
|
|
2544
|
+
const unit = lookupUnit(id);
|
|
2545
|
+
if (!unit) {
|
|
2546
|
+
throw new RecipeBookError(
|
|
2547
|
+
"unknown_unit",
|
|
2548
|
+
`${fn}: unit must be a unit id such as "g", "ml", or "cup", received ${describeValue(id)}. findUnit converts author text to ids; parseQuantity also suggests a fix for a misspelling.`
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
return unit;
|
|
2552
|
+
}
|
|
2553
|
+
function joinParts(parts) {
|
|
2554
|
+
return parts.map((part) => part.value).join("");
|
|
2555
|
+
}
|
|
2556
|
+
function showAmount(value, settings, steps = QUARTERS) {
|
|
2557
|
+
const { rounding, fractions: fractions2 } = settings;
|
|
2558
|
+
if (rounding === "none") {
|
|
2559
|
+
return decimal(value, 3, settings);
|
|
2560
|
+
}
|
|
2561
|
+
if (typeof steps === "number") {
|
|
2562
|
+
return decimal(value, steps, settings);
|
|
2563
|
+
}
|
|
2564
|
+
const [numerator, denominator] = steps[0];
|
|
2565
|
+
if (value < numerator / denominator * (1 - 1e-9)) {
|
|
2566
|
+
return decimal(value, 1, settings);
|
|
2567
|
+
}
|
|
2568
|
+
const [whole, part, glyph] = snap(value, steps);
|
|
2569
|
+
if (!fractions2) {
|
|
2570
|
+
return decimal(whole + part, 2, settings);
|
|
2571
|
+
}
|
|
2572
|
+
const wholeText = settings.number(whole, 0);
|
|
2573
|
+
const text = glyph === "" ? wholeText : whole === 0 ? glyph : `${wholeText}${NBSP}${glyph}`;
|
|
2574
|
+
return { text, plural: whole + part <= 1 ? "one" : "other" };
|
|
2575
|
+
}
|
|
2576
|
+
function unitSteps(unit, value) {
|
|
2577
|
+
if (unit.dimension === "count") {
|
|
2578
|
+
return QUARTERS;
|
|
2579
|
+
}
|
|
2580
|
+
if (unit.systems.includes("us")) {
|
|
2581
|
+
return EIGHTHS_AND_THIRDS;
|
|
2582
|
+
}
|
|
2583
|
+
switch (unit.id) {
|
|
2584
|
+
case "mg":
|
|
2585
|
+
return 0;
|
|
2586
|
+
case "kg":
|
|
2587
|
+
case "l":
|
|
2588
|
+
return 2;
|
|
2589
|
+
default:
|
|
2590
|
+
return value < 10 ? 1 : 0;
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
function snap(value, steps) {
|
|
2594
|
+
const floor = Math.floor(value);
|
|
2595
|
+
const part = value - floor;
|
|
2596
|
+
let nearest = 0;
|
|
2597
|
+
let nearestGlyph = "";
|
|
2598
|
+
for (const [numerator, denominator, glyph] of steps) {
|
|
2599
|
+
const step = numerator / denominator;
|
|
2600
|
+
if (Math.abs(part - step) <= Math.abs(part - nearest) + 1e-9) {
|
|
2601
|
+
nearest = step;
|
|
2602
|
+
nearestGlyph = glyph;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
return nearest === 1 ? [floor + 1, 0, ""] : [floor, nearest, nearestGlyph];
|
|
2606
|
+
}
|
|
2607
|
+
function decimal(value, digits, settings) {
|
|
2608
|
+
const [rounded, used] = roundAboveZero(value, digits);
|
|
2609
|
+
return { text: settings.number(rounded, used), plural: pluralForm(rounded) };
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// src/format/ingredient.ts
|
|
2613
|
+
function formatIngredient(item, options) {
|
|
2614
|
+
const fn = "formatIngredient";
|
|
2615
|
+
const ingredient = item?.ingredient;
|
|
2616
|
+
if (typeof ingredient?.name !== "string") {
|
|
2617
|
+
throw new RecipeBookError(
|
|
2618
|
+
"invalid_argument",
|
|
2619
|
+
`${fn}: expected { ingredient, quantity? }, such as a resolved ingredient, received ${describeValue(item)}.`
|
|
2620
|
+
);
|
|
2621
|
+
}
|
|
2622
|
+
for (const key of ["plural", "note"]) {
|
|
2623
|
+
const value = ingredient[key];
|
|
2624
|
+
if (value !== void 0 && typeof value !== "string") {
|
|
2625
|
+
throw new RecipeBookError(
|
|
2626
|
+
"invalid_argument",
|
|
2627
|
+
`${fn}: ingredient.${key} must be a string, received ${describeValue(value)}.`
|
|
2628
|
+
);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
const settings = readQuantitySettings(fn, options);
|
|
2632
|
+
const quantity = item.quantity ?? ingredient.quantity;
|
|
2633
|
+
const { note } = ingredient;
|
|
2634
|
+
const noteText = note ? `, ${note}` : "";
|
|
2635
|
+
if (!quantity) {
|
|
2636
|
+
const { name: name2 } = ingredient;
|
|
2637
|
+
return { name: name2, ...note && { note }, text: name2 + noteText };
|
|
2638
|
+
}
|
|
2639
|
+
const { parts, plural } = quantityParts(fn, quantity, settings);
|
|
2640
|
+
const quantityText = joinParts(parts);
|
|
2641
|
+
const isCount = quantity.unit === void 0;
|
|
2642
|
+
const name = isCount && plural === "other" ? ingredient.plural ?? ingredient.name : ingredient.name;
|
|
2643
|
+
return {
|
|
2644
|
+
quantity: quantityText,
|
|
2645
|
+
name,
|
|
2646
|
+
...note && { note },
|
|
2647
|
+
text: `${quantityText}${isCount ? NBSP : " "}${name}${noteText}`
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// src/format/servings.ts
|
|
2652
|
+
function formatServings(servings, options) {
|
|
2653
|
+
const fn = "formatServings";
|
|
2654
|
+
return showRange(
|
|
2655
|
+
fn,
|
|
2656
|
+
servings,
|
|
2657
|
+
"servings such as { amount: 4, max: 6 }",
|
|
2658
|
+
readQuantitySettings(fn, options)
|
|
2659
|
+
).text;
|
|
2660
|
+
}
|
|
2661
|
+
function formatYield(yield_, options) {
|
|
2662
|
+
const fn = "formatYield";
|
|
2663
|
+
const { text, plural } = showRange(
|
|
2664
|
+
fn,
|
|
2665
|
+
yield_,
|
|
2666
|
+
'a yield such as { amount: 24, unit: "cookie" }',
|
|
2667
|
+
readQuantitySettings(fn, options)
|
|
2668
|
+
);
|
|
2669
|
+
if (typeof yield_.unit !== "string") {
|
|
2670
|
+
throw new RecipeBookError(
|
|
2671
|
+
"invalid_argument",
|
|
2672
|
+
`${fn}: unit must be a string such as "cookie", received ${describeValue(yield_.unit)}.`
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
2675
|
+
if (yield_.plural !== void 0 && typeof yield_.plural !== "string") {
|
|
2676
|
+
throw new RecipeBookError(
|
|
2677
|
+
"invalid_argument",
|
|
2678
|
+
`${fn}: plural must be a string, received ${describeValue(yield_.plural)}.`
|
|
2679
|
+
);
|
|
2680
|
+
}
|
|
2681
|
+
return `${text}${NBSP}${plural === "other" ? yield_.plural ?? yield_.unit : yield_.unit}`;
|
|
2682
|
+
}
|
|
2683
|
+
function showRange(fn, value, expected, settings) {
|
|
2684
|
+
if (typeof value !== "object" || value === null) {
|
|
2685
|
+
throw new RecipeBookError(
|
|
2686
|
+
"invalid_argument",
|
|
2687
|
+
`${fn}: expected ${expected}, received ${describeValue(value)}.`
|
|
2688
|
+
);
|
|
2689
|
+
}
|
|
2690
|
+
const { amount, max } = value;
|
|
2691
|
+
if (!isPositive(amount)) {
|
|
2692
|
+
throw new RecipeBookError(
|
|
2693
|
+
"invalid_argument",
|
|
2694
|
+
`${fn}: amount must be a finite number greater than 0, received ${describeValue(amount)}.`
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
if (max !== void 0 && !(isPositive(max) && max > amount)) {
|
|
2698
|
+
throw new RecipeBookError(
|
|
2699
|
+
"invalid_argument",
|
|
2700
|
+
`${fn}: max must be a finite number greater than amount (${amount}), received ${describeValue(max)}. Leave max out for a single amount.`
|
|
2701
|
+
);
|
|
2702
|
+
}
|
|
2703
|
+
const low = showAmount(amount, settings);
|
|
2704
|
+
const high = max === void 0 ? low : showAmount(max, settings);
|
|
2705
|
+
return high.text === low.text ? low : { text: `${low.text}\u2013${high.text}`, plural: "other" };
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
exports.NBSP = NBSP;
|
|
2709
|
+
exports.NUTRIENT_UNITS = NUTRIENT_UNITS;
|
|
2710
|
+
exports.RecipeBookError = RecipeBookError;
|
|
2711
|
+
exports.RecipeValidationError = RecipeValidationError;
|
|
2712
|
+
exports.asRecord = asRecord;
|
|
2713
|
+
exports.checkQuantity = checkQuantity;
|
|
2714
|
+
exports.checkResult = checkResult;
|
|
2715
|
+
exports.checkSystem = checkSystem;
|
|
2716
|
+
exports.describeValue = describeValue;
|
|
2717
|
+
exports.disallowedScheme = disallowedScheme;
|
|
2718
|
+
exports.findUnit = findUnit;
|
|
2719
|
+
exports.formatIngredient = formatIngredient;
|
|
2720
|
+
exports.formatQuantity = formatQuantity;
|
|
2721
|
+
exports.formatQuantityToParts = formatQuantityToParts;
|
|
2722
|
+
exports.formatServings = formatServings;
|
|
2723
|
+
exports.formatYield = formatYield;
|
|
2724
|
+
exports.getUnit = getUnit;
|
|
2725
|
+
exports.hasOwn = hasOwn;
|
|
2726
|
+
exports.inSystem = inSystem;
|
|
2727
|
+
exports.isNative = isNative;
|
|
2728
|
+
exports.isRecipeBookError = isRecipeBookError;
|
|
2729
|
+
exports.isRecord = isRecord;
|
|
2730
|
+
exports.issue = issue;
|
|
2731
|
+
exports.lookupUnit = lookupUnit;
|
|
2732
|
+
exports.mapAmount = mapAmount;
|
|
2733
|
+
exports.matchAmount = matchAmount;
|
|
2734
|
+
exports.multiply = multiply;
|
|
2735
|
+
exports.option = option;
|
|
2736
|
+
exports.parseIngredientLine = parseIngredientLine;
|
|
2737
|
+
exports.parseIsoDuration = parseIsoDuration;
|
|
2738
|
+
exports.parseQuantity = parseQuantity;
|
|
2739
|
+
exports.parseRecipe = parseRecipe;
|
|
2740
|
+
exports.parseRecipeWith = parseRecipeWith;
|
|
2741
|
+
exports.pluralForm = pluralForm;
|
|
2742
|
+
exports.quote = quote;
|
|
2743
|
+
exports.readLineOption = readLineOption;
|
|
2744
|
+
exports.readSettings = readSettings;
|
|
2745
|
+
exports.resolveIngredient = resolveIngredient;
|
|
2746
|
+
exports.resolveRecipe = resolveRecipe;
|
|
2747
|
+
exports.round = round;
|
|
2748
|
+
exports.scaleQuantity = scaleQuantity;
|
|
2749
|
+
exports.toResult = toResult;
|
|
2750
|
+
exports.units = units;
|
|
2751
|
+
exports.unknownUnitError = unknownUnitError;
|
|
2752
|
+
exports.warning = warning;
|
|
2753
|
+
//# sourceMappingURL=chunk-D5CV3UF7.cjs.map
|
|
2754
|
+
//# sourceMappingURL=chunk-D5CV3UF7.cjs.map
|