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