soustack 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,1119 +1,1058 @@
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
- // src/schema.json
134
- var schema_default = {
243
+ // src/schemas/recipe/base.schema.json
244
+ var base_schema_default = {
135
245
  $schema: "http://json-schema.org/draft-07/schema#",
136
- $id: "http://soustack.org/schema/v0.2",
137
- title: "Soustack Recipe Schema v0.2",
138
- description: "A portable, scalable, interoperable recipe format.",
246
+ $id: "http://soustack.org/schema/recipe/base.schema.json",
247
+ title: "Soustack Recipe Base Schema",
248
+ description: "Base document shape for Soustack recipe documents. Profiles and modules build on this baseline.",
139
249
  type: "object",
140
- required: ["name", "ingredients", "instructions"],
250
+ additionalProperties: true,
141
251
  properties: {
142
- id: {
143
- type: "string",
144
- description: "Unique identifier (slug or UUID)"
252
+ "@type": {
253
+ const: "Recipe",
254
+ description: "Document marker for Soustack recipes"
145
255
  },
146
- name: {
256
+ profile: {
147
257
  type: "string",
148
- description: "The title of the recipe"
258
+ description: "Profile identifier applied to this recipe"
149
259
  },
150
- version: {
151
- type: "string",
152
- pattern: "^\\d+\\.\\d+\\.\\d+$",
153
- description: "Semantic versioning (e.g., 1.0.0)"
154
- },
155
- description: {
156
- type: "string"
260
+ modules: {
261
+ type: "array",
262
+ description: "List of module identifiers applied to this recipe",
263
+ items: {
264
+ type: "string"
265
+ }
157
266
  },
158
- category: {
267
+ name: {
159
268
  type: "string",
160
- examples: ["Main Course", "Dessert"]
269
+ description: "Human-readable recipe name"
161
270
  },
162
- tags: {
271
+ ingredients: {
163
272
  type: "array",
164
- items: { type: "string" }
273
+ description: "Ingredients payload; content is validated by profiles/modules"
165
274
  },
166
- image: {
167
- description: "Recipe-level hero image(s)",
168
- anyOf: [
169
- {
170
- type: "string",
171
- format: "uri"
275
+ instructions: {
276
+ type: "array",
277
+ description: "Instruction payload; content is validated by profiles/modules"
278
+ }
279
+ },
280
+ required: ["@type"]
281
+ };
282
+
283
+ // src/schemas/recipe/profiles/core.schema.json
284
+ var core_schema_default = {
285
+ $schema: "http://json-schema.org/draft-07/schema#",
286
+ $id: "http://soustack.org/schema/recipe/profiles/core.schema.json",
287
+ title: "Soustack Recipe Core Profile",
288
+ description: "Core profile that builds on the minimal profile and is intended to be combined with recipe modules.",
289
+ allOf: [
290
+ { $ref: "http://soustack.org/schema/recipe/base.schema.json" },
291
+ {
292
+ type: "object",
293
+ properties: {
294
+ profile: { const: "core" },
295
+ modules: {
296
+ type: "array",
297
+ items: { type: "string" },
298
+ uniqueItems: true,
299
+ default: []
172
300
  },
173
- {
301
+ name: { type: "string", minLength: 1 },
302
+ ingredients: { type: "array", minItems: 1 },
303
+ instructions: { type: "array", minItems: 1 }
304
+ },
305
+ required: ["profile", "name", "ingredients", "instructions"],
306
+ additionalProperties: true
307
+ }
308
+ ]
309
+ };
310
+
311
+ // src/schemas/recipe/profiles/minimal.schema.json
312
+ var minimal_schema_default = {
313
+ $schema: "http://json-schema.org/draft-07/schema#",
314
+ $id: "http://soustack.org/schema/recipe/profiles/minimal.schema.json",
315
+ title: "Soustack Recipe Minimal Profile",
316
+ description: "Minimal profile that ensures the basic Recipe structure is present while allowing modules to extend it.",
317
+ allOf: [
318
+ {
319
+ $ref: "http://soustack.org/schema/recipe/base.schema.json"
320
+ },
321
+ {
322
+ type: "object",
323
+ properties: {
324
+ profile: {
325
+ const: "minimal"
326
+ },
327
+ modules: {
174
328
  type: "array",
175
- minItems: 1,
176
329
  items: {
177
330
  type: "string",
178
- format: "uri"
179
- }
331
+ enum: [
332
+ "attribution@1",
333
+ "taxonomy@1",
334
+ "media@1",
335
+ "nutrition@1",
336
+ "times@1"
337
+ ]
338
+ },
339
+ default: []
340
+ },
341
+ name: {
342
+ type: "string",
343
+ minLength: 1
344
+ },
345
+ ingredients: {
346
+ type: "array",
347
+ minItems: 1
348
+ },
349
+ instructions: {
350
+ type: "array",
351
+ minItems: 1
180
352
  }
181
- ]
182
- },
183
- dateAdded: {
184
- type: "string",
185
- format: "date-time"
353
+ },
354
+ required: [
355
+ "profile",
356
+ "name",
357
+ "ingredients",
358
+ "instructions"
359
+ ],
360
+ additionalProperties: true
361
+ }
362
+ ]
363
+ };
364
+
365
+ // src/schemas/recipe/modules/schedule/1.schema.json
366
+ var schema_default = {
367
+ $schema: "http://json-schema.org/draft-07/schema#",
368
+ $id: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
369
+ title: "Soustack Recipe Module: schedule v1",
370
+ description: "Schema for the schedule module. Enforces bidirectional module gating and restricts usage to the core profile.",
371
+ type: "object",
372
+ properties: {
373
+ profile: { type: "string" },
374
+ modules: {
375
+ type: "array",
376
+ items: { type: "string" }
186
377
  },
187
- source: {
378
+ schedule: {
188
379
  type: "object",
189
380
  properties: {
190
- author: { type: "string" },
191
- url: { type: "string", format: "uri" },
192
- name: { type: "string" },
193
- adapted: { type: "boolean" }
381
+ tasks: { type: "array" }
382
+ },
383
+ additionalProperties: false
384
+ }
385
+ },
386
+ allOf: [
387
+ {
388
+ if: {
389
+ properties: {
390
+ modules: {
391
+ type: "array",
392
+ contains: { const: "schedule@1" }
393
+ }
394
+ }
395
+ },
396
+ then: {
397
+ required: ["schedule", "profile"],
398
+ properties: {
399
+ profile: { const: "core" }
400
+ }
194
401
  }
195
402
  },
196
- yield: {
197
- $ref: "#/definitions/yield"
198
- },
199
- time: {
200
- $ref: "#/definitions/time"
201
- },
202
- equipment: {
203
- type: "array",
204
- items: { $ref: "#/definitions/equipment" }
205
- },
206
- ingredients: {
207
- type: "array",
208
- items: {
209
- anyOf: [
210
- { type: "string" },
211
- { $ref: "#/definitions/ingredient" },
212
- { $ref: "#/definitions/ingredientSubsection" }
213
- ]
403
+ {
404
+ if: {
405
+ required: ["schedule"]
406
+ },
407
+ then: {
408
+ required: ["modules", "profile"],
409
+ properties: {
410
+ modules: {
411
+ type: "array",
412
+ items: { type: "string" },
413
+ contains: { const: "schedule@1" }
414
+ },
415
+ profile: { const: "core" }
416
+ }
214
417
  }
215
- },
216
- instructions: {
418
+ }
419
+ ],
420
+ additionalProperties: true
421
+ };
422
+
423
+ // src/schemas/recipe/modules/nutrition/1.schema.json
424
+ var schema_default2 = {
425
+ $schema: "http://json-schema.org/draft-07/schema#",
426
+ $id: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
427
+ title: "Soustack Recipe Module: nutrition v1",
428
+ description: "Schema for the nutrition module. Keeps nutrition data aligned with module declarations and vice versa.",
429
+ type: "object",
430
+ properties: {
431
+ modules: {
217
432
  type: "array",
218
- items: {
219
- anyOf: [
220
- { type: "string" },
221
- { $ref: "#/definitions/instruction" },
222
- { $ref: "#/definitions/instructionSubsection" }
223
- ]
224
- }
225
- },
226
- storage: {
227
- $ref: "#/definitions/storage"
433
+ items: { type: "string" }
228
434
  },
229
- substitutions: {
230
- type: "array",
231
- items: { $ref: "#/definitions/substitution" }
232
- }
233
- },
234
- definitions: {
235
- yield: {
435
+ nutrition: {
236
436
  type: "object",
237
- required: ["amount", "unit"],
238
437
  properties: {
239
- amount: { type: "number" },
240
- unit: { type: "string" },
241
- servings: { type: "number" },
242
- description: { type: "string" }
438
+ calories: { type: "number" },
439
+ protein_g: { type: "number" }
440
+ },
441
+ additionalProperties: false
442
+ }
443
+ },
444
+ allOf: [
445
+ {
446
+ if: {
447
+ properties: {
448
+ modules: {
449
+ type: "array",
450
+ contains: { const: "nutrition@1" }
451
+ }
452
+ }
453
+ },
454
+ then: {
455
+ required: ["nutrition"]
243
456
  }
244
457
  },
245
- time: {
246
- oneOf: [
247
- {
248
- type: "object",
249
- properties: {
250
- prep: { type: "number" },
251
- active: { type: "number" },
252
- passive: { type: "number" },
253
- total: { type: "number" }
254
- }
255
- },
256
- {
257
- type: "object",
258
- properties: {
259
- prepTime: { type: "string" },
260
- cookTime: { type: "string" }
458
+ {
459
+ if: {
460
+ required: ["nutrition"]
461
+ },
462
+ then: {
463
+ required: ["modules"],
464
+ properties: {
465
+ modules: {
466
+ type: "array",
467
+ items: { type: "string" },
468
+ contains: { const: "nutrition@1" }
261
469
  }
262
470
  }
263
- ]
264
- },
265
- quantity: {
266
- type: "object",
267
- required: ["amount"],
268
- properties: {
269
- amount: { type: "number" },
270
- unit: { type: ["string", "null"] }
271
471
  }
472
+ }
473
+ ],
474
+ additionalProperties: true
475
+ };
476
+
477
+ // src/schemas/recipe/modules/attribution/1.schema.json
478
+ var schema_default3 = {
479
+ $schema: "http://json-schema.org/draft-07/schema#",
480
+ $id: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
481
+ title: "Soustack Recipe Module: attribution v1",
482
+ description: "Schema for the attribution module. Ensures namespace data is present when the module is enabled and vice versa.",
483
+ type: "object",
484
+ properties: {
485
+ modules: {
486
+ type: "array",
487
+ items: { type: "string" }
272
488
  },
273
- scaling: {
489
+ attribution: {
274
490
  type: "object",
275
- required: ["type"],
276
491
  properties: {
277
- type: {
278
- type: "string",
279
- enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
280
- },
281
- factor: { type: "number" },
282
- referenceId: { type: "string" },
283
- roundTo: { type: "number" },
284
- min: { type: "number" },
285
- max: { type: "number" }
492
+ url: { type: "string" },
493
+ author: { type: "string" },
494
+ datePublished: { type: "string" }
286
495
  },
496
+ additionalProperties: false
497
+ }
498
+ },
499
+ allOf: [
500
+ {
287
501
  if: {
288
- properties: { type: { const: "bakers_percentage" } }
502
+ properties: {
503
+ modules: {
504
+ type: "array",
505
+ contains: { const: "attribution@1" }
506
+ }
507
+ }
289
508
  },
290
509
  then: {
291
- required: ["referenceId"]
510
+ required: ["attribution"]
292
511
  }
293
512
  },
294
- ingredient: {
295
- type: "object",
296
- required: ["item"],
297
- properties: {
298
- id: { type: "string" },
299
- item: { type: "string" },
300
- quantity: { $ref: "#/definitions/quantity" },
301
- name: { type: "string" },
302
- aisle: { type: "string" },
303
- prep: { type: "string" },
304
- prepAction: { type: "string" },
305
- prepTime: { type: "number" },
306
- destination: { type: "string" },
307
- scaling: { $ref: "#/definitions/scaling" },
308
- critical: { type: "boolean" },
309
- optional: { type: "boolean" },
310
- notes: { type: "string" }
311
- }
312
- },
313
- ingredientSubsection: {
314
- type: "object",
315
- required: ["subsection", "items"],
316
- properties: {
317
- subsection: { type: "string" },
318
- items: {
319
- type: "array",
320
- items: { $ref: "#/definitions/ingredient" }
513
+ {
514
+ if: {
515
+ required: ["attribution"]
516
+ },
517
+ then: {
518
+ required: ["modules"],
519
+ properties: {
520
+ modules: {
521
+ type: "array",
522
+ items: { type: "string" },
523
+ contains: { const: "attribution@1" }
524
+ }
321
525
  }
322
526
  }
527
+ }
528
+ ],
529
+ additionalProperties: true
530
+ };
531
+
532
+ // src/schemas/recipe/modules/taxonomy/1.schema.json
533
+ var schema_default4 = {
534
+ $schema: "http://json-schema.org/draft-07/schema#",
535
+ $id: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
536
+ title: "Soustack Recipe Module: taxonomy v1",
537
+ description: "Schema for the taxonomy module. Enforces keyword and categorization data when enabled and ensures module declaration accompanies the namespace block.",
538
+ type: "object",
539
+ properties: {
540
+ modules: {
541
+ type: "array",
542
+ items: { type: "string" }
323
543
  },
324
- equipment: {
544
+ taxonomy: {
325
545
  type: "object",
326
- required: ["name"],
327
546
  properties: {
328
- id: { type: "string" },
329
- name: { type: "string" },
330
- required: { type: "boolean" },
331
- label: { type: "string" },
332
- capacity: { $ref: "#/definitions/quantity" },
333
- scalingLimit: { type: "number" },
334
- alternatives: {
335
- type: "array",
336
- items: { type: "string" }
547
+ keywords: { type: "array", items: { type: "string" } },
548
+ category: { type: "string" },
549
+ cuisine: { type: "string" }
550
+ },
551
+ additionalProperties: false
552
+ }
553
+ },
554
+ allOf: [
555
+ {
556
+ if: {
557
+ properties: {
558
+ modules: {
559
+ type: "array",
560
+ contains: { const: "taxonomy@1" }
561
+ }
337
562
  }
563
+ },
564
+ then: {
565
+ required: ["taxonomy"]
338
566
  }
339
567
  },
340
- instruction: {
341
- type: "object",
342
- required: ["text"],
343
- properties: {
344
- id: { type: "string" },
345
- text: { type: "string" },
346
- image: {
347
- type: "string",
348
- format: "uri",
349
- description: "Optional image that illustrates this instruction"
350
- },
351
- destination: { type: "string" },
352
- dependsOn: {
353
- type: "array",
354
- items: { type: "string" }
355
- },
356
- inputs: {
357
- type: "array",
358
- items: { type: "string" }
359
- },
360
- timing: {
361
- type: "object",
362
- required: ["duration", "type"],
363
- properties: {
364
- duration: { type: "number" },
365
- type: { type: "string", enum: ["active", "passive"] },
366
- scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
568
+ {
569
+ if: {
570
+ required: ["taxonomy"]
571
+ },
572
+ then: {
573
+ required: ["modules"],
574
+ properties: {
575
+ modules: {
576
+ type: "array",
577
+ items: { type: "string" },
578
+ contains: { const: "taxonomy@1" }
367
579
  }
368
580
  }
369
581
  }
582
+ }
583
+ ],
584
+ additionalProperties: true
585
+ };
586
+
587
+ // src/schemas/recipe/modules/media/1.schema.json
588
+ var schema_default5 = {
589
+ $schema: "http://json-schema.org/draft-07/schema#",
590
+ $id: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
591
+ title: "Soustack Recipe Module: media v1",
592
+ description: "Schema for the media module. Guards media blocks based on module activation and ensures declarations accompany payloads.",
593
+ type: "object",
594
+ properties: {
595
+ modules: {
596
+ type: "array",
597
+ items: { type: "string" }
370
598
  },
371
- instructionSubsection: {
599
+ media: {
372
600
  type: "object",
373
- required: ["subsection", "items"],
374
601
  properties: {
375
- subsection: { type: "string" },
376
- items: {
377
- type: "array",
378
- items: {
379
- anyOf: [
380
- { type: "string" },
381
- { $ref: "#/definitions/instruction" }
382
- ]
602
+ images: { type: "array", items: { type: "string" } },
603
+ videos: { type: "array", items: { type: "string" } }
604
+ },
605
+ additionalProperties: false
606
+ }
607
+ },
608
+ allOf: [
609
+ {
610
+ if: {
611
+ properties: {
612
+ modules: {
613
+ type: "array",
614
+ contains: { const: "media@1" }
383
615
  }
384
616
  }
617
+ },
618
+ then: {
619
+ required: ["media"]
385
620
  }
386
621
  },
387
- storage: {
388
- type: "object",
389
- properties: {
390
- roomTemp: { $ref: "#/definitions/storageMethod" },
391
- refrigerated: { $ref: "#/definitions/storageMethod" },
392
- frozen: {
393
- allOf: [
394
- { $ref: "#/definitions/storageMethod" },
395
- {
396
- type: "object",
397
- properties: { thawing: { type: "string" } }
398
- }
399
- ]
400
- },
401
- reheating: { type: "string" },
402
- makeAhead: {
403
- type: "array",
404
- items: {
405
- allOf: [
406
- { $ref: "#/definitions/storageMethod" },
407
- {
408
- type: "object",
409
- required: ["component", "storage"],
410
- properties: {
411
- component: { type: "string" },
412
- storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
413
- }
414
- }
415
- ]
622
+ {
623
+ if: {
624
+ required: ["media"]
625
+ },
626
+ then: {
627
+ required: ["modules"],
628
+ properties: {
629
+ modules: {
630
+ type: "array",
631
+ items: { type: "string" },
632
+ contains: { const: "media@1" }
416
633
  }
417
634
  }
418
635
  }
636
+ }
637
+ ],
638
+ additionalProperties: true
639
+ };
640
+
641
+ // src/schemas/recipe/modules/times/1.schema.json
642
+ var schema_default6 = {
643
+ $schema: "http://json-schema.org/draft-07/schema#",
644
+ $id: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
645
+ title: "Soustack Recipe Module: times v1",
646
+ description: "Schema for the times module. Maintains alignment between module declarations and timing payloads.",
647
+ type: "object",
648
+ properties: {
649
+ modules: {
650
+ type: "array",
651
+ items: { type: "string" }
419
652
  },
420
- storageMethod: {
653
+ times: {
421
654
  type: "object",
422
- required: ["duration"],
423
655
  properties: {
424
- duration: { type: "string", pattern: "^P" },
425
- method: { type: "string" },
426
- notes: { type: "string" }
656
+ prepMinutes: { type: "number" },
657
+ cookMinutes: { type: "number" },
658
+ totalMinutes: { type: "number" }
659
+ },
660
+ additionalProperties: false
661
+ }
662
+ },
663
+ allOf: [
664
+ {
665
+ if: {
666
+ properties: {
667
+ modules: {
668
+ type: "array",
669
+ contains: { const: "times@1" }
670
+ }
671
+ }
672
+ },
673
+ then: {
674
+ required: ["times"]
427
675
  }
428
676
  },
429
- substitution: {
430
- type: "object",
431
- required: ["ingredient"],
432
- properties: {
433
- ingredient: { type: "string" },
434
- critical: { type: "boolean" },
435
- notes: { type: "string" },
436
- alternatives: {
437
- type: "array",
438
- items: {
439
- type: "object",
440
- required: ["name", "ratio"],
441
- properties: {
442
- name: { type: "string" },
443
- ratio: { type: "string" },
444
- notes: { type: "string" },
445
- impact: { type: "string" },
446
- dietary: {
447
- type: "array",
448
- items: { type: "string" }
449
- }
450
- }
677
+ {
678
+ if: {
679
+ required: ["times"]
680
+ },
681
+ then: {
682
+ required: ["modules"],
683
+ properties: {
684
+ modules: {
685
+ type: "array",
686
+ items: { type: "string" },
687
+ contains: { const: "times@1" }
451
688
  }
452
689
  }
453
690
  }
454
691
  }
455
- }
692
+ ],
693
+ additionalProperties: true
456
694
  };
457
695
 
458
696
  // src/validator.ts
459
- var ajv = new Ajv();
460
- addFormats(ajv);
461
- var validate = ajv.compile(schema_default);
462
- function validateRecipe(data) {
463
- const isValid = validate(data);
464
- if (!isValid) {
465
- throw new Error(JSON.stringify(validate.errors, null, 2));
466
- }
467
- return true;
468
- }
469
-
470
- // src/parsers/ingredient.ts
471
- var FRACTION_DECIMALS = {
472
- "\xBD": 0.5,
473
- "\u2153": 1 / 3,
474
- "\u2154": 2 / 3,
475
- "\xBC": 0.25,
476
- "\xBE": 0.75,
477
- "\u2155": 0.2,
478
- "\u2156": 0.4,
479
- "\u2157": 0.6,
480
- "\u2158": 0.8,
481
- "\u2159": 1 / 6,
482
- "\u215A": 5 / 6,
483
- "\u215B": 0.125,
484
- "\u215C": 0.375,
485
- "\u215D": 0.625,
486
- "\u215E": 0.875
487
- };
488
- var NUMBER_WORDS = {
489
- zero: 0,
490
- one: 1,
491
- two: 2,
492
- three: 3,
493
- four: 4,
494
- five: 5,
495
- six: 6,
496
- seven: 7,
497
- eight: 8,
498
- nine: 9,
499
- ten: 10,
500
- eleven: 11,
501
- twelve: 12,
502
- thirteen: 13,
503
- fourteen: 14,
504
- fifteen: 15,
505
- sixteen: 16,
506
- seventeen: 17,
507
- eighteen: 18,
508
- nineteen: 19,
509
- twenty: 20,
510
- thirty: 30,
511
- forty: 40,
512
- fifty: 50,
513
- sixty: 60,
514
- seventy: 70,
515
- eighty: 80,
516
- ninety: 90,
517
- hundred: 100,
518
- half: 0.5,
519
- quarter: 0.25
520
- };
521
- var UNIT_SYNONYMS = {
522
- cup: "cup",
523
- cups: "cup",
524
- c: "cup",
525
- tbsp: "tbsp",
526
- tablespoon: "tbsp",
527
- tablespoons: "tbsp",
528
- tbs: "tbsp",
529
- tsp: "tsp",
530
- teaspoon: "tsp",
531
- teaspoons: "tsp",
532
- t: "tsp",
533
- gram: "g",
534
- grams: "g",
535
- g: "g",
536
- kilogram: "kg",
537
- kilograms: "kg",
538
- kg: "kg",
539
- milliliter: "ml",
540
- milliliters: "ml",
541
- ml: "ml",
542
- liter: "l",
543
- liters: "l",
544
- l: "l",
545
- ounce: "oz",
546
- ounces: "oz",
547
- oz: "oz",
548
- pound: "lb",
549
- pounds: "lb",
550
- lb: "lb",
551
- lbs: "lb",
552
- pint: "pint",
553
- pints: "pint",
554
- quart: "quart",
555
- quarts: "quart",
556
- stick: "stick",
557
- sticks: "stick",
558
- dash: "dash",
559
- pinches: "pinch",
560
- pinch: "pinch"
561
- };
562
- var PREP_PHRASES = [
563
- "diced",
564
- "finely diced",
565
- "roughly diced",
566
- "minced",
567
- "finely minced",
568
- "chopped",
569
- "finely chopped",
570
- "roughly chopped",
571
- "sliced",
572
- "thinly sliced",
573
- "thickly sliced",
574
- "grated",
575
- "finely grated",
576
- "zested",
577
- "sifted",
578
- "softened",
579
- "at room temperature",
580
- "room temperature",
581
- "room temp",
582
- "melted",
583
- "toasted",
584
- "drained",
585
- "drained and rinsed",
586
- "beaten",
587
- "divided",
588
- "cut into cubes",
589
- "cut into pieces",
590
- "cut into strips",
591
- "cut into chunks",
592
- "cut into bite-size pieces"
593
- ].map((value) => value.toLowerCase());
594
- var COUNT_DESCRIPTORS = /* @__PURE__ */ new Set([
595
- "clove",
596
- "cloves",
597
- "can",
598
- "cans",
599
- "stick",
600
- "sticks",
601
- "sprig",
602
- "sprigs",
603
- "bunch",
604
- "bunches",
605
- "slice",
606
- "slices",
607
- "package",
608
- "packages"
609
- ]);
610
- var DESCRIPTOR_NOTE_SET = /* @__PURE__ */ new Set(["can", "cans", "jar", "jars", "package", "packages", "bottle", "bottles"]);
611
- var WEIGHT_PRIORITY_UNITS = /* @__PURE__ */ new Set(["g", "kg", "oz", "lb", "ml", "l"]);
612
- var SPICE_KEYWORDS = [
613
- "salt",
614
- "pepper",
615
- "paprika",
616
- "cumin",
617
- "coriander",
618
- "turmeric",
619
- "chili powder",
620
- "garlic powder",
621
- "onion powder",
622
- "cayenne",
623
- "cinnamon",
624
- "nutmeg",
625
- "allspice",
626
- "ginger",
627
- "oregano",
628
- "thyme",
629
- "rosemary",
630
- "basil",
631
- "sage",
632
- "clove",
633
- "spice",
634
- "seasoning"
635
- ];
636
- var PURPOSE_KEYWORDS = ["frying", "greasing", "drizzling", "garnish", "serving", "brushing"];
637
- var RANGE_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(?:\s*(?:-|to)\s*((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?))/i;
638
- var NUMBER_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)/i;
639
- var QUALIFIER_REGEX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
640
- var FLAVOR_NOTE_REGEX = /\b(to taste|as needed|as necessary)\b/gi;
641
- var VAGUE_QUANTITY_PATTERNS = [
642
- { regex: /^(a\s+pinch|pinch)\b/i, note: "a pinch" },
643
- { regex: /^(a\s+handful|handful)\b/i, note: "a handful" },
644
- { regex: /^(a\s+dash|dash)\b/i, note: "a dash" },
645
- { regex: /^(a\s+sprinkle|sprinkle)\b/i, note: "a sprinkle" },
646
- { regex: /^(some)\b/i, note: "some" },
647
- { regex: /^(few\s+sprigs)/i, note: "few sprigs" },
648
- { regex: /^(a\s+few|few)\b/i, note: "a few" },
649
- { regex: /^(several)\b/i, note: "several" }
650
- ];
651
- var JUICE_PREFIXES = ["juice of", "zest of"];
652
- function normalizeIngredientInput(input) {
653
- if (!input) return "";
654
- let result = input.replace(/\u00A0/g, " ").trim();
655
- result = replaceDashes(result);
656
- result = replaceUnicodeFractions(result);
657
- result = replaceNumberWords(result);
658
- result = result.replace(/\s+/g, " ").trim();
659
- return result;
660
- }
661
- function parseIngredient(text) {
662
- const original = text ?? "";
663
- const normalized = normalizeIngredientInput(original);
664
- if (!normalized) {
665
- return {
666
- item: original,
667
- scaling: { type: "linear" }
668
- };
697
+ var CANONICAL_BASE_SCHEMA_ID = base_schema_default.$id || "http://soustack.org/schema/recipe/base.schema.json";
698
+ var canonicalProfileId = (profile) => {
699
+ if (profile === "minimal") {
700
+ return minimal_schema_default.$id;
669
701
  }
670
- let working = normalized;
671
- const notes = [];
672
- let optional = false;
673
- if (/\boptional\b/i.test(working)) {
674
- optional = true;
675
- working = working.replace(/\(?\s*optional\s*\)?/gi, "").trim();
676
- working = working.replace(/\(\s*\)/g, " ").trim();
677
- }
678
- const flavorExtraction = extractFlavorNotes(working);
679
- working = flavorExtraction.cleaned;
680
- notes.push(...flavorExtraction.notes);
681
- const parenthetical = extractParentheticals(working);
682
- working = parenthetical.cleaned;
683
- notes.push(...parenthetical.notes);
684
- optional = optional || parenthetical.optional;
685
- const purposeExtraction = extractPurposeNotes(working);
686
- working = purposeExtraction.cleaned;
687
- notes.push(...purposeExtraction.notes);
688
- const juiceExtraction = extractJuicePhrase(working);
689
- if (juiceExtraction) {
690
- working = juiceExtraction.cleaned;
691
- notes.push(juiceExtraction.note);
692
- }
693
- const vagueQuantity = extractVagueQuantity(working);
694
- let quantityResult;
695
- if (vagueQuantity) {
696
- notes.push(vagueQuantity.note);
697
- quantityResult = {
698
- amount: null,
699
- unit: null,
700
- descriptor: void 0,
701
- remainder: vagueQuantity.remainder,
702
- notes: [],
703
- originalAmount: null
704
- };
705
- } else {
706
- quantityResult = extractQuantity(working);
707
- }
708
- working = quantityResult.remainder;
709
- const { quantity, usedParenthetical } = mergeQuantities(quantityResult, parenthetical.measurement);
710
- if (usedParenthetical && quantityResult.originalAmount !== null && quantityResult.originalAmount > 1 && quantityResult.descriptor && DESCRIPTOR_NOTE_SET.has(quantityResult.descriptor.toLowerCase())) {
711
- notes.push(formatCountNote(quantityResult.originalAmount, quantityResult.descriptor));
712
- }
713
- notes.push(...quantityResult.notes);
714
- working = working.replace(/^[,.\s-]+/, "").trim();
715
- working = working.replace(/^of\s+/i, "").trim();
716
- if (quantityResult.descriptor && /^cans?$/i.test(quantityResult.descriptor) && working && !/^canned\b/i.test(working)) {
717
- working = `canned ${working}`.trim();
718
- }
719
- const nameExtraction = extractNameAndPrep(working);
720
- notes.push(...nameExtraction.notes);
721
- const name = nameExtraction.name || void 0;
722
- const scaling = inferScaling(
723
- name,
724
- quantity.unit,
725
- quantity.amount,
726
- notes,
727
- quantityResult.descriptor
728
- );
729
- const mergedNotes = formatNotes(notes);
730
- const parsed = {
731
- item: original,
732
- quantity,
733
- ...name ? { name } : {},
734
- ...nameExtraction.prep ? { prep: nameExtraction.prep } : {},
735
- ...optional ? { optional: true } : {},
736
- scaling
737
- };
738
- if (mergedNotes) {
739
- parsed.notes = mergedNotes;
702
+ if (profile === "core") {
703
+ return core_schema_default.$id;
740
704
  }
741
- return parsed;
742
- }
743
- function parseIngredientLine(text) {
744
- return parseIngredient(text);
745
- }
746
- function parseIngredients(texts) {
747
- if (!Array.isArray(texts)) return [];
748
- return texts.map((item) => typeof item === "string" ? item : String(item ?? "")).map((entry) => parseIngredient(entry));
749
- }
750
- function replaceDashes(value) {
751
- return value.replace(/[\u2012\u2013\u2014\u2212]/g, "-");
752
- }
753
- function replaceUnicodeFractions(value) {
754
- return value.replace(/(\d+)?(?:\s+)?([½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])/g, (_match, whole, fraction) => {
755
- const fractionValue = FRACTION_DECIMALS[fraction];
756
- if (fractionValue === void 0) return _match;
757
- const base = whole ? parseInt(whole, 10) : 0;
758
- const combined = base + fractionValue;
759
- return formatDecimal(combined);
760
- });
761
- }
762
- function replaceNumberWords(value) {
763
- return value.replace(
764
- /\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,
765
- (match, word, hyphenPart) => {
766
- const lower = word.toLowerCase();
767
- const baseValue = NUMBER_WORDS[lower];
768
- if (baseValue === void 0) return match;
769
- if (!hyphenPart) {
770
- return formatDecimal(baseValue);
771
- }
772
- const hyphenValue = NUMBER_WORDS[hyphenPart.toLowerCase()];
773
- if (hyphenValue === void 0) {
774
- return formatDecimal(baseValue);
775
- }
776
- return formatDecimal(baseValue + hyphenValue);
777
- }
778
- );
779
- }
780
- function formatDecimal(value) {
781
- if (Number.isInteger(value)) {
782
- return value.toString();
783
- }
784
- return parseFloat(value.toFixed(3)).toString().replace(/\.0+$/, "");
785
- }
786
- function extractFlavorNotes(value) {
787
- const notes = [];
788
- const cleaned = value.replace(FLAVOR_NOTE_REGEX, (_, phrase) => {
789
- notes.push(phrase.toLowerCase());
790
- return "";
791
- });
792
- return {
793
- cleaned: cleaned.replace(/\s+/g, " ").trim(),
794
- notes
795
- };
796
- }
797
- function extractPurposeNotes(value) {
798
- const notes = [];
799
- let working = value.trim();
800
- let match = working.match(/\bfor\s+(frying|greasing|drizzling|garnish|serving|brushing)\b\.?$/i);
801
- if (match) {
802
- notes.push(`for ${match[1].toLowerCase()}`);
803
- working = working.slice(0, match.index).trim();
804
- }
805
- return { cleaned: working, notes };
806
- }
807
- function extractJuicePhrase(value) {
808
- const lower = value.toLowerCase();
809
- for (const prefix of JUICE_PREFIXES) {
810
- if (lower.startsWith(prefix)) {
811
- const remainder = value.slice(prefix.length).trim();
812
- if (!remainder) break;
813
- const cleanedSource = remainder.replace(/^of\s+/i, "").trim();
814
- if (!cleanedSource) break;
815
- const sourceForName = cleanedSource.replace(
816
- /^(?:\d+(?:\.\d+)?|\d+\s+\d+\/\d+|\d+\/\d+|one|two|three|four|five|six|seven|eight|nine|ten|a|an)\s+/i,
817
- ""
818
- ).replace(/^(?:large|small|medium)\s+/i, "").trim();
819
- const baseName = sourceForName || cleanedSource;
820
- const singular = singularize(baseName);
821
- const suffix = prefix.startsWith("zest") ? "zest" : "juice";
822
- return {
823
- cleaned: `${singular} ${suffix}`.trim(),
824
- note: `from ${cleanedSource}`
825
- };
826
- }
827
- }
828
- return void 0;
829
- }
830
- function extractVagueQuantity(value) {
831
- for (const pattern of VAGUE_QUANTITY_PATTERNS) {
832
- const match = value.match(pattern.regex);
833
- if (match) {
834
- let remainder = value.slice(match[0].length).trim();
835
- remainder = remainder.replace(/^of\s+/i, "").trim();
836
- return {
837
- remainder,
838
- note: pattern.note
839
- };
840
- }
841
- }
842
- return void 0;
843
- }
844
- function extractParentheticals(value) {
845
- let optional = false;
846
- let measurement;
847
- const notes = [];
848
- const cleaned = value.replace(/\(([^)]+)\)/g, (_match, group) => {
849
- const trimmed = String(group).trim();
850
- if (!trimmed) return "";
851
- if (/optional/i.test(trimmed)) {
852
- optional = true;
853
- return "";
854
- }
855
- const maybeMeasurement = parseMeasurement(trimmed);
856
- if (maybeMeasurement && !measurement) {
857
- measurement = maybeMeasurement;
858
- return "";
859
- }
860
- notes.push(trimmed);
861
- return "";
862
- });
863
- return {
864
- cleaned: cleaned.replace(/\s+/g, " ").trim(),
865
- measurement,
866
- notes,
867
- optional
705
+ throw new Error(`Unknown profile: ${profile}`);
706
+ };
707
+ var moduleIdToSchemaRef = (moduleId) => {
708
+ const match = moduleId.match(/^([a-z0-9_-]+)@(\d+(?:\.\d+)*)$/i);
709
+ if (!match) {
710
+ throw new Error(`Invalid module identifier '${moduleId}'. Expected <name>@<version>.`);
711
+ }
712
+ const [, name, version] = match;
713
+ const moduleSchemas2 = {
714
+ "schedule@1": schema_default,
715
+ "nutrition@1": schema_default2,
716
+ "attribution@1": schema_default3,
717
+ "taxonomy@1": schema_default4,
718
+ "media@1": schema_default5,
719
+ "times@1": schema_default6
868
720
  };
869
- }
870
- function parseMeasurement(value) {
871
- const stripped = value.replace(/^(about|around|approximately|approx\.?|roughly)\s+/i, "").trim();
872
- const match = stripped.match(
873
- /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(?:\s*)([a-zA-Z]+)?$/
874
- );
875
- if (!match) return void 0;
876
- const amount = parseNumber(match[1]);
877
- if (amount === null) return void 0;
878
- const unit = match[2] ? normalizeUnit(match[2]) ?? match[2].toLowerCase() : null;
879
- return { amount, unit };
880
- }
881
- function extractQuantity(value) {
882
- let working = value.trim();
883
- const notes = [];
884
- let amount = null;
885
- let originalAmount = null;
886
- let unit = null;
887
- let descriptor;
888
- while (QUALIFIER_REGEX.test(working)) {
889
- working = working.replace(QUALIFIER_REGEX, "").trim();
890
- }
891
- const rangeMatch = working.match(RANGE_REGEX);
892
- if (rangeMatch) {
893
- amount = parseNumber(rangeMatch[1]);
894
- originalAmount = amount;
895
- const rangeText = rangeMatch[0].trim();
896
- const afterRange = working.slice(rangeMatch[0].length).trim();
897
- const descriptorMatch = afterRange.match(/^([a-zA-Z]+)/);
898
- if (descriptorMatch && COUNT_DESCRIPTORS.has(descriptorMatch[1].toLowerCase())) {
899
- notes.push(`${rangeText} ${descriptorMatch[1]}`);
721
+ const schema = moduleSchemas2[moduleId];
722
+ if (schema && schema.$id) {
723
+ return schema.$id;
724
+ }
725
+ return `https://soustack.org/schemas/recipe/modules/${name}/${version}.schema.json`;
726
+ };
727
+ var profileSchemas = {
728
+ minimal: minimal_schema_default,
729
+ core: core_schema_default
730
+ };
731
+ var moduleSchemas = {
732
+ "schedule@1": schema_default,
733
+ "nutrition@1": schema_default2,
734
+ "attribution@1": schema_default3,
735
+ "taxonomy@1": schema_default4,
736
+ "media@1": schema_default5,
737
+ "times@1": schema_default6
738
+ };
739
+ var validationContexts = /* @__PURE__ */ new Map();
740
+ function createContext(collectAllErrors) {
741
+ const ajv = new Ajv({ strict: false, allErrors: collectAllErrors });
742
+ addFormats(ajv);
743
+ const addSchemaWithAlias = (schema, alias) => {
744
+ if (!schema) return;
745
+ const schemaId = schema.$id || alias;
746
+ if (schemaId) {
747
+ ajv.addSchema(schema, schemaId);
900
748
  } else {
901
- notes.push(rangeText);
902
- }
903
- working = afterRange;
904
- } else {
905
- const numberMatch = working.match(NUMBER_REGEX);
906
- if (numberMatch) {
907
- amount = parseNumber(numberMatch[1]);
908
- originalAmount = amount;
909
- working = working.slice(numberMatch[0].length).trim();
910
- }
911
- }
912
- if (working) {
913
- const unitMatch = working.match(/^([a-zA-Z]+)\b/);
914
- if (unitMatch) {
915
- const normalized = normalizeUnit(unitMatch[1]);
916
- if (normalized) {
917
- unit = normalized;
918
- working = working.slice(unitMatch[0].length).trim();
919
- } else if (COUNT_DESCRIPTORS.has(unitMatch[1].toLowerCase())) {
920
- descriptor = unitMatch[1];
921
- working = working.slice(unitMatch[0].length).trim();
922
- }
749
+ ajv.addSchema(schema);
923
750
  }
924
- }
925
- return {
926
- amount,
927
- unit,
928
- descriptor,
929
- remainder: working.trim(),
930
- notes,
931
- originalAmount
932
751
  };
752
+ addSchemaWithAlias(base_schema_default, CANONICAL_BASE_SCHEMA_ID);
753
+ Object.entries(profileSchemas).forEach(([name, schema]) => {
754
+ addSchemaWithAlias(schema, canonicalProfileId(name));
755
+ });
756
+ Object.entries(moduleSchemas).forEach(([moduleId, schema]) => {
757
+ addSchemaWithAlias(schema, moduleIdToSchemaRef(moduleId));
758
+ });
759
+ return { ajv, validators: /* @__PURE__ */ new Map() };
933
760
  }
934
- function parseNumber(value) {
935
- const trimmed = value.trim();
936
- if (!trimmed) return null;
937
- if (/^\d+\s+\d+\/\d+$/.test(trimmed)) {
938
- const [whole, fraction] = trimmed.split(/\s+/);
939
- return parseInt(whole, 10) + parseFraction(fraction);
940
- }
941
- if (/^\d+\/\d+$/.test(trimmed)) {
942
- return parseFraction(trimmed);
761
+ function getContext(collectAllErrors) {
762
+ if (!validationContexts.has(collectAllErrors)) {
763
+ validationContexts.set(collectAllErrors, createContext(collectAllErrors));
943
764
  }
944
- const parsed = Number(trimmed);
945
- return Number.isNaN(parsed) ? null : parsed;
765
+ return validationContexts.get(collectAllErrors);
946
766
  }
947
- function parseFraction(value) {
948
- const [numerator, denominator] = value.split("/").map(Number);
949
- if (!denominator) return numerator;
950
- return numerator / denominator;
951
- }
952
- function normalizeUnit(raw) {
953
- const lower = raw.toLowerCase();
954
- if (UNIT_SYNONYMS[lower]) {
955
- return UNIT_SYNONYMS[lower];
767
+ function cloneRecipe(recipe) {
768
+ if (typeof structuredClone === "function") {
769
+ return structuredClone(recipe);
956
770
  }
957
- if (raw === "T") return "tbsp";
958
- if (raw === "t") return "tsp";
959
- if (raw === "C") return "cup";
960
- return null;
771
+ return JSON.parse(JSON.stringify(recipe));
961
772
  }
962
- function mergeQuantities(extracted, measurement) {
963
- const quantity = {
964
- amount: extracted.amount ?? null,
965
- unit: extracted.unit ?? null
966
- };
967
- if (!measurement) {
968
- return { quantity, usedParenthetical: false };
969
- }
970
- const measurementUnit = measurement.unit?.toLowerCase() ?? null;
971
- const shouldPrefer = !quantity.unit || measurementUnit !== null && WEIGHT_PRIORITY_UNITS.has(measurementUnit);
972
- if (shouldPrefer) {
973
- return {
974
- quantity: {
975
- amount: measurement.amount,
976
- unit: measurement.unit ?? null
977
- },
978
- usedParenthetical: true
979
- };
773
+ function detectProfileFromSchema(schemaRef) {
774
+ if (!schemaRef) return void 0;
775
+ const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
776
+ if (match) {
777
+ const profile = match[1].toLowerCase();
778
+ if (profile in profileSchemas) return profile;
980
779
  }
981
- return { quantity, usedParenthetical: false };
780
+ return void 0;
982
781
  }
983
- function extractNameAndPrep(value) {
984
- let working = value.trim();
985
- const notes = [];
986
- let prep;
987
- const lastComma = working.lastIndexOf(",");
988
- if (lastComma >= 0) {
989
- const trailing = working.slice(lastComma + 1).trim();
990
- if (isPrepPhrase(trailing)) {
991
- prep = trailing;
992
- working = working.slice(0, lastComma).trim();
782
+ function resolveSchemaRef(inputSchema, requestedSchema) {
783
+ if (typeof requestedSchema === "string") return requestedSchema;
784
+ if (typeof inputSchema !== "string") return void 0;
785
+ return detectProfileFromSchema(inputSchema) ? inputSchema : void 0;
786
+ }
787
+ function inferModulesFromPayload(recipe) {
788
+ const inferred = [];
789
+ const payloadToModule = {
790
+ attribution: "attribution@1",
791
+ taxonomy: "taxonomy@1",
792
+ media: "media@1",
793
+ times: "times@1",
794
+ nutrition: "nutrition@1",
795
+ schedule: "schedule@1"
796
+ };
797
+ for (const [field, moduleId] of Object.entries(payloadToModule)) {
798
+ if (recipe && typeof recipe === "object" && field in recipe && recipe[field] != null) {
799
+ const payload = recipe[field];
800
+ if (typeof payload === "object" && !Array.isArray(payload)) {
801
+ if (Object.keys(payload).length > 0) {
802
+ inferred.push(moduleId);
803
+ }
804
+ } else if (Array.isArray(payload) && payload.length > 0) {
805
+ inferred.push(moduleId);
806
+ } else if (payload !== null && payload !== void 0) {
807
+ inferred.push(moduleId);
808
+ }
993
809
  }
994
810
  }
995
- working = working.replace(/^[,.\s-]+/, "").trim();
996
- working = working.replace(/^of\s+/i, "").trim();
997
- if (!working) {
998
- return { name: void 0, prep, notes };
999
- }
1000
- let name = cleanupIngredientName(working);
1001
- return {
1002
- name: name || void 0,
1003
- prep,
1004
- notes
811
+ return inferred;
812
+ }
813
+ function getCombinedValidator(profile, modules, recipe, context) {
814
+ const inferredModules = inferModulesFromPayload(recipe);
815
+ const allModules = /* @__PURE__ */ new Set([...modules, ...inferredModules]);
816
+ const sortedModules = Array.from(allModules).sort();
817
+ const cacheKey = `${profile}::${sortedModules.join(",")}`;
818
+ const cached = context.validators.get(cacheKey);
819
+ if (cached) return cached;
820
+ if (!profileSchemas[profile]) {
821
+ throw new Error(`Unknown Soustack profile: ${profile}`);
822
+ }
823
+ const schema = {
824
+ $id: `urn:soustack:recipe:${cacheKey}`,
825
+ allOf: [
826
+ { $ref: CANONICAL_BASE_SCHEMA_ID },
827
+ { $ref: canonicalProfileId(profile) },
828
+ ...sortedModules.map((moduleId) => ({ $ref: moduleIdToSchemaRef(moduleId) }))
829
+ ]
1005
830
  };
1006
- }
1007
- function cleanupIngredientName(value) {
1008
- let result = value.trim();
1009
- if (/^cans?\b/i.test(result)) {
1010
- result = result.replace(/^cans?\b/i, "canned").trim();
1011
- }
1012
- let changed = true;
1013
- while (changed) {
1014
- changed = false;
1015
- if (/^of\s+/i.test(result)) {
1016
- result = result.replace(/^of\s+/i, "").trim();
1017
- changed = true;
1018
- continue;
1019
- }
1020
- const match = result.match(/^(clove|cloves|sprig|sprigs|bunch|bunches|stick|sticks|slice|slices)\b/i);
1021
- if (match) {
1022
- result = result.slice(match[0].length).trim();
1023
- changed = true;
831
+ const validateFn = context.ajv.compile(schema);
832
+ context.validators.set(cacheKey, validateFn);
833
+ return validateFn;
834
+ }
835
+ function normalizeRecipe(recipe) {
836
+ const normalized = cloneRecipe(recipe);
837
+ const warnings = [];
838
+ normalizeTime(normalized);
839
+ if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
840
+ normalized.recipeVersion = normalized.version;
841
+ warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
842
+ }
843
+ return { normalized, warnings };
844
+ }
845
+ function normalizeTime(recipe) {
846
+ const time = recipe == null ? void 0 : recipe.time;
847
+ if (!time || typeof time !== "object" || Array.isArray(time)) return;
848
+ const structuredKeys = [
849
+ "prep",
850
+ "active",
851
+ "passive",
852
+ "total"
853
+ ];
854
+ structuredKeys.forEach((key) => {
855
+ const value = time[key];
856
+ if (typeof value === "number") return;
857
+ const parsed = parseDuration(value);
858
+ if (parsed !== null) {
859
+ time[key] = parsed;
1024
860
  }
1025
- }
1026
- return result;
1027
- }
1028
- function isPrepPhrase(value) {
1029
- const normalized = value.toLowerCase();
1030
- return PREP_PHRASES.includes(normalized);
1031
- }
1032
- function inferScaling(name, unit, amount, notes, descriptor) {
1033
- const lowerName = name?.toLowerCase() ?? "";
1034
- const normalizedNotes = notes.map((note) => note.toLowerCase());
1035
- const descriptorLower = descriptor?.toLowerCase();
1036
- if (lowerName.includes("egg") || descriptorLower === "clove" || descriptorLower === "cloves" || normalizedNotes.some((note) => note.includes("clove"))) {
1037
- return { type: "discrete", roundTo: 1 };
1038
- }
1039
- if (descriptorLower === "stick" || descriptorLower === "sticks") {
1040
- return { type: "discrete", roundTo: 1 };
1041
- }
1042
- if (normalizedNotes.some((note) => PURPOSE_KEYWORDS.some((keyword) => note.includes(keyword)))) {
1043
- return { type: "fixed" };
1044
- }
1045
- const isSpice = SPICE_KEYWORDS.some((keyword) => lowerName.includes(keyword));
1046
- const smallUnit = unit ? ["tsp", "tbsp", "dash", "pinch"].includes(unit) : false;
1047
- if (normalizedNotes.some((note) => note.includes("to taste")) || isSpice && (smallUnit || amount !== null && amount <= 1)) {
1048
- return { type: "proportional", factor: 0.7 };
1049
- }
1050
- return { type: "linear" };
861
+ });
1051
862
  }
1052
- function formatNotes(notes) {
1053
- const cleaned = Array.from(
1054
- new Set(
1055
- notes.map((note) => note.trim()).filter(Boolean)
1056
- )
863
+ var _a, _b;
864
+ var allowedTopLevelProps = /* @__PURE__ */ new Set([
865
+ ...Object.keys((_b = (_a = base_schema_default) == null ? void 0 : _a.properties) != null ? _b : {}),
866
+ "$schema",
867
+ // Module fields (validated by module schemas)
868
+ "attribution",
869
+ "taxonomy",
870
+ "media",
871
+ "times",
872
+ "nutrition",
873
+ "schedule",
874
+ // Common recipe fields (allowed by base schema's additionalProperties: true)
875
+ "description",
876
+ "image",
877
+ "category",
878
+ "tags",
879
+ "source",
880
+ "dateAdded",
881
+ "dateModified",
882
+ "yield",
883
+ "time",
884
+ "id",
885
+ "title",
886
+ "recipeVersion",
887
+ "version",
888
+ // deprecated but allowed
889
+ "equipment",
890
+ "storage",
891
+ "substitutions"
892
+ ]);
893
+ function detectUnknownTopLevelKeys(recipe) {
894
+ if (!recipe || typeof recipe !== "object") return [];
895
+ const disallowedKeys = Object.keys(recipe).filter(
896
+ (key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
1057
897
  );
1058
- return cleaned.length ? cleaned.join("; ") : void 0;
1059
- }
1060
- function formatCountNote(amount, descriptor) {
1061
- const lower = descriptor.toLowerCase();
1062
- const singular = lower.endsWith("s") ? lower.slice(0, -1) : lower;
1063
- 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`;
1064
- return `${formatDecimal(amount)} ${word}`;
1065
- }
1066
- function singularize(value) {
1067
- const trimmed = value.trim();
1068
- if (trimmed.endsWith("ies")) {
1069
- return `${trimmed.slice(0, -3)}y`;
1070
- }
1071
- if (/(ches|shes|sses|xes|zes)$/i.test(trimmed)) {
1072
- return trimmed.slice(0, -2);
1073
- }
1074
- if (trimmed.endsWith("s")) {
1075
- return trimmed.slice(0, -1);
898
+ return disallowedKeys.map((key) => ({
899
+ path: `/${key}`,
900
+ keyword: "additionalProperties",
901
+ message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
902
+ }));
903
+ }
904
+ function formatAjvError(error) {
905
+ var _a2;
906
+ let path = error.instancePath || "/";
907
+ if (error.keyword === "additionalProperties" && ((_a2 = error.params) == null ? void 0 : _a2.additionalProperty)) {
908
+ const extra = error.params.additionalProperty;
909
+ path = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
1076
910
  }
1077
- return trimmed;
1078
- }
1079
-
1080
- // src/converters/ingredient.ts
1081
- function parseIngredientLine2(line) {
1082
- const parsed = parseIngredient(line);
1083
- const ingredient = {
1084
- item: parsed.item,
1085
- scaling: parsed.scaling ?? { type: "linear" }
911
+ return {
912
+ path,
913
+ keyword: error.keyword,
914
+ message: error.message || "Validation error"
1086
915
  };
1087
- if (parsed.name) {
1088
- ingredient.name = parsed.name;
1089
- }
1090
- if (parsed.prep) {
1091
- ingredient.prep = parsed.prep;
1092
- }
1093
- if (parsed.optional) {
1094
- ingredient.optional = true;
1095
- }
1096
- if (parsed.notes) {
1097
- ingredient.notes = parsed.notes;
1098
- }
1099
- const quantity = buildQuantity(parsed.quantity);
1100
- if (quantity) {
1101
- ingredient.quantity = quantity;
1102
- }
1103
- return ingredient;
1104
916
  }
1105
- function buildQuantity(parsedQuantity) {
1106
- if (!parsedQuantity) {
1107
- return void 0;
917
+ function runAjvValidation(data, profile, modules, context) {
918
+ try {
919
+ const validateFn = getCombinedValidator(profile, modules, data, context);
920
+ const isValid = validateFn(data);
921
+ return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
922
+ } catch (error) {
923
+ return [
924
+ {
925
+ path: "/",
926
+ message: error instanceof Error ? error.message : "Validation failed to initialize"
927
+ }
928
+ ];
929
+ }
930
+ }
931
+ function isInstruction(item) {
932
+ return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
933
+ }
934
+ function isInstructionSubsection(item) {
935
+ return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
936
+ }
937
+ function checkInstructionGraph(recipe) {
938
+ const instructions = recipe == null ? void 0 : recipe.instructions;
939
+ if (!Array.isArray(instructions)) return [];
940
+ const instructionIds = /* @__PURE__ */ new Set();
941
+ const dependencyRefs = [];
942
+ const collect = (items, basePath) => {
943
+ items.forEach((item, index) => {
944
+ const currentPath = `${basePath}/${index}`;
945
+ if (isInstructionSubsection(item) && Array.isArray(item.items)) {
946
+ collect(item.items, `${currentPath}/items`);
947
+ return;
948
+ }
949
+ if (isInstruction(item)) {
950
+ const id = typeof item.id === "string" ? item.id : void 0;
951
+ if (id) instructionIds.add(id);
952
+ if (Array.isArray(item.dependsOn)) {
953
+ item.dependsOn.forEach((depId, depIndex) => {
954
+ if (typeof depId === "string") {
955
+ dependencyRefs.push({
956
+ fromId: id,
957
+ toId: depId,
958
+ path: `${currentPath}/dependsOn/${depIndex}`
959
+ });
960
+ }
961
+ });
962
+ }
963
+ }
964
+ });
965
+ };
966
+ collect(instructions, "/instructions");
967
+ const errors = [];
968
+ dependencyRefs.forEach((ref) => {
969
+ if (!instructionIds.has(ref.toId)) {
970
+ errors.push({
971
+ path: ref.path,
972
+ message: `Instruction dependency references missing id '${ref.toId}'.`
973
+ });
974
+ }
975
+ });
976
+ const adjacency = /* @__PURE__ */ new Map();
977
+ dependencyRefs.forEach((ref) => {
978
+ var _a2;
979
+ if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
980
+ const list = (_a2 = adjacency.get(ref.fromId)) != null ? _a2 : [];
981
+ list.push({ toId: ref.toId, path: ref.path });
982
+ adjacency.set(ref.fromId, list);
983
+ }
984
+ });
985
+ const visiting = /* @__PURE__ */ new Set();
986
+ const visited = /* @__PURE__ */ new Set();
987
+ const detectCycles = (nodeId) => {
988
+ var _a2;
989
+ if (visiting.has(nodeId)) return;
990
+ if (visited.has(nodeId)) return;
991
+ visiting.add(nodeId);
992
+ const neighbors = (_a2 = adjacency.get(nodeId)) != null ? _a2 : [];
993
+ neighbors.forEach((edge) => {
994
+ if (visiting.has(edge.toId)) {
995
+ errors.push({
996
+ path: edge.path,
997
+ message: `Circular dependency detected involving instruction id '${edge.toId}'.`
998
+ });
999
+ return;
1000
+ }
1001
+ detectCycles(edge.toId);
1002
+ });
1003
+ visiting.delete(nodeId);
1004
+ visited.add(nodeId);
1005
+ };
1006
+ instructionIds.forEach((id) => detectCycles(id));
1007
+ return errors;
1008
+ }
1009
+ function validateRecipe(input, options = {}) {
1010
+ var _a2, _b2, _c, _d;
1011
+ const collectAllErrors = (_a2 = options.collectAllErrors) != null ? _a2 : true;
1012
+ const context = getContext(collectAllErrors);
1013
+ const schemaRef = resolveSchemaRef(input == null ? void 0 : input.$schema, options.schema);
1014
+ const profileFromDocument = typeof (input == null ? void 0 : input.profile) === "string" ? input.profile : void 0;
1015
+ const profile = (_d = (_c = (_b2 = options.profile) != null ? _b2 : profileFromDocument) != null ? _c : detectProfileFromSchema(schemaRef)) != null ? _d : "core";
1016
+ const modulesFromDocument = Array.isArray(input == null ? void 0 : input.modules) ? input.modules.filter((value) => typeof value === "string") : [];
1017
+ const modules = modulesFromDocument.length > 0 ? [...modulesFromDocument].sort() : [];
1018
+ const { normalized, warnings } = normalizeRecipe(input);
1019
+ if (!profileFromDocument) {
1020
+ normalized.profile = profile;
1021
+ } else {
1022
+ normalized.profile = profileFromDocument;
1108
1023
  }
1109
- if (parsedQuantity.amount === null || Number.isNaN(parsedQuantity.amount)) {
1110
- return void 0;
1024
+ if (!("modules" in normalized) || normalized.modules === void 0 || normalized.modules === null) {
1025
+ normalized.modules = [];
1026
+ } else if (modulesFromDocument.length > 0) {
1027
+ normalized.modules = modules;
1111
1028
  }
1029
+ const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
1030
+ const validationErrors = runAjvValidation(normalized, profile, modules, context);
1031
+ const graphErrors = modules.includes("schedule@1") && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
1032
+ const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
1112
1033
  return {
1113
- amount: parsedQuantity.amount,
1114
- unit: parsedQuantity.unit ?? null
1034
+ valid: errors.length === 0,
1035
+ errors,
1036
+ warnings,
1037
+ normalized: errors.length === 0 ? normalized : void 0
1115
1038
  };
1116
1039
  }
1040
+ function detectProfiles(recipe) {
1041
+ var _a2;
1042
+ const result = validateRecipe(recipe, { profile: "core", collectAllErrors: false });
1043
+ if (!result.valid) return [];
1044
+ const normalizedRecipe = (_a2 = result.normalized) != null ? _a2 : recipe;
1045
+ const profiles = [];
1046
+ const context = getContext(false);
1047
+ Object.keys(profileSchemas).forEach((profile) => {
1048
+ if (!profileSchemas[profile]) return;
1049
+ const errors = runAjvValidation(normalizedRecipe, profile, [], context);
1050
+ if (errors.length === 0) {
1051
+ profiles.push(profile);
1052
+ }
1053
+ });
1054
+ return profiles;
1055
+ }
1117
1056
 
1118
1057
  // src/converters/yield.ts
1119
1058
  function parseYield(value) {
@@ -1155,96 +1094,16 @@ function parseYield(value) {
1155
1094
  return void 0;
1156
1095
  }
1157
1096
  function formatYield(yieldValue) {
1097
+ var _a2;
1158
1098
  if (!yieldValue) return void 0;
1159
1099
  if (!yieldValue.amount && !yieldValue.unit) {
1160
1100
  return void 0;
1161
1101
  }
1162
- const amount = yieldValue.amount ?? "";
1102
+ const amount = (_a2 = yieldValue.amount) != null ? _a2 : "";
1163
1103
  const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
1164
1104
  return `${amount}${unit}`.trim() || yieldValue.description;
1165
1105
  }
1166
1106
 
1167
- // src/parsers/duration.ts
1168
- var ISO_DURATION_REGEX = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i;
1169
- var HUMAN_OVERNIGHT = 8 * 60;
1170
- function isFiniteNumber(value) {
1171
- return typeof value === "number" && Number.isFinite(value);
1172
- }
1173
- function parseDuration(iso) {
1174
- if (!iso || typeof iso !== "string") return null;
1175
- const trimmed = iso.trim();
1176
- if (!trimmed) return null;
1177
- const match = trimmed.match(ISO_DURATION_REGEX);
1178
- if (!match) return null;
1179
- const [, daysRaw, hoursRaw, minutesRaw, secondsRaw] = match;
1180
- if (!daysRaw && !hoursRaw && !minutesRaw && !secondsRaw) {
1181
- return null;
1182
- }
1183
- let total = 0;
1184
- if (daysRaw) total += parseFloat(daysRaw) * 24 * 60;
1185
- if (hoursRaw) total += parseFloat(hoursRaw) * 60;
1186
- if (minutesRaw) total += parseFloat(minutesRaw);
1187
- if (secondsRaw) total += Math.ceil(parseFloat(secondsRaw) / 60);
1188
- return Math.round(total);
1189
- }
1190
- function formatDuration(minutes) {
1191
- if (!isFiniteNumber(minutes) || minutes <= 0) {
1192
- return "PT0M";
1193
- }
1194
- const rounded = Math.round(minutes);
1195
- const days = Math.floor(rounded / (24 * 60));
1196
- const afterDays = rounded % (24 * 60);
1197
- const hours = Math.floor(afterDays / 60);
1198
- const mins = afterDays % 60;
1199
- let result = "P";
1200
- if (days > 0) {
1201
- result += `${days}D`;
1202
- }
1203
- if (hours > 0 || mins > 0) {
1204
- result += "T";
1205
- if (hours > 0) {
1206
- result += `${hours}H`;
1207
- }
1208
- if (mins > 0) {
1209
- result += `${mins}M`;
1210
- }
1211
- }
1212
- if (result === "P") {
1213
- return "PT0M";
1214
- }
1215
- return result;
1216
- }
1217
- function parseHumanDuration(text) {
1218
- if (!text || typeof text !== "string") return null;
1219
- const normalized = text.toLowerCase().trim();
1220
- if (!normalized) return null;
1221
- if (normalized === "overnight") {
1222
- return HUMAN_OVERNIGHT;
1223
- }
1224
- let total = 0;
1225
- const hourRegex = /(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|hr|h)\b/g;
1226
- let hourMatch;
1227
- while ((hourMatch = hourRegex.exec(normalized)) !== null) {
1228
- total += parseFloat(hourMatch[1]) * 60;
1229
- }
1230
- const minuteRegex = /(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|min|m)\b/g;
1231
- let minuteMatch;
1232
- while ((minuteMatch = minuteRegex.exec(normalized)) !== null) {
1233
- total += parseFloat(minuteMatch[1]);
1234
- }
1235
- if (total <= 0) {
1236
- return null;
1237
- }
1238
- return Math.round(total);
1239
- }
1240
- function smartParseDuration(input) {
1241
- const iso = parseDuration(input);
1242
- if (iso !== null) {
1243
- return iso;
1244
- }
1245
- return parseHumanDuration(input);
1246
- }
1247
-
1248
1107
  // src/utils/image.ts
1249
1108
  function normalizeImage(image) {
1250
1109
  if (!image) {
@@ -1281,6 +1140,7 @@ function extractUrl(value) {
1281
1140
 
1282
1141
  // src/fromSchemaOrg.ts
1283
1142
  function fromSchemaOrg(input) {
1143
+ var _a2;
1284
1144
  const recipeNode = extractRecipeNode(input);
1285
1145
  if (!recipeNode) {
1286
1146
  return null;
@@ -1292,21 +1152,39 @@ function fromSchemaOrg(input) {
1292
1152
  const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
1293
1153
  const category = extractFirst(recipeNode.recipeCategory);
1294
1154
  const source = convertSource(recipeNode);
1295
- const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
1155
+ const dateModified = recipeNode.dateModified || void 0;
1156
+ const nutrition = convertNutrition(recipeNode.nutrition);
1157
+ const attribution = convertAttribution(recipeNode);
1158
+ const taxonomy = convertTaxonomy(tags, category, extractFirst(recipeNode.recipeCuisine));
1159
+ const media = convertMedia(recipeNode.image, recipeNode.video);
1160
+ const times = convertTimes(time);
1161
+ const modules = [];
1162
+ if (attribution) modules.push("attribution@1");
1163
+ if (taxonomy) modules.push("taxonomy@1");
1164
+ if (media) modules.push("media@1");
1165
+ if (nutrition) modules.push("nutrition@1");
1166
+ if (times) modules.push("times@1");
1296
1167
  return {
1168
+ "@type": "Recipe",
1169
+ profile: "minimal",
1170
+ modules: modules.sort(),
1297
1171
  name: recipeNode.name.trim(),
1298
- description: recipeNode.description?.trim() || void 0,
1172
+ description: ((_a2 = recipeNode.description) == null ? void 0 : _a2.trim()) || void 0,
1299
1173
  image: normalizeImage(recipeNode.image),
1300
1174
  category,
1301
1175
  tags: tags.length ? tags : void 0,
1302
1176
  source,
1303
1177
  dateAdded: recipeNode.datePublished || void 0,
1304
- dateModified: recipeNode.dateModified || void 0,
1305
1178
  yield: recipeYield,
1306
1179
  time,
1307
1180
  ingredients,
1308
1181
  instructions,
1309
- nutrition
1182
+ ...dateModified ? { dateModified } : {},
1183
+ ...nutrition ? { nutrition } : {},
1184
+ ...attribution ? { attribution } : {},
1185
+ ...taxonomy ? { taxonomy } : {},
1186
+ ...media ? { media } : {},
1187
+ ...times ? { times } : {}
1310
1188
  };
1311
1189
  }
1312
1190
  function extractRecipeNode(input) {
@@ -1341,8 +1219,6 @@ function extractRecipeNode(input) {
1341
1219
  function hasRecipeType(value) {
1342
1220
  if (!value) return false;
1343
1221
  const types = Array.isArray(value) ? value : [value];
1344
- 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(() => {
1345
- });
1346
1222
  return types.some(
1347
1223
  (entry) => typeof entry === "string" && entry.toLowerCase() === "recipe"
1348
1224
  );
@@ -1353,9 +1229,10 @@ function isValidName(name) {
1353
1229
  function convertIngredients(value) {
1354
1230
  if (!value) return [];
1355
1231
  const normalized = Array.isArray(value) ? value : [value];
1356
- return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean).map((line) => parseIngredientLine2(line));
1232
+ return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean);
1357
1233
  }
1358
1234
  function convertInstructions(value) {
1235
+ var _a2;
1359
1236
  if (!value) return [];
1360
1237
  const normalized = Array.isArray(value) ? value : [value];
1361
1238
  const result = [];
@@ -1372,7 +1249,7 @@ function convertInstructions(value) {
1372
1249
  const subsectionItems = extractSectionItems(entry.itemListElement);
1373
1250
  if (subsectionItems.length) {
1374
1251
  result.push({
1375
- subsection: entry.name?.trim() || "Section",
1252
+ subsection: ((_a2 = entry.name) == null ? void 0 : _a2.trim()) || "Section",
1376
1253
  items: subsectionItems
1377
1254
  });
1378
1255
  }
@@ -1421,10 +1298,33 @@ function convertHowToStep(step) {
1421
1298
  return void 0;
1422
1299
  }
1423
1300
  const normalizedImage = normalizeImage(step.image);
1424
- if (typeof normalizedImage === "string") {
1425
- return { text, image: normalizedImage };
1301
+ const image = Array.isArray(normalizedImage) ? normalizedImage[0] : normalizedImage;
1302
+ const id = extractInstructionId(step);
1303
+ const timing = extractInstructionTiming(step);
1304
+ if (!image && !id && !timing) {
1305
+ return text;
1306
+ }
1307
+ const instruction = { text };
1308
+ if (id) instruction.id = id;
1309
+ if (image) instruction.image = image;
1310
+ if (timing) instruction.timing = timing;
1311
+ return instruction;
1312
+ }
1313
+ function extractInstructionTiming(step) {
1314
+ const duration = step.totalTime || step.performTime || step.prepTime || step.duration;
1315
+ if (!duration || typeof duration !== "string") {
1316
+ return void 0;
1317
+ }
1318
+ const parsed = smartParseDuration(duration);
1319
+ return { duration: parsed != null ? parsed : duration, type: "active" };
1320
+ }
1321
+ function extractInstructionId(step) {
1322
+ const raw = step["@id"] || step.id || step.url;
1323
+ if (typeof raw !== "string") {
1324
+ return void 0;
1426
1325
  }
1427
- return text;
1326
+ const trimmed = raw.trim();
1327
+ return trimmed || void 0;
1428
1328
  }
1429
1329
  function isHowToStep(value) {
1430
1330
  return Boolean(value) && typeof value === "object" && value["@type"] === "HowToStep";
@@ -1433,9 +1333,10 @@ function isHowToSection(value) {
1433
1333
  return Boolean(value) && typeof value === "object" && value["@type"] === "HowToSection" && Array.isArray(value.itemListElement);
1434
1334
  }
1435
1335
  function convertTime(recipe) {
1436
- const prep = smartParseDuration(recipe.prepTime ?? "");
1437
- const cook = smartParseDuration(recipe.cookTime ?? "");
1438
- const total = smartParseDuration(recipe.totalTime ?? "");
1336
+ var _a2, _b2, _c;
1337
+ const prep = smartParseDuration((_a2 = recipe.prepTime) != null ? _a2 : "");
1338
+ const cook = smartParseDuration((_b2 = recipe.cookTime) != null ? _b2 : "");
1339
+ const total = smartParseDuration((_c = recipe.totalTime) != null ? _c : "");
1439
1340
  const structured = {};
1440
1341
  if (prep !== null && prep !== void 0) structured.prep = prep;
1441
1342
  if (cook !== null && cook !== void 0) structured.active = cook;
@@ -1468,9 +1369,10 @@ function extractFirst(value) {
1468
1369
  return arr.length ? arr[0] : void 0;
1469
1370
  }
1470
1371
  function convertSource(recipe) {
1372
+ var _a2;
1471
1373
  const author = extractEntityName(recipe.author);
1472
1374
  const publisher = extractEntityName(recipe.publisher);
1473
- const url = (recipe.url || recipe.mainEntityOfPage)?.trim();
1375
+ const url = (_a2 = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a2.trim();
1474
1376
  const source = {};
1475
1377
  if (author) source.author = author;
1476
1378
  if (publisher) source.name = publisher;
@@ -1498,16 +1400,186 @@ function extractEntityName(value) {
1498
1400
  }
1499
1401
  return void 0;
1500
1402
  }
1403
+ function convertAttribution(recipe) {
1404
+ var _a2, _b2;
1405
+ const attribution = {};
1406
+ const url = (_a2 = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a2.trim();
1407
+ const author = extractEntityName(recipe.author);
1408
+ const datePublished = (_b2 = recipe.datePublished) == null ? void 0 : _b2.trim();
1409
+ if (url) attribution.url = url;
1410
+ if (author) attribution.author = author;
1411
+ if (datePublished) attribution.datePublished = datePublished;
1412
+ return Object.keys(attribution).length ? attribution : void 0;
1413
+ }
1414
+ function convertTaxonomy(keywords, category, cuisine) {
1415
+ const taxonomy = {};
1416
+ if (keywords.length) taxonomy.keywords = keywords;
1417
+ if (category) taxonomy.category = category;
1418
+ if (cuisine) taxonomy.cuisine = cuisine;
1419
+ return Object.keys(taxonomy).length ? taxonomy : void 0;
1420
+ }
1421
+ function normalizeMediaList(value) {
1422
+ if (!value) return [];
1423
+ if (typeof value === "string") return [value.trim()].filter(Boolean);
1424
+ if (Array.isArray(value)) {
1425
+ return value.map((item) => typeof item === "string" ? item.trim() : extractMediaUrl(item)).filter((entry) => Boolean(entry == null ? void 0 : entry.length));
1426
+ }
1427
+ const url = extractMediaUrl(value);
1428
+ return url ? [url] : [];
1429
+ }
1430
+ function extractMediaUrl(value) {
1431
+ if (value && typeof value === "object" && "url" in value && typeof value.url === "string") {
1432
+ const trimmed = value.url.trim();
1433
+ return trimmed || void 0;
1434
+ }
1435
+ return void 0;
1436
+ }
1437
+ function convertMedia(image, video) {
1438
+ const normalizedImage = normalizeImage(image);
1439
+ const images = normalizedImage ? Array.isArray(normalizedImage) ? normalizedImage : [normalizedImage] : [];
1440
+ const videos = normalizeMediaList(video);
1441
+ const media = {};
1442
+ if (images.length) media.images = images;
1443
+ if (videos.length) media.videos = videos;
1444
+ return Object.keys(media).length ? media : void 0;
1445
+ }
1446
+ function convertTimes(time) {
1447
+ if (!time) return void 0;
1448
+ const times = {};
1449
+ if (typeof time.prep === "number") times.prepMinutes = time.prep;
1450
+ if (typeof time.active === "number") times.cookMinutes = time.active;
1451
+ if (typeof time.total === "number") times.totalMinutes = time.total;
1452
+ return Object.keys(times).length ? times : void 0;
1453
+ }
1454
+ function convertNutrition(nutrition) {
1455
+ if (!nutrition || typeof nutrition !== "object") {
1456
+ return void 0;
1457
+ }
1458
+ const result = {};
1459
+ let hasData = false;
1460
+ if ("calories" in nutrition) {
1461
+ const calories = nutrition.calories;
1462
+ if (typeof calories === "number") {
1463
+ result.calories = calories;
1464
+ hasData = true;
1465
+ } else if (typeof calories === "string") {
1466
+ const parsed = parseFloat(calories.replace(/[^\d.-]/g, ""));
1467
+ if (!isNaN(parsed)) {
1468
+ result.calories = parsed;
1469
+ hasData = true;
1470
+ }
1471
+ }
1472
+ }
1473
+ if ("proteinContent" in nutrition || "protein_g" in nutrition) {
1474
+ const protein = nutrition.proteinContent || nutrition.protein_g;
1475
+ if (typeof protein === "number") {
1476
+ result.protein_g = protein;
1477
+ hasData = true;
1478
+ } else if (typeof protein === "string") {
1479
+ const parsed = parseFloat(protein.replace(/[^\d.-]/g, ""));
1480
+ if (!isNaN(parsed)) {
1481
+ result.protein_g = parsed;
1482
+ hasData = true;
1483
+ }
1484
+ }
1485
+ }
1486
+ return hasData ? result : void 0;
1487
+ }
1488
+
1489
+ // src/schemas/registry/modules.json
1490
+ var modules_default = {
1491
+ modules: [
1492
+ {
1493
+ id: "attribution",
1494
+ versions: [
1495
+ 1
1496
+ ],
1497
+ latest: 1,
1498
+ namespace: "https://soustack.org/schemas/recipe/modules/attribution",
1499
+ schema: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
1500
+ schemaOrgMappable: true,
1501
+ schemaOrgConfidence: "medium",
1502
+ minProfile: "minimal",
1503
+ allowedOnMinimal: true
1504
+ },
1505
+ {
1506
+ id: "taxonomy",
1507
+ versions: [
1508
+ 1
1509
+ ],
1510
+ latest: 1,
1511
+ namespace: "https://soustack.org/schemas/recipe/modules/taxonomy",
1512
+ schema: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
1513
+ schemaOrgMappable: true,
1514
+ schemaOrgConfidence: "high",
1515
+ minProfile: "minimal",
1516
+ allowedOnMinimal: true
1517
+ },
1518
+ {
1519
+ id: "media",
1520
+ versions: [
1521
+ 1
1522
+ ],
1523
+ latest: 1,
1524
+ namespace: "https://soustack.org/schemas/recipe/modules/media",
1525
+ schema: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
1526
+ schemaOrgMappable: true,
1527
+ schemaOrgConfidence: "medium",
1528
+ minProfile: "minimal",
1529
+ allowedOnMinimal: true
1530
+ },
1531
+ {
1532
+ id: "nutrition",
1533
+ versions: [
1534
+ 1
1535
+ ],
1536
+ latest: 1,
1537
+ namespace: "https://soustack.org/schemas/recipe/modules/nutrition",
1538
+ schema: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
1539
+ schemaOrgMappable: false,
1540
+ schemaOrgConfidence: "low",
1541
+ minProfile: "minimal",
1542
+ allowedOnMinimal: true
1543
+ },
1544
+ {
1545
+ id: "times",
1546
+ versions: [
1547
+ 1
1548
+ ],
1549
+ latest: 1,
1550
+ namespace: "https://soustack.org/schemas/recipe/modules/times",
1551
+ schema: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
1552
+ schemaOrgMappable: true,
1553
+ schemaOrgConfidence: "medium",
1554
+ minProfile: "minimal",
1555
+ allowedOnMinimal: true
1556
+ },
1557
+ {
1558
+ id: "schedule",
1559
+ versions: [
1560
+ 1
1561
+ ],
1562
+ latest: 1,
1563
+ namespace: "https://soustack.org/schemas/recipe/modules/schedule",
1564
+ schema: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
1565
+ schemaOrgMappable: false,
1566
+ schemaOrgConfidence: "low",
1567
+ minProfile: "core",
1568
+ allowedOnMinimal: false
1569
+ }
1570
+ ]
1571
+ };
1501
1572
 
1502
1573
  // src/converters/toSchemaOrg.ts
1503
1574
  function convertBasicMetadata(recipe) {
1575
+ var _a2;
1504
1576
  return cleanOutput({
1505
1577
  "@context": "https://schema.org",
1506
1578
  "@type": "Recipe",
1507
1579
  name: recipe.name,
1508
1580
  description: recipe.description,
1509
1581
  image: recipe.image,
1510
- url: recipe.source?.url,
1582
+ url: (_a2 = recipe.source) == null ? void 0 : _a2.url,
1511
1583
  datePublished: recipe.dateAdded,
1512
1584
  dateModified: recipe.dateModified
1513
1585
  });
@@ -1515,6 +1587,7 @@ function convertBasicMetadata(recipe) {
1515
1587
  function convertIngredients2(ingredients = []) {
1516
1588
  const result = [];
1517
1589
  ingredients.forEach((ingredient) => {
1590
+ var _a2;
1518
1591
  if (!ingredient) {
1519
1592
  return;
1520
1593
  }
@@ -1544,7 +1617,7 @@ function convertIngredients2(ingredients = []) {
1544
1617
  });
1545
1618
  return;
1546
1619
  }
1547
- const value = ingredient.item?.trim();
1620
+ const value = (_a2 = ingredient.item) == null ? void 0 : _a2.trim();
1548
1621
  if (value) {
1549
1622
  result.push(value);
1550
1623
  }
@@ -1559,10 +1632,11 @@ function convertInstruction(entry) {
1559
1632
  return null;
1560
1633
  }
1561
1634
  if (typeof entry === "string") {
1562
- return createHowToStep(entry);
1635
+ const value = entry.trim();
1636
+ return value || null;
1563
1637
  }
1564
1638
  if ("subsection" in entry) {
1565
- const steps = entry.items.map((item) => createHowToStep(item)).filter((step) => Boolean(step));
1639
+ const steps = entry.items.map((item) => convertInstruction(item)).filter((step) => Boolean(step));
1566
1640
  if (!steps.length) {
1567
1641
  return null;
1568
1642
  }
@@ -1578,18 +1652,13 @@ function convertInstruction(entry) {
1578
1652
  return createHowToStep(String(entry));
1579
1653
  }
1580
1654
  function createHowToStep(entry) {
1655
+ var _a2;
1581
1656
  if (!entry) return null;
1582
1657
  if (typeof entry === "string") {
1583
1658
  const trimmed2 = entry.trim();
1584
- if (!trimmed2) {
1585
- return null;
1586
- }
1587
- return {
1588
- "@type": "HowToStep",
1589
- text: trimmed2
1590
- };
1659
+ return trimmed2 || null;
1591
1660
  }
1592
- const trimmed = entry.text?.trim();
1661
+ const trimmed = (_a2 = entry.text) == null ? void 0 : _a2.trim();
1593
1662
  if (!trimmed) {
1594
1663
  return null;
1595
1664
  }
@@ -1597,10 +1666,23 @@ function createHowToStep(entry) {
1597
1666
  "@type": "HowToStep",
1598
1667
  text: trimmed
1599
1668
  };
1669
+ if (entry.id) {
1670
+ step["@id"] = entry.id;
1671
+ }
1672
+ if (entry.timing) {
1673
+ if (typeof entry.timing.duration === "number") {
1674
+ step.performTime = formatDuration(entry.timing.duration);
1675
+ } else if (entry.timing.duration) {
1676
+ step.performTime = entry.timing.duration;
1677
+ }
1678
+ }
1600
1679
  if (entry.image) {
1601
1680
  step.image = entry.image;
1602
1681
  }
1603
- return step;
1682
+ if (step["@id"] || step.performTime || step.image) {
1683
+ return step;
1684
+ }
1685
+ return trimmed;
1604
1686
  }
1605
1687
  function convertTime2(time) {
1606
1688
  if (!time) {
@@ -1628,6 +1710,22 @@ function convertTime2(time) {
1628
1710
  }
1629
1711
  return result;
1630
1712
  }
1713
+ function convertTimesModule(times) {
1714
+ if (!times) {
1715
+ return {};
1716
+ }
1717
+ const result = {};
1718
+ if (times.prepMinutes !== void 0) {
1719
+ result.prepTime = formatDuration(times.prepMinutes);
1720
+ }
1721
+ if (times.cookMinutes !== void 0) {
1722
+ result.cookTime = formatDuration(times.cookMinutes);
1723
+ }
1724
+ if (times.totalMinutes !== void 0) {
1725
+ result.totalTime = formatDuration(times.totalMinutes);
1726
+ }
1727
+ return result;
1728
+ }
1631
1729
  function convertYield(yld) {
1632
1730
  if (!yld) {
1633
1731
  return void 0;
@@ -1666,145 +1764,63 @@ function convertCategoryTags(category, tags) {
1666
1764
  }
1667
1765
  return result;
1668
1766
  }
1669
- function convertNutrition(nutrition) {
1767
+ function convertNutrition2(nutrition) {
1670
1768
  if (!nutrition) {
1671
1769
  return void 0;
1672
1770
  }
1673
- return {
1674
- ...nutrition,
1771
+ const result = {
1675
1772
  "@type": "NutritionInformation"
1676
1773
  };
1774
+ if (nutrition.calories !== void 0) {
1775
+ if (typeof nutrition.calories === "number") {
1776
+ result.calories = `${nutrition.calories} calories`;
1777
+ } else {
1778
+ result.calories = nutrition.calories;
1779
+ }
1780
+ }
1781
+ Object.keys(nutrition).forEach((key) => {
1782
+ if (key !== "calories" && key !== "@type") {
1783
+ result[key] = nutrition[key];
1784
+ }
1785
+ });
1786
+ return result;
1677
1787
  }
1678
1788
  function cleanOutput(obj) {
1679
1789
  return Object.fromEntries(
1680
1790
  Object.entries(obj).filter(([, value]) => value !== void 0)
1681
1791
  );
1682
1792
  }
1793
+ function getSchemaOrgMappableModules(modules = []) {
1794
+ const mappableModules = modules_default.modules.filter((m) => m.schemaOrgMappable).map((m) => `${m.id}@${m.latest}`);
1795
+ return modules.filter((moduleId) => mappableModules.includes(moduleId));
1796
+ }
1683
1797
  function toSchemaOrg(recipe) {
1684
1798
  const base = convertBasicMetadata(recipe);
1685
1799
  const ingredients = convertIngredients2(recipe.ingredients);
1686
1800
  const instructions = convertInstructions2(recipe.instructions);
1687
- const nutrition = convertNutrition(recipe.nutrition);
1801
+ const recipeModules = Array.isArray(recipe.modules) ? recipe.modules : [];
1802
+ const mappableModules = getSchemaOrgMappableModules(recipeModules);
1803
+ const hasMappableNutrition = mappableModules.includes("nutrition@1");
1804
+ const nutrition = hasMappableNutrition ? convertNutrition2(recipe.nutrition) : void 0;
1805
+ const hasMappableTimes = mappableModules.includes("times@1");
1806
+ const timeData = hasMappableTimes ? recipe.times ? convertTimesModule(recipe.times) : convertTime2(recipe.time) : {};
1807
+ const hasMappableAttribution = mappableModules.includes("attribution@1");
1808
+ const attributionData = hasMappableAttribution ? convertAuthor(recipe.source) : {};
1809
+ const hasMappableTaxonomy = mappableModules.includes("taxonomy@1");
1810
+ const taxonomyData = hasMappableTaxonomy ? convertCategoryTags(recipe.category, recipe.tags) : {};
1688
1811
  return cleanOutput({
1689
1812
  ...base,
1690
- recipeIngredient: ingredients.length ? ingredients : void 0,
1691
- recipeInstructions: instructions.length ? instructions : void 0,
1692
- recipeYield: convertYield(recipe.yield),
1693
- ...convertTime2(recipe.time),
1694
- ...convertAuthor(recipe.source),
1695
- ...convertCategoryTags(recipe.category, recipe.tags),
1696
- nutrition
1697
- });
1698
- }
1699
- function isStructuredTime(time) {
1700
- return typeof time.prep !== "undefined" || typeof time.active !== "undefined" || typeof time.passive !== "undefined" || typeof time.total !== "undefined";
1701
- }
1702
-
1703
- // src/scraper/fetch.ts
1704
- var DEFAULT_USER_AGENTS = [
1705
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
1706
- "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",
1707
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
1708
- ];
1709
- function chooseUserAgent(provided) {
1710
- if (provided) return provided;
1711
- const index = Math.floor(Math.random() * DEFAULT_USER_AGENTS.length);
1712
- return DEFAULT_USER_AGENTS[index];
1713
- }
1714
- function resolveFetch(fetchFn) {
1715
- if (fetchFn) {
1716
- return fetchFn;
1717
- }
1718
- const globalFetch = globalThis.fetch;
1719
- if (!globalFetch) {
1720
- throw new Error(
1721
- "A global fetch implementation is not available. Provide window.fetch in browsers or upgrade to Node 18+."
1722
- );
1723
- }
1724
- return globalFetch;
1725
- }
1726
- function isBrowserEnvironment() {
1727
- return typeof globalThis.document !== "undefined";
1728
- }
1729
- function isClientError(error) {
1730
- if (typeof error.status === "number") {
1731
- return error.status >= 400 && error.status < 500;
1732
- }
1733
- return error.message.includes("HTTP 4");
1734
- }
1735
- async function wait(ms) {
1736
- return new Promise((resolve) => setTimeout(resolve, ms));
1737
- }
1738
- async function fetchPage(url, options = {}) {
1739
- const {
1740
- timeout = 1e4,
1741
- userAgent,
1742
- maxRetries = 2,
1743
- fetchFn
1744
- } = options;
1745
- let lastError = null;
1746
- const resolvedFetch = resolveFetch(fetchFn);
1747
- const isBrowser2 = isBrowserEnvironment();
1748
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
1749
- const controller = new AbortController();
1750
- const timeoutId = setTimeout(() => controller.abort(), timeout);
1751
- try {
1752
- const headers = {
1753
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1754
- "Accept-Language": "en-US,en;q=0.5"
1755
- };
1756
- if (!isBrowser2) {
1757
- headers["User-Agent"] = chooseUserAgent(userAgent);
1758
- }
1759
- const requestInit = {
1760
- headers,
1761
- signal: controller.signal,
1762
- redirect: "follow"
1763
- };
1764
- const response = await resolvedFetch(url, requestInit);
1765
- clearTimeout(timeoutId);
1766
- if (response && typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1767
- try {
1768
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1769
- if (globalFetch) {
1770
- 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(() => {
1771
- });
1772
- }
1773
- } catch {
1774
- }
1775
- }
1776
- if (!response.ok) {
1777
- const error = new Error(
1778
- `HTTP ${response.status}: ${response.statusText}`
1779
- );
1780
- error.status = response.status;
1781
- throw error;
1782
- }
1783
- const html = await response.text();
1784
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1785
- try {
1786
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1787
- if (globalFetch) {
1788
- 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(() => {
1789
- });
1790
- }
1791
- } catch {
1792
- }
1793
- }
1794
- return html;
1795
- } catch (err) {
1796
- clearTimeout(timeoutId);
1797
- lastError = err instanceof Error ? err : new Error(String(err));
1798
- if (isClientError(lastError)) {
1799
- throw lastError;
1800
- }
1801
- if (attempt < maxRetries) {
1802
- await wait(1e3 * (attempt + 1));
1803
- continue;
1804
- }
1805
- }
1806
- }
1807
- throw lastError ?? new Error("Failed to fetch page");
1813
+ recipeIngredient: ingredients.length ? ingredients : void 0,
1814
+ recipeInstructions: instructions.length ? instructions : void 0,
1815
+ recipeYield: convertYield(recipe.yield),
1816
+ ...timeData,
1817
+ ...attributionData,
1818
+ ...taxonomyData,
1819
+ nutrition
1820
+ });
1821
+ }
1822
+ function isStructuredTime(time) {
1823
+ return typeof time.prep !== "undefined" || typeof time.active !== "undefined" || typeof time.passive !== "undefined" || typeof time.total !== "undefined";
1808
1824
  }
1809
1825
 
1810
1826
  // src/scraper/extractors/utils.ts
@@ -1843,97 +1859,8 @@ function normalizeText(value) {
1843
1859
  return trimmed || void 0;
1844
1860
  }
1845
1861
 
1846
- // src/scraper/extractors/jsonld.ts
1847
- function extractJsonLd(html) {
1848
- const $ = load(html);
1849
- const scripts = $('script[type="application/ld+json"]');
1850
- 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(() => {
1851
- });
1852
- const candidates = [];
1853
- scripts.each((_, element) => {
1854
- const content = $(element).html();
1855
- if (!content) return;
1856
- const parsed = safeJsonParse(content);
1857
- if (!parsed) return;
1858
- 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(() => {
1859
- });
1860
- collectCandidates(parsed, candidates);
1861
- });
1862
- 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(() => {
1863
- });
1864
- return candidates[0] ?? null;
1865
- }
1866
- function collectCandidates(payload, bucket) {
1867
- if (!payload) return;
1868
- if (Array.isArray(payload)) {
1869
- payload.forEach((entry) => collectCandidates(entry, bucket));
1870
- return;
1871
- }
1872
- if (typeof payload !== "object") {
1873
- return;
1874
- }
1875
- if (isRecipeNode(payload)) {
1876
- bucket.push(payload);
1877
- return;
1878
- }
1879
- const graph = payload["@graph"];
1880
- if (Array.isArray(graph)) {
1881
- graph.forEach((entry) => collectCandidates(entry, bucket));
1882
- }
1883
- }
1884
- var SIMPLE_PROPS = [
1885
- "name",
1886
- "description",
1887
- "image",
1888
- "recipeYield",
1889
- "prepTime",
1890
- "cookTime",
1891
- "totalTime"
1892
- ];
1893
- function extractMicrodata(html) {
1894
- const $ = load(html);
1895
- const recipeEl = $('[itemscope][itemtype*="schema.org/Recipe"]').first();
1896
- if (!recipeEl.length) {
1897
- return null;
1898
- }
1899
- const recipe = {
1900
- "@type": "Recipe"
1901
- };
1902
- SIMPLE_PROPS.forEach((prop) => {
1903
- const value = findPropertyValue($, recipeEl, prop);
1904
- if (value) {
1905
- recipe[prop] = value;
1906
- }
1907
- });
1908
- const ingredients = [];
1909
- recipeEl.find('[itemprop="recipeIngredient"]').each((_, el) => {
1910
- const text = normalizeText($(el).attr("content") || $(el).text());
1911
- if (text) ingredients.push(text);
1912
- });
1913
- if (ingredients.length) {
1914
- recipe.recipeIngredient = ingredients;
1915
- }
1916
- const instructions = [];
1917
- recipeEl.find('[itemprop="recipeInstructions"]').each((_, el) => {
1918
- const text = normalizeText($(el).attr("content")) || normalizeText($(el).find('[itemprop="text"]').first().text()) || normalizeText($(el).text());
1919
- if (text) instructions.push(text);
1920
- });
1921
- if (instructions.length) {
1922
- recipe.recipeInstructions = instructions;
1923
- }
1924
- if (recipe.name || ingredients.length) {
1925
- return recipe;
1926
- }
1927
- return null;
1928
- }
1929
- function findPropertyValue($, context, prop) {
1930
- const node = context.find(`[itemprop="${prop}"]`).first();
1931
- if (!node.length) return void 0;
1932
- return normalizeText(node.attr("content")) || normalizeText(node.attr("href")) || normalizeText(node.attr("src")) || normalizeText(node.text());
1933
- }
1934
-
1935
1862
  // src/scraper/extractors/browser.ts
1936
- var SIMPLE_PROPS2 = ["name", "description", "image", "recipeYield", "prepTime", "cookTime", "totalTime"];
1863
+ var SIMPLE_PROPS = ["name", "description", "image", "recipeYield", "prepTime", "cookTime", "totalTime"];
1937
1864
  function extractRecipeBrowser(html) {
1938
1865
  const jsonLdRecipe = extractJsonLdBrowser(html);
1939
1866
  if (jsonLdRecipe) {
@@ -1946,6 +1873,7 @@ function extractRecipeBrowser(html) {
1946
1873
  return { recipe: null, source: null };
1947
1874
  }
1948
1875
  function extractJsonLdBrowser(html) {
1876
+ var _a2;
1949
1877
  if (typeof globalThis.DOMParser === "undefined") {
1950
1878
  return null;
1951
1879
  }
@@ -1958,9 +1886,9 @@ function extractJsonLdBrowser(html) {
1958
1886
  if (!content) return;
1959
1887
  const parsed = safeJsonParse(content);
1960
1888
  if (!parsed) return;
1961
- collectCandidates2(parsed, candidates);
1889
+ collectCandidates(parsed, candidates);
1962
1890
  });
1963
- return candidates[0] ?? null;
1891
+ return (_a2 = candidates[0]) != null ? _a2 : null;
1964
1892
  }
1965
1893
  function extractMicrodataBrowser(html) {
1966
1894
  if (typeof globalThis.DOMParser === "undefined") {
@@ -1975,8 +1903,8 @@ function extractMicrodataBrowser(html) {
1975
1903
  const recipe = {
1976
1904
  "@type": "Recipe"
1977
1905
  };
1978
- SIMPLE_PROPS2.forEach((prop) => {
1979
- const value = findPropertyValue2(recipeEl, prop);
1906
+ SIMPLE_PROPS.forEach((prop) => {
1907
+ const value = findPropertyValue(recipeEl, prop);
1980
1908
  if (value) {
1981
1909
  recipe[prop] = value;
1982
1910
  }
@@ -1993,7 +1921,8 @@ function extractMicrodataBrowser(html) {
1993
1921
  }
1994
1922
  const instructions = [];
1995
1923
  recipeEl.querySelectorAll('[itemprop="recipeInstructions"]').forEach((el) => {
1996
- const text = normalizeText(el.getAttribute("content")) || normalizeText(el.querySelector('[itemprop="text"]')?.textContent || void 0) || normalizeText(el.textContent || void 0);
1924
+ var _a2;
1925
+ const text = normalizeText(el.getAttribute("content")) || normalizeText(((_a2 = el.querySelector('[itemprop="text"]')) == null ? void 0 : _a2.textContent) || void 0) || normalizeText(el.textContent || void 0);
1997
1926
  if (text) instructions.push(text);
1998
1927
  });
1999
1928
  if (instructions.length) {
@@ -2004,15 +1933,15 @@ function extractMicrodataBrowser(html) {
2004
1933
  }
2005
1934
  return null;
2006
1935
  }
2007
- function findPropertyValue2(context, prop) {
1936
+ function findPropertyValue(context, prop) {
2008
1937
  const node = context.querySelector(`[itemprop="${prop}"]`);
2009
1938
  if (!node) return void 0;
2010
1939
  return normalizeText(node.getAttribute("content")) || normalizeText(node.getAttribute("href")) || normalizeText(node.getAttribute("src")) || normalizeText(node.textContent || void 0);
2011
1940
  }
2012
- function collectCandidates2(payload, bucket) {
1941
+ function collectCandidates(payload, bucket) {
2013
1942
  if (!payload) return;
2014
1943
  if (Array.isArray(payload)) {
2015
- payload.forEach((entry) => collectCandidates2(entry, bucket));
1944
+ payload.forEach((entry) => collectCandidates(entry, bucket));
2016
1945
  return;
2017
1946
  }
2018
1947
  if (typeof payload !== "object") {
@@ -2024,364 +1953,532 @@ function collectCandidates2(payload, bucket) {
2024
1953
  }
2025
1954
  const graph = payload["@graph"];
2026
1955
  if (Array.isArray(graph)) {
2027
- graph.forEach((entry) => collectCandidates2(entry, bucket));
1956
+ graph.forEach((entry) => collectCandidates(entry, bucket));
2028
1957
  }
2029
1958
  }
2030
1959
 
2031
- // src/scraper/extractors/index.ts
2032
- function isBrowser() {
2033
- try {
2034
- return typeof globalThis.DOMParser !== "undefined";
2035
- } catch {
2036
- return false;
2037
- }
1960
+ // src/scraper/browser.ts
1961
+ function extractSchemaOrgRecipeFromHTML(html) {
1962
+ const { recipe } = extractRecipeBrowser(html);
1963
+ return recipe;
2038
1964
  }
2039
- function extractRecipe(html) {
2040
- if (isBrowser()) {
2041
- return extractRecipeBrowser(html);
2042
- }
2043
- const jsonLdRecipe = extractJsonLd(html);
2044
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2045
- try {
2046
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2047
- if (globalFetch) {
2048
- 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(() => {
2049
- });
2050
- }
2051
- } catch {
2052
- }
1965
+
1966
+ // src/specVersion.ts
1967
+ var SOUSTACK_SPEC_VERSION = "0.3.0";
1968
+
1969
+ // src/conversion/units.ts
1970
+ var MASS_UNITS = {
1971
+ g: {
1972
+ dimension: "mass",
1973
+ toMetricBase: 1,
1974
+ metricBaseUnit: "g",
1975
+ isMetric: true
1976
+ },
1977
+ kg: {
1978
+ dimension: "mass",
1979
+ toMetricBase: 1e3,
1980
+ metricBaseUnit: "g",
1981
+ isMetric: true
1982
+ },
1983
+ oz: {
1984
+ dimension: "mass",
1985
+ toMetricBase: 28.349523125,
1986
+ metricBaseUnit: "g",
1987
+ isMetric: false
1988
+ },
1989
+ lb: {
1990
+ dimension: "mass",
1991
+ toMetricBase: 453.59237,
1992
+ metricBaseUnit: "g",
1993
+ isMetric: false
2053
1994
  }
2054
- if (jsonLdRecipe) {
2055
- return { recipe: jsonLdRecipe, source: "jsonld" };
1995
+ };
1996
+ var VOLUME_UNITS = {
1997
+ ml: {
1998
+ dimension: "volume",
1999
+ toMetricBase: 1,
2000
+ metricBaseUnit: "ml",
2001
+ isMetric: true
2002
+ },
2003
+ l: {
2004
+ dimension: "volume",
2005
+ toMetricBase: 1e3,
2006
+ metricBaseUnit: "ml",
2007
+ isMetric: true
2008
+ },
2009
+ tsp: {
2010
+ dimension: "volume",
2011
+ toMetricBase: 4.92892159375,
2012
+ metricBaseUnit: "ml",
2013
+ isMetric: false
2014
+ },
2015
+ tbsp: {
2016
+ dimension: "volume",
2017
+ toMetricBase: 14.78676478125,
2018
+ metricBaseUnit: "ml",
2019
+ isMetric: false
2020
+ },
2021
+ fl_oz: {
2022
+ dimension: "volume",
2023
+ toMetricBase: 29.5735295625,
2024
+ metricBaseUnit: "ml",
2025
+ isMetric: false
2026
+ },
2027
+ cup: {
2028
+ dimension: "volume",
2029
+ toMetricBase: 236.5882365,
2030
+ metricBaseUnit: "ml",
2031
+ isMetric: false
2032
+ },
2033
+ pint: {
2034
+ dimension: "volume",
2035
+ toMetricBase: 473.176473,
2036
+ metricBaseUnit: "ml",
2037
+ isMetric: false
2038
+ },
2039
+ quart: {
2040
+ dimension: "volume",
2041
+ toMetricBase: 946.352946,
2042
+ metricBaseUnit: "ml",
2043
+ isMetric: false
2044
+ },
2045
+ gallon: {
2046
+ dimension: "volume",
2047
+ toMetricBase: 3785.411784,
2048
+ metricBaseUnit: "ml",
2049
+ isMetric: false
2056
2050
  }
2057
- const microdataRecipe = extractMicrodata(html);
2058
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2059
- try {
2060
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2061
- if (globalFetch) {
2062
- 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(() => {
2063
- });
2064
- }
2065
- } catch {
2066
- }
2051
+ };
2052
+ var COUNT_UNITS = {
2053
+ clove: {
2054
+ dimension: "count",
2055
+ toMetricBase: 1,
2056
+ metricBaseUnit: "count",
2057
+ isMetric: true
2058
+ },
2059
+ sprig: {
2060
+ dimension: "count",
2061
+ toMetricBase: 1,
2062
+ metricBaseUnit: "count",
2063
+ isMetric: true
2064
+ },
2065
+ leaf: {
2066
+ dimension: "count",
2067
+ toMetricBase: 1,
2068
+ metricBaseUnit: "count",
2069
+ isMetric: true
2070
+ },
2071
+ pinch: {
2072
+ dimension: "count",
2073
+ toMetricBase: 1,
2074
+ metricBaseUnit: "count",
2075
+ isMetric: true
2076
+ },
2077
+ bottle: {
2078
+ dimension: "count",
2079
+ toMetricBase: 1,
2080
+ metricBaseUnit: "count",
2081
+ isMetric: true
2082
+ },
2083
+ count: {
2084
+ dimension: "count",
2085
+ toMetricBase: 1,
2086
+ metricBaseUnit: "count",
2087
+ isMetric: true
2067
2088
  }
2068
- if (microdataRecipe) {
2069
- return { recipe: microdataRecipe, source: "microdata" };
2089
+ };
2090
+ var UNIT_DEFINITIONS = {
2091
+ ...MASS_UNITS,
2092
+ ...VOLUME_UNITS,
2093
+ ...COUNT_UNITS
2094
+ };
2095
+ function normalizeUnitToken(unit) {
2096
+ var _a2;
2097
+ if (!unit) {
2098
+ return null;
2070
2099
  }
2071
- return { recipe: null, source: null };
2100
+ const token = unit.trim().toLowerCase().replace(/[\s-]+/g, "_");
2101
+ const canonical = (_a2 = UNIT_SYNONYMS[token]) != null ? _a2 : token;
2102
+ return canonical in UNIT_DEFINITIONS ? canonical : null;
2103
+ }
2104
+ var UNIT_SYNONYMS = {
2105
+ teaspoons: "tsp",
2106
+ teaspoon: "tsp",
2107
+ tsps: "tsp",
2108
+ tbsp: "tbsp",
2109
+ tbsps: "tbsp",
2110
+ tablespoon: "tbsp",
2111
+ tablespoons: "tbsp",
2112
+ cup: "cup",
2113
+ cups: "cup",
2114
+ pint: "pint",
2115
+ pints: "pint",
2116
+ quart: "quart",
2117
+ quarts: "quart",
2118
+ gallon: "gallon",
2119
+ gallons: "gallon",
2120
+ ml: "ml",
2121
+ milliliter: "ml",
2122
+ milliliters: "ml",
2123
+ millilitre: "ml",
2124
+ millilitres: "ml",
2125
+ l: "l",
2126
+ liter: "l",
2127
+ liters: "l",
2128
+ litre: "l",
2129
+ litres: "l",
2130
+ fl_oz: "fl_oz",
2131
+ "fl.oz": "fl_oz",
2132
+ "fl.oz.": "fl_oz",
2133
+ "fl_oz.": "fl_oz",
2134
+ "fl oz": "fl_oz",
2135
+ "fl oz.": "fl_oz",
2136
+ fluid_ounce: "fl_oz",
2137
+ fluid_ounces: "fl_oz",
2138
+ oz: "oz",
2139
+ ounce: "oz",
2140
+ ounces: "oz",
2141
+ lb: "lb",
2142
+ lbs: "lb",
2143
+ pound: "lb",
2144
+ pounds: "lb",
2145
+ g: "g",
2146
+ gram: "g",
2147
+ grams: "g",
2148
+ kg: "kg",
2149
+ kilogram: "kg",
2150
+ kilograms: "kg",
2151
+ clove: "clove",
2152
+ cloves: "clove",
2153
+ sprig: "sprig",
2154
+ sprigs: "sprig",
2155
+ leaf: "leaf",
2156
+ leaves: "leaf",
2157
+ pinch: "pinch",
2158
+ pinches: "pinch",
2159
+ bottle: "bottle",
2160
+ bottles: "bottle",
2161
+ count: "count",
2162
+ counts: "count"
2163
+ };
2164
+ function convertToMetricBase(quantity, unit) {
2165
+ const definition = UNIT_DEFINITIONS[unit];
2166
+ const quantityInMetricBase = quantity * definition.toMetricBase;
2167
+ return {
2168
+ quantity: quantityInMetricBase,
2169
+ baseUnit: definition.metricBaseUnit,
2170
+ definition
2171
+ };
2072
2172
  }
2073
2173
 
2074
- // src/scraper/index.ts
2075
- async function scrapeRecipe(url, options = {}) {
2076
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2077
- try {
2078
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2079
- if (globalFetch) {
2080
- 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(() => {
2081
- });
2082
- }
2083
- } catch {
2084
- }
2174
+ // src/conversion/convertLineItem.ts
2175
+ var UnknownUnitError = class extends Error {
2176
+ constructor(unit) {
2177
+ super(`Unknown unit "${unit}".`);
2178
+ this.unit = unit;
2179
+ this.name = "UnknownUnitError";
2085
2180
  }
2086
- const html = await fetchPage(url, options);
2087
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2088
- try {
2089
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2090
- if (globalFetch) {
2091
- 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(() => {
2092
- });
2093
- }
2094
- } catch {
2095
- }
2181
+ };
2182
+ var UnsupportedConversionError = class extends Error {
2183
+ constructor(unit, mode) {
2184
+ super(`Cannot convert unit "${unit}" in ${mode} mode.`);
2185
+ this.unit = unit;
2186
+ this.mode = mode;
2187
+ this.name = "UnsupportedConversionError";
2096
2188
  }
2097
- const { recipe } = extractRecipe(html);
2098
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2099
- try {
2100
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2101
- if (globalFetch) {
2102
- 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(() => {
2103
- });
2104
- }
2105
- } catch {
2189
+ };
2190
+ var MissingEquivalencyError = class extends Error {
2191
+ constructor(ingredient, unit) {
2192
+ super(
2193
+ `No volume to mass equivalency for "${ingredient}" (${unit}).`
2194
+ );
2195
+ this.ingredient = ingredient;
2196
+ this.unit = unit;
2197
+ this.name = "MissingEquivalencyError";
2198
+ }
2199
+ };
2200
+ var VOLUME_TO_MASS_EQUIV_G_PER_UNIT = {
2201
+ flour: {
2202
+ cup: 120
2203
+ }
2204
+ };
2205
+ var DEFAULT_ROUND_MODE = "sane";
2206
+ function convertLineItemToMetric(item, mode, opts) {
2207
+ var _a2, _b2, _c, _d;
2208
+ const roundMode = (_a2 = opts == null ? void 0 : opts.round) != null ? _a2 : DEFAULT_ROUND_MODE;
2209
+ const normalizedUnit = normalizeUnitToken(item.unit);
2210
+ if (!normalizedUnit) {
2211
+ if (!item.unit || item.unit.trim() === "") {
2212
+ return item;
2106
2213
  }
2214
+ throw new UnknownUnitError(item.unit);
2107
2215
  }
2108
- if (!recipe) {
2109
- throw new Error("No Schema.org recipe data found in page");
2216
+ const definition = UNIT_DEFINITIONS[normalizedUnit];
2217
+ if (definition.dimension === "count") {
2218
+ return item;
2110
2219
  }
2111
- const soustackRecipe = fromSchemaOrg(recipe);
2112
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2113
- try {
2114
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2115
- if (globalFetch) {
2116
- 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(() => {
2117
- });
2118
- }
2119
- } catch {
2220
+ if (mode === "volume") {
2221
+ if (definition.dimension !== "volume") {
2222
+ throw new UnsupportedConversionError((_b2 = item.unit) != null ? _b2 : "", mode);
2120
2223
  }
2224
+ const { quantity, unit } = finalizeMetricVolume(
2225
+ convertToMetricBase(item.quantity, normalizedUnit).quantity,
2226
+ roundMode
2227
+ );
2228
+ return {
2229
+ ...item,
2230
+ quantity,
2231
+ unit
2232
+ };
2121
2233
  }
2122
- if (!soustackRecipe) {
2123
- throw new Error("Schema.org data did not include a valid recipe");
2234
+ if (definition.dimension === "mass") {
2235
+ const { quantity, unit } = finalizeMetricMass(
2236
+ convertToMetricBase(item.quantity, normalizedUnit).quantity,
2237
+ roundMode
2238
+ );
2239
+ return {
2240
+ ...item,
2241
+ quantity,
2242
+ unit
2243
+ };
2124
2244
  }
2125
- return soustackRecipe;
2126
- }
2127
- function extractRecipeFromHTML(html) {
2128
- const { recipe } = extractRecipe(html);
2129
- if (!recipe) {
2130
- throw new Error("No Schema.org recipe data found in HTML");
2245
+ if (definition.dimension !== "volume") {
2246
+ throw new UnsupportedConversionError((_c = item.unit) != null ? _c : "", mode);
2131
2247
  }
2132
- const soustackRecipe = fromSchemaOrg(recipe);
2133
- if (!soustackRecipe) {
2134
- throw new Error("Schema.org data did not include a valid recipe");
2248
+ const gramsPerUnit = lookupEquivalency(
2249
+ item.ingredient,
2250
+ normalizedUnit
2251
+ );
2252
+ if (!gramsPerUnit) {
2253
+ throw new MissingEquivalencyError(item.ingredient, (_d = item.unit) != null ? _d : "");
2135
2254
  }
2136
- return soustackRecipe;
2137
- }
2138
- function extractSchemaOrgRecipeFromHTML(html) {
2139
- const { recipe } = extractRecipe(html);
2140
- return recipe;
2141
- }
2142
-
2143
- // src/parsers/yield.ts
2144
- var RANGE_PATTERN = /^(\d+)(?:\s*(?:[-–—]|to)\s*)(\d+)\s+(.+)$/i;
2145
- var MAKES_PREFIX = /^(makes?|yields?)\s*:?\s*(.+)$/i;
2146
- var APPROX_PREFIX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
2147
- var SERVING_UNITS = ["servings", "serving", "portions", "portion", "people", "persons"];
2148
- var DEFAULT_DOZEN_UNIT = "cookies";
2149
- var NUMBER_WORDS2 = {
2150
- a: 1,
2151
- an: 1,
2152
- one: 1,
2153
- two: 2,
2154
- three: 3,
2155
- four: 4,
2156
- five: 5,
2157
- six: 6,
2158
- seven: 7,
2159
- eight: 8,
2160
- nine: 9,
2161
- ten: 10,
2162
- eleven: 11,
2163
- twelve: 12
2164
- };
2165
- function normalizeYield(text) {
2166
- if (!text || typeof text !== "string") return "";
2167
- return text.normalize("NFKC").replace(/\u00A0/g, " ").replace(/[–—−]/g, "-").trim().replace(/\s+/g, " ");
2255
+ const grams = item.quantity * gramsPerUnit;
2256
+ const massResult = finalizeMetricMass(grams, roundMode);
2257
+ return {
2258
+ ...item,
2259
+ quantity: massResult.quantity,
2260
+ unit: massResult.unit,
2261
+ notes: `Converted using ${gramsPerUnit}g per ${normalizedUnit} for ${item.ingredient}.`
2262
+ };
2168
2263
  }
2169
- function parseYield2(text) {
2170
- const normalized = normalizeYield(text);
2171
- if (!normalized) return null;
2172
- const { main, paren } = extractParenthetical(normalized);
2173
- const core = parseYieldCore(main, normalized);
2174
- if (!core) return null;
2175
- const servingsFromParen = paren ? extractServingsFromParen(paren) : null;
2176
- if (servingsFromParen !== null) {
2177
- core.servings = servingsFromParen;
2178
- core.description = normalized;
2264
+ function finalizeMetricVolume(milliliters, roundMode) {
2265
+ if (roundMode === "none") {
2266
+ return milliliters >= 1e3 ? { quantity: milliliters / 1e3, unit: "l" } : { quantity: milliliters, unit: "ml" };
2179
2267
  }
2180
- if (core.servings === void 0) {
2181
- const inferred = inferServings(core.amount, core.unit);
2182
- if (inferred !== void 0) {
2183
- core.servings = inferred;
2184
- }
2268
+ const roundedMl = roundMilliliters(milliliters);
2269
+ if (roundedMl >= 1e3) {
2270
+ const liters = roundedMl / 1e3;
2271
+ return {
2272
+ quantity: roundLargeMetric(liters),
2273
+ unit: "l"
2274
+ };
2185
2275
  }
2186
- return core;
2276
+ return { quantity: roundedMl, unit: "ml" };
2187
2277
  }
2188
- function formatYield2(value) {
2189
- if (value.description) {
2190
- return value.description;
2278
+ function finalizeMetricMass(grams, roundMode) {
2279
+ if (roundMode === "none") {
2280
+ return grams >= 1e3 ? { quantity: grams / 1e3, unit: "kg" } : { quantity: grams, unit: "g" };
2191
2281
  }
2192
- if (value.servings && value.unit === "servings") {
2193
- return `Serves ${value.amount}`;
2194
- }
2195
- let result = `${value.amount} ${value.unit}`.trim();
2196
- if (value.servings && value.unit !== "servings") {
2197
- result += ` (${value.servings} servings)`;
2282
+ const roundedGrams = roundGrams(grams);
2283
+ if (roundedGrams >= 1e3) {
2284
+ const kilograms = roundedGrams / 1e3;
2285
+ return {
2286
+ quantity: roundLargeMetric(kilograms),
2287
+ unit: "kg"
2288
+ };
2198
2289
  }
2199
- return result;
2290
+ return { quantity: roundedGrams, unit: "g" };
2200
2291
  }
2201
- function parseYieldCore(text, original) {
2202
- return parseServesPattern(text, original) ?? parseMakesPattern(text, original) ?? parseRangePattern(text, original) ?? parseNumberUnitPattern(text, original) ?? parsePlainNumberPattern(text);
2203
- }
2204
- function parseServesPattern(text, original) {
2205
- const patterns = [
2206
- /^serves?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
2207
- /^servings?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
2208
- /^serving\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
2209
- /^makes?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i,
2210
- /^(\d+)\s+servings?$/i
2211
- ];
2212
- for (const regex of patterns) {
2213
- const match = text.match(regex);
2214
- if (!match) continue;
2215
- const amount = parseInt(match[1], 10);
2216
- if (Number.isNaN(amount)) continue;
2217
- const result = {
2218
- amount,
2219
- unit: "servings",
2220
- servings: amount
2221
- };
2222
- if (match[2]) {
2223
- result.description = original;
2224
- }
2225
- return result;
2292
+ function roundGrams(value) {
2293
+ if (value < 1e3) {
2294
+ return Math.round(value);
2226
2295
  }
2227
- return null;
2296
+ return Math.round(value / 5) * 5;
2228
2297
  }
2229
- function parseMakesPattern(text, original) {
2230
- const match = text.match(MAKES_PREFIX);
2231
- if (!match) return null;
2232
- const remainder = match[2].trim();
2233
- if (!remainder) return null;
2234
- const servingsMatch = remainder.match(/^(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i);
2235
- if (servingsMatch) {
2236
- const amount = parseInt(servingsMatch[1], 10);
2237
- const result = {
2238
- amount,
2239
- unit: "servings",
2240
- servings: amount
2241
- };
2242
- if (servingsMatch[2]) {
2243
- result.description = original;
2244
- }
2245
- return result;
2298
+ function roundMilliliters(value) {
2299
+ if (value < 1e3) {
2300
+ return Math.round(value);
2246
2301
  }
2247
- return parseRangePattern(remainder, original) ?? parseNumberUnitPattern(remainder, original) ?? parsePlainNumberPattern(remainder);
2302
+ return Math.round(value / 10) * 10;
2248
2303
  }
2249
- function parseRangePattern(text, descriptionSource) {
2250
- const match = text.match(RANGE_PATTERN);
2251
- if (!match) return null;
2252
- const amount = parseInt(match[1], 10);
2253
- const unit = cleanupUnit(match[3]);
2254
- if (!unit) return null;
2255
- const result = {
2256
- amount,
2257
- unit,
2258
- description: descriptionSource
2259
- };
2260
- return result;
2304
+ function roundLargeMetric(value) {
2305
+ return Math.round(value * 100) / 100;
2261
2306
  }
2262
- function parseNumberUnitPattern(text, descriptionSource) {
2263
- if (!text) return null;
2264
- const { value, approximate } = stripApproximation(text);
2265
- if (!value) return null;
2266
- const dozenResult = handleDozen(value);
2267
- if (dozenResult) {
2268
- const unit = cleanupUnit(dozenResult.remainder || DEFAULT_DOZEN_UNIT);
2269
- const parsed = {
2270
- amount: dozenResult.amount,
2271
- unit
2307
+ function lookupEquivalency(ingredient, unit) {
2308
+ var _a2;
2309
+ const key = ingredient.trim().toLowerCase();
2310
+ return (_a2 = VOLUME_TO_MASS_EQUIV_G_PER_UNIT[key]) == null ? void 0 : _a2[unit];
2311
+ }
2312
+
2313
+ // src/mise-en-place/index.ts
2314
+ function miseEnPlace(ingredients) {
2315
+ const list = Array.isArray(ingredients) ? ingredients : [];
2316
+ const prepGroups = /* @__PURE__ */ new Map();
2317
+ const stateGroups = /* @__PURE__ */ new Map();
2318
+ let measureTask;
2319
+ let otherTask;
2320
+ const ungrouped = [];
2321
+ for (const ingredient of list) {
2322
+ if (!ingredient || typeof ingredient !== "object") continue;
2323
+ const label = deriveIngredientLabel(ingredient);
2324
+ const quantity = normalizeQuantity(ingredient.quantity);
2325
+ const baseNotes = toDisplayString(ingredient.notes);
2326
+ const prepNotes = toDisplayString(ingredient.prep);
2327
+ const isOptional = typeof ingredient.optional === "boolean" ? ingredient.optional : void 0;
2328
+ const buildItem = (extraNotes) => {
2329
+ const item = {
2330
+ ingredient: label
2331
+ };
2332
+ if (quantity) {
2333
+ item.quantity = { ...quantity };
2334
+ }
2335
+ if (typeof isOptional === "boolean") {
2336
+ item.optional = isOptional;
2337
+ }
2338
+ const notes = combineNotes(extraNotes, baseNotes);
2339
+ if (notes) {
2340
+ item.notes = notes;
2341
+ }
2342
+ return item;
2272
2343
  };
2273
- if (approximate) {
2274
- parsed.description = descriptionSource;
2275
- }
2276
- return parsed;
2277
- }
2278
- const numericMatch = value.match(/^(\d+(?:\.\d+)?)\s+(.+)$/);
2279
- if (numericMatch) {
2280
- const amount = parseFloat(numericMatch[1]);
2281
- if (!Number.isNaN(amount)) {
2282
- const unit = cleanupUnit(numericMatch[2]);
2283
- if (unit) {
2284
- const parsed = { amount, unit };
2285
- if (approximate) {
2286
- parsed.description = descriptionSource;
2287
- }
2288
- return parsed;
2344
+ let addedToTask = false;
2345
+ let hasPrepGrouping = false;
2346
+ const prepActionKeys = extractNormalizedList(ingredient.prepActions);
2347
+ if (prepActionKeys.length > 0) {
2348
+ hasPrepGrouping = true;
2349
+ for (const actionKey of prepActionKeys) {
2350
+ const task = ensureGroup(prepGroups, actionKey, () => ({
2351
+ category: "prep",
2352
+ action: actionKey,
2353
+ items: []
2354
+ }));
2355
+ task.items.push(buildItem());
2356
+ addedToTask = true;
2289
2357
  }
2290
- }
2291
- }
2292
- const wordMatch = value.match(/^([a-zA-Z]+)\s+(.+)$/);
2293
- if (wordMatch) {
2294
- const amount = wordToNumber(wordMatch[1]);
2295
- if (amount !== null) {
2296
- const unit = cleanupUnit(wordMatch[2]);
2297
- if (unit) {
2298
- const parsed = { amount, unit };
2299
- if (approximate) {
2300
- parsed.description = descriptionSource;
2301
- }
2302
- return parsed;
2358
+ } else {
2359
+ const singleActionKey = normalizeKey(ingredient.prepAction);
2360
+ if (singleActionKey) {
2361
+ hasPrepGrouping = true;
2362
+ const task = ensureGroup(prepGroups, singleActionKey, () => ({
2363
+ category: "prep",
2364
+ action: singleActionKey,
2365
+ items: []
2366
+ }));
2367
+ task.items.push(buildItem());
2368
+ addedToTask = true;
2369
+ } else if (prepNotes) {
2370
+ otherTask = otherTask != null ? otherTask : { category: "other", items: [] };
2371
+ otherTask.items.push(buildItem(prepNotes));
2372
+ addedToTask = true;
2303
2373
  }
2304
2374
  }
2375
+ const formKey = normalizeKey(ingredient.form);
2376
+ const hasStateGrouping = Boolean(formKey);
2377
+ if (formKey) {
2378
+ const task = ensureGroup(stateGroups, formKey, () => ({
2379
+ category: "state",
2380
+ form: formKey,
2381
+ items: []
2382
+ }));
2383
+ task.items.push(buildItem());
2384
+ addedToTask = true;
2385
+ }
2386
+ const shouldMeasure = Boolean(quantity) && !hasPrepGrouping && !hasStateGrouping;
2387
+ if (shouldMeasure) {
2388
+ measureTask = measureTask != null ? measureTask : { category: "measure", items: [] };
2389
+ measureTask.items.push(buildItem());
2390
+ addedToTask = true;
2391
+ }
2392
+ if (!addedToTask) {
2393
+ ungrouped.push(ingredient);
2394
+ }
2305
2395
  }
2306
- return null;
2396
+ const tasks = [
2397
+ ...Array.from(prepGroups.values()).sort((a, b) => localeCompare(a.action, b.action)),
2398
+ ...Array.from(stateGroups.values()).sort((a, b) => localeCompare(a.form, b.form))
2399
+ ];
2400
+ if (measureTask) {
2401
+ tasks.push(measureTask);
2402
+ }
2403
+ if (otherTask) {
2404
+ tasks.push(otherTask);
2405
+ }
2406
+ return { tasks, ungrouped };
2307
2407
  }
2308
- function parsePlainNumberPattern(text) {
2309
- const match = text.match(/^(\d+)$/);
2310
- if (!match) return null;
2311
- const amount = parseInt(match[1], 10);
2312
- if (Number.isNaN(amount)) return null;
2313
- return {
2314
- amount,
2315
- unit: "servings",
2316
- servings: amount
2317
- };
2408
+ function deriveIngredientLabel(ingredient) {
2409
+ var _a2, _b2, _c;
2410
+ return (_c = (_b2 = (_a2 = toDisplayString(ingredient.name)) != null ? _a2 : toDisplayString(ingredient.item)) != null ? _b2 : toDisplayString(ingredient.id)) != null ? _c : "ingredient";
2318
2411
  }
2319
- function stripApproximation(value) {
2320
- const match = value.match(APPROX_PREFIX);
2321
- if (!match) {
2322
- return { value: value.trim(), approximate: false };
2412
+ function extractNormalizedList(values) {
2413
+ if (!Array.isArray(values)) {
2414
+ return [];
2323
2415
  }
2324
- const stripped = value.slice(match[0].length).trim();
2325
- return { value: stripped, approximate: true };
2326
- }
2327
- function handleDozen(text) {
2328
- const match = text.match(
2329
- /^((?:\d+(?:\.\d+)?)|(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|a|an|half))\s+dozens?\b(.*)$/i
2330
- );
2331
- if (!match) return null;
2332
- const token = match[1].toLowerCase();
2333
- let multiplier = null;
2334
- if (token === "half") {
2335
- multiplier = 0.5;
2336
- } else if (!Number.isNaN(Number(token))) {
2337
- multiplier = parseFloat(token);
2338
- } else {
2339
- multiplier = wordToNumber(token);
2416
+ const seen = /* @__PURE__ */ new Set();
2417
+ const result = [];
2418
+ for (const value of values) {
2419
+ const key = normalizeKey(value);
2420
+ if (key && !seen.has(key)) {
2421
+ seen.add(key);
2422
+ result.push(key);
2423
+ }
2340
2424
  }
2341
- if (multiplier === null) return null;
2342
- const amount = multiplier * 12;
2343
- return {
2344
- amount,
2345
- remainder: match[2].trim()
2346
- };
2425
+ return result;
2347
2426
  }
2348
- function cleanupUnit(value) {
2349
- let unit = value.trim();
2350
- unit = unit.replace(/^[,.-]+/, "").trim();
2351
- unit = unit.replace(/[.,]+$/, "").trim();
2352
- unit = unit.replace(/^of\s+/i, "").trim();
2353
- return unit;
2427
+ function normalizeKey(value) {
2428
+ if (typeof value !== "string") {
2429
+ return null;
2430
+ }
2431
+ const trimmed = value.trim().toLowerCase();
2432
+ return trimmed || null;
2354
2433
  }
2355
- function extractParenthetical(text) {
2356
- const match = text.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
2357
- if (!match) {
2358
- return { main: text, paren: null };
2434
+ function toDisplayString(value) {
2435
+ if (typeof value !== "string") {
2436
+ return void 0;
2359
2437
  }
2360
- return {
2361
- main: match[1].trim(),
2362
- paren: match[2].trim()
2363
- };
2438
+ const trimmed = value.trim();
2439
+ return trimmed || void 0;
2364
2440
  }
2365
- function extractServingsFromParen(text) {
2366
- const match = text.match(/(\d+)/);
2367
- if (!match) return null;
2368
- const value = parseInt(match[1], 10);
2369
- return Number.isNaN(value) ? null : value;
2441
+ function combineNotes(...notes) {
2442
+ const cleaned = notes.map((note) => toDisplayString(note != null ? note : void 0)).filter(Boolean);
2443
+ if (cleaned.length === 0) {
2444
+ return void 0;
2445
+ }
2446
+ return cleaned.join(" | ");
2370
2447
  }
2371
- function inferServings(amount, unit) {
2372
- if (SERVING_UNITS.includes(unit.toLowerCase())) {
2373
- return amount;
2448
+ function normalizeQuantity(quantity) {
2449
+ if (!quantity || typeof quantity !== "object") {
2450
+ return void 0;
2374
2451
  }
2375
- return void 0;
2452
+ const amount = quantity.amount;
2453
+ if (typeof amount !== "number" || Number.isNaN(amount)) {
2454
+ return void 0;
2455
+ }
2456
+ const normalized = { amount };
2457
+ if ("unit" in quantity) {
2458
+ const unit = quantity.unit;
2459
+ if (typeof unit === "string") {
2460
+ const trimmed = unit.trim();
2461
+ if (trimmed) {
2462
+ normalized.unit = trimmed;
2463
+ }
2464
+ } else if (unit === null) {
2465
+ normalized.unit = null;
2466
+ }
2467
+ }
2468
+ return normalized;
2376
2469
  }
2377
- function wordToNumber(word) {
2378
- const normalized = word.toLowerCase();
2379
- if (NUMBER_WORDS2.hasOwnProperty(normalized)) {
2380
- return NUMBER_WORDS2[normalized];
2470
+ function ensureGroup(map, key, factory) {
2471
+ let task = map.get(key);
2472
+ if (!task) {
2473
+ task = factory();
2474
+ map.set(key, task);
2381
2475
  }
2382
- return null;
2476
+ return task;
2477
+ }
2478
+ function localeCompare(left, right) {
2479
+ return (left != null ? left : "").localeCompare(right != null ? right : "");
2383
2480
  }
2384
2481
 
2385
- export { extractRecipeFromHTML, extractSchemaOrgRecipeFromHTML, formatDuration, formatYield2 as formatYield, fromSchemaOrg, normalizeImage, normalizeIngredientInput, normalizeYield, parseDuration, parseHumanDuration, parseIngredient, parseIngredientLine, parseIngredients, parseYield2 as parseYield, scaleRecipe, scrapeRecipe, smartParseDuration, toSchemaOrg, validateRecipe };
2482
+ export { MissingEquivalencyError, SOUSTACK_SPEC_VERSION, UnknownUnitError, UnsupportedConversionError, convertLineItemToMetric, detectProfiles, extractSchemaOrgRecipeFromHTML, fromSchemaOrg, miseEnPlace, scaleRecipe, toSchemaOrg, validateRecipe };
2386
2483
  //# sourceMappingURL=index.mjs.map
2387
2484
  //# sourceMappingURL=index.mjs.map