soustack 0.1.3 → 0.2.3

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 CHANGED
@@ -2,150 +2,269 @@
2
2
 
3
3
  var Ajv = require('ajv');
4
4
  var addFormats = require('ajv-formats');
5
- var cheerio = require('cheerio');
6
5
 
7
6
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
8
7
 
9
8
  var Ajv__default = /*#__PURE__*/_interopDefault(Ajv);
10
9
  var addFormats__default = /*#__PURE__*/_interopDefault(addFormats);
11
10
 
11
+ // src/parsers/duration.ts
12
+ var ISO_DURATION_REGEX = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i;
13
+ var HUMAN_OVERNIGHT = 8 * 60;
14
+ function isFiniteNumber(value) {
15
+ return typeof value === "number" && Number.isFinite(value);
16
+ }
17
+ function parseDuration(iso) {
18
+ if (typeof iso === "number" && Number.isFinite(iso)) {
19
+ return iso;
20
+ }
21
+ if (!iso || typeof iso !== "string") return null;
22
+ const trimmed = iso.trim();
23
+ if (!trimmed) return null;
24
+ const match = trimmed.match(ISO_DURATION_REGEX);
25
+ if (!match) return null;
26
+ const [, daysRaw, hoursRaw, minutesRaw, secondsRaw] = match;
27
+ if (!daysRaw && !hoursRaw && !minutesRaw && !secondsRaw) {
28
+ return null;
29
+ }
30
+ let total = 0;
31
+ if (daysRaw) total += parseFloat(daysRaw) * 24 * 60;
32
+ if (hoursRaw) total += parseFloat(hoursRaw) * 60;
33
+ if (minutesRaw) total += parseFloat(minutesRaw);
34
+ if (secondsRaw) total += Math.ceil(parseFloat(secondsRaw) / 60);
35
+ return Math.round(total);
36
+ }
37
+ function formatDuration(minutes) {
38
+ if (!isFiniteNumber(minutes) || minutes <= 0) {
39
+ return "PT0M";
40
+ }
41
+ const rounded = Math.round(minutes);
42
+ const days = Math.floor(rounded / (24 * 60));
43
+ const afterDays = rounded % (24 * 60);
44
+ const hours = Math.floor(afterDays / 60);
45
+ const mins = afterDays % 60;
46
+ let result = "P";
47
+ if (days > 0) {
48
+ result += `${days}D`;
49
+ }
50
+ if (hours > 0 || mins > 0) {
51
+ result += "T";
52
+ if (hours > 0) {
53
+ result += `${hours}H`;
54
+ }
55
+ if (mins > 0) {
56
+ result += `${mins}M`;
57
+ }
58
+ }
59
+ if (result === "P") {
60
+ return "PT0M";
61
+ }
62
+ return result;
63
+ }
64
+ function parseHumanDuration(text) {
65
+ if (!text || typeof text !== "string") return null;
66
+ const normalized = text.toLowerCase().trim();
67
+ if (!normalized) return null;
68
+ if (normalized === "overnight") {
69
+ return HUMAN_OVERNIGHT;
70
+ }
71
+ let total = 0;
72
+ const hourRegex = /(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|hr|h)\b/g;
73
+ let hourMatch;
74
+ while ((hourMatch = hourRegex.exec(normalized)) !== null) {
75
+ total += parseFloat(hourMatch[1]) * 60;
76
+ }
77
+ const minuteRegex = /(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|min|m)\b/g;
78
+ let minuteMatch;
79
+ while ((minuteMatch = minuteRegex.exec(normalized)) !== null) {
80
+ total += parseFloat(minuteMatch[1]);
81
+ }
82
+ if (total <= 0) {
83
+ return null;
84
+ }
85
+ return Math.round(total);
86
+ }
87
+ function smartParseDuration(input) {
88
+ const iso = parseDuration(input);
89
+ if (iso !== null) {
90
+ return iso;
91
+ }
92
+ return parseHumanDuration(input);
93
+ }
94
+
12
95
  // 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);
96
+ function scaleRecipe(recipe, options = {}) {
97
+ const multiplier = resolveMultiplier(recipe, options);
98
+ const scaled = deepClone(recipe);
99
+ applyYieldScaling(scaled, options, multiplier);
100
+ const baseAmounts = collectBaseIngredientAmounts(scaled.ingredients || []);
101
+ const scaledAmounts = /* @__PURE__ */ new Map();
102
+ const orderedIngredients = [];
103
+ collectIngredients(scaled.ingredients || [], orderedIngredients);
104
+ orderedIngredients.filter((ing) => {
105
+ var _a2;
106
+ return (((_a2 = ing.scaling) == null ? void 0 : _a2.type) || "linear") !== "bakers_percentage";
107
+ }).forEach((ing) => {
108
+ const key = getIngredientKey(ing);
109
+ scaledAmounts.set(key, calculateIndependentIngredient(ing, multiplier));
110
+ });
111
+ orderedIngredients.filter((ing) => {
112
+ var _a2;
113
+ return ((_a2 = ing.scaling) == null ? void 0 : _a2.type) === "bakers_percentage";
114
+ }).forEach((ing) => {
115
+ var _a2, _b2;
116
+ const key = getIngredientKey(ing);
117
+ const scaling = ing.scaling;
118
+ if (!(scaling == null ? void 0 : scaling.referenceId)) {
119
+ throw new Error(`Baker's percentage ingredient "${key}" is missing a referenceId`);
22
120
  }
121
+ const referenceAmount = scaledAmounts.get(scaling.referenceId);
122
+ if (referenceAmount === void 0) {
123
+ throw new Error(`Reference ingredient "${scaling.referenceId}" not found for baker's percentage item "${key}"`);
124
+ }
125
+ const baseAmount = ((_a2 = ing.quantity) == null ? void 0 : _a2.amount) || 0;
126
+ const referenceBase = baseAmounts.get(scaling.referenceId);
127
+ const factor = (_b2 = scaling.factor) != null ? _b2 : referenceBase ? baseAmount / referenceBase : void 0;
128
+ if (factor === void 0) {
129
+ throw new Error(`Unable to determine factor for baker's percentage ingredient "${key}"`);
130
+ }
131
+ scaledAmounts.set(key, referenceAmount * factor);
23
132
  });
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);
133
+ orderedIngredients.forEach((ing) => {
134
+ const key = getIngredientKey(ing);
135
+ const amount = scaledAmounts.get(key);
136
+ if (amount === void 0) return;
137
+ if (!ing.quantity) {
138
+ ing.quantity = { amount, unit: null };
139
+ } else {
140
+ ing.quantity.amount = amount;
32
141
  }
33
142
  });
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
143
+ scaleInstructionItems(scaled.instructions || [], multiplier);
144
+ return scaled;
145
+ }
146
+ function resolveMultiplier(recipe, options) {
147
+ var _a2, _b2;
148
+ if (options.multiplier && options.multiplier > 0) {
149
+ return options.multiplier;
150
+ }
151
+ if ((_a2 = options.targetYield) == null ? void 0 : _a2.amount) {
152
+ const base = ((_b2 = recipe.yield) == null ? void 0 : _b2.amount) || 1;
153
+ return options.targetYield.amount / base;
154
+ }
155
+ return 1;
156
+ }
157
+ function applyYieldScaling(recipe, options, multiplier) {
158
+ var _a2, _b2, _c, _d, _e, _f, _g;
159
+ const baseAmount = (_b2 = (_a2 = recipe.yield) == null ? void 0 : _a2.amount) != null ? _b2 : 1;
160
+ const targetAmount = (_d = (_c = options.targetYield) == null ? void 0 : _c.amount) != null ? _d : baseAmount * multiplier;
161
+ const unit = (_g = (_e = options.targetYield) == null ? void 0 : _e.unit) != null ? _g : (_f = recipe.yield) == null ? void 0 : _f.unit;
162
+ if (!recipe.yield && !options.targetYield) return;
163
+ recipe.yield = {
164
+ amount: targetAmount,
165
+ unit: unit != null ? unit : ""
56
166
  };
57
167
  }
58
- function isIndependent(type) {
59
- return !type || type === "linear" || type === "fixed" || type === "discrete";
168
+ function getIngredientKey(ing) {
169
+ return ing.id || ing.item;
60
170
  }
61
- function calculateIngredient(ing, multiplier, referenceValue) {
62
- const baseAmount = ing.quantity?.amount || 0;
63
- const type = ing.scaling?.type || "linear";
64
- let newAmount = baseAmount;
171
+ function calculateIndependentIngredient(ing, multiplier) {
172
+ var _a2, _b2, _c, _d, _e, _f;
173
+ const baseAmount = ((_a2 = ing.quantity) == null ? void 0 : _a2.amount) || 0;
174
+ const type = ((_b2 = ing.scaling) == null ? void 0 : _b2.type) || "linear";
65
175
  switch (type) {
66
- case "linear":
67
- newAmount = baseAmount * multiplier;
68
- break;
69
176
  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);
177
+ return baseAmount;
178
+ case "discrete": {
179
+ const scaled = baseAmount * multiplier;
180
+ const step = (_d = (_c = ing.scaling) == null ? void 0 : _c.roundTo) != null ? _d : 1;
181
+ const rounded = Math.round(scaled / step) * step;
182
+ return Math.round(rounded);
183
+ }
184
+ case "proportional": {
185
+ const factor = (_f = (_e = ing.scaling) == null ? void 0 : _e.factor) != null ? _f : 1;
186
+ return baseAmount * multiplier * factor;
187
+ }
188
+ default:
189
+ return baseAmount * multiplier;
105
190
  }
106
- return {
107
- id: inst.id || "step",
108
- text: inst.text,
109
- durationMinutes: Math.ceil(newDuration),
110
- type: inst.timing?.type || "active"
111
- };
112
191
  }
113
- function flattenIngredients(items) {
114
- const result = [];
192
+ function collectIngredients(items, bucket) {
115
193
  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));
194
+ if (typeof item === "string") return;
195
+ if ("subsection" in item) {
196
+ collectIngredients(item.items, bucket);
120
197
  } else {
121
- result.push(item);
198
+ bucket.push(item);
122
199
  }
123
200
  });
124
- return result;
125
201
  }
126
- function flattenInstructions(items) {
127
- const result = [];
202
+ function collectBaseIngredientAmounts(items, map = /* @__PURE__ */ new Map()) {
128
203
  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));
204
+ var _a2, _b2;
205
+ if (typeof item === "string") return;
206
+ if ("subsection" in item) {
207
+ collectBaseIngredientAmounts(item.items, map);
133
208
  } else {
134
- result.push(item);
209
+ map.set(getIngredientKey(item), (_b2 = (_a2 = item.quantity) == null ? void 0 : _a2.amount) != null ? _b2 : 0);
135
210
  }
