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