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