136
211
  });
137
- return result;
212
+ return map;
213
+ }
214
+ function scaleInstructionItems(items, multiplier) {
215
+ items.forEach((item) => {
216
+ if (typeof item === "string") return;
217
+ if ("subsection" in item) {
218
+ scaleInstructionItems(item.items, multiplier);
219
+ return;
220
+ }
221
+ const timing = item.timing;
222
+ if (!timing) return;
223
+ const baseDuration = toDurationMinutes(timing.duration);
224
+ const scalingType = timing.scaling || "fixed";
225
+ let newDuration = baseDuration;
226
+ if (scalingType === "linear") {
227
+ newDuration = baseDuration * multiplier;
228
+ } else if (scalingType === "sqrt") {
229
+ newDuration = baseDuration * Math.sqrt(multiplier);
230
+ }
231
+ timing.duration = Math.ceil(newDuration);
232
+ });
233
+ }
234
+ function deepClone(value) {
235
+ return JSON.parse(JSON.stringify(value));
236
+ }
237
+ function toDurationMinutes(duration) {
238
+ if (typeof duration === "number" && Number.isFinite(duration)) {
239
+ return duration;
240
+ }
241
+ if (typeof duration === "string" && duration.trim().startsWith("P")) {
242
+ const parsed = parseDuration(duration.trim());
243
+ if (parsed !== null) {
244
+ return parsed;
245
+ }
246
+ }
247
+ return 0;
138
248
  }
139
249
 
140
250
  // src/schema.json
