soustack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/cli/index.js +2009 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.mts +336 -0
- package/dist/index.d.ts +336 -0
- package/dist/index.js +2126 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2104 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +74 -0
- package/src/schema.json +305 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2104 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import addFormats from 'ajv-formats';
|
|
3
|
+
import { load } from 'cheerio';
|
|
4
|
+
|
|
5
|
+
// src/parser.ts
|
|
6
|
+
function scaleRecipe(recipe, targetYieldAmount) {
|
|
7
|
+
const baseYield = recipe.yield?.amount || 1;
|
|
8
|
+
const multiplier = targetYieldAmount / baseYield;
|
|
9
|
+
const flatIngredients = flattenIngredients(recipe.ingredients);
|
|
10
|
+
const scaledIngredientsMap = /* @__PURE__ */ new Map();
|
|
11
|
+
flatIngredients.forEach((ing) => {
|
|
12
|
+
if (isIndependent(ing.scaling?.type)) {
|
|
13
|
+
const computed = calculateIngredient(ing, multiplier);
|
|
14
|
+
scaledIngredientsMap.set(ing.id || ing.item, computed);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
flatIngredients.forEach((ing) => {
|
|
18
|
+
if (!isIndependent(ing.scaling?.type)) {
|
|
19
|
+
if (ing.scaling?.type === "bakers_percentage" && ing.scaling.referenceId) {
|
|
20
|
+
const refIng = scaledIngredientsMap.get(ing.scaling.referenceId);
|
|
21
|
+
if (refIng) refIng.amount;
|
|
22
|
+
}
|
|
23
|
+
const computed = calculateIngredient(ing, multiplier);
|
|
24
|
+
scaledIngredientsMap.set(ing.id || ing.item, computed);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
const flatInstructions = flattenInstructions(recipe.instructions);
|
|
28
|
+
const computedInstructions = flatInstructions.map(
|
|
29
|
+
(inst) => calculateInstruction(inst, multiplier)
|
|
30
|
+
);
|
|
31
|
+
const timing = computedInstructions.reduce(
|
|
32
|
+
(acc, step) => {
|
|
33
|
+
if (step.type === "active") acc.active += step.durationMinutes;
|
|
34
|
+
else acc.passive += step.durationMinutes;
|
|
35
|
+
acc.total += step.durationMinutes;
|
|
36
|
+
return acc;
|
|
37
|
+
},
|
|
38
|
+
{ active: 0, passive: 0, total: 0 }
|
|
39
|
+
);
|
|
40
|
+
return {
|
|
41
|
+
metadata: {
|
|
42
|
+
targetYield: targetYieldAmount,
|
|
43
|
+
baseYield,
|
|
44
|
+
multiplier
|
|
45
|
+
},
|
|
46
|
+
ingredients: Array.from(scaledIngredientsMap.values()),
|
|
47
|
+
instructions: computedInstructions,
|
|
48
|
+
timing
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function isIndependent(type) {
|
|
52
|
+
return !type || type === "linear" || type === "fixed" || type === "discrete";
|
|
53
|
+
}
|
|
54
|
+
function calculateIngredient(ing, multiplier, referenceValue) {
|
|
55
|
+
const baseAmount = ing.quantity?.amount || 0;
|
|
56
|
+
const type = ing.scaling?.type || "linear";
|
|
57
|
+
let newAmount = baseAmount;
|
|
58
|
+
switch (type) {
|
|
59
|
+
case "linear":
|
|
60
|
+
newAmount = baseAmount * multiplier;
|
|
61
|
+
break;
|
|
62
|
+
case "fixed":
|
|
63
|
+
newAmount = baseAmount;
|
|
64
|
+
break;
|
|
65
|
+
case "discrete":
|
|
66
|
+
const raw = baseAmount * multiplier;
|
|
67
|
+
const step = ing.scaling.roundTo || 1;
|
|
68
|
+
newAmount = Math.round(raw / step) * step;
|
|
69
|
+
break;
|
|
70
|
+
case "bakers_percentage":
|
|
71
|
+
newAmount = baseAmount * multiplier;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
const unit = ing.quantity?.unit || "";
|
|
75
|
+
const ingredientName = ing.name || extractNameFromItem(ing.item);
|
|
76
|
+
const text = `${parseFloat(newAmount.toFixed(2))}${unit ? " " + unit : ""} ${ingredientName}`;
|
|
77
|
+
return {
|
|
78
|
+
id: ing.id || ing.item,
|
|
79
|
+
name: ingredientName,
|
|
80
|
+
amount: newAmount,
|
|
81
|
+
unit: ing.quantity?.unit || null,
|
|
82
|
+
text,
|
|
83
|
+
notes: ing.notes
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function extractNameFromItem(item) {
|
|
87
|
+
const match = item.match(/^\s*\d+(?:\.\d+)?\s*\w*\s*(.+)$/);
|
|
88
|
+
return match ? match[1].trim() : item;
|
|
89
|
+
}
|
|
90
|
+
function calculateInstruction(inst, multiplier) {
|
|
91
|
+
const baseDuration = inst.timing?.duration || 0;
|
|
92
|
+
const scalingType = inst.timing?.scaling || "fixed";
|
|
93
|
+
let newDuration = baseDuration;
|
|
94
|
+
if (scalingType === "linear") {
|
|
95
|
+
newDuration = baseDuration * multiplier;
|
|
96
|
+
} else if (scalingType === "sqrt") {
|
|
97
|
+
newDuration = baseDuration * Math.sqrt(multiplier);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
id: inst.id || "step",
|
|
101
|
+
text: inst.text,
|
|
102
|
+
durationMinutes: Math.ceil(newDuration),
|
|
103
|
+
type: inst.timing?.type || "active"
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function flattenIngredients(items) {
|
|
107
|
+
const result = [];
|
|
108
|
+
items.forEach((item) => {
|
|
109
|
+
if (typeof item === "string") {
|
|
110
|
+
result.push({ item, quantity: { amount: 0, unit: null }, scaling: { type: "fixed" } });
|
|
111
|
+
} else if ("subsection" in item) {
|
|
112
|
+
result.push(...flattenIngredients(item.items));
|
|
113
|
+
} else {
|
|
114
|
+
result.push(item);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
function flattenInstructions(items) {
|
|
120
|
+
const result = [];
|
|
121
|
+
items.forEach((item) => {
|
|
122
|
+
if (typeof item === "string") {
|
|
123
|
+
result.push({ text: item, timing: { duration: 0, type: "active" } });
|
|
124
|
+
} else if ("subsection" in item) {
|
|
125
|
+
result.push(...flattenInstructions(item.items));
|
|
126
|
+
} else {
|
|
127
|
+
result.push(item);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/schema.json
|
|
134
|
+
var schema_default = {
|
|
135
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
136
|
+
$id: "http://soustack.org/schema/v0.1",
|
|
137
|
+
title: "Soustack Recipe Schema v0.1",
|
|
138
|
+
description: "A portable, scalable, interoperable recipe format.",
|
|
139
|
+
type: "object",
|
|
140
|
+
required: ["name", "ingredients", "instructions"],
|
|
141
|
+
properties: {
|
|
142
|
+
id: {
|
|
143
|
+
type: "string",
|
|
144
|
+
description: "Unique identifier (slug or UUID)"
|
|
145
|
+
},
|
|
146
|
+
name: {
|
|
147
|
+
type: "string",
|
|
148
|
+
description: "The title of the recipe"
|
|
149
|
+
},
|
|
150
|
+
version: {
|
|
151
|
+
type: "string",
|
|
152
|
+
pattern: "^\\d+\\.\\d+\\.\\d+$",
|
|
153
|
+
description: "Semantic versioning (e.g., 1.0.0)"
|
|
154
|
+
},
|
|
155
|
+
description: {
|
|
156
|
+
type: "string"
|
|
157
|
+
},
|
|
158
|
+
category: {
|
|
159
|
+
type: "string",
|
|
160
|
+
examples: ["Main Course", "Dessert"]
|
|
161
|
+
},
|
|
162
|
+
tags: {
|
|
163
|
+
type: "array",
|
|
164
|
+
items: { type: "string" }
|
|
165
|
+
},
|
|
166
|
+
image: {
|
|
167
|
+
type: "string",
|
|
168
|
+
format: "uri"
|
|
169
|
+
},
|
|
170
|
+
dateAdded: {
|
|
171
|
+
type: "string",
|
|
172
|
+
format: "date-time"
|
|
173
|
+
},
|
|
174
|
+
source: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: {
|
|
177
|
+
author: { type: "string" },
|
|
178
|
+
url: { type: "string", format: "uri" },
|
|
179
|
+
name: { type: "string" },
|
|
180
|
+
adapted: { type: "boolean" }
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
yield: {
|
|
184
|
+
$ref: "#/definitions/yield"
|
|
185
|
+
},
|
|
186
|
+
time: {
|
|
187
|
+
$ref: "#/definitions/time"
|
|
188
|
+
},
|
|
189
|
+
equipment: {
|
|
190
|
+
type: "array",
|
|
191
|
+
items: { $ref: "#/definitions/equipment" }
|
|
192
|
+
},
|
|
193
|
+
ingredients: {
|
|
194
|
+
type: "array",
|
|
195
|
+
items: {
|
|
196
|
+
anyOf: [
|
|
197
|
+
{ type: "string" },
|
|
198
|
+
{ $ref: "#/definitions/ingredient" },
|
|
199
|
+
{ $ref: "#/definitions/ingredientSubsection" }
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
instructions: {
|
|
204
|
+
type: "array",
|
|
205
|
+
items: {
|
|
206
|
+
anyOf: [
|
|
207
|
+
{ type: "string" },
|
|
208
|
+
{ $ref: "#/definitions/instruction" },
|
|
209
|
+
{ $ref: "#/definitions/instructionSubsection" }
|
|
210
|
+
]
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
storage: {
|
|
214
|
+
$ref: "#/definitions/storage"
|
|
215
|
+
},
|
|
216
|
+
substitutions: {
|
|
217
|
+
type: "array",
|
|
218
|
+
items: { $ref: "#/definitions/substitution" }
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
definitions: {
|
|
222
|
+
yield: {
|
|
223
|
+
type: "object",
|
|
224
|
+
required: ["amount", "unit"],
|
|
225
|
+
properties: {
|
|
226
|
+
amount: { type: "number" },
|
|
227
|
+
unit: { type: "string" },
|
|
228
|
+
servings: { type: "number" },
|
|
229
|
+
description: { type: "string" }
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
time: {
|
|
233
|
+
oneOf: [
|
|
234
|
+
{
|
|
235
|
+
type: "object",
|
|
236
|
+
properties: {
|
|
237
|
+
prep: { type: "number" },
|
|
238
|
+
active: { type: "number" },
|
|
239
|
+
passive: { type: "number" },
|
|
240
|
+
total: { type: "number" }
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: {
|
|
246
|
+
prepTime: { type: "string" },
|
|
247
|
+
cookTime: { type: "string" }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
},
|
|
252
|
+
quantity: {
|
|
253
|
+
type: "object",
|
|
254
|
+
required: ["amount"],
|
|
255
|
+
properties: {
|
|
256
|
+
amount: { type: "number" },
|
|
257
|
+
unit: { type: ["string", "null"] }
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
scaling: {
|
|
261
|
+
type: "object",
|
|
262
|
+
required: ["type"],
|
|
263
|
+
properties: {
|
|
264
|
+
type: {
|
|
265
|
+
type: "string",
|
|
266
|
+
enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
|
|
267
|
+
},
|
|
268
|
+
factor: { type: "number" },
|
|
269
|
+
referenceId: { type: "string" },
|
|
270
|
+
roundTo: { type: "number" },
|
|
271
|
+
min: { type: "number" },
|
|
272
|
+
max: { type: "number" }
|
|
273
|
+
},
|
|
274
|
+
if: {
|
|
275
|
+
properties: { type: { const: "bakers_percentage" } }
|
|
276
|
+
},
|
|
277
|
+
then: {
|
|
278
|
+
required: ["referenceId"]
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
ingredient: {
|
|
282
|
+
type: "object",
|
|
283
|
+
required: ["item"],
|
|
284
|
+
properties: {
|
|
285
|
+
id: { type: "string" },
|
|
286
|
+
item: { type: "string" },
|
|
287
|
+
quantity: { $ref: "#/definitions/quantity" },
|
|
288
|
+
name: { type: "string" },
|
|
289
|
+
aisle: { type: "string" },
|
|
290
|
+
prep: { type: "string" },
|
|
291
|
+
prepAction: { type: "string" },
|
|
292
|
+
prepTime: { type: "number" },
|
|
293
|
+
destination: { type: "string" },
|
|
294
|
+
scaling: { $ref: "#/definitions/scaling" },
|
|
295
|
+
critical: { type: "boolean" },
|
|
296
|
+
optional: { type: "boolean" },
|
|
297
|
+
notes: { type: "string" }
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
ingredientSubsection: {
|
|
301
|
+
type: "object",
|
|
302
|
+
required: ["subsection", "items"],
|
|
303
|
+
properties: {
|
|
304
|
+
subsection: { type: "string" },
|
|
305
|
+
items: {
|
|
306
|
+
type: "array",
|
|
307
|
+
items: { $ref: "#/definitions/ingredient" }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
equipment: {
|
|
312
|
+
type: "object",
|
|
313
|
+
required: ["name"],
|
|
314
|
+
properties: {
|
|
315
|
+
id: { type: "string" },
|
|
316
|
+
name: { type: "string" },
|
|
317
|
+
required: { type: "boolean" },
|
|
318
|
+
label: { type: "string" },
|
|
319
|
+
capacity: { $ref: "#/definitions/quantity" },
|
|
320
|
+
scalingLimit: { type: "number" },
|
|
321
|
+
alternatives: {
|
|
322
|
+
type: "array",
|
|
323
|
+
items: { type: "string" }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
instruction: {
|
|
328
|
+
type: "object",
|
|
329
|
+
required: ["text"],
|
|
330
|
+
properties: {
|
|
331
|
+
id: { type: "string" },
|
|
332
|
+
text: { type: "string" },
|
|
333
|
+
destination: { type: "string" },
|
|
334
|
+
dependsOn: {
|
|
335
|
+
type: "array",
|
|
336
|
+
items: { type: "string" }
|
|
337
|
+
},
|
|
338
|
+
inputs: {
|
|
339
|
+
type: "array",
|
|
340
|
+
items: { type: "string" }
|
|
341
|
+
},
|
|
342
|
+
timing: {
|
|
343
|
+
type: "object",
|
|
344
|
+
required: ["duration", "type"],
|
|
345
|
+
properties: {
|
|
346
|
+
duration: { type: "number" },
|
|
347
|
+
type: { type: "string", enum: ["active", "passive"] },
|
|
348
|
+
scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
instructionSubsection: {
|
|
354
|
+
type: "object",
|
|
355
|
+
required: ["subsection", "items"],
|
|
356
|
+
properties: {
|
|
357
|
+
subsection: { type: "string" },
|
|
358
|
+
items: {
|
|
359
|
+
type: "array",
|
|
360
|
+
items: {
|
|
361
|
+
anyOf: [
|
|
362
|
+
{ type: "string" },
|
|
363
|
+
{ $ref: "#/definitions/instruction" }
|
|
364
|
+
]
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
storage: {
|
|
370
|
+
type: "object",
|
|
371
|
+
properties: {
|
|
372
|
+
roomTemp: { $ref: "#/definitions/storageMethod" },
|
|
373
|
+
refrigerated: { $ref: "#/definitions/storageMethod" },
|
|
374
|
+
frozen: {
|
|
375
|
+
allOf: [
|
|
376
|
+
{ $ref: "#/definitions/storageMethod" },
|
|
377
|
+
{
|
|
378
|
+
type: "object",
|
|
379
|
+
properties: { thawing: { type: "string" } }
|
|
380
|
+
}
|
|
381
|
+
]
|
|
382
|
+
},
|
|
383
|
+
reheating: { type: "string" },
|
|
384
|
+
makeAhead: {
|
|
385
|
+
type: "array",
|
|
386
|
+
items: {
|
|
387
|
+
allOf: [
|
|
388
|
+
{ $ref: "#/definitions/storageMethod" },
|
|
389
|
+
{
|
|
390
|
+
type: "object",
|
|
391
|
+
required: ["component", "storage"],
|
|
392
|
+
properties: {
|
|
393
|
+
component: { type: "string" },
|
|
394
|
+
storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
]
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
storageMethod: {
|
|
403
|
+
type: "object",
|
|
404
|
+
required: ["duration"],
|
|
405
|
+
properties: {
|
|
406
|
+
duration: { type: "string", pattern: "^P" },
|
|
407
|
+
method: { type: "string" },
|
|
408
|
+
notes: { type: "string" }
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
substitution: {
|
|
412
|
+
type: "object",
|
|
413
|
+
required: ["ingredient"],
|
|
414
|
+
properties: {
|
|
415
|
+
ingredient: { type: "string" },
|
|
416
|
+
critical: { type: "boolean" },
|
|
417
|
+
notes: { type: "string" },
|
|
418
|
+
alternatives: {
|
|
419
|
+
type: "array",
|
|
420
|
+
items: {
|
|
421
|
+
type: "object",
|
|
422
|
+
required: ["name", "ratio"],
|
|
423
|
+
properties: {
|
|
424
|
+
name: { type: "string" },
|
|
425
|
+
ratio: { type: "string" },
|
|
426
|
+
notes: { type: "string" },
|
|
427
|
+
impact: { type: "string" },
|
|
428
|
+
dietary: {
|
|
429
|
+
type: "array",
|
|
430
|
+
items: { type: "string" }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// src/validator.ts
|
|
441
|
+
var ajv = new Ajv();
|
|
442
|
+
addFormats(ajv);
|
|
443
|
+
var validate = ajv.compile(schema_default);
|
|
444
|
+
function validateRecipe(data) {
|
|
445
|
+
const isValid = validate(data);
|
|
446
|
+
if (!isValid) {
|
|
447
|
+
throw new Error(JSON.stringify(validate.errors, null, 2));
|
|
448
|
+
}
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/parsers/ingredient.ts
|
|
453
|
+
var FRACTION_DECIMALS = {
|
|
454
|
+
"\xBD": 0.5,
|
|
455
|
+
"\u2153": 1 / 3,
|
|
456
|
+
"\u2154": 2 / 3,
|
|
457
|
+
"\xBC": 0.25,
|
|
458
|
+
"\xBE": 0.75,
|
|
459
|
+
"\u2155": 0.2,
|
|
460
|
+
"\u2156": 0.4,
|
|
461
|
+
"\u2157": 0.6,
|
|
462
|
+
"\u2158": 0.8,
|
|
463
|
+
"\u2159": 1 / 6,
|
|
464
|
+
"\u215A": 5 / 6,
|
|
465
|
+
"\u215B": 0.125,
|
|
466
|
+
"\u215C": 0.375,
|
|
467
|
+
"\u215D": 0.625,
|
|
468
|
+
"\u215E": 0.875
|
|
469
|
+
};
|
|
470
|
+
var NUMBER_WORDS = {
|
|
471
|
+
zero: 0,
|
|
472
|
+
one: 1,
|
|
473
|
+
two: 2,
|
|
474
|
+
three: 3,
|
|
475
|
+
four: 4,
|
|
476
|
+
five: 5,
|
|
477
|
+
six: 6,
|
|
478
|
+
seven: 7,
|
|
479
|
+
eight: 8,
|
|
480
|
+
nine: 9,
|
|
481
|
+
ten: 10,
|
|
482
|
+
eleven: 11,
|
|
483
|
+
twelve: 12,
|
|
484
|
+
thirteen: 13,
|
|
485
|
+
fourteen: 14,
|
|
486
|
+
fifteen: 15,
|
|
487
|
+
sixteen: 16,
|
|
488
|
+
seventeen: 17,
|
|
489
|
+
eighteen: 18,
|
|
490
|
+
nineteen: 19,
|
|
491
|
+
twenty: 20,
|
|
492
|
+
thirty: 30,
|
|
493
|
+
forty: 40,
|
|
494
|
+
fifty: 50,
|
|
495
|
+
sixty: 60,
|
|
496
|
+
seventy: 70,
|
|
497
|
+
eighty: 80,
|
|
498
|
+
ninety: 90,
|
|
499
|
+
hundred: 100,
|
|
500
|
+
half: 0.5,
|
|
501
|
+
quarter: 0.25
|
|
502
|
+
};
|
|
503
|
+
var UNIT_SYNONYMS = {
|
|
504
|
+
cup: "cup",
|
|
505
|
+
cups: "cup",
|
|
506
|
+
c: "cup",
|
|
507
|
+
tbsp: "tbsp",
|
|
508
|
+
tablespoon: "tbsp",
|
|
509
|
+
tablespoons: "tbsp",
|
|
510
|
+
tbs: "tbsp",
|
|
511
|
+
tsp: "tsp",
|
|
512
|
+
teaspoon: "tsp",
|
|
513
|
+
teaspoons: "tsp",
|
|
514
|
+
t: "tsp",
|
|
515
|
+
gram: "g",
|
|
516
|
+
grams: "g",
|
|
517
|
+
g: "g",
|
|
518
|
+
kilogram: "kg",
|
|
519
|
+
kilograms: "kg",
|
|
520
|
+
kg: "kg",
|
|
521
|
+
milliliter: "ml",
|
|
522
|
+
milliliters: "ml",
|
|
523
|
+
ml: "ml",
|
|
524
|
+
liter: "l",
|
|
525
|
+
liters: "l",
|
|
526
|
+
l: "l",
|
|
527
|
+
ounce: "oz",
|
|
528
|
+
ounces: "oz",
|
|
529
|
+
oz: "oz",
|
|
530
|
+
pound: "lb",
|
|
531
|
+
pounds: "lb",
|
|
532
|
+
lb: "lb",
|
|
533
|
+
lbs: "lb",
|
|
534
|
+
pint: "pint",
|
|
535
|
+
pints: "pint",
|
|
536
|
+
quart: "quart",
|
|
537
|
+
quarts: "quart",
|
|
538
|
+
stick: "stick",
|
|
539
|
+
sticks: "stick",
|
|
540
|
+
dash: "dash",
|
|
541
|
+
pinches: "pinch",
|
|
542
|
+
pinch: "pinch"
|
|
543
|
+
};
|
|
544
|
+
var PREP_PHRASES = [
|
|
545
|
+
"diced",
|
|
546
|
+
"finely diced",
|
|
547
|
+
"roughly diced",
|
|
548
|
+
"minced",
|
|
549
|
+
"finely minced",
|
|
550
|
+
"chopped",
|
|
551
|
+
"finely chopped",
|
|
552
|
+
"roughly chopped",
|
|
553
|
+
"sliced",
|
|
554
|
+
"thinly sliced",
|
|
555
|
+
"thickly sliced",
|
|
556
|
+
"grated",
|
|
557
|
+
"finely grated",
|
|
558
|
+
"zested",
|
|
559
|
+
"sifted",
|
|
560
|
+
"softened",
|
|
561
|
+
"at room temperature",
|
|
562
|
+
"room temperature",
|
|
563
|
+
"room temp",
|
|
564
|
+
"melted",
|
|
565
|
+
"toasted",
|
|
566
|
+
"drained",
|
|
567
|
+
"drained and rinsed",
|
|
568
|
+
"beaten",
|
|
569
|
+
"divided",
|
|
570
|
+
"cut into cubes",
|
|
571
|
+
"cut into pieces",
|
|
572
|
+
"cut into strips",
|
|
573
|
+
"cut into chunks",
|
|
574
|
+
"cut into bite-size pieces"
|
|
575
|
+
].map((value) => value.toLowerCase());
|
|
576
|
+
var COUNT_DESCRIPTORS = /* @__PURE__ */ new Set([
|
|
577
|
+
"clove",
|
|
578
|
+
"cloves",
|
|
579
|
+
"can",
|
|
580
|
+
"cans",
|
|
581
|
+
"stick",
|
|
582
|
+
"sticks",
|
|
583
|
+
"sprig",
|
|
584
|
+
"sprigs",
|
|
585
|
+
"bunch",
|
|
586
|
+
"bunches",
|
|
587
|
+
"slice",
|
|
588
|
+
"slices",
|
|
589
|
+
"package",
|
|
590
|
+
"packages"
|
|
591
|
+
]);
|
|
592
|
+
var DESCRIPTOR_NOTE_SET = /* @__PURE__ */ new Set(["can", "cans", "jar", "jars", "package", "packages", "bottle", "bottles"]);
|
|
593
|
+
var WEIGHT_PRIORITY_UNITS = /* @__PURE__ */ new Set(["g", "kg", "oz", "lb", "ml", "l"]);
|
|
594
|
+
var SPICE_KEYWORDS = [
|
|
595
|
+
"salt",
|
|
596
|
+
"pepper",
|
|
597
|
+
"paprika",
|
|
598
|
+
"cumin",
|
|
599
|
+
"coriander",
|
|
600
|
+
"turmeric",
|
|
601
|
+
"chili powder",
|
|
602
|
+
"garlic powder",
|
|
603
|
+
"onion powder",
|
|
604
|
+
"cayenne",
|
|
605
|
+
"cinnamon",
|
|
606
|
+
"nutmeg",
|
|
607
|
+
"allspice",
|
|
608
|
+
"ginger",
|
|
609
|
+
"oregano",
|
|
610
|
+
"thyme",
|
|
611
|
+
"rosemary",
|
|
612
|
+
"basil",
|
|
613
|
+
"sage",
|
|
614
|
+
"clove",
|
|
615
|
+
"spice",
|
|
616
|
+
"seasoning"
|
|
617
|
+
];
|
|
618
|
+
var PURPOSE_KEYWORDS = ["frying", "greasing", "drizzling", "garnish", "serving", "brushing"];
|
|
619
|
+
var RANGE_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(?:\s*(?:-|to)\s*((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?))/i;
|
|
620
|
+
var NUMBER_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)/i;
|
|
621
|
+
var QUALIFIER_REGEX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
|
|
622
|
+
var FLAVOR_NOTE_REGEX = /\b(to taste|as needed|as necessary)\b/gi;
|
|
623
|
+
var VAGUE_QUANTITY_PATTERNS = [
|
|
624
|
+
{ regex: /^(a\s+pinch|pinch)\b/i, note: "a pinch" },
|
|
625
|
+
{ regex: /^(a\s+handful|handful)\b/i, note: "a handful" },
|
|
626
|
+
{ regex: /^(a\s+dash|dash)\b/i, note: "a dash" },
|
|
627
|
+
{ regex: /^(a\s+sprinkle|sprinkle)\b/i, note: "a sprinkle" },
|
|
628
|
+
{ regex: /^(some)\b/i, note: "some" },
|
|
629
|
+
{ regex: /^(few\s+sprigs)/i, note: "few sprigs" },
|
|
630
|
+
{ regex: /^(a\s+few|few)\b/i, note: "a few" },
|
|
631
|
+
{ regex: /^(several)\b/i, note: "several" }
|
|
632
|
+
];
|
|
633
|
+
var JUICE_PREFIXES = ["juice of", "zest of"];
|
|
634
|
+
function normalizeIngredientInput(input) {
|
|
635
|
+
if (!input) return "";
|
|
636
|
+
let result = input.replace(/\u00A0/g, " ").trim();
|
|
637
|
+
result = replaceDashes(result);
|
|
638
|
+
result = replaceUnicodeFractions(result);
|
|
639
|
+
result = replaceNumberWords(result);
|
|
640
|
+
result = result.replace(/\s+/g, " ").trim();
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
function parseIngredient(text) {
|
|
644
|
+
const original = text ?? "";
|
|
645
|
+
const normalized = normalizeIngredientInput(original);
|
|
646
|
+
if (!normalized) {
|
|
647
|
+
return {
|
|
648
|
+
item: original,
|
|
649
|
+
scaling: { type: "linear" }
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
let working = normalized;
|
|
653
|
+
const notes = [];
|
|
654
|
+
let optional = false;
|
|
655
|
+
if (/\boptional\b/i.test(working)) {
|
|
656
|
+
optional = true;
|
|
657
|
+
working = working.replace(/\(?\s*optional\s*\)?/gi, "").trim();
|
|
658
|
+
working = working.replace(/\(\s*\)/g, " ").trim();
|
|
659
|
+
}
|
|
660
|
+
const flavorExtraction = extractFlavorNotes(working);
|
|
661
|
+
working = flavorExtraction.cleaned;
|
|
662
|
+
notes.push(...flavorExtraction.notes);
|
|
663
|
+
const parenthetical = extractParentheticals(working);
|
|
664
|
+
working = parenthetical.cleaned;
|
|
665
|
+
notes.push(...parenthetical.notes);
|
|
666
|
+
optional = optional || parenthetical.optional;
|
|
667
|
+
const purposeExtraction = extractPurposeNotes(working);
|
|
668
|
+
working = purposeExtraction.cleaned;
|
|
669
|
+
notes.push(...purposeExtraction.notes);
|
|
670
|
+
const juiceExtraction = extractJuicePhrase(working);
|
|
671
|
+
if (juiceExtraction) {
|
|
672
|
+
working = juiceExtraction.cleaned;
|
|
673
|
+
notes.push(juiceExtraction.note);
|
|
674
|
+
}
|
|
675
|
+
const vagueQuantity = extractVagueQuantity(working);
|
|
676
|
+
let quantityResult;
|
|
677
|
+
if (vagueQuantity) {
|
|
678
|
+
notes.push(vagueQuantity.note);
|
|
679
|
+
quantityResult = {
|
|
680
|
+
amount: null,
|
|
681
|
+
unit: null,
|
|
682
|
+
descriptor: void 0,
|
|
683
|
+
remainder: vagueQuantity.remainder,
|
|
684
|
+
notes: [],
|
|
685
|
+
originalAmount: null
|
|
686
|
+
};
|
|
687
|
+
} else {
|
|
688
|
+
quantityResult = extractQuantity(working);
|
|
689
|
+
}
|
|
690
|
+
working = quantityResult.remainder;
|
|
691
|
+
const { quantity, usedParenthetical } = mergeQuantities(quantityResult, parenthetical.measurement);
|
|
692
|
+
if (usedParenthetical && quantityResult.originalAmount !== null && quantityResult.originalAmount > 1 && quantityResult.descriptor && DESCRIPTOR_NOTE_SET.has(quantityResult.descriptor.toLowerCase())) {
|
|
693
|
+
notes.push(formatCountNote(quantityResult.originalAmount, quantityResult.descriptor));
|
|
694
|
+
}
|
|
695
|
+
notes.push(...quantityResult.notes);
|
|
696
|
+
working = working.replace(/^[,.\s-]+/, "").trim();
|
|
697
|
+
working = working.replace(/^of\s+/i, "").trim();
|
|
698
|
+
if (quantityResult.descriptor && /^cans?$/i.test(quantityResult.descriptor) && working && !/^canned\b/i.test(working)) {
|
|
699
|
+
working = `canned ${working}`.trim();
|
|
700
|
+
}
|
|
701
|
+
const nameExtraction = extractNameAndPrep(working);
|
|
702
|
+
notes.push(...nameExtraction.notes);
|
|
703
|
+
const name = nameExtraction.name || void 0;
|
|
704
|
+
const scaling = inferScaling(
|
|
705
|
+
name,
|
|
706
|
+
quantity.unit,
|
|
707
|
+
quantity.amount,
|
|
708
|
+
notes,
|
|
709
|
+
quantityResult.descriptor
|
|
710
|
+
);
|
|
711
|
+
const mergedNotes = formatNotes(notes);
|
|
712
|
+
const parsed = {
|
|
713
|
+
item: original,
|
|
714
|
+
quantity,
|
|
715
|
+
...name ? { name } : {},
|
|
716
|
+
...nameExtraction.prep ? { prep: nameExtraction.prep } : {},
|
|
717
|
+
...optional ? { optional: true } : {},
|
|
718
|
+
scaling
|
|
719
|
+
};
|
|
720
|
+
if (mergedNotes) {
|
|
721
|
+
parsed.notes = mergedNotes;
|
|
722
|
+
}
|
|
723
|
+
return parsed;
|
|
724
|
+
}
|
|
725
|
+
function parseIngredientLine(text) {
|
|
726
|
+
return parseIngredient(text);
|
|
727
|
+
}
|
|
728
|
+
function parseIngredients(texts) {
|
|
729
|
+
if (!Array.isArray(texts)) return [];
|
|
730
|
+
return texts.map((item) => typeof item === "string" ? item : String(item ?? "")).map((entry) => parseIngredient(entry));
|
|
731
|
+
}
|
|
732
|
+
function replaceDashes(value) {
|
|
733
|
+
return value.replace(/[\u2012\u2013\u2014\u2212]/g, "-");
|
|
734
|
+
}
|
|
735
|
+
function replaceUnicodeFractions(value) {
|
|
736
|
+
return value.replace(/(\d+)?(?:\s+)?([½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])/g, (_match, whole, fraction) => {
|
|
737
|
+
const fractionValue = FRACTION_DECIMALS[fraction];
|
|
738
|
+
if (fractionValue === void 0) return _match;
|
|
739
|
+
const base = whole ? parseInt(whole, 10) : 0;
|
|
740
|
+
const combined = base + fractionValue;
|
|
741
|
+
return formatDecimal(combined);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
function replaceNumberWords(value) {
|
|
745
|
+
return value.replace(
|
|
746
|
+
/\b(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|half|quarter)(?:-(one|two|three|four|five|six|seven|eight|nine))?\b/gi,
|
|
747
|
+
(match, word, hyphenPart) => {
|
|
748
|
+
const lower = word.toLowerCase();
|
|
749
|
+
const baseValue = NUMBER_WORDS[lower];
|
|
750
|
+
if (baseValue === void 0) return match;
|
|
751
|
+
if (!hyphenPart) {
|
|
752
|
+
return formatDecimal(baseValue);
|
|
753
|
+
}
|
|
754
|
+
const hyphenValue = NUMBER_WORDS[hyphenPart.toLowerCase()];
|
|
755
|
+
if (hyphenValue === void 0) {
|
|
756
|
+
return formatDecimal(baseValue);
|
|
757
|
+
}
|
|
758
|
+
return formatDecimal(baseValue + hyphenValue);
|
|
759
|
+
}
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
function formatDecimal(value) {
|
|
763
|
+
if (Number.isInteger(value)) {
|
|
764
|
+
return value.toString();
|
|
765
|
+
}
|
|
766
|
+
return parseFloat(value.toFixed(3)).toString().replace(/\.0+$/, "");
|
|
767
|
+
}
|
|
768
|
+
function extractFlavorNotes(value) {
|
|
769
|
+
const notes = [];
|
|
770
|
+
const cleaned = value.replace(FLAVOR_NOTE_REGEX, (_, phrase) => {
|
|
771
|
+
notes.push(phrase.toLowerCase());
|
|
772
|
+
return "";
|
|
773
|
+
});
|
|
774
|
+
return {
|
|
775
|
+
cleaned: cleaned.replace(/\s+/g, " ").trim(),
|
|
776
|
+
notes
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
function extractPurposeNotes(value) {
|
|
780
|
+
const notes = [];
|
|
781
|
+
let working = value.trim();
|
|
782
|
+
let match = working.match(/\bfor\s+(frying|greasing|drizzling|garnish|serving|brushing)\b\.?$/i);
|
|
783
|
+
if (match) {
|
|
784
|
+
notes.push(`for ${match[1].toLowerCase()}`);
|
|
785
|
+
working = working.slice(0, match.index).trim();
|
|
786
|
+
}
|
|
787
|
+
return { cleaned: working, notes };
|
|
788
|
+
}
|
|
789
|
+
function extractJuicePhrase(value) {
|
|
790
|
+
const lower = value.toLowerCase();
|
|
791
|
+
for (const prefix of JUICE_PREFIXES) {
|
|
792
|
+
if (lower.startsWith(prefix)) {
|
|
793
|
+
const remainder = value.slice(prefix.length).trim();
|
|
794
|
+
if (!remainder) break;
|
|
795
|
+
const cleanedSource = remainder.replace(/^of\s+/i, "").trim();
|
|
796
|
+
if (!cleanedSource) break;
|
|
797
|
+
const sourceForName = cleanedSource.replace(
|
|
798
|
+
/^(?:\d+(?:\.\d+)?|\d+\s+\d+\/\d+|\d+\/\d+|one|two|three|four|five|six|seven|eight|nine|ten|a|an)\s+/i,
|
|
799
|
+
""
|
|
800
|
+
).replace(/^(?:large|small|medium)\s+/i, "").trim();
|
|
801
|
+
const baseName = sourceForName || cleanedSource;
|
|
802
|
+
const singular = singularize(baseName);
|
|
803
|
+
const suffix = prefix.startsWith("zest") ? "zest" : "juice";
|
|
804
|
+
return {
|
|
805
|
+
cleaned: `${singular} ${suffix}`.trim(),
|
|
806
|
+
note: `from ${cleanedSource}`
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return void 0;
|
|
811
|
+
}
|
|
812
|
+
function extractVagueQuantity(value) {
|
|
813
|
+
for (const pattern of VAGUE_QUANTITY_PATTERNS) {
|
|
814
|
+
const match = value.match(pattern.regex);
|
|
815
|
+
if (match) {
|
|
816
|
+
let remainder = value.slice(match[0].length).trim();
|
|
817
|
+
remainder = remainder.replace(/^of\s+/i, "").trim();
|
|
818
|
+
return {
|
|
819
|
+
remainder,
|
|
820
|
+
note: pattern.note
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return void 0;
|
|
825
|
+
}
|
|
826
|
+
function extractParentheticals(value) {
|
|
827
|
+
let optional = false;
|
|
828
|
+
let measurement;
|
|
829
|
+
const notes = [];
|
|
830
|
+
const cleaned = value.replace(/\(([^)]+)\)/g, (_match, group) => {
|
|
831
|
+
const trimmed = String(group).trim();
|
|
832
|
+
if (!trimmed) return "";
|
|
833
|
+
if (/optional/i.test(trimmed)) {
|
|
834
|
+
optional = true;
|
|
835
|
+
return "";
|
|
836
|
+
}
|
|
837
|
+
const maybeMeasurement = parseMeasurement(trimmed);
|
|
838
|
+
if (maybeMeasurement && !measurement) {
|
|
839
|
+
measurement = maybeMeasurement;
|
|
840
|
+
return "";
|
|
841
|
+
}
|
|
842
|
+
notes.push(trimmed);
|
|
843
|
+
return "";
|
|
844
|
+
});
|
|
845
|
+
return {
|
|
846
|
+
cleaned: cleaned.replace(/\s+/g, " ").trim(),
|
|
847
|
+
measurement,
|
|
848
|
+
notes,
|
|
849
|
+
optional
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
function parseMeasurement(value) {
|
|
853
|
+
const stripped = value.replace(/^(about|around|approximately|approx\.?|roughly)\s+/i, "").trim();
|
|
854
|
+
const match = stripped.match(
|
|
855
|
+
/^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(?:\s*)([a-zA-Z]+)?$/
|
|
856
|
+
);
|
|
857
|
+
if (!match) return void 0;
|
|
858
|
+
const amount = parseNumber(match[1]);
|
|
859
|
+
if (amount === null) return void 0;
|
|
860
|
+
const unit = match[2] ? normalizeUnit(match[2]) ?? match[2].toLowerCase() : null;
|
|
861
|
+
return { amount, unit };
|
|
862
|
+
}
|
|
863
|
+
function extractQuantity(value) {
|
|
864
|
+
let working = value.trim();
|
|
865
|
+
const notes = [];
|
|
866
|
+
let amount = null;
|
|
867
|
+
let originalAmount = null;
|
|
868
|
+
let unit = null;
|
|
869
|
+
let descriptor;
|
|
870
|
+
while (QUALIFIER_REGEX.test(working)) {
|
|
871
|
+
working = working.replace(QUALIFIER_REGEX, "").trim();
|
|
872
|
+
}
|
|
873
|
+
const rangeMatch = working.match(RANGE_REGEX);
|
|
874
|
+
if (rangeMatch) {
|
|
875
|
+
amount = parseNumber(rangeMatch[1]);
|
|
876
|
+
originalAmount = amount;
|
|
877
|
+
const rangeText = rangeMatch[0].trim();
|
|
878
|
+
const afterRange = working.slice(rangeMatch[0].length).trim();
|
|
879
|
+
const descriptorMatch = afterRange.match(/^([a-zA-Z]+)/);
|
|
880
|
+
if (descriptorMatch && COUNT_DESCRIPTORS.has(descriptorMatch[1].toLowerCase())) {
|
|
881
|
+
notes.push(`${rangeText} ${descriptorMatch[1]}`);
|
|
882
|
+
} else {
|
|
883
|
+
notes.push(rangeText);
|
|
884
|
+
}
|
|
885
|
+
working = afterRange;
|
|
886
|
+
} else {
|
|
887
|
+
const numberMatch = working.match(NUMBER_REGEX);
|
|
888
|
+
if (numberMatch) {
|
|
889
|
+
amount = parseNumber(numberMatch[1]);
|
|
890
|
+
originalAmount = amount;
|
|
891
|
+
working = working.slice(numberMatch[0].length).trim();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (working) {
|
|
895
|
+
const unitMatch = working.match(/^([a-zA-Z]+)\b/);
|
|
896
|
+
if (unitMatch) {
|
|
897
|
+
const normalized = normalizeUnit(unitMatch[1]);
|
|
898
|
+
if (normalized) {
|
|
899
|
+
unit = normalized;
|
|
900
|
+
working = working.slice(unitMatch[0].length).trim();
|
|
901
|
+
} else if (COUNT_DESCRIPTORS.has(unitMatch[1].toLowerCase())) {
|
|
902
|
+
descriptor = unitMatch[1];
|
|
903
|
+
working = working.slice(unitMatch[0].length).trim();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return {
|
|
908
|
+
amount,
|
|
909
|
+
unit,
|
|
910
|
+
descriptor,
|
|
911
|
+
remainder: working.trim(),
|
|
912
|
+
notes,
|
|
913
|
+
originalAmount
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
function parseNumber(value) {
|
|
917
|
+
const trimmed = value.trim();
|
|
918
|
+
if (!trimmed) return null;
|
|
919
|
+
if (/^\d+\s+\d+\/\d+$/.test(trimmed)) {
|
|
920
|
+
const [whole, fraction] = trimmed.split(/\s+/);
|
|
921
|
+
return parseInt(whole, 10) + parseFraction(fraction);
|
|
922
|
+
}
|
|
923
|
+
if (/^\d+\/\d+$/.test(trimmed)) {
|
|
924
|
+
return parseFraction(trimmed);
|
|
925
|
+
}
|
|
926
|
+
const parsed = Number(trimmed);
|
|
927
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
928
|
+
}
|
|
929
|
+
function parseFraction(value) {
|
|
930
|
+
const [numerator, denominator] = value.split("/").map(Number);
|
|
931
|
+
if (!denominator) return numerator;
|
|
932
|
+
return numerator / denominator;
|
|
933
|
+
}
|
|
934
|
+
function normalizeUnit(raw) {
|
|
935
|
+
const lower = raw.toLowerCase();
|
|
936
|
+
if (UNIT_SYNONYMS[lower]) {
|
|
937
|
+
return UNIT_SYNONYMS[lower];
|
|
938
|
+
}
|
|
939
|
+
if (raw === "T") return "tbsp";
|
|
940
|
+
if (raw === "t") return "tsp";
|
|
941
|
+
if (raw === "C") return "cup";
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
function mergeQuantities(extracted, measurement) {
|
|
945
|
+
const quantity = {
|
|
946
|
+
amount: extracted.amount ?? null,
|
|
947
|
+
unit: extracted.unit ?? null
|
|
948
|
+
};
|
|
949
|
+
if (!measurement) {
|
|
950
|
+
return { quantity, usedParenthetical: false };
|
|
951
|
+
}
|
|
952
|
+
const measurementUnit = measurement.unit?.toLowerCase() ?? null;
|
|
953
|
+
const shouldPrefer = !quantity.unit || measurementUnit !== null && WEIGHT_PRIORITY_UNITS.has(measurementUnit);
|
|
954
|
+
if (shouldPrefer) {
|
|
955
|
+
return {
|
|
956
|
+
quantity: {
|
|
957
|
+
amount: measurement.amount,
|
|
958
|
+
unit: measurement.unit ?? null
|
|
959
|
+
},
|
|
960
|
+
usedParenthetical: true
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
return { quantity, usedParenthetical: false };
|
|
964
|
+
}
|
|
965
|
+
function extractNameAndPrep(value) {
|
|
966
|
+
let working = value.trim();
|
|
967
|
+
const notes = [];
|
|
968
|
+
let prep;
|
|
969
|
+
const lastComma = working.lastIndexOf(",");
|
|
970
|
+
if (lastComma >= 0) {
|
|
971
|
+
const trailing = working.slice(lastComma + 1).trim();
|
|
972
|
+
if (isPrepPhrase(trailing)) {
|
|
973
|
+
prep = trailing;
|
|
974
|
+
working = working.slice(0, lastComma).trim();
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
working = working.replace(/^[,.\s-]+/, "").trim();
|
|
978
|
+
working = working.replace(/^of\s+/i, "").trim();
|
|
979
|
+
if (!working) {
|
|
980
|
+
return { name: void 0, prep, notes };
|
|
981
|
+
}
|
|
982
|
+
let name = cleanupIngredientName(working);
|
|
983
|
+
return {
|
|
984
|
+
name: name || void 0,
|
|
985
|
+
prep,
|
|
986
|
+
notes
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
function cleanupIngredientName(value) {
|
|
990
|
+
let result = value.trim();
|
|
991
|
+
if (/^cans?\b/i.test(result)) {
|
|
992
|
+
result = result.replace(/^cans?\b/i, "canned").trim();
|
|
993
|
+
}
|
|
994
|
+
let changed = true;
|
|
995
|
+
while (changed) {
|
|
996
|
+
changed = false;
|
|
997
|
+
if (/^of\s+/i.test(result)) {
|
|
998
|
+
result = result.replace(/^of\s+/i, "").trim();
|
|
999
|
+
changed = true;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
const match = result.match(/^(clove|cloves|sprig|sprigs|bunch|bunches|stick|sticks|slice|slices)\b/i);
|
|
1003
|
+
if (match) {
|
|
1004
|
+
result = result.slice(match[0].length).trim();
|
|
1005
|
+
changed = true;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return result;
|
|
1009
|
+
}
|
|
1010
|
+
function isPrepPhrase(value) {
|
|
1011
|
+
const normalized = value.toLowerCase();
|
|
1012
|
+
return PREP_PHRASES.includes(normalized);
|
|
1013
|
+
}
|
|
1014
|
+
function inferScaling(name, unit, amount, notes, descriptor) {
|
|
1015
|
+
const lowerName = name?.toLowerCase() ?? "";
|
|
1016
|
+
const normalizedNotes = notes.map((note) => note.toLowerCase());
|
|
1017
|
+
const descriptorLower = descriptor?.toLowerCase();
|
|
1018
|
+
if (lowerName.includes("egg") || descriptorLower === "clove" || descriptorLower === "cloves" || normalizedNotes.some((note) => note.includes("clove"))) {
|
|
1019
|
+
return { type: "discrete", roundTo: 1 };
|
|
1020
|
+
}
|
|
1021
|
+
if (descriptorLower === "stick" || descriptorLower === "sticks") {
|
|
1022
|
+
return { type: "discrete", roundTo: 1 };
|
|
1023
|
+
}
|
|
1024
|
+
if (normalizedNotes.some((note) => PURPOSE_KEYWORDS.some((keyword) => note.includes(keyword)))) {
|
|
1025
|
+
return { type: "fixed" };
|
|
1026
|
+
}
|
|
1027
|
+
const isSpice = SPICE_KEYWORDS.some((keyword) => lowerName.includes(keyword));
|
|
1028
|
+
const smallUnit = unit ? ["tsp", "tbsp", "dash", "pinch"].includes(unit) : false;
|
|
1029
|
+
if (normalizedNotes.some((note) => note.includes("to taste")) || isSpice && (smallUnit || amount !== null && amount <= 1)) {
|
|
1030
|
+
return { type: "proportional", factor: 0.7 };
|
|
1031
|
+
}
|
|
1032
|
+
return { type: "linear" };
|
|
1033
|
+
}
|
|
1034
|
+
function formatNotes(notes) {
|
|
1035
|
+
const cleaned = Array.from(
|
|
1036
|
+
new Set(
|
|
1037
|
+
notes.map((note) => note.trim()).filter(Boolean)
|
|
1038
|
+
)
|
|
1039
|
+
);
|
|
1040
|
+
return cleaned.length ? cleaned.join("; ") : void 0;
|
|
1041
|
+
}
|
|
1042
|
+
function formatCountNote(amount, descriptor) {
|
|
1043
|
+
const lower = descriptor.toLowerCase();
|
|
1044
|
+
const singular = lower.endsWith("s") ? lower.slice(0, -1) : lower;
|
|
1045
|
+
const word = amount === 1 ? singular : singular.endsWith("ch") || singular.endsWith("sh") || singular.endsWith("s") || singular.endsWith("x") || singular.endsWith("z") ? `${singular}es` : singular.endsWith("y") && !/[aeiou]y$/.test(singular) ? `${singular.slice(0, -1)}ies` : `${singular}s`;
|
|
1046
|
+
return `${formatDecimal(amount)} ${word}`;
|
|
1047
|
+
}
|
|
1048
|
+
function singularize(value) {
|
|
1049
|
+
const trimmed = value.trim();
|
|
1050
|
+
if (trimmed.endsWith("ies")) {
|
|
1051
|
+
return `${trimmed.slice(0, -3)}y`;
|
|
1052
|
+
}
|
|
1053
|
+
if (/(ches|shes|sses|xes|zes)$/i.test(trimmed)) {
|
|
1054
|
+
return trimmed.slice(0, -2);
|
|
1055
|
+
}
|
|
1056
|
+
if (trimmed.endsWith("s")) {
|
|
1057
|
+
return trimmed.slice(0, -1);
|
|
1058
|
+
}
|
|
1059
|
+
return trimmed;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// src/converters/ingredient.ts
|
|
1063
|
+
function parseIngredientLine2(line) {
|
|
1064
|
+
const parsed = parseIngredient(line);
|
|
1065
|
+
const ingredient = {
|
|
1066
|
+
item: parsed.item,
|
|
1067
|
+
scaling: parsed.scaling ?? { type: "linear" }
|
|
1068
|
+
};
|
|
1069
|
+
if (parsed.name) {
|
|
1070
|
+
ingredient.name = parsed.name;
|
|
1071
|
+
}
|
|
1072
|
+
if (parsed.prep) {
|
|
1073
|
+
ingredient.prep = parsed.prep;
|
|
1074
|
+
}
|
|
1075
|
+
if (parsed.optional) {
|
|
1076
|
+
ingredient.optional = true;
|
|
1077
|
+
}
|
|
1078
|
+
if (parsed.notes) {
|
|
1079
|
+
ingredient.notes = parsed.notes;
|
|
1080
|
+
}
|
|
1081
|
+
const quantity = buildQuantity(parsed.quantity);
|
|
1082
|
+
if (quantity) {
|
|
1083
|
+
ingredient.quantity = quantity;
|
|
1084
|
+
}
|
|
1085
|
+
return ingredient;
|
|
1086
|
+
}
|
|
1087
|
+
function buildQuantity(parsedQuantity) {
|
|
1088
|
+
if (!parsedQuantity) {
|
|
1089
|
+
return void 0;
|
|
1090
|
+
}
|
|
1091
|
+
if (parsedQuantity.amount === null || Number.isNaN(parsedQuantity.amount)) {
|
|
1092
|
+
return void 0;
|
|
1093
|
+
}
|
|
1094
|
+
return {
|
|
1095
|
+
amount: parsedQuantity.amount,
|
|
1096
|
+
unit: parsedQuantity.unit ?? null
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// src/converters/yield.ts
|
|
1101
|
+
function parseYield(value) {
|
|
1102
|
+
if (value === void 0 || value === null) {
|
|
1103
|
+
return void 0;
|
|
1104
|
+
}
|
|
1105
|
+
if (typeof value === "number") {
|
|
1106
|
+
return {
|
|
1107
|
+
amount: value,
|
|
1108
|
+
unit: "servings"
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
if (Array.isArray(value)) {
|
|
1112
|
+
return parseYield(value[0]);
|
|
1113
|
+
}
|
|
1114
|
+
if (typeof value === "object") {
|
|
1115
|
+
const maybeYield = value;
|
|
1116
|
+
if (typeof maybeYield.amount === "number") {
|
|
1117
|
+
return {
|
|
1118
|
+
amount: maybeYield.amount,
|
|
1119
|
+
unit: typeof maybeYield.unit === "string" ? maybeYield.unit : "servings",
|
|
1120
|
+
description: typeof maybeYield.description === "string" ? maybeYield.description : void 0
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
if (typeof value === "string") {
|
|
1125
|
+
const trimmed = value.trim();
|
|
1126
|
+
const match = trimmed.match(/(\d+(?:\.\d+)?)/);
|
|
1127
|
+
if (match) {
|
|
1128
|
+
const amount = parseFloat(match[1]);
|
|
1129
|
+
const unit = trimmed.slice(match.index + match[1].length).trim();
|
|
1130
|
+
return {
|
|
1131
|
+
amount,
|
|
1132
|
+
unit: unit || "servings",
|
|
1133
|
+
description: trimmed
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
return void 0;
|
|
1138
|
+
}
|
|
1139
|
+
function formatYield(yieldValue) {
|
|
1140
|
+
if (!yieldValue) return void 0;
|
|
1141
|
+
if (!yieldValue.amount && !yieldValue.unit) {
|
|
1142
|
+
return void 0;
|
|
1143
|
+
}
|
|
1144
|
+
const amount = yieldValue.amount ?? "";
|
|
1145
|
+
const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
|
|
1146
|
+
return `${amount}${unit}`.trim() || yieldValue.description;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// src/parsers/duration.ts
|
|
1150
|
+
var ISO_DURATION_REGEX = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i;
|
|
1151
|
+
var HUMAN_OVERNIGHT = 8 * 60;
|
|
1152
|
+
function isFiniteNumber(value) {
|
|
1153
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1154
|
+
}
|
|
1155
|
+
function parseDuration(iso) {
|
|
1156
|
+
if (!iso || typeof iso !== "string") return null;
|
|
1157
|
+
const trimmed = iso.trim();
|
|
1158
|
+
if (!trimmed) return null;
|
|
1159
|
+
const match = trimmed.match(ISO_DURATION_REGEX);
|
|
1160
|
+
if (!match) return null;
|
|
1161
|
+
const [, daysRaw, hoursRaw, minutesRaw, secondsRaw] = match;
|
|
1162
|
+
if (!daysRaw && !hoursRaw && !minutesRaw && !secondsRaw) {
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
let total = 0;
|
|
1166
|
+
if (daysRaw) total += parseFloat(daysRaw) * 24 * 60;
|
|
1167
|
+
if (hoursRaw) total += parseFloat(hoursRaw) * 60;
|
|
1168
|
+
if (minutesRaw) total += parseFloat(minutesRaw);
|
|
1169
|
+
if (secondsRaw) total += Math.ceil(parseFloat(secondsRaw) / 60);
|
|
1170
|
+
return Math.round(total);
|
|
1171
|
+
}
|
|
1172
|
+
function formatDuration(minutes) {
|
|
1173
|
+
if (!isFiniteNumber(minutes) || minutes <= 0) {
|
|
1174
|
+
return "PT0M";
|
|
1175
|
+
}
|
|
1176
|
+
const rounded = Math.round(minutes);
|
|
1177
|
+
const days = Math.floor(rounded / (24 * 60));
|
|
1178
|
+
const afterDays = rounded % (24 * 60);
|
|
1179
|
+
const hours = Math.floor(afterDays / 60);
|
|
1180
|
+
const mins = afterDays % 60;
|
|
1181
|
+
let result = "P";
|
|
1182
|
+
if (days > 0) {
|
|
1183
|
+
result += `${days}D`;
|
|
1184
|
+
}
|
|
1185
|
+
if (hours > 0 || mins > 0) {
|
|
1186
|
+
result += "T";
|
|
1187
|
+
if (hours > 0) {
|
|
1188
|
+
result += `${hours}H`;
|
|
1189
|
+
}
|
|
1190
|
+
if (mins > 0) {
|
|
1191
|
+
result += `${mins}M`;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (result === "P") {
|
|
1195
|
+
return "PT0M";
|
|
1196
|
+
}
|
|
1197
|
+
return result;
|
|
1198
|
+
}
|
|
1199
|
+
function parseHumanDuration(text) {
|
|
1200
|
+
if (!text || typeof text !== "string") return null;
|
|
1201
|
+
const normalized = text.toLowerCase().trim();
|
|
1202
|
+
if (!normalized) return null;
|
|
1203
|
+
if (normalized === "overnight") {
|
|
1204
|
+
return HUMAN_OVERNIGHT;
|
|
1205
|
+
}
|
|
1206
|
+
let total = 0;
|
|
1207
|
+
const hourRegex = /(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|hr|h)\b/g;
|
|
1208
|
+
let hourMatch;
|
|
1209
|
+
while ((hourMatch = hourRegex.exec(normalized)) !== null) {
|
|
1210
|
+
total += parseFloat(hourMatch[1]) * 60;
|
|
1211
|
+
}
|
|
1212
|
+
const minuteRegex = /(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|min|m)\b/g;
|
|
1213
|
+
let minuteMatch;
|
|
1214
|
+
while ((minuteMatch = minuteRegex.exec(normalized)) !== null) {
|
|
1215
|
+
total += parseFloat(minuteMatch[1]);
|
|
1216
|
+
}
|
|
1217
|
+
if (total <= 0) {
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
return Math.round(total);
|
|
1221
|
+
}
|
|
1222
|
+
function smartParseDuration(input) {
|
|
1223
|
+
const iso = parseDuration(input);
|
|
1224
|
+
if (iso !== null) {
|
|
1225
|
+
return iso;
|
|
1226
|
+
}
|
|
1227
|
+
return parseHumanDuration(input);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// src/fromSchemaOrg.ts
|
|
1231
|
+
function fromSchemaOrg(input) {
|
|
1232
|
+
const recipeNode = extractRecipeNode(input);
|
|
1233
|
+
if (!recipeNode) {
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
const ingredients = convertIngredients(recipeNode.recipeIngredient);
|
|
1237
|
+
const instructions = convertInstructions(recipeNode.recipeInstructions);
|
|
1238
|
+
const time = convertTime(recipeNode);
|
|
1239
|
+
const recipeYield = parseYield(recipeNode.recipeYield);
|
|
1240
|
+
const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
|
|
1241
|
+
const category = extractFirst(recipeNode.recipeCategory);
|
|
1242
|
+
const image = convertImage(recipeNode.image);
|
|
1243
|
+
const source = convertSource(recipeNode);
|
|
1244
|
+
const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
|
|
1245
|
+
return {
|
|
1246
|
+
name: recipeNode.name.trim(),
|
|
1247
|
+
description: recipeNode.description?.trim() || void 0,
|
|
1248
|
+
image,
|
|
1249
|
+
category,
|
|
1250
|
+
tags: tags.length ? tags : void 0,
|
|
1251
|
+
source,
|
|
1252
|
+
dateAdded: recipeNode.datePublished || void 0,
|
|
1253
|
+
dateModified: recipeNode.dateModified || void 0,
|
|
1254
|
+
yield: recipeYield,
|
|
1255
|
+
time,
|
|
1256
|
+
ingredients,
|
|
1257
|
+
instructions,
|
|
1258
|
+
nutrition
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
function extractRecipeNode(input) {
|
|
1262
|
+
if (!input) return null;
|
|
1263
|
+
if (Array.isArray(input)) {
|
|
1264
|
+
for (const entry of input) {
|
|
1265
|
+
const found = extractRecipeNode(entry);
|
|
1266
|
+
if (found) {
|
|
1267
|
+
return found;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return null;
|
|
1271
|
+
}
|
|
1272
|
+
if (typeof input !== "object") {
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
const record = input;
|
|
1276
|
+
if (record["@graph"]) {
|
|
1277
|
+
const fromGraph = extractRecipeNode(record["@graph"]);
|
|
1278
|
+
if (fromGraph) {
|
|
1279
|
+
return fromGraph;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
if (!hasRecipeType(record["@type"])) {
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
if (!isValidName(record.name)) {
|
|
1286
|
+
return null;
|
|
1287
|
+
}
|
|
1288
|
+
return record;
|
|
1289
|
+
}
|
|
1290
|
+
function hasRecipeType(value) {
|
|
1291
|
+
if (!value) return false;
|
|
1292
|
+
const types = Array.isArray(value) ? value : [value];
|
|
1293
|
+
return types.some(
|
|
1294
|
+
(entry) => typeof entry === "string" && entry.toLowerCase() === "recipe"
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
function isValidName(name) {
|
|
1298
|
+
return typeof name === "string" && Boolean(name.trim());
|
|
1299
|
+
}
|
|
1300
|
+
function convertIngredients(value) {
|
|
1301
|
+
if (!value) return [];
|
|
1302
|
+
const normalized = Array.isArray(value) ? value : [value];
|
|
1303
|
+
return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean).map((line) => parseIngredientLine2(line));
|
|
1304
|
+
}
|
|
1305
|
+
function convertInstructions(value) {
|
|
1306
|
+
if (!value) return [];
|
|
1307
|
+
const normalized = Array.isArray(value) ? value : [value];
|
|
1308
|
+
const result = [];
|
|
1309
|
+
for (const entry of normalized) {
|
|
1310
|
+
if (!entry) continue;
|
|
1311
|
+
if (typeof entry === "string") {
|
|
1312
|
+
const text = entry.trim();
|
|
1313
|
+
if (text) {
|
|
1314
|
+
result.push(text);
|
|
1315
|
+
}
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
if (isHowToSection(entry)) {
|
|
1319
|
+
const subsectionItems = extractSectionItems(entry.itemListElement);
|
|
1320
|
+
if (subsectionItems.length) {
|
|
1321
|
+
result.push({
|
|
1322
|
+
subsection: entry.name?.trim() || "Section",
|
|
1323
|
+
items: subsectionItems
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
if (isHowToStep(entry)) {
|
|
1329
|
+
const text = extractInstructionText(entry);
|
|
1330
|
+
if (text) {
|
|
1331
|
+
result.push(text);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
return result;
|
|
1336
|
+
}
|
|
1337
|
+
function extractSectionItems(items = []) {
|
|
1338
|
+
const result = [];
|
|
1339
|
+
for (const item of items) {
|
|
1340
|
+
if (!item) continue;
|
|
1341
|
+
if (typeof item === "string") {
|
|
1342
|
+
const text = item.trim();
|
|
1343
|
+
if (text) {
|
|
1344
|
+
result.push(text);
|
|
1345
|
+
}
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
if (isHowToStep(item)) {
|
|
1349
|
+
const text = extractInstructionText(item);
|
|
1350
|
+
if (text) {
|
|
1351
|
+
result.push(text);
|
|
1352
|
+
}
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
if (isHowToSection(item)) {
|
|
1356
|
+
result.push(...extractSectionItems(item.itemListElement));
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return result;
|
|
1360
|
+
}
|
|
1361
|
+
function extractInstructionText(value) {
|
|
1362
|
+
const text = typeof value.text === "string" ? value.text : value.name;
|
|
1363
|
+
return typeof text === "string" ? text.trim() || void 0 : void 0;
|
|
1364
|
+
}
|
|
1365
|
+
function isHowToStep(value) {
|
|
1366
|
+
return Boolean(value) && typeof value === "object" && value["@type"] === "HowToStep";
|
|
1367
|
+
}
|
|
1368
|
+
function isHowToSection(value) {
|
|
1369
|
+
return Boolean(value) && typeof value === "object" && value["@type"] === "HowToSection" && Array.isArray(value.itemListElement);
|
|
1370
|
+
}
|
|
1371
|
+
function convertTime(recipe) {
|
|
1372
|
+
const prep = smartParseDuration(recipe.prepTime ?? "");
|
|
1373
|
+
const cook = smartParseDuration(recipe.cookTime ?? "");
|
|
1374
|
+
const total = smartParseDuration(recipe.totalTime ?? "");
|
|
1375
|
+
const structured = {};
|
|
1376
|
+
if (prep !== null && prep !== void 0) structured.prep = prep;
|
|
1377
|
+
if (cook !== null && cook !== void 0) structured.active = cook;
|
|
1378
|
+
if (total !== null && total !== void 0) structured.total = total;
|
|
1379
|
+
return Object.keys(structured).length ? structured : void 0;
|
|
1380
|
+
}
|
|
1381
|
+
function collectTags(cuisine, keywords) {
|
|
1382
|
+
const tags = /* @__PURE__ */ new Set();
|
|
1383
|
+
flattenStrings(cuisine).forEach((tag) => tags.add(tag));
|
|
1384
|
+
if (typeof keywords === "string") {
|
|
1385
|
+
splitKeywords(keywords).forEach((tag) => tags.add(tag));
|
|
1386
|
+
} else {
|
|
1387
|
+
flattenStrings(keywords).forEach((tag) => tags.add(tag));
|
|
1388
|
+
}
|
|
1389
|
+
return Array.from(tags);
|
|
1390
|
+
}
|
|
1391
|
+
function splitKeywords(value) {
|
|
1392
|
+
return value.split(/[,|]/).map((part) => part.trim()).filter(Boolean);
|
|
1393
|
+
}
|
|
1394
|
+
function flattenStrings(value) {
|
|
1395
|
+
if (!value) return [];
|
|
1396
|
+
if (typeof value === "string") return [value.trim()].filter(Boolean);
|
|
1397
|
+
if (Array.isArray(value)) {
|
|
1398
|
+
return value.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean);
|
|
1399
|
+
}
|
|
1400
|
+
return [];
|
|
1401
|
+
}
|
|
1402
|
+
function extractFirst(value) {
|
|
1403
|
+
const arr = flattenStrings(value);
|
|
1404
|
+
return arr.length ? arr[0] : void 0;
|
|
1405
|
+
}
|
|
1406
|
+
function convertImage(value) {
|
|
1407
|
+
if (!value) return void 0;
|
|
1408
|
+
if (typeof value === "string") {
|
|
1409
|
+
return value;
|
|
1410
|
+
}
|
|
1411
|
+
if (Array.isArray(value)) {
|
|
1412
|
+
for (const item of value) {
|
|
1413
|
+
const url = typeof item === "string" ? item : extractImageUrl(item);
|
|
1414
|
+
if (url) return url;
|
|
1415
|
+
}
|
|
1416
|
+
return void 0;
|
|
1417
|
+
}
|
|
1418
|
+
return extractImageUrl(value);
|
|
1419
|
+
}
|
|
1420
|
+
function extractImageUrl(value) {
|
|
1421
|
+
if (!value || typeof value !== "object") return void 0;
|
|
1422
|
+
const record = value;
|
|
1423
|
+
const candidate = typeof record.url === "string" ? record.url : typeof record.contentUrl === "string" ? record.contentUrl : void 0;
|
|
1424
|
+
return candidate?.trim() || void 0;
|
|
1425
|
+
}
|
|
1426
|
+
function convertSource(recipe) {
|
|
1427
|
+
const author = extractEntityName(recipe.author);
|
|
1428
|
+
const publisher = extractEntityName(recipe.publisher);
|
|
1429
|
+
const url = (recipe.url || recipe.mainEntityOfPage)?.trim();
|
|
1430
|
+
const source = {};
|
|
1431
|
+
if (author) source.author = author;
|
|
1432
|
+
if (publisher) source.name = publisher;
|
|
1433
|
+
if (url) source.url = url;
|
|
1434
|
+
return Object.keys(source).length ? source : void 0;
|
|
1435
|
+
}
|
|
1436
|
+
function extractEntityName(value) {
|
|
1437
|
+
if (!value) return void 0;
|
|
1438
|
+
if (typeof value === "string") {
|
|
1439
|
+
const trimmed = value.trim();
|
|
1440
|
+
return trimmed || void 0;
|
|
1441
|
+
}
|
|
1442
|
+
if (Array.isArray(value)) {
|
|
1443
|
+
for (const entry of value) {
|
|
1444
|
+
const name = extractEntityName(entry);
|
|
1445
|
+
if (name) {
|
|
1446
|
+
return name;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
return void 0;
|
|
1450
|
+
}
|
|
1451
|
+
if (typeof value === "object" && typeof value.name === "string") {
|
|
1452
|
+
const trimmed = value.name.trim();
|
|
1453
|
+
return trimmed || void 0;
|
|
1454
|
+
}
|
|
1455
|
+
return void 0;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// src/converters/toSchemaOrg.ts
|
|
1459
|
+
function convertBasicMetadata(recipe) {
|
|
1460
|
+
return cleanOutput({
|
|
1461
|
+
"@context": "https://schema.org",
|
|
1462
|
+
"@type": "Recipe",
|
|
1463
|
+
name: recipe.name,
|
|
1464
|
+
description: recipe.description,
|
|
1465
|
+
image: recipe.image,
|
|
1466
|
+
url: recipe.source?.url,
|
|
1467
|
+
datePublished: recipe.dateAdded,
|
|
1468
|
+
dateModified: recipe.dateModified
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
function convertIngredients2(ingredients = []) {
|
|
1472
|
+
const result = [];
|
|
1473
|
+
ingredients.forEach((ingredient) => {
|
|
1474
|
+
if (!ingredient) {
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (typeof ingredient === "string") {
|
|
1478
|
+
const value2 = ingredient.trim();
|
|
1479
|
+
if (value2) {
|
|
1480
|
+
result.push(value2);
|
|
1481
|
+
}
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if ("subsection" in ingredient) {
|
|
1485
|
+
ingredient.items.forEach((item) => {
|
|
1486
|
+
if (!item) {
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (typeof item === "string") {
|
|
1490
|
+
const value2 = item.trim();
|
|
1491
|
+
if (value2) {
|
|
1492
|
+
result.push(value2);
|
|
1493
|
+
}
|
|
1494
|
+
} else if (item.item) {
|
|
1495
|
+
const value2 = item.item.trim();
|
|
1496
|
+
if (value2) {
|
|
1497
|
+
result.push(value2);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
const value = ingredient.item?.trim();
|
|
1504
|
+
if (value) {
|
|
1505
|
+
result.push(value);
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
return result;
|
|
1509
|
+
}
|
|
1510
|
+
function convertInstructions2(instructions = []) {
|
|
1511
|
+
return instructions.map((entry) => convertInstruction(entry)).filter((value) => Boolean(value));
|
|
1512
|
+
}
|
|
1513
|
+
function convertInstruction(entry) {
|
|
1514
|
+
if (!entry) {
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
if (typeof entry === "string") {
|
|
1518
|
+
return createHowToStep(entry);
|
|
1519
|
+
}
|
|
1520
|
+
if ("subsection" in entry) {
|
|
1521
|
+
const steps = entry.items.map((item) => typeof item === "string" ? createHowToStep(item) : createHowToStep(item.text)).filter((step) => Boolean(step));
|
|
1522
|
+
if (!steps.length) {
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
return {
|
|
1526
|
+
"@type": "HowToSection",
|
|
1527
|
+
name: entry.subsection,
|
|
1528
|
+
itemListElement: steps
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
if ("text" in entry) {
|
|
1532
|
+
return createHowToStep(entry.text);
|
|
1533
|
+
}
|
|
1534
|
+
return createHowToStep(String(entry));
|
|
1535
|
+
}
|
|
1536
|
+
function createHowToStep(text) {
|
|
1537
|
+
if (!text) return null;
|
|
1538
|
+
const trimmed = text.trim();
|
|
1539
|
+
if (!trimmed) return null;
|
|
1540
|
+
return {
|
|
1541
|
+
"@type": "HowToStep",
|
|
1542
|
+
text: trimmed
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
function convertTime2(time) {
|
|
1546
|
+
if (!time) {
|
|
1547
|
+
return {};
|
|
1548
|
+
}
|
|
1549
|
+
if (isStructuredTime(time)) {
|
|
1550
|
+
const result2 = {};
|
|
1551
|
+
if (time.prep !== void 0) {
|
|
1552
|
+
result2.prepTime = formatDuration(time.prep);
|
|
1553
|
+
}
|
|
1554
|
+
if (time.active !== void 0) {
|
|
1555
|
+
result2.cookTime = formatDuration(time.active);
|
|
1556
|
+
}
|
|
1557
|
+
if (time.total !== void 0) {
|
|
1558
|
+
result2.totalTime = formatDuration(time.total);
|
|
1559
|
+
}
|
|
1560
|
+
return result2;
|
|
1561
|
+
}
|
|
1562
|
+
const result = {};
|
|
1563
|
+
if (time.prepTime) {
|
|
1564
|
+
result.prepTime = time.prepTime;
|
|
1565
|
+
}
|
|
1566
|
+
if (time.cookTime) {
|
|
1567
|
+
result.cookTime = time.cookTime;
|
|
1568
|
+
}
|
|
1569
|
+
return result;
|
|
1570
|
+
}
|
|
1571
|
+
function convertYield(yld) {
|
|
1572
|
+
if (!yld) {
|
|
1573
|
+
return void 0;
|
|
1574
|
+
}
|
|
1575
|
+
return formatYield(yld);
|
|
1576
|
+
}
|
|
1577
|
+
function convertAuthor(source) {
|
|
1578
|
+
if (!source) {
|
|
1579
|
+
return {};
|
|
1580
|
+
}
|
|
1581
|
+
const result = {};
|
|
1582
|
+
if (source.author) {
|
|
1583
|
+
result.author = {
|
|
1584
|
+
"@type": "Person",
|
|
1585
|
+
name: source.author
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
if (source.name) {
|
|
1589
|
+
result.publisher = {
|
|
1590
|
+
"@type": "Organization",
|
|
1591
|
+
name: source.name
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
if (source.url) {
|
|
1595
|
+
result.url = source.url;
|
|
1596
|
+
}
|
|
1597
|
+
return result;
|
|
1598
|
+
}
|
|
1599
|
+
function convertCategoryTags(category, tags) {
|
|
1600
|
+
const result = {};
|
|
1601
|
+
if (category) {
|
|
1602
|
+
result.recipeCategory = category;
|
|
1603
|
+
}
|
|
1604
|
+
if (tags && tags.length > 0) {
|
|
1605
|
+
result.keywords = tags.filter(Boolean).join(", ");
|
|
1606
|
+
}
|
|
1607
|
+
return result;
|
|
1608
|
+
}
|
|
1609
|
+
function convertNutrition(nutrition) {
|
|
1610
|
+
if (!nutrition) {
|
|
1611
|
+
return void 0;
|
|
1612
|
+
}
|
|
1613
|
+
return {
|
|
1614
|
+
...nutrition,
|
|
1615
|
+
"@type": "NutritionInformation"
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
function cleanOutput(obj) {
|
|
1619
|
+
return Object.fromEntries(
|
|
1620
|
+
Object.entries(obj).filter(([, value]) => value !== void 0)
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1623
|
+
function toSchemaOrg(recipe) {
|
|
1624
|
+
const base = convertBasicMetadata(recipe);
|
|
1625
|
+
const ingredients = convertIngredients2(recipe.ingredients);
|
|
1626
|
+
const instructions = convertInstructions2(recipe.instructions);
|
|
1627
|
+
const nutrition = convertNutrition(recipe.nutrition);
|
|
1628
|
+
return cleanOutput({
|
|
1629
|
+
...base,
|
|
1630
|
+
recipeIngredient: ingredients.length ? ingredients : void 0,
|
|
1631
|
+
recipeInstructions: instructions.length ? instructions : void 0,
|
|
1632
|
+
recipeYield: convertYield(recipe.yield),
|
|
1633
|
+
...convertTime2(recipe.time),
|
|
1634
|
+
...convertAuthor(recipe.source),
|
|
1635
|
+
...convertCategoryTags(recipe.category, recipe.tags),
|
|
1636
|
+
nutrition
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
function isStructuredTime(time) {
|
|
1640
|
+
return typeof time.prep !== "undefined" || typeof time.active !== "undefined" || typeof time.passive !== "undefined" || typeof time.total !== "undefined";
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// src/scraper/fetch.ts
|
|
1644
|
+
var DEFAULT_USER_AGENTS = [
|
|
1645
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
1646
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
1647
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
|
|
1648
|
+
];
|
|
1649
|
+
var fetchImpl = null;
|
|
1650
|
+
async function ensureFetch() {
|
|
1651
|
+
if (!fetchImpl) {
|
|
1652
|
+
fetchImpl = import('node-fetch').then((mod) => mod.default);
|
|
1653
|
+
}
|
|
1654
|
+
return fetchImpl;
|
|
1655
|
+
}
|
|
1656
|
+
function chooseUserAgent(provided) {
|
|
1657
|
+
if (provided) return provided;
|
|
1658
|
+
const index = Math.floor(Math.random() * DEFAULT_USER_AGENTS.length);
|
|
1659
|
+
return DEFAULT_USER_AGENTS[index];
|
|
1660
|
+
}
|
|
1661
|
+
function isClientError(error) {
|
|
1662
|
+
if (typeof error.status === "number") {
|
|
1663
|
+
return error.status >= 400 && error.status < 500;
|
|
1664
|
+
}
|
|
1665
|
+
return error.message.includes("HTTP 4");
|
|
1666
|
+
}
|
|
1667
|
+
async function wait(ms) {
|
|
1668
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1669
|
+
}
|
|
1670
|
+
async function fetchPage(url, options = {}) {
|
|
1671
|
+
const {
|
|
1672
|
+
timeout = 1e4,
|
|
1673
|
+
userAgent,
|
|
1674
|
+
maxRetries = 2
|
|
1675
|
+
} = options;
|
|
1676
|
+
let lastError = null;
|
|
1677
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1678
|
+
const controller = new AbortController();
|
|
1679
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1680
|
+
try {
|
|
1681
|
+
const fetch = await ensureFetch();
|
|
1682
|
+
const headers = {
|
|
1683
|
+
"User-Agent": chooseUserAgent(userAgent),
|
|
1684
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
1685
|
+
"Accept-Language": "en-US,en;q=0.5"
|
|
1686
|
+
};
|
|
1687
|
+
const response = await fetch(url, {
|
|
1688
|
+
headers,
|
|
1689
|
+
signal: controller.signal,
|
|
1690
|
+
redirect: "follow"
|
|
1691
|
+
});
|
|
1692
|
+
clearTimeout(timeoutId);
|
|
1693
|
+
if (!response.ok) {
|
|
1694
|
+
const error = new Error(
|
|
1695
|
+
`HTTP ${response.status}: ${response.statusText}`
|
|
1696
|
+
);
|
|
1697
|
+
error.status = response.status;
|
|
1698
|
+
throw error;
|
|
1699
|
+
}
|
|
1700
|
+
return await response.text();
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
clearTimeout(timeoutId);
|
|
1703
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1704
|
+
if (isClientError(lastError)) {
|
|
1705
|
+
throw lastError;
|
|
1706
|
+
}
|
|
1707
|
+
if (attempt < maxRetries) {
|
|
1708
|
+
await wait(1e3 * (attempt + 1));
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
throw lastError ?? new Error("Failed to fetch page");
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// src/scraper/extractors/utils.ts
|
|
1717
|
+
var RECIPE_TYPES = /* @__PURE__ */ new Set([
|
|
1718
|
+
"recipe",
|
|
1719
|
+
"https://schema.org/recipe",
|
|
1720
|
+
"http://schema.org/recipe"
|
|
1721
|
+
]);
|
|
1722
|
+
function isRecipeNode(value) {
|
|
1723
|
+
if (!value || typeof value !== "object") {
|
|
1724
|
+
return false;
|
|
1725
|
+
}
|
|
1726
|
+
const type = value["@type"];
|
|
1727
|
+
if (typeof type === "string") {
|
|
1728
|
+
return RECIPE_TYPES.has(type.toLowerCase());
|
|
1729
|
+
}
|
|
1730
|
+
if (Array.isArray(type)) {
|
|
1731
|
+
return type.some(
|
|
1732
|
+
(entry) => typeof entry === "string" && RECIPE_TYPES.has(entry.toLowerCase())
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
return false;
|
|
1736
|
+
}
|
|
1737
|
+
function safeJsonParse(content) {
|
|
1738
|
+
try {
|
|
1739
|
+
return JSON.parse(content);
|
|
1740
|
+
} catch {
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
function normalizeText(value) {
|
|
1745
|
+
if (!value) return void 0;
|
|
1746
|
+
const trimmed = value.replace(/\s+/g, " ").trim();
|
|
1747
|
+
return trimmed || void 0;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// src/scraper/extractors/jsonld.ts
|
|
1751
|
+
function extractJsonLd(html) {
|
|
1752
|
+
const $ = load(html);
|
|
1753
|
+
const scripts = $('script[type="application/ld+json"]');
|
|
1754
|
+
const candidates = [];
|
|
1755
|
+
scripts.each((_, element) => {
|
|
1756
|
+
const content = $(element).html();
|
|
1757
|
+
if (!content) return;
|
|
1758
|
+
const parsed = safeJsonParse(content);
|
|
1759
|
+
if (!parsed) return;
|
|
1760
|
+
collectCandidates(parsed, candidates);
|
|
1761
|
+
});
|
|
1762
|
+
return candidates[0] ?? null;
|
|
1763
|
+
}
|
|
1764
|
+
function collectCandidates(payload, bucket) {
|
|
1765
|
+
if (!payload) return;
|
|
1766
|
+
if (Array.isArray(payload)) {
|
|
1767
|
+
payload.forEach((entry) => collectCandidates(entry, bucket));
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (typeof payload !== "object") {
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
if (isRecipeNode(payload)) {
|
|
1774
|
+
bucket.push(payload);
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
const graph = payload["@graph"];
|
|
1778
|
+
if (Array.isArray(graph)) {
|
|
1779
|
+
graph.forEach((entry) => collectCandidates(entry, bucket));
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
var SIMPLE_PROPS = [
|
|
1783
|
+
"name",
|
|
1784
|
+
"description",
|
|
1785
|
+
"image",
|
|
1786
|
+
"recipeYield",
|
|
1787
|
+
"prepTime",
|
|
1788
|
+
"cookTime",
|
|
1789
|
+
"totalTime"
|
|
1790
|
+
];
|
|
1791
|
+
function extractMicrodata(html) {
|
|
1792
|
+
const $ = load(html);
|
|
1793
|
+
const recipeEl = $('[itemscope][itemtype*="schema.org/Recipe"]').first();
|
|
1794
|
+
if (!recipeEl.length) {
|
|
1795
|
+
return null;
|
|
1796
|
+
}
|
|
1797
|
+
const recipe = {
|
|
1798
|
+
"@type": "Recipe"
|
|
1799
|
+
};
|
|
1800
|
+
SIMPLE_PROPS.forEach((prop) => {
|
|
1801
|
+
const value = findPropertyValue($, recipeEl, prop);
|
|
1802
|
+
if (value) {
|
|
1803
|
+
recipe[prop] = value;
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
const ingredients = [];
|
|
1807
|
+
recipeEl.find('[itemprop="recipeIngredient"]').each((_, el) => {
|
|
1808
|
+
const text = normalizeText($(el).attr("content") || $(el).text());
|
|
1809
|
+
if (text) ingredients.push(text);
|
|
1810
|
+
});
|
|
1811
|
+
if (ingredients.length) {
|
|
1812
|
+
recipe.recipeIngredient = ingredients;
|
|
1813
|
+
}
|
|
1814
|
+
const instructions = [];
|
|
1815
|
+
recipeEl.find('[itemprop="recipeInstructions"]').each((_, el) => {
|
|
1816
|
+
const text = normalizeText($(el).attr("content")) || normalizeText($(el).find('[itemprop="text"]').first().text()) || normalizeText($(el).text());
|
|
1817
|
+
if (text) instructions.push(text);
|
|
1818
|
+
});
|
|
1819
|
+
if (instructions.length) {
|
|
1820
|
+
recipe.recipeInstructions = instructions;
|
|
1821
|
+
}
|
|
1822
|
+
if (recipe.name || ingredients.length) {
|
|
1823
|
+
return recipe;
|
|
1824
|
+
}
|
|
1825
|
+
return null;
|
|
1826
|
+
}
|
|
1827
|
+
function findPropertyValue($, context, prop) {
|
|
1828
|
+
const node = context.find(`[itemprop="${prop}"]`).first();
|
|
1829
|
+
if (!node.length) return void 0;
|
|
1830
|
+
return normalizeText(node.attr("content")) || normalizeText(node.attr("href")) || normalizeText(node.attr("src")) || normalizeText(node.text());
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// src/scraper/extractors/index.ts
|
|
1834
|
+
function extractRecipe(html) {
|
|
1835
|
+
const jsonLdRecipe = extractJsonLd(html);
|
|
1836
|
+
if (jsonLdRecipe) {
|
|
1837
|
+
return { recipe: jsonLdRecipe, source: "jsonld" };
|
|
1838
|
+
}
|
|
1839
|
+
const microdataRecipe = extractMicrodata(html);
|
|
1840
|
+
if (microdataRecipe) {
|
|
1841
|
+
return { recipe: microdataRecipe, source: "microdata" };
|
|
1842
|
+
}
|
|
1843
|
+
return { recipe: null, source: null };
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// src/scraper/index.ts
|
|
1847
|
+
async function scrapeRecipe(url, options = {}) {
|
|
1848
|
+
const html = await fetchPage(url, options);
|
|
1849
|
+
const { recipe } = extractRecipe(html);
|
|
1850
|
+
if (!recipe) {
|
|
1851
|
+
throw new Error("No Schema.org recipe data found in page");
|
|
1852
|
+
}
|
|
1853
|
+
const soustackRecipe = fromSchemaOrg(recipe);
|
|
1854
|
+
if (!soustackRecipe) {
|
|
1855
|
+
throw new Error("Schema.org data did not include a valid recipe");
|
|
1856
|
+
}
|
|
1857
|
+
return soustackRecipe;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// src/parsers/yield.ts
|
|
1861
|
+
var RANGE_PATTERN = /^(\d+)(?:\s*(?:[-–—]|to)\s*)(\d+)\s+(.+)$/i;
|
|
1862
|
+
var MAKES_PREFIX = /^(makes?|yields?)\s*:?\s*(.+)$/i;
|
|
1863
|
+
var APPROX_PREFIX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
|
|
1864
|
+
var SERVING_UNITS = ["servings", "serving", "portions", "portion", "people", "persons"];
|
|
1865
|
+
var DEFAULT_DOZEN_UNIT = "cookies";
|
|
1866
|
+
var NUMBER_WORDS2 = {
|
|
1867
|
+
a: 1,
|
|
1868
|
+
an: 1,
|
|
1869
|
+
one: 1,
|
|
1870
|
+
two: 2,
|
|
1871
|
+
three: 3,
|
|
1872
|
+
four: 4,
|
|
1873
|
+
five: 5,
|
|
1874
|
+
six: 6,
|
|
1875
|
+
seven: 7,
|
|
1876
|
+
eight: 8,
|
|
1877
|
+
nine: 9,
|
|
1878
|
+
ten: 10,
|
|
1879
|
+
eleven: 11,
|
|
1880
|
+
twelve: 12
|
|
1881
|
+
};
|
|
1882
|
+
function normalizeYield(text) {
|
|
1883
|
+
if (!text || typeof text !== "string") return "";
|
|
1884
|
+
return text.normalize("NFKC").replace(/\u00A0/g, " ").replace(/[–—−]/g, "-").trim().replace(/\s+/g, " ");
|
|
1885
|
+
}
|
|
1886
|
+
function parseYield2(text) {
|
|
1887
|
+
const normalized = normalizeYield(text);
|
|
1888
|
+
if (!normalized) return null;
|
|
1889
|
+
const { main, paren } = extractParenthetical(normalized);
|
|
1890
|
+
const core = parseYieldCore(main, normalized);
|
|
1891
|
+
if (!core) return null;
|
|
1892
|
+
const servingsFromParen = paren ? extractServingsFromParen(paren) : null;
|
|
1893
|
+
if (servingsFromParen !== null) {
|
|
1894
|
+
core.servings = servingsFromParen;
|
|
1895
|
+
core.description = normalized;
|
|
1896
|
+
}
|
|
1897
|
+
if (core.servings === void 0) {
|
|
1898
|
+
const inferred = inferServings(core.amount, core.unit);
|
|
1899
|
+
if (inferred !== void 0) {
|
|
1900
|
+
core.servings = inferred;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
return core;
|
|
1904
|
+
}
|
|
1905
|
+
function formatYield2(value) {
|
|
1906
|
+
if (value.description) {
|
|
1907
|
+
return value.description;
|
|
1908
|
+
}
|
|
1909
|
+
if (value.servings && value.unit === "servings") {
|
|
1910
|
+
return `Serves ${value.amount}`;
|
|
1911
|
+
}
|
|
1912
|
+
let result = `${value.amount} ${value.unit}`.trim();
|
|
1913
|
+
if (value.servings && value.unit !== "servings") {
|
|
1914
|
+
result += ` (${value.servings} servings)`;
|
|
1915
|
+
}
|
|
1916
|
+
return result;
|
|
1917
|
+
}
|
|
1918
|
+
function parseYieldCore(text, original) {
|
|
1919
|
+
return parseServesPattern(text, original) ?? parseMakesPattern(text, original) ?? parseRangePattern(text, original) ?? parseNumberUnitPattern(text, original) ?? parsePlainNumberPattern(text);
|
|
1920
|
+
}
|
|
1921
|
+
function parseServesPattern(text, original) {
|
|
1922
|
+
const patterns = [
|
|
1923
|
+
/^serves?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
1924
|
+
/^servings?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
1925
|
+
/^serving\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
1926
|
+
/^makes?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i,
|
|
1927
|
+
/^(\d+)\s+servings?$/i
|
|
1928
|
+
];
|
|
1929
|
+
for (const regex of patterns) {
|
|
1930
|
+
const match = text.match(regex);
|
|
1931
|
+
if (!match) continue;
|
|
1932
|
+
const amount = parseInt(match[1], 10);
|
|
1933
|
+
if (Number.isNaN(amount)) continue;
|
|
1934
|
+
const result = {
|
|
1935
|
+
amount,
|
|
1936
|
+
unit: "servings",
|
|
1937
|
+
servings: amount
|
|
1938
|
+
};
|
|
1939
|
+
if (match[2]) {
|
|
1940
|
+
result.description = original;
|
|
1941
|
+
}
|
|
1942
|
+
return result;
|
|
1943
|
+
}
|
|
1944
|
+
return null;
|
|
1945
|
+
}
|
|
1946
|
+
function parseMakesPattern(text, original) {
|
|
1947
|
+
const match = text.match(MAKES_PREFIX);
|
|
1948
|
+
if (!match) return null;
|
|
1949
|
+
const remainder = match[2].trim();
|
|
1950
|
+
if (!remainder) return null;
|
|
1951
|
+
const servingsMatch = remainder.match(/^(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i);
|
|
1952
|
+
if (servingsMatch) {
|
|
1953
|
+
const amount = parseInt(servingsMatch[1], 10);
|
|
1954
|
+
const result = {
|
|
1955
|
+
amount,
|
|
1956
|
+
unit: "servings",
|
|
1957
|
+
servings: amount
|
|
1958
|
+
};
|
|
1959
|
+
if (servingsMatch[2]) {
|
|
1960
|
+
result.description = original;
|
|
1961
|
+
}
|
|
1962
|
+
return result;
|
|
1963
|
+
}
|
|
1964
|
+
return parseRangePattern(remainder, original) ?? parseNumberUnitPattern(remainder, original) ?? parsePlainNumberPattern(remainder);
|
|
1965
|
+
}
|
|
1966
|
+
function parseRangePattern(text, descriptionSource) {
|
|
1967
|
+
const match = text.match(RANGE_PATTERN);
|
|
1968
|
+
if (!match) return null;
|
|
1969
|
+
const amount = parseInt(match[1], 10);
|
|
1970
|
+
const unit = cleanupUnit(match[3]);
|
|
1971
|
+
if (!unit) return null;
|
|
1972
|
+
const result = {
|
|
1973
|
+
amount,
|
|
1974
|
+
unit,
|
|
1975
|
+
description: descriptionSource
|
|
1976
|
+
};
|
|
1977
|
+
return result;
|
|
1978
|
+
}
|
|
1979
|
+
function parseNumberUnitPattern(text, descriptionSource) {
|
|
1980
|
+
if (!text) return null;
|
|
1981
|
+
const { value, approximate } = stripApproximation(text);
|
|
1982
|
+
if (!value) return null;
|
|
1983
|
+
const dozenResult = handleDozen(value);
|
|
1984
|
+
if (dozenResult) {
|
|
1985
|
+
const unit = cleanupUnit(dozenResult.remainder || DEFAULT_DOZEN_UNIT);
|
|
1986
|
+
const parsed = {
|
|
1987
|
+
amount: dozenResult.amount,
|
|
1988
|
+
unit
|
|
1989
|
+
};
|
|
1990
|
+
if (approximate) {
|
|
1991
|
+
parsed.description = descriptionSource;
|
|
1992
|
+
}
|
|
1993
|
+
return parsed;
|
|
1994
|
+
}
|
|
1995
|
+
const numericMatch = value.match(/^(\d+(?:\.\d+)?)\s+(.+)$/);
|
|
1996
|
+
if (numericMatch) {
|
|
1997
|
+
const amount = parseFloat(numericMatch[1]);
|
|
1998
|
+
if (!Number.isNaN(amount)) {
|
|
1999
|
+
const unit = cleanupUnit(numericMatch[2]);
|
|
2000
|
+
if (unit) {
|
|
2001
|
+
const parsed = { amount, unit };
|
|
2002
|
+
if (approximate) {
|
|
2003
|
+
parsed.description = descriptionSource;
|
|
2004
|
+
}
|
|
2005
|
+
return parsed;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
const wordMatch = value.match(/^([a-zA-Z]+)\s+(.+)$/);
|
|
2010
|
+
if (wordMatch) {
|
|
2011
|
+
const amount = wordToNumber(wordMatch[1]);
|
|
2012
|
+
if (amount !== null) {
|
|
2013
|
+
const unit = cleanupUnit(wordMatch[2]);
|
|
2014
|
+
if (unit) {
|
|
2015
|
+
const parsed = { amount, unit };
|
|
2016
|
+
if (approximate) {
|
|
2017
|
+
parsed.description = descriptionSource;
|
|
2018
|
+
}
|
|
2019
|
+
return parsed;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
return null;
|
|
2024
|
+
}
|
|
2025
|
+
function parsePlainNumberPattern(text) {
|
|
2026
|
+
const match = text.match(/^(\d+)$/);
|
|
2027
|
+
if (!match) return null;
|
|
2028
|
+
const amount = parseInt(match[1], 10);
|
|
2029
|
+
if (Number.isNaN(amount)) return null;
|
|
2030
|
+
return {
|
|
2031
|
+
amount,
|
|
2032
|
+
unit: "servings",
|
|
2033
|
+
servings: amount
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
function stripApproximation(value) {
|
|
2037
|
+
const match = value.match(APPROX_PREFIX);
|
|
2038
|
+
if (!match) {
|
|
2039
|
+
return { value: value.trim(), approximate: false };
|
|
2040
|
+
}
|
|
2041
|
+
const stripped = value.slice(match[0].length).trim();
|
|
2042
|
+
return { value: stripped, approximate: true };
|
|
2043
|
+
}
|
|
2044
|
+
function handleDozen(text) {
|
|
2045
|
+
const match = text.match(
|
|
2046
|
+
/^((?:\d+(?:\.\d+)?)|(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|a|an|half))\s+dozens?\b(.*)$/i
|
|
2047
|
+
);
|
|
2048
|
+
if (!match) return null;
|
|
2049
|
+
const token = match[1].toLowerCase();
|
|
2050
|
+
let multiplier = null;
|
|
2051
|
+
if (token === "half") {
|
|
2052
|
+
multiplier = 0.5;
|
|
2053
|
+
} else if (!Number.isNaN(Number(token))) {
|
|
2054
|
+
multiplier = parseFloat(token);
|
|
2055
|
+
} else {
|
|
2056
|
+
multiplier = wordToNumber(token);
|
|
2057
|
+
}
|
|
2058
|
+
if (multiplier === null) return null;
|
|
2059
|
+
const amount = multiplier * 12;
|
|
2060
|
+
return {
|
|
2061
|
+
amount,
|
|
2062
|
+
remainder: match[2].trim()
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
function cleanupUnit(value) {
|
|
2066
|
+
let unit = value.trim();
|
|
2067
|
+
unit = unit.replace(/^[,.-]+/, "").trim();
|
|
2068
|
+
unit = unit.replace(/[.,]+$/, "").trim();
|
|
2069
|
+
unit = unit.replace(/^of\s+/i, "").trim();
|
|
2070
|
+
return unit;
|
|
2071
|
+
}
|
|
2072
|
+
function extractParenthetical(text) {
|
|
2073
|
+
const match = text.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
|
|
2074
|
+
if (!match) {
|
|
2075
|
+
return { main: text, paren: null };
|
|
2076
|
+
}
|
|
2077
|
+
return {
|
|
2078
|
+
main: match[1].trim(),
|
|
2079
|
+
paren: match[2].trim()
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
function extractServingsFromParen(text) {
|
|
2083
|
+
const match = text.match(/(\d+)/);
|
|
2084
|
+
if (!match) return null;
|
|
2085
|
+
const value = parseInt(match[1], 10);
|
|
2086
|
+
return Number.isNaN(value) ? null : value;
|
|
2087
|
+
}
|
|
2088
|
+
function inferServings(amount, unit) {
|
|
2089
|
+
if (SERVING_UNITS.includes(unit.toLowerCase())) {
|
|
2090
|
+
return amount;
|
|
2091
|
+
}
|
|
2092
|
+
return void 0;
|
|
2093
|
+
}
|
|
2094
|
+
function wordToNumber(word) {
|
|
2095
|
+
const normalized = word.toLowerCase();
|
|
2096
|
+
if (NUMBER_WORDS2.hasOwnProperty(normalized)) {
|
|
2097
|
+
return NUMBER_WORDS2[normalized];
|
|
2098
|
+
}
|
|
2099
|
+
return null;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
export { formatDuration, formatYield2 as formatYield, fromSchemaOrg, normalizeIngredientInput, normalizeYield, parseDuration, parseHumanDuration, parseIngredient, parseIngredientLine, parseIngredients, parseYield2 as parseYield, scaleRecipe, scrapeRecipe, smartParseDuration, toSchemaOrg, validateRecipe };
|
|
2103
|
+
//# sourceMappingURL=index.mjs.map
|
|
2104
|
+
//# sourceMappingURL=index.mjs.map
|