141
251
  var schema_default = {
142
252
  $schema: "http://json-schema.org/draft-07/schema#",
143
- $id: "http://soustack.org/schema/v0.1",
144
- title: "Soustack Recipe Schema v0.1",
253
+ $id: "http://soustack.org/schema/v0.2.1",
254
+ title: "Soustack Recipe Schema v0.2.1",
145
255
  description: "A portable, scalable, interoperable recipe format.",
146
256
  type: "object",
147
257
  required: ["name", "ingredients", "instructions"],
258
+ additionalProperties: false,
259
+ patternProperties: {
260
+ "^x-": {}
261
+ },
148
262
  properties: {
263
+ $schema: {
264
+ type: "string",
265
+ format: "uri",
266
+ description: "Optional schema hint for tooling compatibility"
267
+ },
149
268
  id: {
150
269
  type: "string",
151
270
  description: "Unique identifier (slug or UUID)"
@@ -154,10 +273,19 @@ var schema_default = {
154
273
  type: "string",
155
274
  description: "The title of the recipe"
156
275
  },
276
+ title: {
277
+ type: "string",
278
+ description: "Optional display title; alias for name"
279
+ },
157
280
  version: {
158
281
  type: "string",
159
282
  pattern: "^\\d+\\.\\d+\\.\\d+$",
160
- description: "Semantic versioning (e.g., 1.0.0)"
283
+ description: "DEPRECATED: use recipeVersion for authoring revisions"
284
+ },
285
+ recipeVersion: {
286
+ type: "string",
287
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
288
+ description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
161
289
  },
162
290
  description: {
163
291
  type: "string"
@@ -171,13 +299,31 @@ var schema_default = {
171
299
  items: { type: "string" }
172
300
  },
173
301
  image: {
174
- type: "string",
175
- format: "uri"
302
+ description: "Recipe-level hero image(s)",
303
+ anyOf: [
304
+ {
305
+ type: "string",
306
+ format: "uri"
307
+ },
308
+ {
309
+ type: "array",
310
+ minItems: 1,
311
+ items: {
312
+ type: "string",
313
+ format: "uri"
314
+ }
315
+ }
316
+ ]
176
317
  },
177
318
  dateAdded: {
178
319
  type: "string",
179
320
  format: "date-time"
180
321
  },
322
+ metadata: {
323
+ type: "object",
324
+ additionalProperties: true,
325
+ description: "Free-form vendor metadata"
326
+ },
181
327
  source: {
182
328
  type: "object",
183
329
  properties: {
@@ -237,24 +383,16 @@ var schema_default = {
237
383
  }
238
384
  },
239
385
  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
- ]
386
+ type: "object",
387
+ properties: {
388
+ prep: { type: "number" },
389
+ active: { type: "number" },
390
+ passive: { type: "number" },
391
+ total: { type: "number" },
392
+ prepTime: { type: "string", format: "duration" },
393
+ cookTime: { type: "string", format: "duration" }
394
+ },
395
+ minProperties: 1
258
396
  },
259
397
  quantity: {
260
398
  type: "object",
@@ -337,6 +475,11 @@ var schema_default = {
337
475
  properties: {
338
476
  id: { type: "string" },
339
477
  text: { type: "string" },
478
+ image: {
479
+ type: "string",
480
+ format: "uri",
481
+ description: "Optional image that illustrates this instruction"
482
+ },
340
483
  destination: { type: "string" },
341
484
  dependsOn: {
342
485
  type: "array",
@@ -350,7 +493,13 @@ var schema_default = {
350
493
  type: "object",
351
494
  required: ["duration", "type"],
352
495
  properties: {
353
- duration: { type: "number" },
496
+ duration: {
497
+ anyOf: [
498
+ { type: "number" },
499
+ { type: "string", pattern: "^P" }
500
+ ],
501
+ description: "Minutes as a number or ISO8601 duration string"
502
+ },
354
503
  type: { type: "string", enum: ["active", "passive"] },
355
504
  scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
356
505
  }
@@ -444,665 +593,756 @@ var schema_default = {
444
593
  }
445
594
  };
446
595
 
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);
596
+ // src/soustack.schema.json
597
+ var soustack_schema_default = {
598
+ $schema: "http://json-schema.org/draft-07/schema#",
599
+ $id: "http://soustack.org/schema/v0.2.1",
600
+ title: "Soustack Recipe Schema v0.2.1",
601
+ description: "A portable, scalable, interoperable recipe format.",
602
+ type: "object",
603
+ required: ["name", "ingredients", "instructions"],
604
+ additionalProperties: false,
605
+ patternProperties: {
606
+ "^x-": {}
607
+ },
608
+ properties: {
609
+ $schema: {
610
+ type: "string",
611
+ format: "uri",
612
+ description: "Optional schema hint for tooling compatibility"
613
+ },
614
+ id: {
615
+ type: "string",
616
+ description: "Unique identifier (slug or UUID)"
617
+ },
618
+ name: {
619
+ type: "string",
620
+ description: "The title of the recipe"
621
+ },
622
+ title: {
623
+ type: "string",
624
+ description: "Optional display title; alias for name"
625
+ },
626
+ version: {
627
+ type: "string",
628
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
629
+ description: "DEPRECATED: use recipeVersion for authoring revisions"
630
+ },
631
+ recipeVersion: {
632
+ type: "string",
633
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
634
+ description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
635
+ },
636
+ description: {
637
+ type: "string"
638
+ },
639
+ category: {
640
+ type: "string",
641
+ examples: ["Main Course", "Dessert"]
642
+ },
643
+ tags: {
644
+ type: "array",
645
+ items: { type: "string" }
646
+ },
647
+ image: {
648
+ description: "Recipe-level hero image(s)",
649
+ anyOf: [
650
+ {
651
+ type: "string",
652
+ format: "uri"
653
+ },
654
+ {
655
+ type: "array",
656
+ minItems: 1,
657
+ items: {
658
+ type: "string",
659
+ format: "uri"
660
+ }
661
+ }
662
+ ]
663
+ },
664
+ dateAdded: {
665
+ type: "string",
666
+ format: "date-time"
667
+ },
668
+ metadata: {
669
+ type: "object",
670
+ additionalProperties: true,
671
+ description: "Free-form vendor metadata"
672
+ },
673
+ source: {
674
+ type: "object",
675
+ properties: {
676
+ author: { type: "string" },
677
+ url: { type: "string", format: "uri" },
678
+ name: { type: "string" },
679
+ adapted: { type: "boolean" }
680
+ }
681
+ },
682
+ yield: {
683
+ $ref: "#/definitions/yield"
684
+ },
685
+ time: {
686
+ $ref: "#/definitions/time"
687
+ },
688
+ equipment: {
689
+ type: "array",
690
+ items: { $ref: "#/definitions/equipment" }
691
+ },
692
+ ingredients: {
693
+ type: "array",
694
+ items: {
695
+ anyOf: [
696
+ { type: "string" },
697
+ { $ref: "#/definitions/ingredient" },
698
+ { $ref: "#/definitions/ingredientSubsection" }
699
+ ]
700
+ }
701
+ },
702
+ instructions: {
703
+ type: "array",
704
+ items: {
705
+ anyOf: [
706
+ { type: "string" },
707
+ { $ref: "#/definitions/instruction" },
708
+ { $ref: "#/definitions/instructionSubsection" }
709
+ ]
710
+ }
711
+ },
712
+ storage: {
713
+ $ref: "#/definitions/storage"
714
+ },
715
+ substitutions: {
716
+ type: "array",
717
+ items: { $ref: "#/definitions/substitution" }
718
+ }
719
+ },
720
+ definitions: {
721
+ yield: {
722
+ type: "object",
723
+ required: ["amount", "unit"],
724
+ properties: {
725
+ amount: { type: "number" },
726
+ unit: { type: "string" },
727
+ servings: { type: "number" },
728
+ description: { type: "string" }
729
+ }
730
+ },
731
+ time: {
732
+ type: "object",
733
+ properties: {
734
+ prep: { type: "number" },
735
+ active: { type: "number" },
736
+ passive: { type: "number" },
737
+ total: { type: "number" },
738
+ prepTime: { type: "string", format: "duration" },
739
+ cookTime: { type: "string", format: "duration" }
740
+ },
741
+ minProperties: 1
742
+ },
743
+ quantity: {
744
+ type: "object",
745
+ required: ["amount"],
746
+ properties: {
747
+ amount: { type: "number" },
748
+ unit: { type: ["string", "null"] }
749
+ }
750
+ },
751
+ scaling: {
752
+ type: "object",
753
+ required: ["type"],
754
+ properties: {
755
+ type: {
756
+ type: "string",
757
+ enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
758
+ },
759
+ factor: { type: "number" },
760
+ referenceId: { type: "string" },
761
+ roundTo: { type: "number" },
762
+ min: { type: "number" },
763
+ max: { type: "number" }
764
+ },
765
+ if: {
766
+ properties: { type: { const: "bakers_percentage" } }
767
+ },
768
+ then: {
769
+ required: ["referenceId"]
770
+ }
771
+ },
772
+ ingredient: {
773
+ type: "object",
774
+ required: ["item"],
775
+ properties: {
776
+ id: { type: "string" },
777
+ item: { type: "string" },
778
+ quantity: { $ref: "#/definitions/quantity" },
779
+ name: { type: "string" },
780
+ aisle: { type: "string" },
781
+ prep: { type: "string" },
782
+ prepAction: { type: "string" },
783
+ prepTime: { type: "number" },
784
+ destination: { type: "string" },
785
+ scaling: { $ref: "#/definitions/scaling" },
786
+ critical: { type: "boolean" },
787
+ optional: { type: "boolean" },
788
+ notes: { type: "string" }
789
+ }
790
+ },
791
+ ingredientSubsection: {
792
+ type: "object",
793
+ required: ["subsection", "items"],
794
+ properties: {
795
+ subsection: { type: "string" },
796
+ items: {
797
+ type: "array",
798
+ items: { $ref: "#/definitions/ingredient" }
799
+ }
800
+ }
801
+ },
802
+ equipment: {
803
+ type: "object",
804
+ required: ["name"],
805
+ properties: {
806
+ id: { type: "string" },
807
+ name: { type: "string" },
808
+ required: { type: "boolean" },
809
+ label: { type: "string" },
810
+ capacity: { $ref: "#/definitions/quantity" },
811
+ scalingLimit: { type: "number" },
812
+ alternatives: {
813
+ type: "array",
814
+ items: { type: "string" }
815
+ }
816
+ }
817
+ },
818
+ instruction: {
819
+ type: "object",
820
+ required: ["text"],
821
+ properties: {
822
+ id: { type: "string" },
823
+ text: { type: "string" },
824
+ image: {
825
+ type: "string",
826
+ format: "uri",
827
+ description: "Optional image that illustrates this instruction"
828
+ },
829
+ destination: { type: "string" },
830
+ dependsOn: {
831
+ type: "array",
832
+ items: { type: "string" }
833
+ },
834
+ inputs: {
835
+ type: "array",
836
+ items: { type: "string" }
837
+ },
838
+ timing: {
839
+ type: "object",
840
+ required: ["duration", "type"],
841
+ properties: {
842
+ duration: {
843
+ anyOf: [
844
+ { type: "number" },
845
+ { type: "string", pattern: "^P" }
846
+ ],
847
+ description: "Minutes as a number or ISO8601 duration string"
848
+ },
849
+ type: { type: "string", enum: ["active", "passive"] },
850
+ scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
851
+ }
852
+ }
853
+ }
854
+ },
855
+ instructionSubsection: {
856
+ type: "object",
857
+ required: ["subsection", "items"],
858
+ properties: {
859
+ subsection: { type: "string" },
860
+ items: {
861
+ type: "array",
862
+ items: {
863
+ anyOf: [
864
+ { type: "string" },
865
+ { $ref: "#/definitions/instruction" }
866
+ ]
867
+ }
868
+ }
869
+ }
870
+ },
871
+ storage: {
872
+ type: "object",
873
+ properties: {
874
+ roomTemp: { $ref: "#/definitions/storageMethod" },
875
+ refrigerated: { $ref: "#/definitions/storageMethod" },
876
+ frozen: {
877
+ allOf: [
878
+ { $ref: "#/definitions/storageMethod" },
879
+ {
880
+ type: "object",
881
+ properties: { thawing: { type: "string" } }
882
+ }
883
+ ]
884
+ },
885
+ reheating: { type: "string" },
886
+ makeAhead: {
887
+ type: "array",
888
+ items: {
889
+ allOf: [
890
+ { $ref: "#/definitions/storageMethod" },
891
+ {
892
+ type: "object",
893
+ required: ["component", "storage"],
894
+ properties: {
895
+ component: { type: "string" },
896
+ storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
897
+ }
898
+ }
899
+ ]
900
+ }
901
+ }
902
+ }
903
+ },
904
+ storageMethod: {
905
+ type: "object",
906
+ required: ["duration"],
907
+ properties: {
908
+ duration: { type: "string", pattern: "^P" },
909
+ method: { type: "string" },
910
+ notes: { type: "string" }
911
+ }
912
+ },
913
+ substitution: {
914
+ type: "object",
915
+ required: ["ingredient"],
916
+ properties: {
917
+ ingredient: { type: "string" },
918
+ critical: { type: "boolean" },
919
+ notes: { type: "string" },
920
+ alternatives: {
921
+ type: "array",
922
+ items: {
923
+ type: "object",
924
+ required: ["name", "ratio"],
925
+ properties: {
926
+ name: { type: "string" },
927
+ ratio: { type: "string" },
928
+ notes: { type: "string" },
929
+ impact: { type: "string" },
930
+ dietary: {
931
+ type: "array",
932
+ items: { type: "string" }
933
+ }
934
+ }
935
+ }
936
+ }
764
937
  }
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
938
  }
830
939
  }
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 "";
940
+ };
941
+
942
+ // src/profiles/base.schema.json
943
+ var base_schema_default = {
944
+ $schema: "http://json-schema.org/draft-07/schema#",
945
+ $id: "http://soustack.org/schema/v0.2.1/profiles/base",
946
+ title: "Soustack Base Profile Schema",
947
+ description: "Wrapper schema that exposes the unmodified Soustack base schema.",
948
+ allOf: [
949
+ { $ref: "http://soustack.org/schema/v0.2.1" }
950
+ ]
951
+ };
952
+
953
+ // src/profiles/cookable.schema.json
954
+ var cookable_schema_default = {
955
+ $schema: "http://json-schema.org/draft-07/schema#",
956
+ $id: "http://soustack.org/schema/v0.2.1/profiles/cookable",
957
+ title: "Soustack Cookable Profile Schema",
958
+ description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
959
+ allOf: [
960
+ { $ref: "http://soustack.org/schema/v0.2.1" },
961
+ {
962
+ required: ["yield", "time", "ingredients", "instructions"],
963
+ properties: {
964
+ yield: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/yield" },
965
+ time: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/time" },
966
+ ingredients: { type: "array", minItems: 1 },
967
+ instructions: { type: "array", minItems: 1 }
968
+ }
843
969
  }
844
- const maybeMeasurement = parseMeasurement(trimmed);
845
- if (maybeMeasurement && !measurement) {
846
- measurement = maybeMeasurement;
847
- return "";
970
+ ]
971
+ };
972
+
973
+ // src/profiles/quantified.schema.json
974
+ var quantified_schema_default = {
975
+ $schema: "http://json-schema.org/draft-07/schema#",
976
+ $id: "http://soustack.org/schema/v0.2.1/profiles/quantified",
977
+ title: "Soustack Quantified Profile Schema",
978
+ description: "Extends the base schema to require quantified ingredient entries.",
979
+ allOf: [
980
+ { $ref: "http://soustack.org/schema/v0.2.1" },
981
+ {
982
+ properties: {
983
+ ingredients: {
984
+ type: "array",
985
+ items: {
986
+ anyOf: [
987
+ { $ref: "#/definitions/quantifiedIngredient" },
988
+ { $ref: "#/definitions/quantifiedIngredientSubsection" }
989
+ ]
990
+ }
991
+ }
992
+ }
848
993
  }
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);
994
+ ],
995
+ definitions: {
996
+ quantifiedIngredient: {
997
+ allOf: [
998
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredient" },
999
+ { required: ["item", "quantity"] }
1000
+ ]
1001
+ },
1002
+ quantifiedIngredientSubsection: {
1003
+ allOf: [
1004
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredientSubsection" },
1005
+ {
1006
+ properties: {
1007
+ items: {
1008
+ type: "array",
1009
+ items: { $ref: "#/definitions/quantifiedIngredient" }
1010
+ }
1011
+ }
1012
+ }
1013
+ ]
891
1014
  }
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();
1015
+ }
1016
+ };
1017
+
1018
+ // src/profiles/illustrated.schema.json
1019
+ var illustrated_schema_default = {
1020
+ $schema: "http://json-schema.org/draft-07/schema#",
1021
+ $id: "http://soustack.org/schema/v0.2.1/profiles/illustrated",
1022
+ title: "Soustack Illustrated Profile Schema",
1023
+ description: "Extends the base schema to guarantee at least one illustrative image.",
1024
+ allOf: [
1025
+ { $ref: "http://soustack.org/schema/v0.2.1" },
1026
+ {
1027
+ anyOf: [
1028
+ { required: ["image"] },
1029
+ {
1030
+ properties: {
1031
+ instructions: {
1032
+ type: "array",
1033
+ contains: {
1034
+ anyOf: [
1035
+ { $ref: "#/definitions/imageInstruction" },
1036
+ { $ref: "#/definitions/instructionSubsectionWithImage" }
1037
+ ]
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ ]
1043
+ }
1044
+ ],
1045
+ definitions: {
1046
+ imageInstruction: {
1047
+ allOf: [
1048
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
1049
+ { required: ["image"] }
1050
+ ]
1051
+ },
1052
+ instructionSubsectionWithImage: {
1053
+ allOf: [
1054
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
1055
+ {
1056
+ properties: {
1057
+ items: {
1058
+ type: "array",
1059
+ contains: { $ref: "#/definitions/imageInstruction" }
1060
+ }
1061
+ }
1062
+ }
1063
+ ]
899
1064
  }
900
1065
  }
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();
1066
+ };
1067
+
1068
+ // src/profiles/schedulable.schema.json
1069
+ var schedulable_schema_default = {
1070
+ $schema: "http://json-schema.org/draft-07/schema#",
1071
+ $id: "http://soustack.org/schema/v0.2.1/profiles/schedulable",
1072
+ title: "Soustack Schedulable Profile Schema",
1073
+ description: "Extends the base schema to ensure every instruction is fully scheduled.",
1074
+ allOf: [
1075
+ { $ref: "http://soustack.org/schema/v0.2.1" },
1076
+ {
1077
+ properties: {
1078
+ instructions: {
1079
+ type: "array",
1080
+ items: {
1081
+ anyOf: [
1082
+ { $ref: "#/definitions/schedulableInstruction" },
1083
+ { $ref: "#/definitions/schedulableInstructionSubsection" }
1084
+ ]
1085
+ }
1086
+ }
911
1087
  }
912
1088
  }
1089
+ ],
1090
+ definitions: {
1091
+ schedulableInstruction: {
1092
+ allOf: [
1093
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
1094
+ { required: ["id", "timing"] }
1095
+ ]
1096
+ },
1097
+ schedulableInstructionSubsection: {
1098
+ allOf: [
1099
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
1100
+ {
1101
+ properties: {
1102
+ items: {
1103
+ type: "array",
1104
+ items: { $ref: "#/definitions/schedulableInstruction" }
1105
+ }
1106
+ }
1107
+ }
1108
+ ]
1109
+ }
913
1110
  }
914
- return {
915
- amount,
916
- unit,
917
- descriptor,
918
- remainder: working.trim(),
919
- notes,
920
- originalAmount
1111
+ };
1112
+
1113
+ // src/validator.ts
1114
+ var profileSchemas = {
1115
+ base: base_schema_default,
1116
+ cookable: cookable_schema_default,
1117
+ scalable: base_schema_default,
1118
+ quantified: quantified_schema_default,
1119
+ illustrated: illustrated_schema_default,
1120
+ schedulable: schedulable_schema_default
1121
+ };
1122
+ var validationContexts = /* @__PURE__ */ new Map();
1123
+ function createContext(collectAllErrors) {
1124
+ const ajv = new Ajv__default.default({ strict: false, allErrors: collectAllErrors });
1125
+ addFormats__default.default(ajv);
1126
+ const loadedIds = /* @__PURE__ */ new Set();
1127
+ const addSchemaIfNew = (schema) => {
1128
+ if (!schema) return;
1129
+ const schemaId = schema == null ? void 0 : schema.$id;
1130
+ if (schemaId && loadedIds.has(schemaId)) return;
1131
+ ajv.addSchema(schema);
1132
+ if (schemaId) loadedIds.add(schemaId);
921
1133
  };
1134
+ addSchemaIfNew(schema_default);
1135
+ addSchemaIfNew(soustack_schema_default);
1136
+ Object.values(profileSchemas).forEach(addSchemaIfNew);
1137
+ return { ajv, validators: {} };
922
1138
  }
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);
1139
+ function getContext(collectAllErrors) {
1140
+ if (!validationContexts.has(collectAllErrors)) {
1141
+ validationContexts.set(collectAllErrors, createContext(collectAllErrors));
932
1142
  }
933
- const parsed = Number(trimmed);
934
- return Number.isNaN(parsed) ? null : parsed;
1143
+ return validationContexts.get(collectAllErrors);
935
1144
  }
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];
1145
+ function cloneRecipe(recipe) {
1146
+ if (typeof structuredClone === "function") {
1147
+ return structuredClone(recipe);
945
1148
  }
946
- if (raw === "T") return "tbsp";
947
- if (raw === "t") return "tsp";
948
- if (raw === "C") return "cup";
949
- return null;
1149
+ return JSON.parse(JSON.stringify(recipe));
950
1150
  }
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
- };
1151
+ function detectProfileFromSchema(schemaRef) {
1152
+ if (!schemaRef) return void 0;
1153
+ const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
1154
+ if (match) {
1155
+ const profile = match[1].toLowerCase();
1156
+ if (profile in profileSchemas) return profile;
969
1157
  }
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();
1158
+ return void 0;
1159
+ }
1160
+ function getValidator(profile, context) {
1161
+ if (!profileSchemas[profile]) {
1162
+ throw new Error(`Unknown Soustack profile: ${profile}`);
1163
+ }
1164
+ if (!context.validators[profile]) {
1165
+ context.validators[profile] = context.ajv.compile(profileSchemas[profile]);
1166
+ }
1167
+ return context.validators[profile];
1168
+ }
1169
+ function normalizeRecipe(recipe) {
1170
+ const normalized = cloneRecipe(recipe);
1171
+ const warnings = [];
1172
+ normalizeTime(normalized);
1173
+ if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
1174
+ normalized.recipeVersion = normalized.version;
1175
+ warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
1176
+ }
1177
+ return { normalized, warnings };
1178
+ }
1179
+ function normalizeTime(recipe) {
1180
+ const time = recipe == null ? void 0 : recipe.time;
1181
+ if (!time || typeof time !== "object" || Array.isArray(time)) return;
1182
+ const structuredKeys = [
1183
+ "prep",
1184
+ "active",
1185
+ "passive",
1186
+ "total"
1187
+ ];
1188
+ structuredKeys.forEach((key) => {
1189
+ const value = time[key];
1190
+ if (typeof value === "number") return;
1191
+ const parsed = parseDuration(value);
1192
+ if (parsed !== null) {
1193
+ time[key] = parsed;
982
1194
  }
1195
+ });
1196
+ }
1197
+ var _a, _b;
1198
+ var allowedTopLevelProps = /* @__PURE__ */ new Set([
1199
+ ...Object.keys((_b = (_a = soustack_schema_default) == null ? void 0 : _a.properties) != null ? _b : {}),
1200
+ "metadata",
1201
+ "$schema"
1202
+ ]);
1203
+ function detectUnknownTopLevelKeys(recipe) {
1204
+ if (!recipe || typeof recipe !== "object") return [];
1205
+ const disallowedKeys = Object.keys(recipe).filter(
1206
+ (key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
1207
+ );
1208
+ return disallowedKeys.map((key) => ({
1209
+ path: `/${key}`,
1210
+ keyword: "additionalProperties",
1211
+ message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
1212
+ }));
1213
+ }
1214
+ function formatAjvError(error) {
1215
+ var _a2;
1216
+ let path = error.instancePath || "/";
1217
+ if (error.keyword === "additionalProperties" && ((_a2 = error.params) == null ? void 0 : _a2.additionalProperty)) {
1218
+ const extra = error.params.additionalProperty;
1219
+ path = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
983
1220
  }
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
1221
  return {
991
- name: name || void 0,
992
- prep,
993
- notes
1222
+ path,
1223
+ keyword: error.keyword,
1224
+ message: error.message || "Validation error"
994
1225
  };
995
1226
  }
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;
1227
+ function runAjvValidation(data, profile, context, schemaRef) {
1228
+ const validator = schemaRef ? context.ajv.getSchema(schemaRef) : void 0;
1229
+ const validateFn = validator != null ? validator : getValidator(profile, context);
1230
+ const isValid = validateFn(data);
1231
+ return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
1232
+ }
1233
+ function isInstruction(item) {
1234
+ return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
1235
+ }
1236
+ function isInstructionSubsection(item) {
1237
+ return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
1238
+ }
1239
+ function checkInstructionGraph(recipe) {
1240
+ const instructions = recipe == null ? void 0 : recipe.instructions;
1241
+ if (!Array.isArray(instructions)) return [];
1242
+ const instructionIds = /* @__PURE__ */ new Set();
1243
+ const dependencyRefs = [];
1244
+ const collect = (items, basePath) => {
1245
+ items.forEach((item, index) => {
1246
+ const currentPath = `${basePath}/${index}`;
1247
+ if (isInstructionSubsection(item) && Array.isArray(item.items)) {
1248
+ collect(item.items, `${currentPath}/items`);
1249
+ return;
1250
+ }
1251
+ if (isInstruction(item)) {
1252
+ const id = typeof item.id === "string" ? item.id : void 0;
1253
+ if (id) instructionIds.add(id);
1254
+ if (Array.isArray(item.dependsOn)) {
1255
+ item.dependsOn.forEach((depId, depIndex) => {
1256
+ if (typeof depId === "string") {
1257
+ dependencyRefs.push({
1258
+ fromId: id,
1259
+ toId: depId,
1260
+ path: `${currentPath}/dependsOn/${depIndex}`
1261
+ });
1262
+ }
1263
+ });
1264
+ }
1265
+ }
1266
+ });
1267
+ };
1268
+ collect(instructions, "/instructions");
1269
+ const errors = [];
1270
+ dependencyRefs.forEach((ref) => {
1271
+ if (!instructionIds.has(ref.toId)) {
1272
+ errors.push({
1273
+ path: ref.path,
1274
+ message: `Instruction dependency references missing id '${ref.toId}'.`
1275
+ });
1008
1276
  }
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;
1277
+ });
1278
+ const adjacency = /* @__PURE__ */ new Map();
1279
+ dependencyRefs.forEach((ref) => {
1280
+ var _a2;
1281
+ if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
1282
+ const list = (_a2 = adjacency.get(ref.fromId)) != null ? _a2 : [];
1283
+ list.push({ toId: ref.toId, path: ref.path });
1284
+ adjacency.set(ref.fromId, list);
1013
1285
  }
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" }
1286
+ });
1287
+ const visiting = /* @__PURE__ */ new Set();
1288
+ const visited = /* @__PURE__ */ new Set();
1289
+ const detectCycles = (nodeId) => {
1290
+ var _a2;
1291
+ if (visiting.has(nodeId)) return;
1292
+ if (visited.has(nodeId)) return;
1293
+ visiting.add(nodeId);
1294
+ const neighbors = (_a2 = adjacency.get(nodeId)) != null ? _a2 : [];
1295
+ neighbors.forEach((edge) => {
1296
+ if (visiting.has(edge.toId)) {
1297
+ errors.push({
1298
+ path: edge.path,
1299
+ message: `Circular dependency detected involving instruction id '${edge.toId}'.`
1300
+ });
1301
+ return;
1302
+ }
1303
+ detectCycles(edge.toId);
1304
+ });
1305
+ visiting.delete(nodeId);
1306
+ visited.add(nodeId);
1075
1307
  };
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
- }
1308
+ instructionIds.forEach((id) => detectCycles(id));
1309
+ return errors;
1310
+ }
1311
+ function validateRecipe(input, options = {}) {
1312
+ var _a2, _b2, _c, _d;
1313
+ const collectAllErrors = (_a2 = options.collectAllErrors) != null ? _a2 : true;
1314
+ const context = getContext(collectAllErrors);
1315
+ const schemaRef = (_b2 = options.schema) != null ? _b2 : typeof (input == null ? void 0 : input.$schema) === "string" ? input.$schema : void 0;
1316
+ const profile = (_d = (_c = options.profile) != null ? _c : detectProfileFromSchema(schemaRef)) != null ? _d : "base";
1317
+ const { normalized, warnings } = normalizeRecipe(input);
1318
+ const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
1319
+ const validationErrors = runAjvValidation(normalized, profile, context, schemaRef);
1320
+ const graphErrors = profile === "schedulable" && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
1321
+ const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
1101
1322
  return {
1102
- amount: parsedQuantity.amount,
1103
- unit: parsedQuantity.unit ?? null
1323
+ valid: errors.length === 0,
1324
+ errors,
1325
+ warnings,
1326
+ normalized: errors.length === 0 ? normalized : void 0
1104
1327
  };
1105
1328
  }
1329
+ function detectProfiles(recipe) {
1330
+ var _a2;
1331
+ const result = validateRecipe(recipe, { profile: "base", collectAllErrors: false });
1332
+ if (!result.valid) return [];
1333
+ const normalizedRecipe = (_a2 = result.normalized) != null ? _a2 : recipe;
1334
+ const profiles = ["base"];
1335
+ const context = getContext(false);
1336
+ Object.keys(profileSchemas).forEach((profile) => {
1337
+ if (profile === "base") return;
1338
+ if (!profileSchemas[profile]) return;
1339
+ const errors = runAjvValidation(normalizedRecipe, profile, context);
1340
+ if (errors.length === 0) {
1341
+ profiles.push(profile);
1342
+ }
1343
+ });
1344
+ return profiles;
1345
+ }
1106
1346
 
1107
1347
  // src/converters/yield.ts
1108
1348
  function parseYield(value) {
@@ -1144,98 +1384,53 @@ function parseYield(value) {
1144
1384
  return void 0;
1145
1385
  }
1146
1386
  function formatYield(yieldValue) {
1387
+ var _a2;
1147
1388
  if (!yieldValue) return void 0;
1148
1389
  if (!yieldValue.amount && !yieldValue.unit) {
1149
1390
  return void 0;
1150
1391
  }
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]);
1392
+ const amount = (_a2 = yieldValue.amount) != null ? _a2 : "";
1393
+ const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
1394
+ return `${amount}${unit}`.trim() || yieldValue.description;
1395
+ }
1396
+
1397
+ // src/utils/image.ts
1398
+ function normalizeImage(image) {
1399
+ if (!image) {
1400
+ return void 0;
1223
1401
  }
1224
- if (total <= 0) {
1225
- return null;
1402
+ if (typeof image === "string") {
1403
+ const trimmed = image.trim();
1404
+ return trimmed || void 0;
1226
1405
  }
1227
- return Math.round(total);
1406
+ if (Array.isArray(image)) {
1407
+ const urls = image.map((entry) => typeof entry === "string" ? entry.trim() : extractUrl(entry)).filter((url) => typeof url === "string" && Boolean(url));
1408
+ if (urls.length === 0) {
1409
+ return void 0;
1410
+ }
1411
+ if (urls.length === 1) {
1412
+ return urls[0];
1413
+ }
1414
+ return urls;
1415
+ }
1416
+ return extractUrl(image);
1228
1417
  }
1229
- function smartParseDuration(input) {
1230
- const iso = parseDuration(input);
1231
- if (iso !== null) {
1232
- return iso;
1418
+ function extractUrl(value) {
1419
+ if (!value || typeof value !== "object") {
1420
+ return void 0;
1233
1421
  }
1234
- return parseHumanDuration(input);
1422
+ const record = value;
1423
+ const candidate = typeof record.url === "string" ? record.url : typeof record.contentUrl === "string" ? record.contentUrl : void 0;
1424
+ if (!candidate) {
1425
+ return void 0;
1426
+ }
1427
+ const trimmed = candidate.trim();
1428
+ return trimmed || void 0;
1235
1429
  }
1236
1430
 
1237
1431
  // src/fromSchemaOrg.ts
1238
1432
  function fromSchemaOrg(input) {
1433
+ var _a2;
1239
1434
  const recipeNode = extractRecipeNode(input);
1240
1435
  if (!recipeNode) {
1241
1436
  return null;
@@ -1246,13 +1441,12 @@ function fromSchemaOrg(input) {
1246
1441
  const recipeYield = parseYield(recipeNode.recipeYield);
1247
1442
  const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
1248
1443
  const category = extractFirst(recipeNode.recipeCategory);
1249
- const image = convertImage(recipeNode.image);
1250
1444
  const source = convertSource(recipeNode);
1251
1445
  const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
1252
1446
  return {
1253
1447
  name: recipeNode.name.trim(),
1254
- description: recipeNode.description?.trim() || void 0,
1255
- image,
1448
+ description: ((_a2 = recipeNode.description) == null ? void 0 : _a2.trim()) || void 0,
1449
+ image: normalizeImage(recipeNode.image),
1256
1450
  category,
1257
1451
  tags: tags.length ? tags : void 0,
1258
1452
  source,
@@ -1297,8 +1491,6 @@ function extractRecipeNode(input) {
1297
1491
  function hasRecipeType(value) {
1298
1492
  if (!value) return false;
1299
1493
  const types = Array.isArray(value) ? value : [value];
1300
- fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "fromSchemaOrg.ts:95", message: "hasRecipeType check", data: { types, typesLower: types.map((t) => typeof t === "string" ? t.toLowerCase() : t), isMatch: types.some((e) => typeof e === "string" && e.toLowerCase() === "recipe") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
1301
- });
1302
1494
  return types.some(
1303
1495
  (entry) => typeof entry === "string" && entry.toLowerCase() === "recipe"
1304
1496
  );
@@ -1309,9 +1501,10 @@ function isValidName(name) {
1309
1501
  function convertIngredients(value) {
1310
1502
  if (!value) return [];
1311
1503
  const normalized = Array.isArray(value) ? value : [value];
1312
- return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean).map((line) => parseIngredientLine2(line));
1504
+ return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean);
1313
1505
  }
1314
1506
  function convertInstructions(value) {
1507
+ var _a2;
1315
1508
  if (!value) return [];
1316
1509
  const normalized = Array.isArray(value) ? value : [value];
1317
1510
  const result = [];
@@ -1328,16 +1521,16 @@ function convertInstructions(value) {
1328
1521
  const subsectionItems = extractSectionItems(entry.itemListElement);
1329
1522
  if (subsectionItems.length) {
1330
1523
  result.push({
1331
- subsection: entry.name?.trim() || "Section",
1524
+ subsection: ((_a2 = entry.name) == null ? void 0 : _a2.trim()) || "Section",
1332
1525
  items: subsectionItems
1333
1526
  });
1334
1527
  }
1335
1528
  continue;
1336
1529
  }
1337
1530
  if (isHowToStep(entry)) {
1338
- const text = extractInstructionText(entry);
1339
- if (text) {
1340
- result.push(text);
1531
+ const parsed = convertHowToStep(entry);
1532
+ if (parsed) {
1533
+ result.push(parsed);
1341
1534
  }
1342
1535
  }
1343
1536
  }
@@ -1355,9 +1548,9 @@ function extractSectionItems(items = []) {
1355
1548
  continue;
1356
1549
  }
1357
1550
  if (isHowToStep(item)) {
1358
- const text = extractInstructionText(item);
1359
- if (text) {
1360
- result.push(text);
1551
+ const parsed = convertHowToStep(item);
1552
+ if (parsed) {
1553
+ result.push(parsed);
1361
1554
  }
1362
1555
  continue;
1363
1556
  }
@@ -1371,6 +1564,40 @@ function extractInstructionText(value) {
1371
1564
  const text = typeof value.text === "string" ? value.text : value.name;
1372
1565
  return typeof text === "string" ? text.trim() || void 0 : void 0;
1373
1566
  }
1567
+ function convertHowToStep(step) {
1568
+ const text = extractInstructionText(step);
1569
+ if (!text) {
1570
+ return void 0;
1571
+ }
1572
+ const normalizedImage = normalizeImage(step.image);
1573
+ const image = Array.isArray(normalizedImage) ? normalizedImage[0] : normalizedImage;
1574
+ const id = extractInstructionId(step);
1575
+ const timing = extractInstructionTiming(step);
1576
+ if (!image && !id && !timing) {
1577
+ return text;
1578
+ }
1579
+ const instruction = { text };
1580
+ if (id) instruction.id = id;
1581
+ if (image) instruction.image = image;
1582
+ if (timing) instruction.timing = timing;
1583
+ return instruction;
1584
+ }
1585
+ function extractInstructionTiming(step) {
1586
+ const duration = step.totalTime || step.performTime || step.prepTime || step.duration;
1587
+ if (!duration || typeof duration !== "string") {
1588
+ return void 0;
1589
+ }
1590
+ const parsed = smartParseDuration(duration);
1591
+ return { duration: parsed != null ? parsed : duration, type: "active" };
1592
+ }
1593
+ function extractInstructionId(step) {
1594
+ const raw = step["@id"] || step.id || step.url;
1595
+ if (typeof raw !== "string") {
1596
+ return void 0;
1597
+ }
1598
+ const trimmed = raw.trim();
1599
+ return trimmed || void 0;
1600
+ }
1374
1601
  function isHowToStep(value) {
1375
1602
  return Boolean(value) && typeof value === "object" && value["@type"] === "HowToStep";
1376
1603
  }
@@ -1378,9 +1605,10 @@ function isHowToSection(value) {
1378
1605
  return Boolean(value) && typeof value === "object" && value["@type"] === "HowToSection" && Array.isArray(value.itemListElement);
1379
1606
  }
1380
1607
  function convertTime(recipe) {
1381
- const prep = smartParseDuration(recipe.prepTime ?? "");
1382
- const cook = smartParseDuration(recipe.cookTime ?? "");
1383
- const total = smartParseDuration(recipe.totalTime ?? "");
1608
+ var _a2, _b2, _c;
1609
+ const prep = smartParseDuration((_a2 = recipe.prepTime) != null ? _a2 : "");
1610
+ const cook = smartParseDuration((_b2 = recipe.cookTime) != null ? _b2 : "");
1611
+ const total = smartParseDuration((_c = recipe.totalTime) != null ? _c : "");
1384
1612
  const structured = {};
1385
1613
  if (prep !== null && prep !== void 0) structured.prep = prep;
1386
1614
  if (cook !== null && cook !== void 0) structured.active = cook;
@@ -1412,30 +1640,11 @@ function extractFirst(value) {
1412
1640
  const arr = flattenStrings(value);
1413
1641
  return arr.length ? arr[0] : void 0;
1414
1642
  }
1415
- function convertImage(value) {
1416
- if (!value) return void 0;
1417
- if (typeof value === "string") {
1418
- return value;
1419
- }
1420
- if (Array.isArray(value)) {
1421
- for (const item of value) {
1422
- const url = typeof item === "string" ? item : extractImageUrl(item);
1423
- if (url) return url;
1424
- }
1425
- return void 0;
1426
- }
1427
- return extractImageUrl(value);
1428
- }
1429
- function extractImageUrl(value) {
1430
- if (!value || typeof value !== "object") return void 0;
1431
- const record = value;
1432
- const candidate = typeof record.url === "string" ? record.url : typeof record.contentUrl === "string" ? record.contentUrl : void 0;
1433
- return candidate?.trim() || void 0;
1434
- }
1435
1643
  function convertSource(recipe) {
1644
+ var _a2;
1436
1645
  const author = extractEntityName(recipe.author);
1437
1646
  const publisher = extractEntityName(recipe.publisher);
1438
- const url = (recipe.url || recipe.mainEntityOfPage)?.trim();
1647
+ const url = (_a2 = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a2.trim();
1439
1648
  const source = {};
1440
1649
  if (author) source.author = author;
1441
1650
  if (publisher) source.name = publisher;
@@ -1466,13 +1675,14 @@ function extractEntityName(value) {
1466
1675
 
1467
1676
  // src/converters/toSchemaOrg.ts
1468
1677
  function convertBasicMetadata(recipe) {
1678
+ var _a2;
1469
1679
  return cleanOutput({
1470
1680
  "@context": "https://schema.org",
1471
1681
  "@type": "Recipe",
1472
1682
  name: recipe.name,
1473
1683
  description: recipe.description,
1474
1684
  image: recipe.image,
1475
- url: recipe.source?.url,
1685
+ url: (_a2 = recipe.source) == null ? void 0 : _a2.url,
1476
1686
  datePublished: recipe.dateAdded,
1477
1687
  dateModified: recipe.dateModified
1478
1688
  });
@@ -1480,6 +1690,7 @@ function convertBasicMetadata(recipe) {
1480
1690
  function convertIngredients2(ingredients = []) {
1481
1691
  const result = [];
1482
1692
  ingredients.forEach((ingredient) => {
1693
+ var _a2;
1483
1694
  if (!ingredient) {
1484
1695
  return;
1485
1696
  }
@@ -1509,7 +1720,7 @@ function convertIngredients2(ingredients = []) {
1509
1720
  });
1510
1721
  return;
1511
1722
  }
1512
- const value = ingredient.item?.trim();
1723
+ const value = (_a2 = ingredient.item) == null ? void 0 : _a2.trim();
1513
1724
  if (value) {
1514
1725
  result.push(value);
1515
1726
  }
@@ -1524,10 +1735,11 @@ function convertInstruction(entry) {
1524
1735
  return null;
1525
1736
  }
1526
1737
  if (typeof entry === "string") {
1527
- return createHowToStep(entry);
1738
+ const value = entry.trim();
1739
+ return value || null;
1528
1740
  }
1529
1741
  if ("subsection" in entry) {
1530
- const steps = entry.items.map((item) => typeof item === "string" ? createHowToStep(item) : createHowToStep(item.text)).filter((step) => Boolean(step));
1742
+ const steps = entry.items.map((item) => convertInstruction(item)).filter((step) => Boolean(step));
1531
1743
  if (!steps.length) {
1532
1744
  return null;
1533
1745
  }
@@ -1538,18 +1750,42 @@ function convertInstruction(entry) {
1538
1750
  };
1539
1751
  }
1540
1752
  if ("text" in entry) {
1541
- return createHowToStep(entry.text);
1753
+ return createHowToStep(entry);
1542
1754
  }
1543
1755
  return createHowToStep(String(entry));
1544
1756
  }
1545
- function createHowToStep(text) {
1546
- if (!text) return null;
1547
- const trimmed = text.trim();
1548
- if (!trimmed) return null;
1549
- return {
1757
+ function createHowToStep(entry) {
1758
+ var _a2;
1759
+ if (!entry) return null;
1760
+ if (typeof entry === "string") {
1761
+ const trimmed2 = entry.trim();
1762
+ return trimmed2 || null;
1763
+ }
1764
+ const trimmed = (_a2 = entry.text) == null ? void 0 : _a2.trim();
1765
+ if (!trimmed) {
1766
+ return null;
1767
+ }
1768
+ const step = {
1550
1769
  "@type": "HowToStep",
1551
1770
  text: trimmed
1552
1771
  };
1772
+ if (entry.id) {
1773
+ step["@id"] = entry.id;
1774
+ }
1775
+ if (entry.timing) {
1776
+ if (typeof entry.timing.duration === "number") {
1777
+ step.performTime = formatDuration(entry.timing.duration);
1778
+ } else if (entry.timing.duration) {
1779
+ step.performTime = entry.timing.duration;
1780
+ }
1781
+ }
1782
+ if (entry.image) {
1783
+ step.image = entry.image;
1784
+ }
1785
+ if (step["@id"] || step.performTime || step.image) {
1786
+ return step;
1787
+ }
1788
+ return trimmed;
1553
1789
  }
1554
1790
  function convertTime2(time) {
1555
1791
  if (!time) {
@@ -1649,113 +1885,6 @@ function isStructuredTime(time) {
1649
1885
  return typeof time.prep !== "undefined" || typeof time.active !== "undefined" || typeof time.passive !== "undefined" || typeof time.total !== "undefined";
1650
1886
  }
1651
1887
 
1652
- // src/scraper/fetch.ts
1653
- var DEFAULT_USER_AGENTS = [
1654
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
1655
- "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",
1656
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
1657
- ];
1658
- function chooseUserAgent(provided) {
1659
- if (provided) return provided;
1660
- const index = Math.floor(Math.random() * DEFAULT_USER_AGENTS.length);
1661
- return DEFAULT_USER_AGENTS[index];
1662
- }
1663
- function resolveFetch(fetchFn) {
1664
- if (fetchFn) {
1665
- return fetchFn;
1666
- }
1667
- const globalFetch = globalThis.fetch;
1668
- if (!globalFetch) {
1669
- throw new Error(
1670
- "A global fetch implementation is not available. Provide window.fetch in browsers or upgrade to Node 18+."
1671
- );
1672
- }
1673
- return globalFetch;
1674
- }
1675
- function isBrowserEnvironment() {
1676
- return typeof globalThis.document !== "undefined";
1677
- }
1678
- function isClientError(error) {
1679
- if (typeof error.status === "number") {
1680
- return error.status >= 400 && error.status < 500;
1681
- }
1682
- return error.message.includes("HTTP 4");
1683
- }
1684
- async function wait(ms) {
1685
- return new Promise((resolve) => setTimeout(resolve, ms));
1686
- }
1687
- async function fetchPage(url, options = {}) {
1688
- const {
1689
- timeout = 1e4,
1690
- userAgent,
1691
- maxRetries = 2,
1692
- fetchFn
1693
- } = options;
1694
- let lastError = null;
1695
- const resolvedFetch = resolveFetch(fetchFn);
1696
- const isBrowser2 = isBrowserEnvironment();
1697
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1698
- const controller = new AbortController();
1699
- const timeoutId = setTimeout(() => controller.abort(), timeout);
1700
- try {
1701
- const headers = {
1702
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1703
- "Accept-Language": "en-US,en;q=0.5"
1704
- };
1705
- if (!isBrowser2) {
1706
- headers["User-Agent"] = chooseUserAgent(userAgent);
1707
- }
1708
- const requestInit = {
1709
- headers,
1710
- signal: controller.signal,
1711
- redirect: "follow"
1712
- };
1713
- const response = await resolvedFetch(url, requestInit);
1714
- clearTimeout(timeoutId);
1715
- if (response && (typeof process === "undefined" || process.env.NODE_ENV !== "test")) {
1716
- try {
1717
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1718
- if (globalFetch) {
1719
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:63", message: "fetch response", data: { url, status: response.status, statusText: response.statusText, ok: response.ok, isNYTimes: url.includes("nytimes.com") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
1720
- });
1721
- }
1722
- } catch {
1723
- }
1724
- }
1725
- if (!response.ok) {
1726
- const error = new Error(
1727
- `HTTP ${response.status}: ${response.statusText}`
1728
- );
1729
- error.status = response.status;
1730
- throw error;
1731
- }
1732
- const html = await response.text();
1733
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
1734
- try {
1735
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1736
- if (globalFetch) {
1737
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:75", message: "HTML received", data: { htmlLength: html.length, hasLoginPage: html.toLowerCase().includes("login") || html.toLowerCase().includes("sign in"), hasRecipeData: html.includes("application/ld+json") || html.includes("schema.org/Recipe") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B,D" }) }).catch(() => {
1738
- });
1739
- }
1740
- } catch {
1741
- }
1742
- }
1743
- return html;
1744
- } catch (err) {
1745
- clearTimeout(timeoutId);
1746
- lastError = err instanceof Error ? err : new Error(String(err));
1747
- if (isClientError(lastError)) {
1748
- throw lastError;
1749
- }
1750
- if (attempt < maxRetries) {
1751
- await wait(1e3 * (attempt + 1));
1752
- continue;
1753
- }
1754
- }
1755
- }
1756
- throw lastError ?? new Error("Failed to fetch page");
1757
- }
1758
-
1759
1888
  // src/scraper/extractors/utils.ts
1760
1889
  var RECIPE_TYPES = /* @__PURE__ */ new Set([
1761
1890
  "recipe",
@@ -1792,97 +1921,8 @@ function normalizeText(value) {
1792
1921
  return trimmed || void 0;
1793
1922
  }
1794
1923
 
1795
- // src/scraper/extractors/jsonld.ts
1796
- function extractJsonLd(html) {
1797
- const $ = cheerio.load(html);
1798
- const scripts = $('script[type="application/ld+json"]');
1799
- fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:8", message: "JSON-LD scripts found", data: { scriptCount: scripts.length }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "C,D" }) }).catch(() => {
1800
- });
1801
- const candidates = [];
1802
- scripts.each((_, element) => {
1803
- const content = $(element).html();
1804
- if (!content) return;
1805
- const parsed = safeJsonParse(content);
1806
- if (!parsed) return;
1807
- fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:18", message: "JSON-LD parsed", data: { hasGraph: !!(parsed && typeof parsed === "object" && "@graph" in parsed), type: parsed && typeof parsed === "object" && "@type" in parsed ? parsed["@type"] : void 0 }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C" }) }).catch(() => {
1808
- });
1809
- collectCandidates(parsed, candidates);
1810
- });
1811
- fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:22", message: "JSON-LD candidates", data: { candidateCount: candidates.length, candidateTypes: candidates.map((c) => c["@type"]) }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C" }) }).catch(() => {
1812
- });
1813
- return candidates[0] ?? null;
1814
- }
1815
- function collectCandidates(payload, bucket) {
1816
- if (!payload) return;
1817
- if (Array.isArray(payload)) {
1818
- payload.forEach((entry) => collectCandidates(entry, bucket));
1819
- return;
1820
- }
1821
- if (typeof payload !== "object") {
1822
- return;
1823
- }
1824
- if (isRecipeNode(payload)) {
1825
- bucket.push(payload);
1826
- return;
1827
- }
1828
- const graph = payload["@graph"];
1829
- if (Array.isArray(graph)) {
1830
- graph.forEach((entry) => collectCandidates(entry, bucket));
1831
- }
1832
- }
1833
- var SIMPLE_PROPS = [
1834
- "name",
1835
- "description",
1836
- "image",
1837
- "recipeYield",
1838
- "prepTime",
1839
- "cookTime",
1840
- "totalTime"
1841
- ];
1842
- function extractMicrodata(html) {
1843
- const $ = cheerio.load(html);
1844
- const recipeEl = $('[itemscope][itemtype*="schema.org/Recipe"]').first();
1845
- if (!recipeEl.length) {
1846
- return null;
1847
- }
1848
- const recipe = {
1849
- "@type": "Recipe"
1850
- };
1851
- SIMPLE_PROPS.forEach((prop) => {
1852
- const value = findPropertyValue($, recipeEl, prop);
1853
- if (value) {
1854
- recipe[prop] = value;
1855
- }
1856
- });
1857
- const ingredients = [];
1858
- recipeEl.find('[itemprop="recipeIngredient"]').each((_, el) => {
1859
- const text = normalizeText($(el).attr("content") || $(el).text());
1860
- if (text) ingredients.push(text);
1861
- });
1862
- if (ingredients.length) {
1863
- recipe.recipeIngredient = ingredients;
1864
- }
1865
- const instructions = [];
1866
- recipeEl.find('[itemprop="recipeInstructions"]').each((_, el) => {
1867
- const text = normalizeText($(el).attr("content")) || normalizeText($(el).find('[itemprop="text"]').first().text()) || normalizeText($(el).text());
1868
- if (text) instructions.push(text);
1869
- });
1870
- if (instructions.length) {
1871
- recipe.recipeInstructions = instructions;
1872
- }
1873
- if (recipe.name || ingredients.length) {
1874
- return recipe;
1875
- }
1876
- return null;
1877
- }
1878
- function findPropertyValue($, context, prop) {
1879
- const node = context.find(`[itemprop="${prop}"]`).first();
1880
- if (!node.length) return void 0;
1881
- return normalizeText(node.attr("content")) || normalizeText(node.attr("href")) || normalizeText(node.attr("src")) || normalizeText(node.text());
1882
- }
1883
-
1884
1924
  // src/scraper/extractors/browser.ts
1885
- var SIMPLE_PROPS2 = ["name", "description", "image", "recipeYield", "prepTime", "cookTime", "totalTime"];
1925
+ var SIMPLE_PROPS = ["name", "description", "image", "recipeYield", "prepTime", "cookTime", "totalTime"];
1886
1926
  function extractRecipeBrowser(html) {
1887
1927
  const jsonLdRecipe = extractJsonLdBrowser(html);
1888
1928
  if (jsonLdRecipe) {
@@ -1895,6 +1935,7 @@ function extractRecipeBrowser(html) {
1895
1935
  return { recipe: null, source: null };
1896
1936
  }
1897
1937
  function extractJsonLdBrowser(html) {
1938
+ var _a2;
1898
1939
  if (typeof globalThis.DOMParser === "undefined") {
1899
1940
  return null;
1900
1941
  }
@@ -1907,9 +1948,9 @@ function extractJsonLdBrowser(html) {
1907
1948
  if (!content) return;
1908
1949
  const parsed = safeJsonParse(content);
1909
1950
  if (!parsed) return;
1910
- collectCandidates2(parsed, candidates);
1951
+ collectCandidates(parsed, candidates);
1911
1952
  });
1912
- return candidates[0] ?? null;
1953
+ return (_a2 = candidates[0]) != null ? _a2 : null;
1913
1954
  }
1914
1955
  function extractMicrodataBrowser(html) {
1915
1956
  if (typeof globalThis.DOMParser === "undefined") {
@@ -1924,8 +1965,8 @@ function extractMicrodataBrowser(html) {
1924
1965
  const recipe = {
1925
1966
  "@type": "Recipe"
1926
1967
  };
1927
- SIMPLE_PROPS2.forEach((prop) => {
1928
- const value = findPropertyValue2(recipeEl, prop);
1968
+ SIMPLE_PROPS.forEach((prop) => {
1969
+ const value = findPropertyValue(recipeEl, prop);
1929
1970
  if (value) {
1930
1971
  recipe[prop] = value;
1931
1972
  }
@@ -1942,7 +1983,8 @@ function extractMicrodataBrowser(html) {
1942
1983
  }
1943
1984
  const instructions = [];
1944
1985
  recipeEl.querySelectorAll('[itemprop="recipeInstructions"]').forEach((el) => {
1945
- const text = normalizeText(el.getAttribute("content")) || normalizeText(el.querySelector('[itemprop="text"]')?.textContent || void 0) || normalizeText(el.textContent || void 0);
1986
+ var _a2;
1987
+ const text = normalizeText(el.getAttribute("content")) || normalizeText(((_a2 = el.querySelector('[itemprop="text"]')) == null ? void 0 : _a2.textContent) || void 0) || normalizeText(el.textContent || void 0);
1946
1988
  if (text) instructions.push(text);
1947
1989
  });
1948
1990
  if (instructions.length) {
@@ -1953,15 +1995,15 @@ function extractMicrodataBrowser(html) {
1953
1995
  }
1954
1996
  return null;
1955
1997
  }
1956
- function findPropertyValue2(context, prop) {
1998
+ function findPropertyValue(context, prop) {
1957
1999
  const node = context.querySelector(`[itemprop="${prop}"]`);
1958
2000
  if (!node) return void 0;
1959
2001
  return normalizeText(node.getAttribute("content")) || normalizeText(node.getAttribute("href")) || normalizeText(node.getAttribute("src")) || normalizeText(node.textContent || void 0);
1960
2002
  }
1961
- function collectCandidates2(payload, bucket) {
2003
+ function collectCandidates(payload, bucket) {
1962
2004
  if (!payload) return;
1963
2005
  if (Array.isArray(payload)) {
1964
- payload.forEach((entry) => collectCandidates2(entry, bucket));
2006
+ payload.forEach((entry) => collectCandidates(entry, bucket));
1965
2007
  return;
1966
2008
  }
1967
2009
  if (typeof payload !== "object") {
@@ -1973,380 +2015,24 @@ function collectCandidates2(payload, bucket) {
1973
2015
  }
1974
2016
  const graph = payload["@graph"];
1975
2017
  if (Array.isArray(graph)) {
1976
- graph.forEach((entry) => collectCandidates2(entry, bucket));
1977
- }
1978
- }
1979
-
1980
- // src/scraper/extractors/index.ts
1981
- function isBrowser() {
1982
- try {
1983
- return typeof globalThis.DOMParser !== "undefined";
1984
- } catch {
1985
- return false;
1986
- }
1987
- }
1988
- function extractRecipe(html) {
1989
- if (isBrowser()) {
1990
- return extractRecipeBrowser(html);
1991
- }
1992
- const jsonLdRecipe = extractJsonLd(html);
1993
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
1994
- try {
1995
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1996
- if (globalFetch) {
1997
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/index.ts:6", message: "JSON-LD extraction result", data: { hasJsonLd: !!jsonLdRecipe }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "C,D" }) }).catch(() => {
1998
- });
1999
- }
2000
- } catch {
2001
- }
2002
- }
2003
- if (jsonLdRecipe) {
2004
- return { recipe: jsonLdRecipe, source: "jsonld" };
2005
- }
2006
- const microdataRecipe = extractMicrodata(html);
2007
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2008
- try {
2009
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2010
- if (globalFetch) {
2011
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/index.ts:12", message: "Microdata extraction result", data: { hasMicrodata: !!microdataRecipe }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "D" }) }).catch(() => {
2012
- });
2013
- }
2014
- } catch {
2015
- }
2016
- }
2017
- if (microdataRecipe) {
2018
- return { recipe: microdataRecipe, source: "microdata" };
2018
+ graph.forEach((entry) => collectCandidates(entry, bucket));
2019
2019
  }
2020
- return { recipe: null, source: null };
2021
2020
  }
2022
2021
 
2023
- // src/scraper/index.ts
2024
- async function scrapeRecipe(url, options = {}) {
2025
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2026
- try {
2027
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2028
- if (globalFetch) {
2029
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:7", message: "scrapeRecipe entry", data: { url, hasOptions: !!options }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,B,C,D,E" }) }).catch(() => {
2030
- });
2031
- }
2032
- } catch {
2033
- }
2034
- }
2035
- const html = await fetchPage(url, options);
2036
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2037
- try {
2038
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2039
- if (globalFetch) {
2040
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:9", message: "HTML fetched", data: { htmlLength: html?.length, htmlPreview: html?.substring(0, 200) }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
2041
- });
2042
- }
2043
- } catch {
2044
- }
2045
- }
2046
- const { recipe } = extractRecipe(html);
2047
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2048
- try {
2049
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2050
- if (globalFetch) {
2051
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:11", message: "extractRecipe result", data: { hasRecipe: !!recipe, recipeType: recipe?.["@type"], recipeName: recipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C,D" }) }).catch(() => {
2052
- });
2053
- }
2054
- } catch {
2055
- }
2056
- }
2057
- if (!recipe) {
2058
- throw new Error("No Schema.org recipe data found in page");
2059
- }
2060
- const soustackRecipe = fromSchemaOrg(recipe);
2061
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2062
- try {
2063
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2064
- if (globalFetch) {
2065
- globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:17", message: "fromSchemaOrg result", data: { hasSoustackRecipe: !!soustackRecipe, soustackRecipeName: soustackRecipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
2066
- });
2067
- }
2068
- } catch {
2069
- }
2070
- }
2071
- if (!soustackRecipe) {
2072
- throw new Error("Schema.org data did not include a valid recipe");
2073
- }
2074
- return soustackRecipe;
2075
- }
2076
- function extractRecipeFromHTML(html) {
2077
- const { recipe } = extractRecipe(html);
2078
- if (!recipe) {
2079
- throw new Error("No Schema.org recipe data found in HTML");
2080
- }
2081
- const soustackRecipe = fromSchemaOrg(recipe);
2082
- if (!soustackRecipe) {
2083
- throw new Error("Schema.org data did not include a valid recipe");
2084
- }
2085
- return soustackRecipe;
2086
- }
2022
+ // src/scraper/browser.ts
2087
2023
  function extractSchemaOrgRecipeFromHTML(html) {
2088
- const { recipe } = extractRecipe(html);
2024
+ const { recipe } = extractRecipeBrowser(html);
2089
2025
  return recipe;
2090
2026
  }
2091
2027
 
2092
- // src/parsers/yield.ts
2093
- var RANGE_PATTERN = /^(\d+)(?:\s*(?:[-–—]|to)\s*)(\d+)\s+(.+)$/i;
2094
- var MAKES_PREFIX = /^(makes?|yields?)\s*:?\s*(.+)$/i;
2095
- var APPROX_PREFIX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
2096
- var SERVING_UNITS = ["servings", "serving", "portions", "portion", "people", "persons"];
2097
- var DEFAULT_DOZEN_UNIT = "cookies";
2098
- var NUMBER_WORDS2 = {
2099
- a: 1,
2100
- an: 1,
2101
- one: 1,
2102
- two: 2,
2103
- three: 3,
2104
- four: 4,
2105
- five: 5,
2106
- six: 6,
2107
- seven: 7,
2108
- eight: 8,
2109
- nine: 9,
2110
- ten: 10,
2111
- eleven: 11,
2112
- twelve: 12
2113
- };
2114
- function normalizeYield(text) {
2115
- if (!text || typeof text !== "string") return "";
2116
- return text.normalize("NFKC").replace(/\u00A0/g, " ").replace(/[–—−]/g, "-").trim().replace(/\s+/g, " ");
2117
- }
2118
- function parseYield2(text) {
2119
- const normalized = normalizeYield(text);
2120
- if (!normalized) return null;
2121
- const { main, paren } = extractParenthetical(normalized);
2122
- const core = parseYieldCore(main, normalized);
2123
- if (!core) return null;
2124
- const servingsFromParen = paren ? extractServingsFromParen(paren) : null;
2125
- if (servingsFromParen !== null) {
2126
- core.servings = servingsFromParen;
2127
- core.description = normalized;
2128
- }
2129
- if (core.servings === void 0) {
2130
- const inferred = inferServings(core.amount, core.unit);
2131
- if (inferred !== void 0) {
2132
- core.servings = inferred;
2133
- }
2134
- }
2135
- return core;
2136
- }
2137
- function formatYield2(value) {
2138
- if (value.description) {
2139
- return value.description;
2140
- }
2141
- if (value.servings && value.unit === "servings") {
2142
- return `Serves ${value.amount}`;
2143
- }
2144
- let result = `${value.amount} ${value.unit}`.trim();
2145
- if (value.servings && value.unit !== "servings") {
2146
- result += ` (${value.servings} servings)`;
2147
- }
2148
- return result;
2149
- }
2150
- function parseYieldCore(text, original) {
2151
- return parseServesPattern(text, original) ?? parseMakesPattern(text, original) ?? parseRangePattern(text, original) ?? parseNumberUnitPattern(text, original) ?? parsePlainNumberPattern(text);
2152
- }
2153
- function parseServesPattern(text, original) {
2154
- const patterns = [
2155
- /^serves?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
2156
- /^servings?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
2157
- /^serving\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
2158
- /^makes?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i,
2159
- /^(\d+)\s+servings?$/i
2160
- ];
2161
- for (const regex of patterns) {
2162
- const match = text.match(regex);
2163
- if (!match) continue;
2164
- const amount = parseInt(match[1], 10);
2165
- if (Number.isNaN(amount)) continue;
2166
- const result = {
2167
- amount,
2168
- unit: "servings",
2169
- servings: amount
2170
- };
2171
- if (match[2]) {
2172
- result.description = original;
2173
- }
2174
- return result;
2175
- }
2176
- return null;
2177
- }
2178
- function parseMakesPattern(text, original) {
2179
- const match = text.match(MAKES_PREFIX);
2180
- if (!match) return null;
2181
- const remainder = match[2].trim();
2182
- if (!remainder) return null;
2183
- const servingsMatch = remainder.match(/^(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i);
2184
- if (servingsMatch) {
2185
- const amount = parseInt(servingsMatch[1], 10);
2186
- const result = {
2187
- amount,
2188
- unit: "servings",
2189
- servings: amount
2190
- };
2191
- if (servingsMatch[2]) {
2192
- result.description = original;
2193
- }
2194
- return result;
2195
- }
2196
- return parseRangePattern(remainder, original) ?? parseNumberUnitPattern(remainder, original) ?? parsePlainNumberPattern(remainder);
2197
- }
2198
- function parseRangePattern(text, descriptionSource) {
2199
- const match = text.match(RANGE_PATTERN);
2200
- if (!match) return null;
2201
- const amount = parseInt(match[1], 10);
2202
- const unit = cleanupUnit(match[3]);
2203
- if (!unit) return null;
2204
- const result = {
2205
- amount,
2206
- unit,
2207
- description: descriptionSource
2208
- };
2209
- return result;
2210
- }
2211
- function parseNumberUnitPattern(text, descriptionSource) {
2212
- if (!text) return null;
2213
- const { value, approximate } = stripApproximation(text);
2214
- if (!value) return null;
2215
- const dozenResult = handleDozen(value);
2216
- if (dozenResult) {
2217
- const unit = cleanupUnit(dozenResult.remainder || DEFAULT_DOZEN_UNIT);
2218
- const parsed = {
2219
- amount: dozenResult.amount,
2220
- unit
2221
- };
2222
- if (approximate) {
2223
- parsed.description = descriptionSource;
2224
- }
2225
- return parsed;
2226
- }
2227
- const numericMatch = value.match(/^(\d+(?:\.\d+)?)\s+(.+)$/);
2228
- if (numericMatch) {
2229
- const amount = parseFloat(numericMatch[1]);
2230
- if (!Number.isNaN(amount)) {
2231
- const unit = cleanupUnit(numericMatch[2]);
2232
- if (unit) {
2233
- const parsed = { amount, unit };
2234
- if (approximate) {
2235
- parsed.description = descriptionSource;
2236
- }
2237
- return parsed;
2238
- }
2239
- }
2240
- }
2241
- const wordMatch = value.match(/^([a-zA-Z]+)\s+(.+)$/);
2242
- if (wordMatch) {
2243
- const amount = wordToNumber(wordMatch[1]);
2244
- if (amount !== null) {
2245
- const unit = cleanupUnit(wordMatch[2]);
2246
- if (unit) {
2247
- const parsed = { amount, unit };
2248
- if (approximate) {
2249
- parsed.description = descriptionSource;
2250
- }
2251
- return parsed;
2252
- }
2253
- }
2254
- }
2255
- return null;
2256
- }
2257
- function parsePlainNumberPattern(text) {
2258
- const match = text.match(/^(\d+)$/);
2259
- if (!match) return null;
2260
- const amount = parseInt(match[1], 10);
2261
- if (Number.isNaN(amount)) return null;
2262
- return {
2263
- amount,
2264
- unit: "servings",
2265
- servings: amount
2266
- };
2267
- }
2268
- function stripApproximation(value) {
2269
- const match = value.match(APPROX_PREFIX);
2270
- if (!match) {
2271
- return { value: value.trim(), approximate: false };
2272
- }
2273
- const stripped = value.slice(match[0].length).trim();
2274
- return { value: stripped, approximate: true };
2275
- }
2276
- function handleDozen(text) {
2277
- const match = text.match(
2278
- /^((?:\d+(?:\.\d+)?)|(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|a|an|half))\s+dozens?\b(.*)$/i
2279
- );
2280
- if (!match) return null;
2281
- const token = match[1].toLowerCase();
2282
- let multiplier = null;
2283
- if (token === "half") {
2284
- multiplier = 0.5;
2285
- } else if (!Number.isNaN(Number(token))) {
2286
- multiplier = parseFloat(token);
2287
- } else {
2288
- multiplier = wordToNumber(token);
2289
- }
2290
- if (multiplier === null) return null;
2291
- const amount = multiplier * 12;
2292
- return {
2293
- amount,
2294
- remainder: match[2].trim()
2295
- };
2296
- }
2297
- function cleanupUnit(value) {
2298
- let unit = value.trim();
2299
- unit = unit.replace(/^[,.-]+/, "").trim();
2300
- unit = unit.replace(/[.,]+$/, "").trim();
2301
- unit = unit.replace(/^of\s+/i, "").trim();
2302
- return unit;
2303
- }
2304
- function extractParenthetical(text) {
2305
- const match = text.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
2306
- if (!match) {
2307
- return { main: text, paren: null };
2308
- }
2309
- return {
2310
- main: match[1].trim(),
2311
- paren: match[2].trim()
2312
- };
2313
- }
2314
- function extractServingsFromParen(text) {
2315
- const match = text.match(/(\d+)/);
2316
- if (!match) return null;
2317
- const value = parseInt(match[1], 10);
2318
- return Number.isNaN(value) ? null : value;
2319
- }
2320
- function inferServings(amount, unit) {
2321
- if (SERVING_UNITS.includes(unit.toLowerCase())) {
2322
- return amount;
2323
- }
2324
- return void 0;
2325
- }
2326
- function wordToNumber(word) {
2327
- const normalized = word.toLowerCase();
2328
- if (NUMBER_WORDS2.hasOwnProperty(normalized)) {
2329
- return NUMBER_WORDS2[normalized];
2330
- }
2331
- return null;
2332
- }
2028
+ // src/specVersion.ts
2029
+ var SOUSTACK_SPEC_VERSION = "0.2.1";
2333
2030
 
2334
- exports.extractRecipeFromHTML = extractRecipeFromHTML;
2031
+ exports.SOUSTACK_SPEC_VERSION = SOUSTACK_SPEC_VERSION;
2032
+ exports.detectProfiles = detectProfiles;
2335
2033
  exports.extractSchemaOrgRecipeFromHTML = extractSchemaOrgRecipeFromHTML;
2336
- exports.formatDuration = formatDuration;
2337
- exports.formatYield = formatYield2;
2338
2034
  exports.fromSchemaOrg = fromSchemaOrg;
2339
- exports.normalizeIngredientInput = normalizeIngredientInput;
2340
- exports.normalizeYield = normalizeYield;
2341
- exports.parseDuration = parseDuration;
2342
- exports.parseHumanDuration = parseHumanDuration;
2343
- exports.parseIngredient = parseIngredient;
2344
- exports.parseIngredientLine = parseIngredientLine;
2345
- exports.parseIngredients = parseIngredients;
2346
- exports.parseYield = parseYield2;
2347
2035
  exports.scaleRecipe = scaleRecipe;
2348
- exports.scrapeRecipe = scrapeRecipe;
2349
- exports.smartParseDuration = smartParseDuration;
2350
2036
  exports.toSchemaOrg = toSchemaOrg;
2351
2037
  exports.validateRecipe = validateRecipe;
2352
2038
  //# sourceMappingURL=index.js.map