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/cli/index.js CHANGED
@@ -3,9 +3,10 @@
3
3
 
4
4
  var fs = require('fs');
5
5
  var path = require('path');
6
+ var glob = require('glob');
7
+ var cheerio = require('cheerio');
6
8
  var Ajv = require('ajv');
7
9
  var addFormats = require('ajv-formats');
8
- var cheerio = require('cheerio');
9
10
 
10
11
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
11
12
 
@@ -32,1110 +33,239 @@ var path__namespace = /*#__PURE__*/_interopNamespace(path);
32
33
  var Ajv__default = /*#__PURE__*/_interopDefault(Ajv);
33
34
  var addFormats__default = /*#__PURE__*/_interopDefault(addFormats);
34
35
 
36
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
37
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
38
+ }) : x)(function(x) {
39
+ if (typeof require !== "undefined") return require.apply(this, arguments);
40
+ throw Error('Dynamic require of "' + x + '" is not supported');
41
+ });
42
+
43
+ // src/parsers/duration.ts
44
+ var ISO_DURATION_REGEX = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i;
45
+ var HUMAN_OVERNIGHT = 8 * 60;
46
+ function isFiniteNumber(value) {
47
+ return typeof value === "number" && Number.isFinite(value);
48
+ }
49
+ function parseDuration(iso) {
50
+ if (typeof iso === "number" && Number.isFinite(iso)) {
51
+ return iso;
52
+ }
53
+ if (!iso || typeof iso !== "string") return null;
54
+ const trimmed = iso.trim();
55
+ if (!trimmed) return null;
56
+ const match = trimmed.match(ISO_DURATION_REGEX);
57
+ if (!match) return null;
58
+ const [, daysRaw, hoursRaw, minutesRaw, secondsRaw] = match;
59
+ if (!daysRaw && !hoursRaw && !minutesRaw && !secondsRaw) {
60
+ return null;
61
+ }
62
+ let total = 0;
63
+ if (daysRaw) total += parseFloat(daysRaw) * 24 * 60;
64
+ if (hoursRaw) total += parseFloat(hoursRaw) * 60;
65
+ if (minutesRaw) total += parseFloat(minutesRaw);
66
+ if (secondsRaw) total += Math.ceil(parseFloat(secondsRaw) / 60);
67
+ return Math.round(total);
68
+ }
69
+ function formatDuration(minutes) {
70
+ if (!isFiniteNumber(minutes) || minutes <= 0) {
71
+ return "PT0M";
72
+ }
73
+ const rounded = Math.round(minutes);
74
+ const days = Math.floor(rounded / (24 * 60));
75
+ const afterDays = rounded % (24 * 60);
76
+ const hours = Math.floor(afterDays / 60);
77
+ const mins = afterDays % 60;
78
+ let result = "P";
79
+ if (days > 0) {
80
+ result += `${days}D`;
81
+ }
82
+ if (hours > 0 || mins > 0) {
83
+ result += "T";
84
+ if (hours > 0) {
85
+ result += `${hours}H`;
86
+ }
87
+ if (mins > 0) {
88
+ result += `${mins}M`;
89
+ }
90
+ }
91
+ if (result === "P") {
92
+ return "PT0M";
93
+ }
94
+ return result;
95
+ }
96
+ function parseHumanDuration(text) {
97
+ if (!text || typeof text !== "string") return null;
98
+ const normalized = text.toLowerCase().trim();
99
+ if (!normalized) return null;
100
+ if (normalized === "overnight") {
101
+ return HUMAN_OVERNIGHT;
102
+ }
103
+ let total = 0;
104
+ const hourRegex = /(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|hr|h)\b/g;
105
+ let hourMatch;
106
+ while ((hourMatch = hourRegex.exec(normalized)) !== null) {
107
+ total += parseFloat(hourMatch[1]) * 60;
108
+ }
109
+ const minuteRegex = /(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|min|m)\b/g;
110
+ let minuteMatch;
111
+ while ((minuteMatch = minuteRegex.exec(normalized)) !== null) {
112
+ total += parseFloat(minuteMatch[1]);
113
+ }
114
+ if (total <= 0) {
115
+ return null;
116
+ }
117
+ return Math.round(total);
118
+ }
119
+ function smartParseDuration(input) {
120
+ const iso = parseDuration(input);
121
+ if (iso !== null) {
122
+ return iso;
123
+ }
124
+ return parseHumanDuration(input);
125
+ }
126
+
35
127
  // src/parser.ts
36
- function scaleRecipe(recipe, targetYieldAmount) {
37
- const baseYield = recipe.yield?.amount || 1;
38
- const multiplier = targetYieldAmount / baseYield;
39
- const flatIngredients = flattenIngredients(recipe.ingredients);
40
- const scaledIngredientsMap = /* @__PURE__ */ new Map();
41
- flatIngredients.forEach((ing) => {
42
- if (isIndependent(ing.scaling?.type)) {
43
- const computed = calculateIngredient(ing, multiplier);
44
- scaledIngredientsMap.set(ing.id || ing.item, computed);
128
+ function scaleRecipe(recipe, options = {}) {
129
+ const multiplier = resolveMultiplier(recipe, options);
130
+ const scaled = deepClone(recipe);
131
+ applyYieldScaling(scaled, options, multiplier);
132
+ const baseAmounts = collectBaseIngredientAmounts(scaled.ingredients || []);
133
+ const scaledAmounts = /* @__PURE__ */ new Map();
134
+ const orderedIngredients = [];
135
+ collectIngredients(scaled.ingredients || [], orderedIngredients);
136
+ orderedIngredients.filter((ing) => (ing.scaling?.type || "linear") !== "bakers_percentage").forEach((ing) => {
137
+ const key = getIngredientKey(ing);
138
+ scaledAmounts.set(key, calculateIndependentIngredient(ing, multiplier));
139
+ });
140
+ orderedIngredients.filter((ing) => ing.scaling?.type === "bakers_percentage").forEach((ing) => {
141
+ const key = getIngredientKey(ing);
142
+ const scaling = ing.scaling;
143
+ if (!scaling?.referenceId) {
144
+ throw new Error(`Baker's percentage ingredient "${key}" is missing a referenceId`);
145
+ }
146
+ const referenceAmount = scaledAmounts.get(scaling.referenceId);
147
+ if (referenceAmount === void 0) {
148
+ throw new Error(`Reference ingredient "${scaling.referenceId}" not found for baker's percentage item "${key}"`);
45
149
  }
150
+ const baseAmount = ing.quantity?.amount || 0;
151
+ const referenceBase = baseAmounts.get(scaling.referenceId);
152
+ const factor = scaling.factor ?? (referenceBase ? baseAmount / referenceBase : void 0);
153
+ if (factor === void 0) {
154
+ throw new Error(`Unable to determine factor for baker's percentage ingredient "${key}"`);
155
+ }
156
+ scaledAmounts.set(key, referenceAmount * factor);
46
157
  });
47
- flatIngredients.forEach((ing) => {
48
- if (!isIndependent(ing.scaling?.type)) {
49
- if (ing.scaling?.type === "bakers_percentage" && ing.scaling.referenceId) {
50
- const refIng = scaledIngredientsMap.get(ing.scaling.referenceId);
51
- if (refIng) refIng.amount;
52
- }
53
- const computed = calculateIngredient(ing, multiplier);
54
- scaledIngredientsMap.set(ing.id || ing.item, computed);
158
+ orderedIngredients.forEach((ing) => {
159
+ const key = getIngredientKey(ing);
160
+ const amount = scaledAmounts.get(key);
161
+ if (amount === void 0) return;
162
+ if (!ing.quantity) {
163
+ ing.quantity = { amount, unit: null };
164
+ } else {
165
+ ing.quantity.amount = amount;
55
166
  }
56
167
  });
57
- const flatInstructions = flattenInstructions(recipe.instructions);
58
- const computedInstructions = flatInstructions.map(
59
- (inst) => calculateInstruction(inst, multiplier)
60
- );
61
- const timing = computedInstructions.reduce(
62
- (acc, step) => {
63
- if (step.type === "active") acc.active += step.durationMinutes;
64
- else acc.passive += step.durationMinutes;
65
- acc.total += step.durationMinutes;
66
- return acc;
67
- },
68
- { active: 0, passive: 0, total: 0 }
69
- );
70
- return {
71
- metadata: {
72
- targetYield: targetYieldAmount,
73
- baseYield,
74
- multiplier
75
- },
76
- ingredients: Array.from(scaledIngredientsMap.values()),
77
- instructions: computedInstructions,
78
- timing
168
+ scaleInstructionItems(scaled.instructions || [], multiplier);
169
+ return scaled;
170
+ }
171
+ function resolveMultiplier(recipe, options) {
172
+ if (options.multiplier && options.multiplier > 0) {
173
+ return options.multiplier;
174
+ }
175
+ if (options.targetYield?.amount) {
176
+ const base = recipe.yield?.amount || 1;
177
+ return options.targetYield.amount / base;
178
+ }
179
+ return 1;
180
+ }
181
+ function applyYieldScaling(recipe, options, multiplier) {
182
+ const baseAmount = recipe.yield?.amount ?? 1;
183
+ const targetAmount = options.targetYield?.amount ?? baseAmount * multiplier;
184
+ const unit = options.targetYield?.unit ?? recipe.yield?.unit;
185
+ if (!recipe.yield && !options.targetYield) return;
186
+ recipe.yield = {
187
+ amount: targetAmount,
188
+ unit: unit ?? ""
79
189
  };
80
190
  }
81
- function isIndependent(type) {
82
- return !type || type === "linear" || type === "fixed" || type === "discrete";
191
+ function getIngredientKey(ing) {
192
+ return ing.id || ing.item;
83
193
  }
84
- function calculateIngredient(ing, multiplier, referenceValue) {
194
+ function calculateIndependentIngredient(ing, multiplier) {
85
195
  const baseAmount = ing.quantity?.amount || 0;
86
196
  const type = ing.scaling?.type || "linear";
87
- let newAmount = baseAmount;
88
197
  switch (type) {
89
- case "linear":
90
- newAmount = baseAmount * multiplier;
91
- break;
92
198
  case "fixed":
93
- newAmount = baseAmount;
94
- break;
95
- case "discrete":
96
- const raw = baseAmount * multiplier;
97
- const step = ing.scaling.roundTo || 1;
98
- newAmount = Math.round(raw / step) * step;
99
- break;
100
- case "bakers_percentage":
101
- newAmount = baseAmount * multiplier;
102
- break;
103
- }
104
- const unit = ing.quantity?.unit || "";
105
- const ingredientName = ing.name || extractNameFromItem(ing.item);
106
- const text = `${parseFloat(newAmount.toFixed(2))}${unit ? " " + unit : ""} ${ingredientName}`;
107
- return {
108
- id: ing.id || ing.item,
109
- name: ingredientName,
110
- amount: newAmount,
111
- unit: ing.quantity?.unit || null,
112
- text,
113
- notes: ing.notes
114
- };
115
- }
116
- function extractNameFromItem(item) {
117
- const match = item.match(/^\s*\d+(?:\.\d+)?\s*\w*\s*(.+)$/);
118
- return match ? match[1].trim() : item;
119
- }
120
- function calculateInstruction(inst, multiplier) {
121
- const baseDuration = inst.timing?.duration || 0;
122
- const scalingType = inst.timing?.scaling || "fixed";
123
- let newDuration = baseDuration;
124
- if (scalingType === "linear") {
125
- newDuration = baseDuration * multiplier;
126
- } else if (scalingType === "sqrt") {
127
- newDuration = baseDuration * Math.sqrt(multiplier);
199
+ return baseAmount;
200
+ case "discrete": {
201
+ const scaled = baseAmount * multiplier;
202
+ const step = ing.scaling?.roundTo ?? 1;
203
+ const rounded = Math.round(scaled / step) * step;
204
+ return Math.round(rounded);
205
+ }
206
+ case "proportional": {
207
+ const factor = ing.scaling?.factor ?? 1;
208
+ return baseAmount * multiplier * factor;
209
+ }
210
+ default:
211
+ return baseAmount * multiplier;
128
212
  }
129
- return {
130
- id: inst.id || "step",
131
- text: inst.text,
132
- durationMinutes: Math.ceil(newDuration),
133
- type: inst.timing?.type || "active"
134
- };
135
213
  }
136
- function flattenIngredients(items) {
137
- const result = [];
214
+ function collectIngredients(items, bucket) {
138
215
  items.forEach((item) => {
139
- if (typeof item === "string") {
140
- result.push({ item, quantity: { amount: 0, unit: null }, scaling: { type: "fixed" } });
141
- } else if ("subsection" in item) {
142
- result.push(...flattenIngredients(item.items));
216
+ if (typeof item === "string") return;
217
+ if ("subsection" in item) {
218
+ collectIngredients(item.items, bucket);
143
219
  } else {
144
- result.push(item);
220
+ bucket.push(item);
145
221
  }
146
222
  });
147
- return result;
148
223
  }
149
- function flattenInstructions(items) {
150
- const result = [];
224
+ function collectBaseIngredientAmounts(items, map = /* @__PURE__ */ new Map()) {
151
225
  items.forEach((item) => {
152
- if (typeof item === "string") {
153
- result.push({ text: item, timing: { duration: 0, type: "active" } });
154
- } else if ("subsection" in item) {
155
- result.push(...flattenInstructions(item.items));
226
+ if (typeof item === "string") return;
227
+ if ("subsection" in item) {
228
+ collectBaseIngredientAmounts(item.items, map);
156
229
  } else {
157
- result.push(item);
230
+ map.set(getIngredientKey(item), item.quantity?.amount ?? 0);
158
231
  }
159
232
  });
160
- return result;
233
+ return map;
161
234
  }
162
-
163
- // src/schema.json
164
- var schema_default = {
165
- $schema: "http://json-schema.org/draft-07/schema#",
166
- $id: "http://soustack.org/schema/v0.2",
167
- title: "Soustack Recipe Schema v0.2",
168
- description: "A portable, scalable, interoperable recipe format.",
169
- type: "object",
170
- required: ["name", "ingredients", "instructions"],
171
- properties: {
172
- id: {
173
- type: "string",
174
- description: "Unique identifier (slug or UUID)"
175
- },
176
- name: {
177
- type: "string",
178
- description: "The title of the recipe"
179
- },
180
- version: {
181
- type: "string",
182
- pattern: "^\\d+\\.\\d+\\.\\d+$",
183
- description: "Semantic versioning (e.g., 1.0.0)"
184
- },
185
- description: {
186
- type: "string"
187
- },
188
- category: {
189
- type: "string",
190
- examples: ["Main Course", "Dessert"]
191
- },
192
- tags: {
193
- type: "array",
194
- items: { type: "string" }
195
- },
196
- image: {
197
- description: "Recipe-level hero image(s)",
198
- anyOf: [
199
- {
200
- type: "string",
201
- format: "uri"
202
- },
203
- {
204
- type: "array",
205
- minItems: 1,
206
- items: {
207
- type: "string",
208
- format: "uri"
209
- }
210
- }
211
- ]
212
- },
213
- dateAdded: {
214
- type: "string",
215
- format: "date-time"
216
- },
217
- source: {
218
- type: "object",
219
- properties: {
220
- author: { type: "string" },
221
- url: { type: "string", format: "uri" },
222
- name: { type: "string" },
223
- adapted: { type: "boolean" }
224
- }
225
- },
226
- yield: {
227
- $ref: "#/definitions/yield"
228
- },
229
- time: {
230
- $ref: "#/definitions/time"
231
- },
232
- equipment: {
233
- type: "array",
234
- items: { $ref: "#/definitions/equipment" }
235
- },
236
- ingredients: {
237
- type: "array",
238
- items: {
239
- anyOf: [
240
- { type: "string" },
241
- { $ref: "#/definitions/ingredient" },
242
- { $ref: "#/definitions/ingredientSubsection" }
243
- ]
244
- }
245
- },
246
- instructions: {
247
- type: "array",
248
- items: {
249
- anyOf: [
250
- { type: "string" },
251
- { $ref: "#/definitions/instruction" },
252
- { $ref: "#/definitions/instructionSubsection" }
253
- ]
254
- }
255
- },
256
- storage: {
257
- $ref: "#/definitions/storage"
258
- },
259
- substitutions: {
260
- type: "array",
261
- items: { $ref: "#/definitions/substitution" }
235
+ function scaleInstructionItems(items, multiplier) {
236
+ items.forEach((item) => {
237
+ if (typeof item === "string") return;
238
+ if ("subsection" in item) {
239
+ scaleInstructionItems(item.items, multiplier);
240
+ return;
262
241
  }
263
- },
264
- definitions: {
265
- yield: {
266
- type: "object",
267
- required: ["amount", "unit"],
268
- properties: {
269
- amount: { type: "number" },
270
- unit: { type: "string" },
271
- servings: { type: "number" },
272
- description: { type: "string" }
273
- }
274
- },
275
- time: {
276
- oneOf: [
277
- {
278
- type: "object",
279
- properties: {
280
- prep: { type: "number" },
281
- active: { type: "number" },
282
- passive: { type: "number" },
283
- total: { type: "number" }
284
- }
285
- },
286
- {
287
- type: "object",
288
- properties: {
289
- prepTime: { type: "string" },
290
- cookTime: { type: "string" }
291
- }
292
- }
293
- ]
294
- },
295
- quantity: {
296
- type: "object",
297
- required: ["amount"],
298
- properties: {
299
- amount: { type: "number" },
300
- unit: { type: ["string", "null"] }
301
- }
302
- },
303
- scaling: {
304
- type: "object",
305
- required: ["type"],
306
- properties: {
307
- type: {
308
- type: "string",
309
- enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
310
- },
311
- factor: { type: "number" },
312
- referenceId: { type: "string" },
313
- roundTo: { type: "number" },
314
- min: { type: "number" },
315
- max: { type: "number" }
316
- },
317
- if: {
318
- properties: { type: { const: "bakers_percentage" } }
319
- },
320
- then: {
321
- required: ["referenceId"]
322
- }
323
- },
324
- ingredient: {
325
- type: "object",
326
- required: ["item"],
327
- properties: {
328
- id: { type: "string" },
329
- item: { type: "string" },
330
- quantity: { $ref: "#/definitions/quantity" },
331
- name: { type: "string" },
332
- aisle: { type: "string" },
333
- prep: { type: "string" },
334
- prepAction: { type: "string" },
335
- prepTime: { type: "number" },
336
- destination: { type: "string" },
337
- scaling: { $ref: "#/definitions/scaling" },
338
- critical: { type: "boolean" },
339
- optional: { type: "boolean" },
340
- notes: { type: "string" }
341
- }
342
- },
343
- ingredientSubsection: {
344
- type: "object",
345
- required: ["subsection", "items"],
346
- properties: {
347
- subsection: { type: "string" },
348
- items: {
349
- type: "array",
350
- items: { $ref: "#/definitions/ingredient" }
351
- }
352
- }
353
- },
354
- equipment: {
355
- type: "object",
356
- required: ["name"],
357
- properties: {
358
- id: { type: "string" },
359
- name: { type: "string" },
360
- required: { type: "boolean" },
361
- label: { type: "string" },
362
- capacity: { $ref: "#/definitions/quantity" },
363
- scalingLimit: { type: "number" },
364
- alternatives: {
365
- type: "array",
366
- items: { type: "string" }
367
- }
368
- }
369
- },
370
- instruction: {
371
- type: "object",
372
- required: ["text"],
373
- properties: {
374
- id: { type: "string" },
375
- text: { type: "string" },
376
- image: {
377
- type: "string",
378
- format: "uri",
379
- description: "Optional image that illustrates this instruction"
380
- },
381
- destination: { type: "string" },
382
- dependsOn: {
383
- type: "array",
384
- items: { type: "string" }
385
- },
386
- inputs: {
387
- type: "array",
388
- items: { type: "string" }
389
- },
390
- timing: {
391
- type: "object",
392
- required: ["duration", "type"],
393
- properties: {
394
- duration: { type: "number" },
395
- type: { type: "string", enum: ["active", "passive"] },
396
- scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
397
- }
398
- }
399
- }
400
- },
401
- instructionSubsection: {
402
- type: "object",
403
- required: ["subsection", "items"],
404
- properties: {
405
- subsection: { type: "string" },
406
- items: {
407
- type: "array",
408
- items: {
409
- anyOf: [
410
- { type: "string" },
411
- { $ref: "#/definitions/instruction" }
412
- ]
413
- }
414
- }
415
- }
416
- },
417
- storage: {
418
- type: "object",
419
- properties: {
420
- roomTemp: { $ref: "#/definitions/storageMethod" },
421
- refrigerated: { $ref: "#/definitions/storageMethod" },
422
- frozen: {
423
- allOf: [
424
- { $ref: "#/definitions/storageMethod" },
425
- {
426
- type: "object",
427
- properties: { thawing: { type: "string" } }
428
- }
429
- ]
430
- },
431
- reheating: { type: "string" },
432
- makeAhead: {
433
- type: "array",
434
- items: {
435
- allOf: [
436
- { $ref: "#/definitions/storageMethod" },
437
- {
438
- type: "object",
439
- required: ["component", "storage"],
440
- properties: {
441
- component: { type: "string" },
442
- storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
443
- }
444
- }
445
- ]
446
- }
447
- }
448
- }
449
- },
450
- storageMethod: {
451
- type: "object",
452
- required: ["duration"],
453
- properties: {
454
- duration: { type: "string", pattern: "^P" },
455
- method: { type: "string" },
456
- notes: { type: "string" }
457
- }
458
- },
459
- substitution: {
460
- type: "object",
461
- required: ["ingredient"],
462
- properties: {
463
- ingredient: { type: "string" },
464
- critical: { type: "boolean" },
465
- notes: { type: "string" },
466
- alternatives: {
467
- type: "array",
468
- items: {
469
- type: "object",
470
- required: ["name", "ratio"],
471
- properties: {
472
- name: { type: "string" },
473
- ratio: { type: "string" },
474
- notes: { type: "string" },
475
- impact: { type: "string" },
476
- dietary: {
477
- type: "array",
478
- items: { type: "string" }
479
- }
480
- }
481
- }
482
- }
483
- }
242
+ const timing = item.timing;
243
+ if (!timing) return;
244
+ const baseDuration = toDurationMinutes(timing.duration);
245
+ const scalingType = timing.scaling || "fixed";
246
+ let newDuration = baseDuration;
247
+ if (scalingType === "linear") {
248
+ newDuration = baseDuration * multiplier;
249
+ } else if (scalingType === "sqrt") {
250
+ newDuration = baseDuration * Math.sqrt(multiplier);
484
251
  }
252
+ timing.duration = Math.ceil(newDuration);
253
+ });
254
+ }
255
+ function deepClone(value) {
256
+ return JSON.parse(JSON.stringify(value));
257
+ }
258
+ function toDurationMinutes(duration) {
259
+ if (typeof duration === "number" && Number.isFinite(duration)) {
260
+ return duration;
485
261
  }
486
- };
487
-
488
- // src/validator.ts
489
- var ajv = new Ajv__default.default();
490
- addFormats__default.default(ajv);
491
- var validate = ajv.compile(schema_default);
492
- function validateRecipe(data) {
493
- const isValid = validate(data);
494
- if (!isValid) {
495
- throw new Error(JSON.stringify(validate.errors, null, 2));
262
+ if (typeof duration === "string" && duration.trim().startsWith("P")) {
263
+ const parsed = parseDuration(duration.trim());
264
+ if (parsed !== null) {
265
+ return parsed;
266
+ }
496
267
  }
497
- return true;
498
- }
499
-
500
- // src/parsers/ingredient.ts
501
- var FRACTION_DECIMALS = {
502
- "\xBD": 0.5,
503
- "\u2153": 1 / 3,
504
- "\u2154": 2 / 3,
505
- "\xBC": 0.25,
506
- "\xBE": 0.75,
507
- "\u2155": 0.2,
508
- "\u2156": 0.4,
509
- "\u2157": 0.6,
510
- "\u2158": 0.8,
511
- "\u2159": 1 / 6,
512
- "\u215A": 5 / 6,
513
- "\u215B": 0.125,
514
- "\u215C": 0.375,
515
- "\u215D": 0.625,
516
- "\u215E": 0.875
517
- };
518
- var NUMBER_WORDS = {
519
- zero: 0,
520
- one: 1,
521
- two: 2,
522
- three: 3,
523
- four: 4,
524
- five: 5,
525
- six: 6,
526
- seven: 7,
527
- eight: 8,
528
- nine: 9,
529
- ten: 10,
530
- eleven: 11,
531
- twelve: 12,
532
- thirteen: 13,
533
- fourteen: 14,
534
- fifteen: 15,
535
- sixteen: 16,
536
- seventeen: 17,
537
- eighteen: 18,
538
- nineteen: 19,
539
- twenty: 20,
540
- thirty: 30,
541
- forty: 40,
542
- fifty: 50,
543
- sixty: 60,
544
- seventy: 70,
545
- eighty: 80,
546
- ninety: 90,
547
- hundred: 100,
548
- half: 0.5,
549
- quarter: 0.25
550
- };
551
- var UNIT_SYNONYMS = {
552
- cup: "cup",
553
- cups: "cup",
554
- c: "cup",
555
- tbsp: "tbsp",
556
- tablespoon: "tbsp",
557
- tablespoons: "tbsp",
558
- tbs: "tbsp",
559
- tsp: "tsp",
560
- teaspoon: "tsp",
561
- teaspoons: "tsp",
562
- t: "tsp",
563
- gram: "g",
564
- grams: "g",
565
- g: "g",
566
- kilogram: "kg",
567
- kilograms: "kg",
568
- kg: "kg",
569
- milliliter: "ml",
570
- milliliters: "ml",
571
- ml: "ml",
572
- liter: "l",
573
- liters: "l",
574
- l: "l",
575
- ounce: "oz",
576
- ounces: "oz",
577
- oz: "oz",
578
- pound: "lb",
579
- pounds: "lb",
580
- lb: "lb",
581
- lbs: "lb",
582
- pint: "pint",
583
- pints: "pint",
584
- quart: "quart",
585
- quarts: "quart",
586
- stick: "stick",
587
- sticks: "stick",
588
- dash: "dash",
589
- pinches: "pinch",
590
- pinch: "pinch"
591
- };
592
- var PREP_PHRASES = [
593
- "diced",
594
- "finely diced",
595
- "roughly diced",
596
- "minced",
597
- "finely minced",
598
- "chopped",
599
- "finely chopped",
600
- "roughly chopped",
601
- "sliced",
602
- "thinly sliced",
603
- "thickly sliced",
604
- "grated",
605
- "finely grated",
606
- "zested",
607
- "sifted",
608
- "softened",
609
- "at room temperature",
610
- "room temperature",
611
- "room temp",
612
- "melted",
613
- "toasted",
614
- "drained",
615
- "drained and rinsed",
616
- "beaten",
617
- "divided",
618
- "cut into cubes",
619
- "cut into pieces",
620
- "cut into strips",
621
- "cut into chunks",
622
- "cut into bite-size pieces"
623
- ].map((value) => value.toLowerCase());
624
- var COUNT_DESCRIPTORS = /* @__PURE__ */ new Set([
625
- "clove",
626
- "cloves",
627
- "can",
628
- "cans",
629
- "stick",
630
- "sticks",
631
- "sprig",
632
- "sprigs",
633
- "bunch",
634
- "bunches",
635
- "slice",
636
- "slices",
637
- "package",
638
- "packages"
639
- ]);
640
- var DESCRIPTOR_NOTE_SET = /* @__PURE__ */ new Set(["can", "cans", "jar", "jars", "package", "packages", "bottle", "bottles"]);
641
- var WEIGHT_PRIORITY_UNITS = /* @__PURE__ */ new Set(["g", "kg", "oz", "lb", "ml", "l"]);
642
- var SPICE_KEYWORDS = [
643
- "salt",
644
- "pepper",
645
- "paprika",
646
- "cumin",
647
- "coriander",
648
- "turmeric",
649
- "chili powder",
650
- "garlic powder",
651
- "onion powder",
652
- "cayenne",
653
- "cinnamon",
654
- "nutmeg",
655
- "allspice",
656
- "ginger",
657
- "oregano",
658
- "thyme",
659
- "rosemary",
660
- "basil",
661
- "sage",
662
- "clove",
663
- "spice",
664
- "seasoning"
665
- ];
666
- var PURPOSE_KEYWORDS = ["frying", "greasing", "drizzling", "garnish", "serving", "brushing"];
667
- var RANGE_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(?:\s*(?:-|to)\s*((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?))/i;
668
- var NUMBER_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)/i;
669
- var QUALIFIER_REGEX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
670
- var FLAVOR_NOTE_REGEX = /\b(to taste|as needed|as necessary)\b/gi;
671
- var VAGUE_QUANTITY_PATTERNS = [
672
- { regex: /^(a\s+pinch|pinch)\b/i, note: "a pinch" },
673
- { regex: /^(a\s+handful|handful)\b/i, note: "a handful" },
674
- { regex: /^(a\s+dash|dash)\b/i, note: "a dash" },
675
- { regex: /^(a\s+sprinkle|sprinkle)\b/i, note: "a sprinkle" },
676
- { regex: /^(some)\b/i, note: "some" },
677
- { regex: /^(few\s+sprigs)/i, note: "few sprigs" },
678
- { regex: /^(a\s+few|few)\b/i, note: "a few" },
679
- { regex: /^(several)\b/i, note: "several" }
680
- ];
681
- var JUICE_PREFIXES = ["juice of", "zest of"];
682
- function normalizeIngredientInput(input) {
683
- if (!input) return "";
684
- let result = input.replace(/\u00A0/g, " ").trim();
685
- result = replaceDashes(result);
686
- result = replaceUnicodeFractions(result);
687
- result = replaceNumberWords(result);
688
- result = result.replace(/\s+/g, " ").trim();
689
- return result;
690
- }
691
- function parseIngredient(text) {
692
- const original = text ?? "";
693
- const normalized = normalizeIngredientInput(original);
694
- if (!normalized) {
695
- return {
696
- item: original,
697
- scaling: { type: "linear" }
698
- };
699
- }
700
- let working = normalized;
701
- const notes = [];
702
- let optional = false;
703
- if (/\boptional\b/i.test(working)) {
704
- optional = true;
705
- working = working.replace(/\(?\s*optional\s*\)?/gi, "").trim();
706
- working = working.replace(/\(\s*\)/g, " ").trim();
707
- }
708
- const flavorExtraction = extractFlavorNotes(working);
709
- working = flavorExtraction.cleaned;
710
- notes.push(...flavorExtraction.notes);
711
- const parenthetical = extractParentheticals(working);
712
- working = parenthetical.cleaned;
713
- notes.push(...parenthetical.notes);
714
- optional = optional || parenthetical.optional;
715
- const purposeExtraction = extractPurposeNotes(working);
716
- working = purposeExtraction.cleaned;
717
- notes.push(...purposeExtraction.notes);
718
- const juiceExtraction = extractJuicePhrase(working);
719
- if (juiceExtraction) {
720
- working = juiceExtraction.cleaned;
721
- notes.push(juiceExtraction.note);
722
- }
723
- const vagueQuantity = extractVagueQuantity(working);
724
- let quantityResult;
725
- if (vagueQuantity) {
726
- notes.push(vagueQuantity.note);
727
- quantityResult = {
728
- amount: null,
729
- unit: null,
730
- descriptor: void 0,
731
- remainder: vagueQuantity.remainder,
732
- notes: [],
733
- originalAmount: null
734
- };
735
- } else {
736
- quantityResult = extractQuantity(working);
737
- }
738
- working = quantityResult.remainder;
739
- const { quantity, usedParenthetical } = mergeQuantities(quantityResult, parenthetical.measurement);
740
- if (usedParenthetical && quantityResult.originalAmount !== null && quantityResult.originalAmount > 1 && quantityResult.descriptor && DESCRIPTOR_NOTE_SET.has(quantityResult.descriptor.toLowerCase())) {
741
- notes.push(formatCountNote(quantityResult.originalAmount, quantityResult.descriptor));
742
- }
743
- notes.push(...quantityResult.notes);
744
- working = working.replace(/^[,.\s-]+/, "").trim();
745
- working = working.replace(/^of\s+/i, "").trim();
746
- if (quantityResult.descriptor && /^cans?$/i.test(quantityResult.descriptor) && working && !/^canned\b/i.test(working)) {
747
- working = `canned ${working}`.trim();
748
- }
749
- const nameExtraction = extractNameAndPrep(working);
750
- notes.push(...nameExtraction.notes);
751
- const name = nameExtraction.name || void 0;
752
- const scaling = inferScaling(
753
- name,
754
- quantity.unit,
755
- quantity.amount,
756
- notes,
757
- quantityResult.descriptor
758
- );
759
- const mergedNotes = formatNotes(notes);
760
- const parsed = {
761
- item: original,
762
- quantity,
763
- ...name ? { name } : {},
764
- ...nameExtraction.prep ? { prep: nameExtraction.prep } : {},
765
- ...optional ? { optional: true } : {},
766
- scaling
767
- };
768
- if (mergedNotes) {
769
- parsed.notes = mergedNotes;
770
- }
771
- return parsed;
772
- }
773
- function replaceDashes(value) {
774
- return value.replace(/[\u2012\u2013\u2014\u2212]/g, "-");
775
- }
776
- function replaceUnicodeFractions(value) {
777
- return value.replace(/(\d+)?(?:\s+)?([½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])/g, (_match, whole, fraction) => {
778
- const fractionValue = FRACTION_DECIMALS[fraction];
779
- if (fractionValue === void 0) return _match;
780
- const base = whole ? parseInt(whole, 10) : 0;
781
- const combined = base + fractionValue;
782
- return formatDecimal(combined);
783
- });
784
- }
785
- function replaceNumberWords(value) {
786
- return value.replace(
787
- /\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,
788
- (match, word, hyphenPart) => {
789
- const lower = word.toLowerCase();
790
- const baseValue = NUMBER_WORDS[lower];
791
- if (baseValue === void 0) return match;
792
- if (!hyphenPart) {
793
- return formatDecimal(baseValue);
794
- }
795
- const hyphenValue = NUMBER_WORDS[hyphenPart.toLowerCase()];
796
- if (hyphenValue === void 0) {
797
- return formatDecimal(baseValue);
798
- }
799
- return formatDecimal(baseValue + hyphenValue);
800
- }
801
- );
802
- }
803
- function formatDecimal(value) {
804
- if (Number.isInteger(value)) {
805
- return value.toString();
806
- }
807
- return parseFloat(value.toFixed(3)).toString().replace(/\.0+$/, "");
808
- }
809
- function extractFlavorNotes(value) {
810
- const notes = [];
811
- const cleaned = value.replace(FLAVOR_NOTE_REGEX, (_, phrase) => {
812
- notes.push(phrase.toLowerCase());
813
- return "";
814
- });
815
- return {
816
- cleaned: cleaned.replace(/\s+/g, " ").trim(),
817
- notes
818
- };
819
- }
820
- function extractPurposeNotes(value) {
821
- const notes = [];
822
- let working = value.trim();
823
- let match = working.match(/\bfor\s+(frying|greasing|drizzling|garnish|serving|brushing)\b\.?$/i);
824
- if (match) {
825
- notes.push(`for ${match[1].toLowerCase()}`);
826
- working = working.slice(0, match.index).trim();
827
- }
828
- return { cleaned: working, notes };
829
- }
830
- function extractJuicePhrase(value) {
831
- const lower = value.toLowerCase();
832
- for (const prefix of JUICE_PREFIXES) {
833
- if (lower.startsWith(prefix)) {
834
- const remainder = value.slice(prefix.length).trim();
835
- if (!remainder) break;
836
- const cleanedSource = remainder.replace(/^of\s+/i, "").trim();
837
- if (!cleanedSource) break;
838
- const sourceForName = cleanedSource.replace(
839
- /^(?:\d+(?:\.\d+)?|\d+\s+\d+\/\d+|\d+\/\d+|one|two|three|four|five|six|seven|eight|nine|ten|a|an)\s+/i,
840
- ""
841
- ).replace(/^(?:large|small|medium)\s+/i, "").trim();
842
- const baseName = sourceForName || cleanedSource;
843
- const singular = singularize(baseName);
844
- const suffix = prefix.startsWith("zest") ? "zest" : "juice";
845
- return {
846
- cleaned: `${singular} ${suffix}`.trim(),
847
- note: `from ${cleanedSource}`
848
- };
849
- }
850
- }
851
- return void 0;
852
- }
853
- function extractVagueQuantity(value) {
854
- for (const pattern of VAGUE_QUANTITY_PATTERNS) {
855
- const match = value.match(pattern.regex);
856
- if (match) {
857
- let remainder = value.slice(match[0].length).trim();
858
- remainder = remainder.replace(/^of\s+/i, "").trim();
859
- return {
860
- remainder,
861
- note: pattern.note
862
- };
863
- }
864
- }
865
- return void 0;
866
- }
867
- function extractParentheticals(value) {
868
- let optional = false;
869
- let measurement;
870
- const notes = [];
871
- const cleaned = value.replace(/\(([^)]+)\)/g, (_match, group) => {
872
- const trimmed = String(group).trim();
873
- if (!trimmed) return "";
874
- if (/optional/i.test(trimmed)) {
875
- optional = true;
876
- return "";
877
- }
878
- const maybeMeasurement = parseMeasurement(trimmed);
879
- if (maybeMeasurement && !measurement) {
880
- measurement = maybeMeasurement;
881
- return "";
882
- }
883
- notes.push(trimmed);
884
- return "";
885
- });
886
- return {
887
- cleaned: cleaned.replace(/\s+/g, " ").trim(),
888
- measurement,
889
- notes,
890
- optional
891
- };
892
- }
893
- function parseMeasurement(value) {
894
- const stripped = value.replace(/^(about|around|approximately|approx\.?|roughly)\s+/i, "").trim();
895
- const match = stripped.match(
896
- /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(?:\s*)([a-zA-Z]+)?$/
897
- );
898
- if (!match) return void 0;
899
- const amount = parseNumber(match[1]);
900
- if (amount === null) return void 0;
901
- const unit = match[2] ? normalizeUnit(match[2]) ?? match[2].toLowerCase() : null;
902
- return { amount, unit };
903
- }
904
- function extractQuantity(value) {
905
- let working = value.trim();
906
- const notes = [];
907
- let amount = null;
908
- let originalAmount = null;
909
- let unit = null;
910
- let descriptor;
911
- while (QUALIFIER_REGEX.test(working)) {
912
- working = working.replace(QUALIFIER_REGEX, "").trim();
913
- }
914
- const rangeMatch = working.match(RANGE_REGEX);
915
- if (rangeMatch) {
916
- amount = parseNumber(rangeMatch[1]);
917
- originalAmount = amount;
918
- const rangeText = rangeMatch[0].trim();
919
- const afterRange = working.slice(rangeMatch[0].length).trim();
920
- const descriptorMatch = afterRange.match(/^([a-zA-Z]+)/);
921
- if (descriptorMatch && COUNT_DESCRIPTORS.has(descriptorMatch[1].toLowerCase())) {
922
- notes.push(`${rangeText} ${descriptorMatch[1]}`);
923
- } else {
924
- notes.push(rangeText);
925
- }
926
- working = afterRange;
927
- } else {
928
- const numberMatch = working.match(NUMBER_REGEX);
929
- if (numberMatch) {
930
- amount = parseNumber(numberMatch[1]);
931
- originalAmount = amount;
932
- working = working.slice(numberMatch[0].length).trim();
933
- }
934
- }
935
- if (working) {
936
- const unitMatch = working.match(/^([a-zA-Z]+)\b/);
937
- if (unitMatch) {
938
- const normalized = normalizeUnit(unitMatch[1]);
939
- if (normalized) {
940
- unit = normalized;
941
- working = working.slice(unitMatch[0].length).trim();
942
- } else if (COUNT_DESCRIPTORS.has(unitMatch[1].toLowerCase())) {
943
- descriptor = unitMatch[1];
944
- working = working.slice(unitMatch[0].length).trim();
945
- }
946
- }
947
- }
948
- return {
949
- amount,
950
- unit,
951
- descriptor,
952
- remainder: working.trim(),
953
- notes,
954
- originalAmount
955
- };
956
- }
957
- function parseNumber(value) {
958
- const trimmed = value.trim();
959
- if (!trimmed) return null;
960
- if (/^\d+\s+\d+\/\d+$/.test(trimmed)) {
961
- const [whole, fraction] = trimmed.split(/\s+/);
962
- return parseInt(whole, 10) + parseFraction(fraction);
963
- }
964
- if (/^\d+\/\d+$/.test(trimmed)) {
965
- return parseFraction(trimmed);
966
- }
967
- const parsed = Number(trimmed);
968
- return Number.isNaN(parsed) ? null : parsed;
969
- }
970
- function parseFraction(value) {
971
- const [numerator, denominator] = value.split("/").map(Number);
972
- if (!denominator) return numerator;
973
- return numerator / denominator;
974
- }
975
- function normalizeUnit(raw) {
976
- const lower = raw.toLowerCase();
977
- if (UNIT_SYNONYMS[lower]) {
978
- return UNIT_SYNONYMS[lower];
979
- }
980
- if (raw === "T") return "tbsp";
981
- if (raw === "t") return "tsp";
982
- if (raw === "C") return "cup";
983
- return null;
984
- }
985
- function mergeQuantities(extracted, measurement) {
986
- const quantity = {
987
- amount: extracted.amount ?? null,
988
- unit: extracted.unit ?? null
989
- };
990
- if (!measurement) {
991
- return { quantity, usedParenthetical: false };
992
- }
993
- const measurementUnit = measurement.unit?.toLowerCase() ?? null;
994
- const shouldPrefer = !quantity.unit || measurementUnit !== null && WEIGHT_PRIORITY_UNITS.has(measurementUnit);
995
- if (shouldPrefer) {
996
- return {
997
- quantity: {
998
- amount: measurement.amount,
999
- unit: measurement.unit ?? null
1000
- },
1001
- usedParenthetical: true
1002
- };
1003
- }
1004
- return { quantity, usedParenthetical: false };
1005
- }
1006
- function extractNameAndPrep(value) {
1007
- let working = value.trim();
1008
- const notes = [];
1009
- let prep;
1010
- const lastComma = working.lastIndexOf(",");
1011
- if (lastComma >= 0) {
1012
- const trailing = working.slice(lastComma + 1).trim();
1013
- if (isPrepPhrase(trailing)) {
1014
- prep = trailing;
1015
- working = working.slice(0, lastComma).trim();
1016
- }
1017
- }
1018
- working = working.replace(/^[,.\s-]+/, "").trim();
1019
- working = working.replace(/^of\s+/i, "").trim();
1020
- if (!working) {
1021
- return { name: void 0, prep, notes };
1022
- }
1023
- let name = cleanupIngredientName(working);
1024
- return {
1025
- name: name || void 0,
1026
- prep,
1027
- notes
1028
- };
1029
- }
1030
- function cleanupIngredientName(value) {
1031
- let result = value.trim();
1032
- if (/^cans?\b/i.test(result)) {
1033
- result = result.replace(/^cans?\b/i, "canned").trim();
1034
- }
1035
- let changed = true;
1036
- while (changed) {
1037
- changed = false;
1038
- if (/^of\s+/i.test(result)) {
1039
- result = result.replace(/^of\s+/i, "").trim();
1040
- changed = true;
1041
- continue;
1042
- }
1043
- const match = result.match(/^(clove|cloves|sprig|sprigs|bunch|bunches|stick|sticks|slice|slices)\b/i);
1044
- if (match) {
1045
- result = result.slice(match[0].length).trim();
1046
- changed = true;
1047
- }
1048
- }
1049
- return result;
1050
- }
1051
- function isPrepPhrase(value) {
1052
- const normalized = value.toLowerCase();
1053
- return PREP_PHRASES.includes(normalized);
1054
- }
1055
- function inferScaling(name, unit, amount, notes, descriptor) {
1056
- const lowerName = name?.toLowerCase() ?? "";
1057
- const normalizedNotes = notes.map((note) => note.toLowerCase());
1058
- const descriptorLower = descriptor?.toLowerCase();
1059
- if (lowerName.includes("egg") || descriptorLower === "clove" || descriptorLower === "cloves" || normalizedNotes.some((note) => note.includes("clove"))) {
1060
- return { type: "discrete", roundTo: 1 };
1061
- }
1062
- if (descriptorLower === "stick" || descriptorLower === "sticks") {
1063
- return { type: "discrete", roundTo: 1 };
1064
- }
1065
- if (normalizedNotes.some((note) => PURPOSE_KEYWORDS.some((keyword) => note.includes(keyword)))) {
1066
- return { type: "fixed" };
1067
- }
1068
- const isSpice = SPICE_KEYWORDS.some((keyword) => lowerName.includes(keyword));
1069
- const smallUnit = unit ? ["tsp", "tbsp", "dash", "pinch"].includes(unit) : false;
1070
- if (normalizedNotes.some((note) => note.includes("to taste")) || isSpice && (smallUnit || amount !== null && amount <= 1)) {
1071
- return { type: "proportional", factor: 0.7 };
1072
- }
1073
- return { type: "linear" };
1074
- }
1075
- function formatNotes(notes) {
1076
- const cleaned = Array.from(
1077
- new Set(
1078
- notes.map((note) => note.trim()).filter(Boolean)
1079
- )
1080
- );
1081
- return cleaned.length ? cleaned.join("; ") : void 0;
1082
- }
1083
- function formatCountNote(amount, descriptor) {
1084
- const lower = descriptor.toLowerCase();
1085
- const singular = lower.endsWith("s") ? lower.slice(0, -1) : lower;
1086
- 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`;
1087
- return `${formatDecimal(amount)} ${word}`;
1088
- }
1089
- function singularize(value) {
1090
- const trimmed = value.trim();
1091
- if (trimmed.endsWith("ies")) {
1092
- return `${trimmed.slice(0, -3)}y`;
1093
- }
1094
- if (/(ches|shes|sses|xes|zes)$/i.test(trimmed)) {
1095
- return trimmed.slice(0, -2);
1096
- }
1097
- if (trimmed.endsWith("s")) {
1098
- return trimmed.slice(0, -1);
1099
- }
1100
- return trimmed;
1101
- }
1102
-
1103
- // src/converters/ingredient.ts
1104
- function parseIngredientLine(line) {
1105
- const parsed = parseIngredient(line);
1106
- const ingredient = {
1107
- item: parsed.item,
1108
- scaling: parsed.scaling ?? { type: "linear" }
1109
- };
1110
- if (parsed.name) {
1111
- ingredient.name = parsed.name;
1112
- }
1113
- if (parsed.prep) {
1114
- ingredient.prep = parsed.prep;
1115
- }
1116
- if (parsed.optional) {
1117
- ingredient.optional = true;
1118
- }
1119
- if (parsed.notes) {
1120
- ingredient.notes = parsed.notes;
1121
- }
1122
- const quantity = buildQuantity(parsed.quantity);
1123
- if (quantity) {
1124
- ingredient.quantity = quantity;
1125
- }
1126
- return ingredient;
1127
- }
1128
- function buildQuantity(parsedQuantity) {
1129
- if (!parsedQuantity) {
1130
- return void 0;
1131
- }
1132
- if (parsedQuantity.amount === null || Number.isNaN(parsedQuantity.amount)) {
1133
- return void 0;
1134
- }
1135
- return {
1136
- amount: parsedQuantity.amount,
1137
- unit: parsedQuantity.unit ?? null
1138
- };
268
+ return 0;
1139
269
  }
1140
270
 
1141
271
  // src/converters/yield.ts
@@ -1187,87 +317,6 @@ function formatYield(yieldValue) {
1187
317
  return `${amount}${unit}`.trim() || yieldValue.description;
1188
318
  }
1189
319
 
1190
- // src/parsers/duration.ts
1191
- var ISO_DURATION_REGEX = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i;
1192
- var HUMAN_OVERNIGHT = 8 * 60;
1193
- function isFiniteNumber(value) {
1194
- return typeof value === "number" && Number.isFinite(value);
1195
- }
1196
- function parseDuration(iso) {
1197
- if (!iso || typeof iso !== "string") return null;
1198
- const trimmed = iso.trim();
1199
- if (!trimmed) return null;
1200
- const match = trimmed.match(ISO_DURATION_REGEX);
1201
- if (!match) return null;
1202
- const [, daysRaw, hoursRaw, minutesRaw, secondsRaw] = match;
1203
- if (!daysRaw && !hoursRaw && !minutesRaw && !secondsRaw) {
1204
- return null;
1205
- }
1206
- let total = 0;
1207
- if (daysRaw) total += parseFloat(daysRaw) * 24 * 60;
1208
- if (hoursRaw) total += parseFloat(hoursRaw) * 60;
1209
- if (minutesRaw) total += parseFloat(minutesRaw);
1210
- if (secondsRaw) total += Math.ceil(parseFloat(secondsRaw) / 60);
1211
- return Math.round(total);
1212
- }
1213
- function formatDuration(minutes) {
1214
- if (!isFiniteNumber(minutes) || minutes <= 0) {
1215
- return "PT0M";
1216
- }
1217
- const rounded = Math.round(minutes);
1218
- const days = Math.floor(rounded / (24 * 60));
1219
- const afterDays = rounded % (24 * 60);
1220
- const hours = Math.floor(afterDays / 60);
1221
- const mins = afterDays % 60;
1222
- let result = "P";
1223
- if (days > 0) {
1224
- result += `${days}D`;
1225
- }
1226
- if (hours > 0 || mins > 0) {
1227
- result += "T";
1228
- if (hours > 0) {
1229
- result += `${hours}H`;
1230
- }
1231
- if (mins > 0) {
1232
- result += `${mins}M`;
1233
- }
1234
- }
1235
- if (result === "P") {
1236
- return "PT0M";
1237
- }
1238
- return result;
1239
- }
1240
- function parseHumanDuration(text) {
1241
- if (!text || typeof text !== "string") return null;
1242
- const normalized = text.toLowerCase().trim();
1243
- if (!normalized) return null;
1244
- if (normalized === "overnight") {
1245
- return HUMAN_OVERNIGHT;
1246
- }
1247
- let total = 0;
1248
- const hourRegex = /(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|hr|h)\b/g;
1249
- let hourMatch;
1250
- while ((hourMatch = hourRegex.exec(normalized)) !== null) {
1251
- total += parseFloat(hourMatch[1]) * 60;
1252
- }
1253
- const minuteRegex = /(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|min|m)\b/g;
1254
- let minuteMatch;
1255
- while ((minuteMatch = minuteRegex.exec(normalized)) !== null) {
1256
- total += parseFloat(minuteMatch[1]);
1257
- }
1258
- if (total <= 0) {
1259
- return null;
1260
- }
1261
- return Math.round(total);
1262
- }
1263
- function smartParseDuration(input) {
1264
- const iso = parseDuration(input);
1265
- if (iso !== null) {
1266
- return iso;
1267
- }
1268
- return parseHumanDuration(input);
1269
- }
1270
-
1271
320
  // src/utils/image.ts
1272
321
  function normalizeImage(image) {
1273
322
  if (!image) {
@@ -1315,8 +364,22 @@ function fromSchemaOrg(input) {
1315
364
  const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
1316
365
  const category = extractFirst(recipeNode.recipeCategory);
1317
366
  const source = convertSource(recipeNode);
1318
- const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
367
+ const dateModified = recipeNode.dateModified || void 0;
368
+ const nutrition = convertNutrition(recipeNode.nutrition);
369
+ const attribution = convertAttribution(recipeNode);
370
+ const taxonomy = convertTaxonomy(tags, category, extractFirst(recipeNode.recipeCuisine));
371
+ const media = convertMedia(recipeNode.image, recipeNode.video);
372
+ const times = convertTimes(time);
373
+ const modules = [];
374
+ if (attribution) modules.push("attribution@1");
375
+ if (taxonomy) modules.push("taxonomy@1");
376
+ if (media) modules.push("media@1");
377
+ if (nutrition) modules.push("nutrition@1");
378
+ if (times) modules.push("times@1");
1319
379
  return {
380
+ "@type": "Recipe",
381
+ profile: "minimal",
382
+ modules: modules.sort(),
1320
383
  name: recipeNode.name.trim(),
1321
384
  description: recipeNode.description?.trim() || void 0,
1322
385
  image: normalizeImage(recipeNode.image),
@@ -1324,12 +387,16 @@ function fromSchemaOrg(input) {
1324
387
  tags: tags.length ? tags : void 0,
1325
388
  source,
1326
389
  dateAdded: recipeNode.datePublished || void 0,
1327
- dateModified: recipeNode.dateModified || void 0,
1328
390
  yield: recipeYield,
1329
391
  time,
1330
392
  ingredients,
1331
393
  instructions,
1332
- nutrition
394
+ ...dateModified ? { dateModified } : {},
395
+ ...nutrition ? { nutrition } : {},
396
+ ...attribution ? { attribution } : {},
397
+ ...taxonomy ? { taxonomy } : {},
398
+ ...media ? { media } : {},
399
+ ...times ? { times } : {}
1333
400
  };
1334
401
  }
1335
402
  function extractRecipeNode(input) {
@@ -1364,8 +431,6 @@ function extractRecipeNode(input) {
1364
431
  function hasRecipeType(value) {
1365
432
  if (!value) return false;
1366
433
  const types = Array.isArray(value) ? value : [value];
1367
- 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(() => {
1368
- });
1369
434
  return types.some(
1370
435
  (entry) => typeof entry === "string" && entry.toLowerCase() === "recipe"
1371
436
  );
@@ -1376,7 +441,7 @@ function isValidName(name) {
1376
441
  function convertIngredients(value) {
1377
442
  if (!value) return [];
1378
443
  const normalized = Array.isArray(value) ? value : [value];
1379
- return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean).map((line) => parseIngredientLine(line));
444
+ return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean);
1380
445
  }
1381
446
  function convertInstructions(value) {
1382
447
  if (!value) return [];
@@ -1444,10 +509,33 @@ function convertHowToStep(step) {
1444
509
  return void 0;
1445
510
  }
1446
511
  const normalizedImage = normalizeImage(step.image);
1447
- if (typeof normalizedImage === "string") {
1448
- return { text, image: normalizedImage };
512
+ const image = Array.isArray(normalizedImage) ? normalizedImage[0] : normalizedImage;
513
+ const id = extractInstructionId(step);
514
+ const timing = extractInstructionTiming(step);
515
+ if (!image && !id && !timing) {
516
+ return text;
517
+ }
518
+ const instruction = { text };
519
+ if (id) instruction.id = id;
520
+ if (image) instruction.image = image;
521
+ if (timing) instruction.timing = timing;
522
+ return instruction;
523
+ }
524
+ function extractInstructionTiming(step) {
525
+ const duration = step.totalTime || step.performTime || step.prepTime || step.duration;
526
+ if (!duration || typeof duration !== "string") {
527
+ return void 0;
528
+ }
529
+ const parsed = smartParseDuration(duration);
530
+ return { duration: parsed ?? duration, type: "active" };
531
+ }
532
+ function extractInstructionId(step) {
533
+ const raw = step["@id"] || step.id || step.url;
534
+ if (typeof raw !== "string") {
535
+ return void 0;
1449
536
  }
1450
- return text;
537
+ const trimmed = raw.trim();
538
+ return trimmed || void 0;
1451
539
  }
1452
540
  function isHowToStep(value) {
1453
541
  return Boolean(value) && typeof value === "object" && value["@type"] === "HowToStep";
@@ -1521,6 +609,174 @@ function extractEntityName(value) {
1521
609
  }
1522
610
  return void 0;
1523
611
  }
612
+ function convertAttribution(recipe) {
613
+ const attribution = {};
614
+ const url = (recipe.url || recipe.mainEntityOfPage)?.trim();
615
+ const author = extractEntityName(recipe.author);
616
+ const datePublished = recipe.datePublished?.trim();
617
+ if (url) attribution.url = url;
618
+ if (author) attribution.author = author;
619
+ if (datePublished) attribution.datePublished = datePublished;
620
+ return Object.keys(attribution).length ? attribution : void 0;
621
+ }
622
+ function convertTaxonomy(keywords, category, cuisine) {
623
+ const taxonomy = {};
624
+ if (keywords.length) taxonomy.keywords = keywords;
625
+ if (category) taxonomy.category = category;
626
+ if (cuisine) taxonomy.cuisine = cuisine;
627
+ return Object.keys(taxonomy).length ? taxonomy : void 0;
628
+ }
629
+ function normalizeMediaList(value) {
630
+ if (!value) return [];
631
+ if (typeof value === "string") return [value.trim()].filter(Boolean);
632
+ if (Array.isArray(value)) {
633
+ return value.map((item) => typeof item === "string" ? item.trim() : extractMediaUrl(item)).filter((entry) => Boolean(entry?.length));
634
+ }
635
+ const url = extractMediaUrl(value);
636
+ return url ? [url] : [];
637
+ }
638
+ function extractMediaUrl(value) {
639
+ if (value && typeof value === "object" && "url" in value && typeof value.url === "string") {
640
+ const trimmed = value.url.trim();
641
+ return trimmed || void 0;
642
+ }
643
+ return void 0;
644
+ }
645
+ function convertMedia(image, video) {
646
+ const normalizedImage = normalizeImage(image);
647
+ const images = normalizedImage ? Array.isArray(normalizedImage) ? normalizedImage : [normalizedImage] : [];
648
+ const videos = normalizeMediaList(video);
649
+ const media = {};
650
+ if (images.length) media.images = images;
651
+ if (videos.length) media.videos = videos;
652
+ return Object.keys(media).length ? media : void 0;
653
+ }
654
+ function convertTimes(time) {
655
+ if (!time) return void 0;
656
+ const times = {};
657
+ if (typeof time.prep === "number") times.prepMinutes = time.prep;
658
+ if (typeof time.active === "number") times.cookMinutes = time.active;
659
+ if (typeof time.total === "number") times.totalMinutes = time.total;
660
+ return Object.keys(times).length ? times : void 0;
661
+ }
662
+ function convertNutrition(nutrition) {
663
+ if (!nutrition || typeof nutrition !== "object") {
664
+ return void 0;
665
+ }
666
+ const result = {};
667
+ let hasData = false;
668
+ if ("calories" in nutrition) {
669
+ const calories = nutrition.calories;
670
+ if (typeof calories === "number") {
671
+ result.calories = calories;
672
+ hasData = true;
673
+ } else if (typeof calories === "string") {
674
+ const parsed = parseFloat(calories.replace(/[^\d.-]/g, ""));
675
+ if (!isNaN(parsed)) {
676
+ result.calories = parsed;
677
+ hasData = true;
678
+ }
679
+ }
680
+ }
681
+ if ("proteinContent" in nutrition || "protein_g" in nutrition) {
682
+ const protein = nutrition.proteinContent || nutrition.protein_g;
683
+ if (typeof protein === "number") {
684
+ result.protein_g = protein;
685
+ hasData = true;
686
+ } else if (typeof protein === "string") {
687
+ const parsed = parseFloat(protein.replace(/[^\d.-]/g, ""));
688
+ if (!isNaN(parsed)) {
689
+ result.protein_g = parsed;
690
+ hasData = true;
691
+ }
692
+ }
693
+ }
694
+ return hasData ? result : void 0;
695
+ }
696
+
697
+ // src/schemas/registry/modules.json
698
+ var modules_default = {
699
+ modules: [
700
+ {
701
+ id: "attribution",
702
+ versions: [
703
+ 1
704
+ ],
705
+ latest: 1,
706
+ namespace: "https://soustack.org/schemas/recipe/modules/attribution",
707
+ schema: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
708
+ schemaOrgMappable: true,
709
+ schemaOrgConfidence: "medium",
710
+ minProfile: "minimal",
711
+ allowedOnMinimal: true
712
+ },
713
+ {
714
+ id: "taxonomy",
715
+ versions: [
716
+ 1
717
+ ],
718
+ latest: 1,
719
+ namespace: "https://soustack.org/schemas/recipe/modules/taxonomy",
720
+ schema: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
721
+ schemaOrgMappable: true,
722
+ schemaOrgConfidence: "high",
723
+ minProfile: "minimal",
724
+ allowedOnMinimal: true
725
+ },
726
+ {
727
+ id: "media",
728
+ versions: [
729
+ 1
730
+ ],
731
+ latest: 1,
732
+ namespace: "https://soustack.org/schemas/recipe/modules/media",
733
+ schema: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
734
+ schemaOrgMappable: true,
735
+ schemaOrgConfidence: "medium",
736
+ minProfile: "minimal",
737
+ allowedOnMinimal: true
738
+ },
739
+ {
740
+ id: "nutrition",
741
+ versions: [
742
+ 1
743
+ ],
744
+ latest: 1,
745
+ namespace: "https://soustack.org/schemas/recipe/modules/nutrition",
746
+ schema: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
747
+ schemaOrgMappable: false,
748
+ schemaOrgConfidence: "low",
749
+ minProfile: "minimal",
750
+ allowedOnMinimal: true
751
+ },
752
+ {
753
+ id: "times",
754
+ versions: [
755
+ 1
756
+ ],
757
+ latest: 1,
758
+ namespace: "https://soustack.org/schemas/recipe/modules/times",
759
+ schema: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
760
+ schemaOrgMappable: true,
761
+ schemaOrgConfidence: "medium",
762
+ minProfile: "minimal",
763
+ allowedOnMinimal: true
764
+ },
765
+ {
766
+ id: "schedule",
767
+ versions: [
768
+ 1
769
+ ],
770
+ latest: 1,
771
+ namespace: "https://soustack.org/schemas/recipe/modules/schedule",
772
+ schema: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
773
+ schemaOrgMappable: false,
774
+ schemaOrgConfidence: "low",
775
+ minProfile: "core",
776
+ allowedOnMinimal: false
777
+ }
778
+ ]
779
+ };
1524
780
 
1525
781
  // src/converters/toSchemaOrg.ts
1526
782
  function convertBasicMetadata(recipe) {
@@ -1582,10 +838,11 @@ function convertInstruction(entry) {
1582
838
  return null;
1583
839
  }
1584
840
  if (typeof entry === "string") {
1585
- return createHowToStep(entry);
841
+ const value = entry.trim();
842
+ return value || null;
1586
843
  }
1587
844
  if ("subsection" in entry) {
1588
- const steps = entry.items.map((item) => createHowToStep(item)).filter((step) => Boolean(step));
845
+ const steps = entry.items.map((item) => convertInstruction(item)).filter((step) => Boolean(step));
1589
846
  if (!steps.length) {
1590
847
  return null;
1591
848
  }
@@ -1604,13 +861,7 @@ function createHowToStep(entry) {
1604
861
  if (!entry) return null;
1605
862
  if (typeof entry === "string") {
1606
863
  const trimmed2 = entry.trim();
1607
- if (!trimmed2) {
1608
- return null;
1609
- }
1610
- return {
1611
- "@type": "HowToStep",
1612
- text: trimmed2
1613
- };
864
+ return trimmed2 || null;
1614
865
  }
1615
866
  const trimmed = entry.text?.trim();
1616
867
  if (!trimmed) {
@@ -1620,10 +871,23 @@ function createHowToStep(entry) {
1620
871
  "@type": "HowToStep",
1621
872
  text: trimmed
1622
873
  };
874
+ if (entry.id) {
875
+ step["@id"] = entry.id;
876
+ }
877
+ if (entry.timing) {
878
+ if (typeof entry.timing.duration === "number") {
879
+ step.performTime = formatDuration(entry.timing.duration);
880
+ } else if (entry.timing.duration) {
881
+ step.performTime = entry.timing.duration;
882
+ }
883
+ }
1623
884
  if (entry.image) {
1624
885
  step.image = entry.image;
1625
886
  }
1626
- return step;
887
+ if (step["@id"] || step.performTime || step.image) {
888
+ return step;
889
+ }
890
+ return trimmed;
1627
891
  }
1628
892
  function convertTime2(time) {
1629
893
  if (!time) {
@@ -1651,6 +915,22 @@ function convertTime2(time) {
1651
915
  }
1652
916
  return result;
1653
917
  }
918
+ function convertTimesModule(times) {
919
+ if (!times) {
920
+ return {};
921
+ }
922
+ const result = {};
923
+ if (times.prepMinutes !== void 0) {
924
+ result.prepTime = formatDuration(times.prepMinutes);
925
+ }
926
+ if (times.cookMinutes !== void 0) {
927
+ result.cookTime = formatDuration(times.cookMinutes);
928
+ }
929
+ if (times.totalMinutes !== void 0) {
930
+ result.totalTime = formatDuration(times.totalMinutes);
931
+ }
932
+ return result;
933
+ }
1654
934
  function convertYield(yld) {
1655
935
  if (!yld) {
1656
936
  return void 0;
@@ -1689,33 +969,58 @@ function convertCategoryTags(category, tags) {
1689
969
  }
1690
970
  return result;
1691
971
  }
1692
- function convertNutrition(nutrition) {
972
+ function convertNutrition2(nutrition) {
1693
973
  if (!nutrition) {
1694
974
  return void 0;
1695
975
  }
1696
- return {
1697
- ...nutrition,
976
+ const result = {
1698
977
  "@type": "NutritionInformation"
1699
978
  };
1700
- }
1701
- function cleanOutput(obj) {
1702
- return Object.fromEntries(
979
+ if (nutrition.calories !== void 0) {
980
+ if (typeof nutrition.calories === "number") {
981
+ result.calories = `${nutrition.calories} calories`;
982
+ } else {
983
+ result.calories = nutrition.calories;
984
+ }
985
+ }
986
+ Object.keys(nutrition).forEach((key) => {
987
+ if (key !== "calories" && key !== "@type") {
988
+ result[key] = nutrition[key];
989
+ }
990
+ });
991
+ return result;
992
+ }
993
+ function cleanOutput(obj) {
994
+ return Object.fromEntries(
1703
995
  Object.entries(obj).filter(([, value]) => value !== void 0)
1704
996
  );
1705
997
  }
998
+ function getSchemaOrgMappableModules(modules = []) {
999
+ const mappableModules = modules_default.modules.filter((m) => m.schemaOrgMappable).map((m) => `${m.id}@${m.latest}`);
1000
+ return modules.filter((moduleId) => mappableModules.includes(moduleId));
1001
+ }
1706
1002
  function toSchemaOrg(recipe) {
1707
1003
  const base = convertBasicMetadata(recipe);
1708
1004
  const ingredients = convertIngredients2(recipe.ingredients);
1709
1005
  const instructions = convertInstructions2(recipe.instructions);
1710
- const nutrition = convertNutrition(recipe.nutrition);
1006
+ const recipeModules = Array.isArray(recipe.modules) ? recipe.modules : [];
1007
+ const mappableModules = getSchemaOrgMappableModules(recipeModules);
1008
+ const hasMappableNutrition = mappableModules.includes("nutrition@1");
1009
+ const nutrition = hasMappableNutrition ? convertNutrition2(recipe.nutrition) : void 0;
1010
+ const hasMappableTimes = mappableModules.includes("times@1");
1011
+ const timeData = hasMappableTimes ? recipe.times ? convertTimesModule(recipe.times) : convertTime2(recipe.time) : {};
1012
+ const hasMappableAttribution = mappableModules.includes("attribution@1");
1013
+ const attributionData = hasMappableAttribution ? convertAuthor(recipe.source) : {};
1014
+ const hasMappableTaxonomy = mappableModules.includes("taxonomy@1");
1015
+ const taxonomyData = hasMappableTaxonomy ? convertCategoryTags(recipe.category, recipe.tags) : {};
1711
1016
  return cleanOutput({
1712
1017
  ...base,
1713
1018
  recipeIngredient: ingredients.length ? ingredients : void 0,
1714
1019
  recipeInstructions: instructions.length ? instructions : void 0,
1715
1020
  recipeYield: convertYield(recipe.yield),
1716
- ...convertTime2(recipe.time),
1717
- ...convertAuthor(recipe.source),
1718
- ...convertCategoryTags(recipe.category, recipe.tags),
1021
+ ...timeData,
1022
+ ...attributionData,
1023
+ ...taxonomyData,
1719
1024
  nutrition
1720
1025
  });
1721
1026
  }
@@ -1989,259 +1294,1232 @@ function extractMicrodataBrowser(html) {
1989
1294
  if (typeof globalThis.DOMParser === "undefined") {
1990
1295
  return null;
1991
1296
  }
1992
- const parser = new globalThis.DOMParser();
1993
- const doc = parser.parseFromString(html, "text/html");
1994
- const recipeEl = doc.querySelector('[itemscope][itemtype*="schema.org/Recipe"]');
1995
- if (!recipeEl) {
1996
- return null;
1297
+ const parser = new globalThis.DOMParser();
1298
+ const doc = parser.parseFromString(html, "text/html");
1299
+ const recipeEl = doc.querySelector('[itemscope][itemtype*="schema.org/Recipe"]');
1300
+ if (!recipeEl) {
1301
+ return null;
1302
+ }
1303
+ const recipe = {
1304
+ "@type": "Recipe"
1305
+ };
1306
+ SIMPLE_PROPS2.forEach((prop) => {
1307
+ const value = findPropertyValue2(recipeEl, prop);
1308
+ if (value) {
1309
+ recipe[prop] = value;
1310
+ }
1311
+ });
1312
+ const ingredients = [];
1313
+ recipeEl.querySelectorAll('[itemprop="recipeIngredient"]').forEach((el) => {
1314
+ const text = normalizeText(
1315
+ el.getAttribute("content") || el.textContent || void 0
1316
+ );
1317
+ if (text) ingredients.push(text);
1318
+ });
1319
+ if (ingredients.length) {
1320
+ recipe.recipeIngredient = ingredients;
1321
+ }
1322
+ const instructions = [];
1323
+ recipeEl.querySelectorAll('[itemprop="recipeInstructions"]').forEach((el) => {
1324
+ const text = normalizeText(el.getAttribute("content")) || normalizeText(el.querySelector('[itemprop="text"]')?.textContent || void 0) || normalizeText(el.textContent || void 0);
1325
+ if (text) instructions.push(text);
1326
+ });
1327
+ if (instructions.length) {
1328
+ recipe.recipeInstructions = instructions;
1329
+ }
1330
+ if (recipe.name || ingredients.length) {
1331
+ return recipe;
1332
+ }
1333
+ return null;
1334
+ }
1335
+ function findPropertyValue2(context, prop) {
1336
+ const node = context.querySelector(`[itemprop="${prop}"]`);
1337
+ if (!node) return void 0;
1338
+ return normalizeText(node.getAttribute("content")) || normalizeText(node.getAttribute("href")) || normalizeText(node.getAttribute("src")) || normalizeText(node.textContent || void 0);
1339
+ }
1340
+ function collectCandidates2(payload, bucket) {
1341
+ if (!payload) return;
1342
+ if (Array.isArray(payload)) {
1343
+ payload.forEach((entry) => collectCandidates2(entry, bucket));
1344
+ return;
1345
+ }
1346
+ if (typeof payload !== "object") {
1347
+ return;
1348
+ }
1349
+ if (isRecipeNode(payload)) {
1350
+ bucket.push(payload);
1351
+ return;
1352
+ }
1353
+ const graph = payload["@graph"];
1354
+ if (Array.isArray(graph)) {
1355
+ graph.forEach((entry) => collectCandidates2(entry, bucket));
1356
+ }
1357
+ }
1358
+
1359
+ // src/scraper/extractors/index.ts
1360
+ function isBrowser() {
1361
+ try {
1362
+ return typeof globalThis.DOMParser !== "undefined";
1363
+ } catch {
1364
+ return false;
1365
+ }
1366
+ }
1367
+ function extractRecipe(html) {
1368
+ if (isBrowser()) {
1369
+ return extractRecipeBrowser(html);
1370
+ }
1371
+ const jsonLdRecipe = extractJsonLd(html);
1372
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1373
+ try {
1374
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1375
+ if (globalFetch) {
1376
+ 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(() => {
1377
+ });
1378
+ }
1379
+ } catch {
1380
+ }
1381
+ }
1382
+ if (jsonLdRecipe) {
1383
+ return { recipe: jsonLdRecipe, source: "jsonld" };
1384
+ }
1385
+ const microdataRecipe = extractMicrodata(html);
1386
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1387
+ try {
1388
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1389
+ if (globalFetch) {
1390
+ 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(() => {
1391
+ });
1392
+ }
1393
+ } catch {
1394
+ }
1395
+ }
1396
+ if (microdataRecipe) {
1397
+ return { recipe: microdataRecipe, source: "microdata" };
1398
+ }
1399
+ return { recipe: null, source: null };
1400
+ }
1401
+
1402
+ // src/scraper/index.ts
1403
+ async function scrapeRecipe(url, options = {}) {
1404
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1405
+ try {
1406
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1407
+ if (globalFetch) {
1408
+ 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(() => {
1409
+ });
1410
+ }
1411
+ } catch {
1412
+ }
1413
+ }
1414
+ const html = await fetchPage(url, options);
1415
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1416
+ try {
1417
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1418
+ if (globalFetch) {
1419
+ 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(() => {
1420
+ });
1421
+ }
1422
+ } catch {
1423
+ }
1424
+ }
1425
+ const { recipe } = extractRecipe(html);
1426
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1427
+ try {
1428
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1429
+ if (globalFetch) {
1430
+ 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(() => {
1431
+ });
1432
+ }
1433
+ } catch {
1434
+ }
1435
+ }
1436
+ if (!recipe) {
1437
+ throw new Error("No Schema.org recipe data found in page");
1438
+ }
1439
+ const soustackRecipe = fromSchemaOrg(recipe);
1440
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1441
+ try {
1442
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1443
+ if (globalFetch) {
1444
+ 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(() => {
1445
+ });
1446
+ }
1447
+ } catch {
1448
+ }
1449
+ }
1450
+ if (!soustackRecipe) {
1451
+ throw new Error("Schema.org data did not include a valid recipe");
1452
+ }
1453
+ return soustackRecipe;
1454
+ }
1455
+
1456
+ // src/schemas/recipe/base.schema.json
1457
+ var base_schema_default = {
1458
+ $schema: "http://json-schema.org/draft-07/schema#",
1459
+ $id: "http://soustack.org/schema/recipe/base.schema.json",
1460
+ title: "Soustack Recipe Base Schema",
1461
+ description: "Base document shape for Soustack recipe documents. Profiles and modules build on this baseline.",
1462
+ type: "object",
1463
+ additionalProperties: true,
1464
+ properties: {
1465
+ "@type": {
1466
+ const: "Recipe",
1467
+ description: "Document marker for Soustack recipes"
1468
+ },
1469
+ profile: {
1470
+ type: "string",
1471
+ description: "Profile identifier applied to this recipe"
1472
+ },
1473
+ modules: {
1474
+ type: "array",
1475
+ description: "List of module identifiers applied to this recipe",
1476
+ items: {
1477
+ type: "string"
1478
+ }
1479
+ },
1480
+ name: {
1481
+ type: "string",
1482
+ description: "Human-readable recipe name"
1483
+ },
1484
+ ingredients: {
1485
+ type: "array",
1486
+ description: "Ingredients payload; content is validated by profiles/modules"
1487
+ },
1488
+ instructions: {
1489
+ type: "array",
1490
+ description: "Instruction payload; content is validated by profiles/modules"
1491
+ }
1492
+ },
1493
+ required: ["@type"]
1494
+ };
1495
+
1496
+ // src/schemas/recipe/profiles/core.schema.json
1497
+ var core_schema_default = {
1498
+ $schema: "http://json-schema.org/draft-07/schema#",
1499
+ $id: "http://soustack.org/schema/recipe/profiles/core.schema.json",
1500
+ title: "Soustack Recipe Core Profile",
1501
+ description: "Core profile that builds on the minimal profile and is intended to be combined with recipe modules.",
1502
+ allOf: [
1503
+ { $ref: "http://soustack.org/schema/recipe/base.schema.json" },
1504
+ {
1505
+ type: "object",
1506
+ properties: {
1507
+ profile: { const: "core" },
1508
+ modules: {
1509
+ type: "array",
1510
+ items: { type: "string" },
1511
+ uniqueItems: true,
1512
+ default: []
1513
+ },
1514
+ name: { type: "string", minLength: 1 },
1515
+ ingredients: { type: "array", minItems: 1 },
1516
+ instructions: { type: "array", minItems: 1 }
1517
+ },
1518
+ required: ["profile", "name", "ingredients", "instructions"],
1519
+ additionalProperties: true
1520
+ }
1521
+ ]
1522
+ };
1523
+
1524
+ // src/schemas/recipe/profiles/minimal.schema.json
1525
+ var minimal_schema_default = {
1526
+ $schema: "http://json-schema.org/draft-07/schema#",
1527
+ $id: "http://soustack.org/schema/recipe/profiles/minimal.schema.json",
1528
+ title: "Soustack Recipe Minimal Profile",
1529
+ description: "Minimal profile that ensures the basic Recipe structure is present while allowing modules to extend it.",
1530
+ allOf: [
1531
+ {
1532
+ $ref: "http://soustack.org/schema/recipe/base.schema.json"
1533
+ },
1534
+ {
1535
+ type: "object",
1536
+ properties: {
1537
+ profile: {
1538
+ const: "minimal"
1539
+ },
1540
+ modules: {
1541
+ type: "array",
1542
+ items: {
1543
+ type: "string",
1544
+ enum: [
1545
+ "attribution@1",
1546
+ "taxonomy@1",
1547
+ "media@1",
1548
+ "nutrition@1",
1549
+ "times@1"
1550
+ ]
1551
+ },
1552
+ default: []
1553
+ },
1554
+ name: {
1555
+ type: "string",
1556
+ minLength: 1
1557
+ },
1558
+ ingredients: {
1559
+ type: "array",
1560
+ minItems: 1
1561
+ },
1562
+ instructions: {
1563
+ type: "array",
1564
+ minItems: 1
1565
+ }
1566
+ },
1567
+ required: [
1568
+ "profile",
1569
+ "name",
1570
+ "ingredients",
1571
+ "instructions"
1572
+ ],
1573
+ additionalProperties: true
1574
+ }
1575
+ ]
1576
+ };
1577
+
1578
+ // src/schemas/recipe/modules/schedule/1.schema.json
1579
+ var schema_default = {
1580
+ $schema: "http://json-schema.org/draft-07/schema#",
1581
+ $id: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
1582
+ title: "Soustack Recipe Module: schedule v1",
1583
+ description: "Schema for the schedule module. Enforces bidirectional module gating and restricts usage to the core profile.",
1584
+ type: "object",
1585
+ properties: {
1586
+ profile: { type: "string" },
1587
+ modules: {
1588
+ type: "array",
1589
+ items: { type: "string" }
1590
+ },
1591
+ schedule: {
1592
+ type: "object",
1593
+ properties: {
1594
+ tasks: { type: "array" }
1595
+ },
1596
+ additionalProperties: false
1597
+ }
1598
+ },
1599
+ allOf: [
1600
+ {
1601
+ if: {
1602
+ properties: {
1603
+ modules: {
1604
+ type: "array",
1605
+ contains: { const: "schedule@1" }
1606
+ }
1607
+ }
1608
+ },
1609
+ then: {
1610
+ required: ["schedule", "profile"],
1611
+ properties: {
1612
+ profile: { const: "core" }
1613
+ }
1614
+ }
1615
+ },
1616
+ {
1617
+ if: {
1618
+ required: ["schedule"]
1619
+ },
1620
+ then: {
1621
+ required: ["modules", "profile"],
1622
+ properties: {
1623
+ modules: {
1624
+ type: "array",
1625
+ items: { type: "string" },
1626
+ contains: { const: "schedule@1" }
1627
+ },
1628
+ profile: { const: "core" }
1629
+ }
1630
+ }
1631
+ }
1632
+ ],
1633
+ additionalProperties: true
1634
+ };
1635
+
1636
+ // src/schemas/recipe/modules/nutrition/1.schema.json
1637
+ var schema_default2 = {
1638
+ $schema: "http://json-schema.org/draft-07/schema#",
1639
+ $id: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
1640
+ title: "Soustack Recipe Module: nutrition v1",
1641
+ description: "Schema for the nutrition module. Keeps nutrition data aligned with module declarations and vice versa.",
1642
+ type: "object",
1643
+ properties: {
1644
+ modules: {
1645
+ type: "array",
1646
+ items: { type: "string" }
1647
+ },
1648
+ nutrition: {
1649
+ type: "object",
1650
+ properties: {
1651
+ calories: { type: "number" },
1652
+ protein_g: { type: "number" }
1653
+ },
1654
+ additionalProperties: false
1655
+ }
1656
+ },
1657
+ allOf: [
1658
+ {
1659
+ if: {
1660
+ properties: {
1661
+ modules: {
1662
+ type: "array",
1663
+ contains: { const: "nutrition@1" }
1664
+ }
1665
+ }
1666
+ },
1667
+ then: {
1668
+ required: ["nutrition"]
1669
+ }
1670
+ },
1671
+ {
1672
+ if: {
1673
+ required: ["nutrition"]
1674
+ },
1675
+ then: {
1676
+ required: ["modules"],
1677
+ properties: {
1678
+ modules: {
1679
+ type: "array",
1680
+ items: { type: "string" },
1681
+ contains: { const: "nutrition@1" }
1682
+ }
1683
+ }
1684
+ }
1685
+ }
1686
+ ],
1687
+ additionalProperties: true
1688
+ };
1689
+
1690
+ // src/schemas/recipe/modules/attribution/1.schema.json
1691
+ var schema_default3 = {
1692
+ $schema: "http://json-schema.org/draft-07/schema#",
1693
+ $id: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
1694
+ title: "Soustack Recipe Module: attribution v1",
1695
+ description: "Schema for the attribution module. Ensures namespace data is present when the module is enabled and vice versa.",
1696
+ type: "object",
1697
+ properties: {
1698
+ modules: {
1699
+ type: "array",
1700
+ items: { type: "string" }
1701
+ },
1702
+ attribution: {
1703
+ type: "object",
1704
+ properties: {
1705
+ url: { type: "string" },
1706
+ author: { type: "string" },
1707
+ datePublished: { type: "string" }
1708
+ },
1709
+ additionalProperties: false
1710
+ }
1711
+ },
1712
+ allOf: [
1713
+ {
1714
+ if: {
1715
+ properties: {
1716
+ modules: {
1717
+ type: "array",
1718
+ contains: { const: "attribution@1" }
1719
+ }
1720
+ }
1721
+ },
1722
+ then: {
1723
+ required: ["attribution"]
1724
+ }
1725
+ },
1726
+ {
1727
+ if: {
1728
+ required: ["attribution"]
1729
+ },
1730
+ then: {
1731
+ required: ["modules"],
1732
+ properties: {
1733
+ modules: {
1734
+ type: "array",
1735
+ items: { type: "string" },
1736
+ contains: { const: "attribution@1" }
1737
+ }
1738
+ }
1739
+ }
1740
+ }
1741
+ ],
1742
+ additionalProperties: true
1743
+ };
1744
+
1745
+ // src/schemas/recipe/modules/taxonomy/1.schema.json
1746
+ var schema_default4 = {
1747
+ $schema: "http://json-schema.org/draft-07/schema#",
1748
+ $id: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
1749
+ title: "Soustack Recipe Module: taxonomy v1",
1750
+ description: "Schema for the taxonomy module. Enforces keyword and categorization data when enabled and ensures module declaration accompanies the namespace block.",
1751
+ type: "object",
1752
+ properties: {
1753
+ modules: {
1754
+ type: "array",
1755
+ items: { type: "string" }
1756
+ },
1757
+ taxonomy: {
1758
+ type: "object",
1759
+ properties: {
1760
+ keywords: { type: "array", items: { type: "string" } },
1761
+ category: { type: "string" },
1762
+ cuisine: { type: "string" }
1763
+ },
1764
+ additionalProperties: false
1765
+ }
1766
+ },
1767
+ allOf: [
1768
+ {
1769
+ if: {
1770
+ properties: {
1771
+ modules: {
1772
+ type: "array",
1773
+ contains: { const: "taxonomy@1" }
1774
+ }
1775
+ }
1776
+ },
1777
+ then: {
1778
+ required: ["taxonomy"]
1779
+ }
1780
+ },
1781
+ {
1782
+ if: {
1783
+ required: ["taxonomy"]
1784
+ },
1785
+ then: {
1786
+ required: ["modules"],
1787
+ properties: {
1788
+ modules: {
1789
+ type: "array",
1790
+ items: { type: "string" },
1791
+ contains: { const: "taxonomy@1" }
1792
+ }
1793
+ }
1794
+ }
1795
+ }
1796
+ ],
1797
+ additionalProperties: true
1798
+ };
1799
+
1800
+ // src/schemas/recipe/modules/media/1.schema.json
1801
+ var schema_default5 = {
1802
+ $schema: "http://json-schema.org/draft-07/schema#",
1803
+ $id: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
1804
+ title: "Soustack Recipe Module: media v1",
1805
+ description: "Schema for the media module. Guards media blocks based on module activation and ensures declarations accompany payloads.",
1806
+ type: "object",
1807
+ properties: {
1808
+ modules: {
1809
+ type: "array",
1810
+ items: { type: "string" }
1811
+ },
1812
+ media: {
1813
+ type: "object",
1814
+ properties: {
1815
+ images: { type: "array", items: { type: "string" } },
1816
+ videos: { type: "array", items: { type: "string" } }
1817
+ },
1818
+ additionalProperties: false
1819
+ }
1820
+ },
1821
+ allOf: [
1822
+ {
1823
+ if: {
1824
+ properties: {
1825
+ modules: {
1826
+ type: "array",
1827
+ contains: { const: "media@1" }
1828
+ }
1829
+ }
1830
+ },
1831
+ then: {
1832
+ required: ["media"]
1833
+ }
1834
+ },
1835
+ {
1836
+ if: {
1837
+ required: ["media"]
1838
+ },
1839
+ then: {
1840
+ required: ["modules"],
1841
+ properties: {
1842
+ modules: {
1843
+ type: "array",
1844
+ items: { type: "string" },
1845
+ contains: { const: "media@1" }
1846
+ }
1847
+ }
1848
+ }
1849
+ }
1850
+ ],
1851
+ additionalProperties: true
1852
+ };
1853
+
1854
+ // src/schemas/recipe/modules/times/1.schema.json
1855
+ var schema_default6 = {
1856
+ $schema: "http://json-schema.org/draft-07/schema#",
1857
+ $id: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
1858
+ title: "Soustack Recipe Module: times v1",
1859
+ description: "Schema for the times module. Maintains alignment between module declarations and timing payloads.",
1860
+ type: "object",
1861
+ properties: {
1862
+ modules: {
1863
+ type: "array",
1864
+ items: { type: "string" }
1865
+ },
1866
+ times: {
1867
+ type: "object",
1868
+ properties: {
1869
+ prepMinutes: { type: "number" },
1870
+ cookMinutes: { type: "number" },
1871
+ totalMinutes: { type: "number" }
1872
+ },
1873
+ additionalProperties: false
1874
+ }
1875
+ },
1876
+ allOf: [
1877
+ {
1878
+ if: {
1879
+ properties: {
1880
+ modules: {
1881
+ type: "array",
1882
+ contains: { const: "times@1" }
1883
+ }
1884
+ }
1885
+ },
1886
+ then: {
1887
+ required: ["times"]
1888
+ }
1889
+ },
1890
+ {
1891
+ if: {
1892
+ required: ["times"]
1893
+ },
1894
+ then: {
1895
+ required: ["modules"],
1896
+ properties: {
1897
+ modules: {
1898
+ type: "array",
1899
+ items: { type: "string" },
1900
+ contains: { const: "times@1" }
1901
+ }
1902
+ }
1903
+ }
1904
+ }
1905
+ ],
1906
+ additionalProperties: true
1907
+ };
1908
+
1909
+ // src/validator.ts
1910
+ var CANONICAL_BASE_SCHEMA_ID = base_schema_default.$id || "http://soustack.org/schema/recipe/base.schema.json";
1911
+ var canonicalProfileId = (profile) => {
1912
+ if (profile === "minimal") {
1913
+ return minimal_schema_default.$id;
1914
+ }
1915
+ if (profile === "core") {
1916
+ return core_schema_default.$id;
1997
1917
  }
1998
- const recipe = {
1999
- "@type": "Recipe"
1918
+ throw new Error(`Unknown profile: ${profile}`);
1919
+ };
1920
+ var moduleIdToSchemaRef = (moduleId) => {
1921
+ const match = moduleId.match(/^([a-z0-9_-]+)@(\d+(?:\.\d+)*)$/i);
1922
+ if (!match) {
1923
+ throw new Error(`Invalid module identifier '${moduleId}'. Expected <name>@<version>.`);
1924
+ }
1925
+ const [, name, version] = match;
1926
+ const moduleSchemas2 = {
1927
+ "schedule@1": schema_default,
1928
+ "nutrition@1": schema_default2,
1929
+ "attribution@1": schema_default3,
1930
+ "taxonomy@1": schema_default4,
1931
+ "media@1": schema_default5,
1932
+ "times@1": schema_default6
2000
1933
  };
2001
- SIMPLE_PROPS2.forEach((prop) => {
2002
- const value = findPropertyValue2(recipeEl, prop);
2003
- if (value) {
2004
- recipe[prop] = value;
1934
+ const schema = moduleSchemas2[moduleId];
1935
+ if (schema && schema.$id) {
1936
+ return schema.$id;
1937
+ }
1938
+ return `https://soustack.org/schemas/recipe/modules/${name}/${version}.schema.json`;
1939
+ };
1940
+ var profileSchemas = {
1941
+ minimal: minimal_schema_default,
1942
+ core: core_schema_default
1943
+ };
1944
+ var moduleSchemas = {
1945
+ "schedule@1": schema_default,
1946
+ "nutrition@1": schema_default2,
1947
+ "attribution@1": schema_default3,
1948
+ "taxonomy@1": schema_default4,
1949
+ "media@1": schema_default5,
1950
+ "times@1": schema_default6
1951
+ };
1952
+ var validationContexts = /* @__PURE__ */ new Map();
1953
+ function createContext(collectAllErrors) {
1954
+ const ajv = new Ajv__default.default({ strict: false, allErrors: collectAllErrors });
1955
+ addFormats__default.default(ajv);
1956
+ const addSchemaWithAlias = (schema, alias) => {
1957
+ if (!schema) return;
1958
+ const schemaId = schema.$id || alias;
1959
+ if (schemaId) {
1960
+ ajv.addSchema(schema, schemaId);
1961
+ } else {
1962
+ ajv.addSchema(schema);
2005
1963
  }
1964
+ };
1965
+ addSchemaWithAlias(base_schema_default, CANONICAL_BASE_SCHEMA_ID);
1966
+ Object.entries(profileSchemas).forEach(([name, schema]) => {
1967
+ addSchemaWithAlias(schema, canonicalProfileId(name));
2006
1968
  });
2007
- const ingredients = [];
2008
- recipeEl.querySelectorAll('[itemprop="recipeIngredient"]').forEach((el) => {
2009
- const text = normalizeText(
2010
- el.getAttribute("content") || el.textContent || void 0
2011
- );
2012
- if (text) ingredients.push(text);
2013
- });
2014
- if (ingredients.length) {
2015
- recipe.recipeIngredient = ingredients;
2016
- }
2017
- const instructions = [];
2018
- recipeEl.querySelectorAll('[itemprop="recipeInstructions"]').forEach((el) => {
2019
- const text = normalizeText(el.getAttribute("content")) || normalizeText(el.querySelector('[itemprop="text"]')?.textContent || void 0) || normalizeText(el.textContent || void 0);
2020
- if (text) instructions.push(text);
1969
+ Object.entries(moduleSchemas).forEach(([moduleId, schema]) => {
1970
+ addSchemaWithAlias(schema, moduleIdToSchemaRef(moduleId));
2021
1971
  });
2022
- if (instructions.length) {
2023
- recipe.recipeInstructions = instructions;
2024
- }
2025
- if (recipe.name || ingredients.length) {
2026
- return recipe;
2027
- }
2028
- return null;
2029
- }
2030
- function findPropertyValue2(context, prop) {
2031
- const node = context.querySelector(`[itemprop="${prop}"]`);
2032
- if (!node) return void 0;
2033
- return normalizeText(node.getAttribute("content")) || normalizeText(node.getAttribute("href")) || normalizeText(node.getAttribute("src")) || normalizeText(node.textContent || void 0);
1972
+ return { ajv, validators: /* @__PURE__ */ new Map() };
2034
1973
  }
2035
- function collectCandidates2(payload, bucket) {
2036
- if (!payload) return;
2037
- if (Array.isArray(payload)) {
2038
- payload.forEach((entry) => collectCandidates2(entry, bucket));
2039
- return;
2040
- }
2041
- if (typeof payload !== "object") {
2042
- return;
2043
- }
2044
- if (isRecipeNode(payload)) {
2045
- bucket.push(payload);
2046
- return;
2047
- }
2048
- const graph = payload["@graph"];
2049
- if (Array.isArray(graph)) {
2050
- graph.forEach((entry) => collectCandidates2(entry, bucket));
1974
+ function getContext(collectAllErrors) {
1975
+ if (!validationContexts.has(collectAllErrors)) {
1976
+ validationContexts.set(collectAllErrors, createContext(collectAllErrors));
2051
1977
  }
1978
+ return validationContexts.get(collectAllErrors);
2052
1979
  }
2053
-
2054
- // src/scraper/extractors/index.ts
2055
- function isBrowser() {
2056
- try {
2057
- return typeof globalThis.DOMParser !== "undefined";
2058
- } catch {
2059
- return false;
1980
+ function cloneRecipe(recipe) {
1981
+ if (typeof structuredClone === "function") {
1982
+ return structuredClone(recipe);
2060
1983
  }
1984
+ return JSON.parse(JSON.stringify(recipe));
2061
1985
  }
2062
- function extractRecipe(html) {
2063
- if (isBrowser()) {
2064
- return extractRecipeBrowser(html);
1986
+ function detectProfileFromSchema(schemaRef) {
1987
+ if (!schemaRef) return void 0;
1988
+ const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
1989
+ if (match) {
1990
+ const profile = match[1].toLowerCase();
1991
+ if (profile in profileSchemas) return profile;
2065
1992
  }
2066
- const jsonLdRecipe = extractJsonLd(html);
2067
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2068
- try {
2069
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2070
- if (globalFetch) {
2071
- 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(() => {
2072
- });
1993
+ return void 0;
1994
+ }
1995
+ function resolveSchemaRef(inputSchema, requestedSchema) {
1996
+ if (typeof requestedSchema === "string") return requestedSchema;
1997
+ if (typeof inputSchema !== "string") return void 0;
1998
+ return detectProfileFromSchema(inputSchema) ? inputSchema : void 0;
1999
+ }
2000
+ function inferModulesFromPayload(recipe) {
2001
+ const inferred = [];
2002
+ const payloadToModule = {
2003
+ attribution: "attribution@1",
2004
+ taxonomy: "taxonomy@1",
2005
+ media: "media@1",
2006
+ times: "times@1",
2007
+ nutrition: "nutrition@1",
2008
+ schedule: "schedule@1"
2009
+ };
2010
+ for (const [field, moduleId] of Object.entries(payloadToModule)) {
2011
+ if (recipe && typeof recipe === "object" && field in recipe && recipe[field] != null) {
2012
+ const payload = recipe[field];
2013
+ if (typeof payload === "object" && !Array.isArray(payload)) {
2014
+ if (Object.keys(payload).length > 0) {
2015
+ inferred.push(moduleId);
2016
+ }
2017
+ } else if (Array.isArray(payload) && payload.length > 0) {
2018
+ inferred.push(moduleId);
2019
+ } else if (payload !== null && payload !== void 0) {
2020
+ inferred.push(moduleId);
2073
2021
  }
2074
- } catch {
2075
2022
  }
2076
2023
  }
2077
- if (jsonLdRecipe) {
2078
- return { recipe: jsonLdRecipe, source: "jsonld" };
2079
- }
2080
- const microdataRecipe = extractMicrodata(html);
2081
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2082
- try {
2083
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2084
- if (globalFetch) {
2085
- 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(() => {
2086
- });
2087
- }
2088
- } catch {
2024
+ return inferred;
2025
+ }
2026
+ function getCombinedValidator(profile, modules, recipe, context) {
2027
+ const inferredModules = inferModulesFromPayload(recipe);
2028
+ const allModules = /* @__PURE__ */ new Set([...modules, ...inferredModules]);
2029
+ const sortedModules = Array.from(allModules).sort();
2030
+ const cacheKey = `${profile}::${sortedModules.join(",")}`;
2031
+ const cached = context.validators.get(cacheKey);
2032
+ if (cached) return cached;
2033
+ if (!profileSchemas[profile]) {
2034
+ throw new Error(`Unknown Soustack profile: ${profile}`);
2035
+ }
2036
+ const schema = {
2037
+ $id: `urn:soustack:recipe:${cacheKey}`,
2038
+ allOf: [
2039
+ { $ref: CANONICAL_BASE_SCHEMA_ID },
2040
+ { $ref: canonicalProfileId(profile) },
2041
+ ...sortedModules.map((moduleId) => ({ $ref: moduleIdToSchemaRef(moduleId) }))
2042
+ ]
2043
+ };
2044
+ const validateFn = context.ajv.compile(schema);
2045
+ context.validators.set(cacheKey, validateFn);
2046
+ return validateFn;
2047
+ }
2048
+ function normalizeRecipe(recipe) {
2049
+ const normalized = cloneRecipe(recipe);
2050
+ const warnings = [];
2051
+ normalizeTime(normalized);
2052
+ if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
2053
+ normalized.recipeVersion = normalized.version;
2054
+ warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
2055
+ }
2056
+ return { normalized, warnings };
2057
+ }
2058
+ function normalizeTime(recipe) {
2059
+ const time = recipe?.time;
2060
+ if (!time || typeof time !== "object" || Array.isArray(time)) return;
2061
+ const structuredKeys = [
2062
+ "prep",
2063
+ "active",
2064
+ "passive",
2065
+ "total"
2066
+ ];
2067
+ structuredKeys.forEach((key) => {
2068
+ const value = time[key];
2069
+ if (typeof value === "number") return;
2070
+ const parsed = parseDuration(value);
2071
+ if (parsed !== null) {
2072
+ time[key] = parsed;
2089
2073
  }
2074
+ });
2075
+ }
2076
+ var allowedTopLevelProps = /* @__PURE__ */ new Set([
2077
+ ...Object.keys(base_schema_default?.properties ?? {}),
2078
+ "$schema",
2079
+ // Module fields (validated by module schemas)
2080
+ "attribution",
2081
+ "taxonomy",
2082
+ "media",
2083
+ "times",
2084
+ "nutrition",
2085
+ "schedule",
2086
+ // Common recipe fields (allowed by base schema's additionalProperties: true)
2087
+ "description",
2088
+ "image",
2089
+ "category",
2090
+ "tags",
2091
+ "source",
2092
+ "dateAdded",
2093
+ "dateModified",
2094
+ "yield",
2095
+ "time",
2096
+ "id",
2097
+ "title",
2098
+ "recipeVersion",
2099
+ "version",
2100
+ // deprecated but allowed
2101
+ "equipment",
2102
+ "storage",
2103
+ "substitutions"
2104
+ ]);
2105
+ function detectUnknownTopLevelKeys(recipe) {
2106
+ if (!recipe || typeof recipe !== "object") return [];
2107
+ const disallowedKeys = Object.keys(recipe).filter(
2108
+ (key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
2109
+ );
2110
+ return disallowedKeys.map((key) => ({
2111
+ path: `/${key}`,
2112
+ keyword: "additionalProperties",
2113
+ message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
2114
+ }));
2115
+ }
2116
+ function formatAjvError(error) {
2117
+ let path2 = error.instancePath || "/";
2118
+ if (error.keyword === "additionalProperties" && error.params?.additionalProperty) {
2119
+ const extra = error.params.additionalProperty;
2120
+ path2 = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
2090
2121
  }
2091
- if (microdataRecipe) {
2092
- return { recipe: microdataRecipe, source: "microdata" };
2093
- }
2094
- return { recipe: null, source: null };
2122
+ return {
2123
+ path: path2,
2124
+ keyword: error.keyword,
2125
+ message: error.message || "Validation error"
2126
+ };
2095
2127
  }
2096
-
2097
- // src/scraper/index.ts
2098
- async function scrapeRecipe(url, options = {}) {
2099
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2100
- try {
2101
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2102
- if (globalFetch) {
2103
- 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(() => {
2104
- });
2128
+ function runAjvValidation(data, profile, modules, context) {
2129
+ try {
2130
+ const validateFn = getCombinedValidator(profile, modules, data, context);
2131
+ const isValid = validateFn(data);
2132
+ return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
2133
+ } catch (error) {
2134
+ return [
2135
+ {
2136
+ path: "/",
2137
+ message: error instanceof Error ? error.message : "Validation failed to initialize"
2105
2138
  }
2106
- } catch {
2107
- }
2108
- }
2109
- const html = await fetchPage(url, options);
2110
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2111
- try {
2112
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2113
- if (globalFetch) {
2114
- 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(() => {
2115
- });
2139
+ ];
2140
+ }
2141
+ }
2142
+ function isInstruction(item) {
2143
+ return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
2144
+ }
2145
+ function isInstructionSubsection(item) {
2146
+ return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
2147
+ }
2148
+ function checkInstructionGraph(recipe) {
2149
+ const instructions = recipe?.instructions;
2150
+ if (!Array.isArray(instructions)) return [];
2151
+ const instructionIds = /* @__PURE__ */ new Set();
2152
+ const dependencyRefs = [];
2153
+ const collect = (items, basePath) => {
2154
+ items.forEach((item, index) => {
2155
+ const currentPath = `${basePath}/${index}`;
2156
+ if (isInstructionSubsection(item) && Array.isArray(item.items)) {
2157
+ collect(item.items, `${currentPath}/items`);
2158
+ return;
2116
2159
  }
2117
- } catch {
2118
- }
2119
- }
2120
- const { recipe } = extractRecipe(html);
2121
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2122
- try {
2123
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2124
- if (globalFetch) {
2125
- 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(() => {
2126
- });
2160
+ if (isInstruction(item)) {
2161
+ const id = typeof item.id === "string" ? item.id : void 0;
2162
+ if (id) instructionIds.add(id);
2163
+ if (Array.isArray(item.dependsOn)) {
2164
+ item.dependsOn.forEach((depId, depIndex) => {
2165
+ if (typeof depId === "string") {
2166
+ dependencyRefs.push({
2167
+ fromId: id,
2168
+ toId: depId,
2169
+ path: `${currentPath}/dependsOn/${depIndex}`
2170
+ });
2171
+ }
2172
+ });
2173
+ }
2127
2174
  }
2128
- } catch {
2175
+ });
2176
+ };
2177
+ collect(instructions, "/instructions");
2178
+ const errors = [];
2179
+ dependencyRefs.forEach((ref) => {
2180
+ if (!instructionIds.has(ref.toId)) {
2181
+ errors.push({
2182
+ path: ref.path,
2183
+ message: `Instruction dependency references missing id '${ref.toId}'.`
2184
+ });
2129
2185
  }
2130
- }
2131
- if (!recipe) {
2132
- throw new Error("No Schema.org recipe data found in page");
2133
- }
2134
- const soustackRecipe = fromSchemaOrg(recipe);
2135
- if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
2136
- try {
2137
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2138
- if (globalFetch) {
2139
- 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(() => {
2186
+ });
2187
+ const adjacency = /* @__PURE__ */ new Map();
2188
+ dependencyRefs.forEach((ref) => {
2189
+ if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
2190
+ const list = adjacency.get(ref.fromId) ?? [];
2191
+ list.push({ toId: ref.toId, path: ref.path });
2192
+ adjacency.set(ref.fromId, list);
2193
+ }
2194
+ });
2195
+ const visiting = /* @__PURE__ */ new Set();
2196
+ const visited = /* @__PURE__ */ new Set();
2197
+ const detectCycles = (nodeId) => {
2198
+ if (visiting.has(nodeId)) return;
2199
+ if (visited.has(nodeId)) return;
2200
+ visiting.add(nodeId);
2201
+ const neighbors = adjacency.get(nodeId) ?? [];
2202
+ neighbors.forEach((edge) => {
2203
+ if (visiting.has(edge.toId)) {
2204
+ errors.push({
2205
+ path: edge.path,
2206
+ message: `Circular dependency detected involving instruction id '${edge.toId}'.`
2140
2207
  });
2208
+ return;
2141
2209
  }
2142
- } catch {
2143
- }
2210
+ detectCycles(edge.toId);
2211
+ });
2212
+ visiting.delete(nodeId);
2213
+ visited.add(nodeId);
2214
+ };
2215
+ instructionIds.forEach((id) => detectCycles(id));
2216
+ return errors;
2217
+ }
2218
+ function validateRecipe(input, options = {}) {
2219
+ const collectAllErrors = options.collectAllErrors ?? true;
2220
+ const context = getContext(collectAllErrors);
2221
+ const schemaRef = resolveSchemaRef(input?.$schema, options.schema);
2222
+ const profileFromDocument = typeof input?.profile === "string" ? input.profile : void 0;
2223
+ const profile = options.profile ?? profileFromDocument ?? detectProfileFromSchema(schemaRef) ?? "core";
2224
+ const modulesFromDocument = Array.isArray(input?.modules) ? input.modules.filter((value) => typeof value === "string") : [];
2225
+ const modules = modulesFromDocument.length > 0 ? [...modulesFromDocument].sort() : [];
2226
+ const { normalized, warnings } = normalizeRecipe(input);
2227
+ if (!profileFromDocument) {
2228
+ normalized.profile = profile;
2229
+ } else {
2230
+ normalized.profile = profileFromDocument;
2144
2231
  }
2145
- if (!soustackRecipe) {
2146
- throw new Error("Schema.org data did not include a valid recipe");
2232
+ if (!("modules" in normalized) || normalized.modules === void 0 || normalized.modules === null) {
2233
+ normalized.modules = [];
2234
+ } else if (modulesFromDocument.length > 0) {
2235
+ normalized.modules = modules;
2147
2236
  }
2148
- return soustackRecipe;
2237
+ const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
2238
+ const validationErrors = runAjvValidation(normalized, profile, modules, context);
2239
+ const graphErrors = modules.includes("schedule@1") && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
2240
+ const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
2241
+ return {
2242
+ valid: errors.length === 0,
2243
+ errors,
2244
+ warnings,
2245
+ normalized: errors.length === 0 ? normalized : void 0
2246
+ };
2149
2247
  }
2150
2248
 
2151
2249
  // bin/cli.ts
2152
- var [, , command, ...args] = process.argv;
2153
- async function main() {
2250
+ var supportedProfiles = ["base", "cookable", "scalable", "quantified", "illustrated", "schedulable"];
2251
+ async function runCli(argv) {
2252
+ const [command, ...args] = argv;
2154
2253
  try {
2155
2254
  switch (command) {
2156
2255
  case "validate":
2157
2256
  await handleValidate(args);
2158
- break;
2159
- case "scale":
2160
- await handleScale(args);
2161
- break;
2257
+ return;
2258
+ case "convert":
2259
+ await handleConvert(args);
2260
+ return;
2162
2261
  case "import":
2163
2262
  await handleImport(args);
2164
- break;
2165
- case "export":
2166
- await handleExport(args);
2167
- break;
2263
+ return;
2264
+ case "scale":
2265
+ await handleScale(args);
2266
+ return;
2168
2267
  case "scrape":
2169
2268
  await handleScrape(args);
2170
- break;
2269
+ return;
2270
+ case "test":
2271
+ await handleTest(args);
2272
+ return;
2171
2273
  default:
2172
2274
  printUsage();
2173
- process.exit(1);
2275
+ process.exitCode = 1;
2174
2276
  }
2175
2277
  } catch (error) {
2176
- console.error(`\u274C ${error.message}`);
2278
+ console.error(`\u274C ${error?.message ?? error}`);
2177
2279
  process.exit(1);
2178
2280
  }
2179
2281
  }
2180
2282
  function printUsage() {
2181
2283
  console.log("Usage:");
2182
- console.log(" npx soustack validate <soustack.json>");
2183
- console.log(" npx soustack scale <soustack.json> <multiplier>");
2184
- console.log(" npx soustack import <schema-org.jsonld> -o <soustack.json>");
2185
- console.log(" npx soustack export <soustack.json> -o <schema-org.jsonld>");
2186
- console.log(" npx soustack scrape <url> -o <soustack.json>");
2187
- }
2188
- async function handleValidate(args2) {
2189
- const filePath = args2[0];
2190
- if (!filePath) throw new Error("Path to recipe JSON is required");
2191
- const recipe = readJsonFile(filePath);
2192
- validateRecipe(recipe);
2193
- console.log("\u2705 Valid Soustack Recipe");
2284
+ console.log(" soustack validate <fileOrGlob> [--profile <name>] [--strict] [--json]");
2285
+ console.log(" soustack convert --from <schemaorg|soustack> --to <schemaorg|soustack> <input> [-o <output>]");
2286
+ console.log(" soustack import --url <url> [-o <soustack.json>]");
2287
+ console.log(" soustack test [--profile <name>] [--strict] [--json]");
2288
+ console.log(" soustack scale <soustack.json> <multiplier>");
2289
+ console.log(" soustack scrape <url> -o <soustack.json>");
2290
+ }
2291
+ async function handleValidate(args) {
2292
+ const { target, profile, strict, json } = parseValidateArgs(args);
2293
+ if (!target) throw new Error("Path or glob to Soustack recipe JSON is required");
2294
+ const files = expandTargets(target);
2295
+ if (files.length === 0) throw new Error(`No files matched pattern: ${target}`);
2296
+ const results = files.map((file) => validateFile(file, profile));
2297
+ reportValidation(results, { strict, json });
2298
+ }
2299
+ async function handleTest(args) {
2300
+ const { profile, strict, json } = parseValidationFlags(args);
2301
+ const cwd = process.cwd();
2302
+ const files = glob.globSync("**/*.soustack.json", {
2303
+ cwd,
2304
+ absolute: true,
2305
+ nodir: true,
2306
+ ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**"]
2307
+ }).map((file) => path__namespace.resolve(cwd, file));
2308
+ if (files.length === 0) {
2309
+ console.log("No *.soustack.json files found in the current repository.");
2310
+ return;
2311
+ }
2312
+ const results = files.map((file) => validateFile(file, profile));
2313
+ reportValidation(results, { strict, json, context: "test" });
2314
+ }
2315
+ async function handleConvert(args) {
2316
+ const { from, to, inputPath, outputPath } = parseConvertArgs(args);
2317
+ const fromKey = from?.toLowerCase();
2318
+ const toKey = to?.toLowerCase();
2319
+ if (!inputPath || !fromKey || !toKey) {
2320
+ throw new Error("Convert usage: convert --from <schemaorg|soustack> --to <schemaorg|soustack> <input> [-o <output>]");
2321
+ }
2322
+ const direction = resolveConvertDirection(fromKey, toKey);
2323
+ if (!direction) {
2324
+ throw new Error(`Unsupported conversion from "${from}" to "${to}"`);
2325
+ }
2326
+ const input = readJsonFile(inputPath);
2327
+ const result = direction === "schemaorg-to-soustack" ? fromSchemaOrg(input) : toSchemaOrg(input);
2328
+ if (!result) {
2329
+ throw new Error("Unable to convert input with the provided formats.");
2330
+ }
2331
+ writeOutput(result, outputPath);
2332
+ console.log(`\u2705 Converted ${fromKey} \u2192 ${toKey}${outputPath ? ` (${outputPath})` : ""}`);
2333
+ }
2334
+ async function handleImport(args) {
2335
+ const { url, outputPath } = parseImportArgs(args);
2336
+ if (!url) throw new Error("Import usage: import --url <url> [-o <soustack.json>]");
2337
+ const recipe = await scrapeRecipe(url);
2338
+ writeOutput(recipe, outputPath);
2339
+ console.log(`\u2705 Imported recipe from ${url}${outputPath ? ` (${outputPath})` : ""}`);
2194
2340
  }
2195
- async function handleScale(args2) {
2196
- const filePath = args2[0];
2197
- const multiplier = args2[1] ? parseFloat(args2[1]) : 1;
2341
+ async function handleScale(args) {
2342
+ const filePath = args[0];
2343
+ const multiplier = args[1] ? parseFloat(args[1]) : 1;
2198
2344
  if (!filePath || Number.isNaN(multiplier)) {
2199
2345
  throw new Error("Scale usage: scale <soustack.json> <multiplier>");
2200
2346
  }
2201
2347
  const recipe = readJsonFile(filePath);
2202
2348
  console.log(`
2203
- \u2696\uFE0F Scaling "${recipe.name}" by ${multiplier}x...
2349
+ \u2696\uFE0F Scaling "${recipe?.name ?? filePath}" by ${multiplier}x...
2204
2350
  `);
2205
- const baseYield = recipe.yield?.amount || 1;
2206
- const targetYield = baseYield * multiplier;
2207
- const result = scaleRecipe(recipe, targetYield);
2208
- console.log("--- INGREDIENTS ---");
2209
- result.ingredients.forEach((ing) => {
2210
- console.log(`\u2022 ${ing.text}`);
2211
- });
2212
- console.log("\n--- TIMING ---");
2213
- console.log(`Total Time: ${result.timing.total} minutes`);
2214
- console.log(`(Active: ${result.timing.active}m | Passive: ${result.timing.passive}m)`);
2215
- }
2216
- async function handleImport(args2) {
2217
- const filePath = args2[0];
2218
- const outputPath = resolveOutputPath(args2.slice(1));
2219
- if (!filePath) throw new Error("Import usage: import <schema-org.json> -o <soustack.json>");
2220
- const schemaOrg = readJsonFile(filePath);
2221
- const soustack = fromSchemaOrg(schemaOrg);
2222
- if (!soustack) {
2223
- throw new Error("No valid Schema.org recipe found in input");
2224
- }
2225
- writeOutput(soustack, outputPath);
2226
- console.log(`\u2705 Converted Schema.org \u2192 Soustack${outputPath ? ` (${outputPath})` : ""}`);
2227
- }
2228
- async function handleExport(args2) {
2229
- const filePath = args2[0];
2230
- const outputPath = resolveOutputPath(args2.slice(1));
2231
- if (!filePath) throw new Error("Export usage: export <soustack.json> -o <schema-org.jsonld>");
2232
- const soustack = readJsonFile(filePath);
2233
- const schemaOrg = toSchemaOrg(soustack);
2234
- writeOutput(schemaOrg, outputPath);
2235
- console.log(`\u2705 Converted Soustack \u2192 Schema.org${outputPath ? ` (${outputPath})` : ""}`);
2236
- }
2237
- async function handleScrape(args2) {
2238
- const url = args2[0];
2239
- const outputPath = resolveOutputPath(args2.slice(1));
2351
+ const result = scaleRecipe(recipe, { multiplier });
2352
+ console.log(JSON.stringify(result, null, 2));
2353
+ }
2354
+ async function handleScrape(args) {
2355
+ const url = args[0];
2356
+ const outputPath = resolveOutputPath(args.slice(1));
2240
2357
  if (!url) throw new Error("Scrape usage: scrape <url> -o <soustack.json>");
2241
2358
  const recipe = await scrapeRecipe(url);
2242
2359
  writeOutput(recipe, outputPath);
2243
2360
  console.log(`\u2705 Scraped recipe from ${url}${outputPath ? ` (${outputPath})` : ""}`);
2244
2361
  }
2362
+ function parseValidateArgs(args) {
2363
+ let profile;
2364
+ let strict = false;
2365
+ let json = false;
2366
+ let target;
2367
+ for (let i = 0; i < args.length; i++) {
2368
+ const arg = args[i];
2369
+ switch (arg) {
2370
+ case "--profile":
2371
+ profile = normalizeProfile(args[i + 1]);
2372
+ i++;
2373
+ break;
2374
+ case "--strict":
2375
+ strict = true;
2376
+ break;
2377
+ case "--json":
2378
+ json = true;
2379
+ break;
2380
+ default:
2381
+ if (!arg.startsWith("--") && !target) {
2382
+ target = arg;
2383
+ }
2384
+ break;
2385
+ }
2386
+ }
2387
+ return { profile, strict, json, target };
2388
+ }
2389
+ function parseValidationFlags(args) {
2390
+ const { profile, strict, json } = parseValidateArgs(args);
2391
+ return { profile, strict, json };
2392
+ }
2393
+ function normalizeProfile(value) {
2394
+ if (!value) return void 0;
2395
+ const normalized = value.toLowerCase();
2396
+ if (supportedProfiles.includes(normalized)) {
2397
+ return normalized;
2398
+ }
2399
+ throw new Error(`Unknown Soustack profile: ${value}`);
2400
+ }
2401
+ function parseConvertArgs(args) {
2402
+ let from;
2403
+ let to;
2404
+ let outputPath;
2405
+ let inputPath;
2406
+ for (let i = 0; i < args.length; i++) {
2407
+ const arg = args[i];
2408
+ switch (arg) {
2409
+ case "--from":
2410
+ from = args[i + 1];
2411
+ if (!from) throw new Error("Missing value for --from");
2412
+ i++;
2413
+ break;
2414
+ case "--to":
2415
+ to = args[i + 1];
2416
+ if (!to) throw new Error("Missing value for --to");
2417
+ i++;
2418
+ break;
2419
+ case "-o":
2420
+ case "--output":
2421
+ outputPath = args[i + 1];
2422
+ if (!outputPath) throw new Error("Missing value for output");
2423
+ i++;
2424
+ break;
2425
+ default:
2426
+ if (!inputPath && !arg.startsWith("--")) {
2427
+ inputPath = arg;
2428
+ }
2429
+ break;
2430
+ }
2431
+ }
2432
+ return { from, to, inputPath, outputPath };
2433
+ }
2434
+ function parseImportArgs(args) {
2435
+ let url;
2436
+ let outputPath;
2437
+ for (let i = 0; i < args.length; i++) {
2438
+ const arg = args[i];
2439
+ if (arg === "--url") {
2440
+ url = args[i + 1];
2441
+ if (!url) {
2442
+ throw new Error("URL flag provided without a value");
2443
+ }
2444
+ i++;
2445
+ } else if (arg === "-o" || arg === "--output") {
2446
+ outputPath = args[i + 1];
2447
+ if (!outputPath) {
2448
+ throw new Error("Output flag provided without a path");
2449
+ }
2450
+ i++;
2451
+ }
2452
+ }
2453
+ return { url, outputPath };
2454
+ }
2455
+ function resolveConvertDirection(from, to) {
2456
+ if (from === "schemaorg" && to === "soustack") return "schemaorg-to-soustack";
2457
+ if (from === "soustack" && to === "schemaorg") return "soustack-to-schemaorg";
2458
+ return null;
2459
+ }
2460
+ function expandTargets(target) {
2461
+ const matches = glob.globSync(target, { absolute: true, nodir: true });
2462
+ const unique = Array.from(new Set(matches.map((match) => path__namespace.resolve(match))));
2463
+ return unique;
2464
+ }
2465
+ function validateFile(file, profile) {
2466
+ const recipe = readJsonFile(file);
2467
+ const result = validateRecipe(recipe, profile ? { profile } : {});
2468
+ return {
2469
+ file,
2470
+ profile,
2471
+ valid: result.valid,
2472
+ warnings: result.warnings,
2473
+ errors: result.errors
2474
+ };
2475
+ }
2476
+ function reportValidation(results, options) {
2477
+ const summary = {
2478
+ strict: options.strict,
2479
+ total: results.length,
2480
+ passed: 0,
2481
+ failed: 0
2482
+ };
2483
+ const serializable = results.map((result) => {
2484
+ const passed = isEffectivelyValid(result, options.strict);
2485
+ if (passed) summary.passed += 1;
2486
+ else summary.failed += 1;
2487
+ return {
2488
+ file: path__namespace.relative(process.cwd(), result.file),
2489
+ profile: result.profile,
2490
+ valid: result.valid,
2491
+ warnings: result.warnings,
2492
+ errors: result.errors,
2493
+ passed
2494
+ };
2495
+ });
2496
+ if (options.json) {
2497
+ console.log(JSON.stringify({ summary, results: serializable }, null, 2));
2498
+ } else {
2499
+ serializable.forEach((entry) => {
2500
+ const prefix = entry.passed ? "\u2705" : "\u274C";
2501
+ console.log(`${prefix} ${entry.file}`);
2502
+ if (!entry.passed && entry.errors.length) {
2503
+ entry.errors.forEach((error) => {
2504
+ console.log(` \u2022 [${error.path}] ${error.message}`);
2505
+ });
2506
+ }
2507
+ if (!entry.passed && options.strict && entry.warnings.length) {
2508
+ entry.warnings.forEach((warning) => {
2509
+ console.log(` \u2022 [${warning.path}] ${warning.message} (warning)`);
2510
+ });
2511
+ }
2512
+ });
2513
+ const contextLabel = options.context === "test" ? "Test summary" : "Validation summary";
2514
+ console.log(`${contextLabel}: ${summary.passed}/${summary.total} files valid${options.strict ? " (strict)" : ""}`);
2515
+ }
2516
+ if (summary.failed > 0) {
2517
+ process.exitCode = 1;
2518
+ }
2519
+ }
2520
+ function isEffectivelyValid(result, strict) {
2521
+ return result.valid && (!strict || result.warnings.length === 0);
2522
+ }
2245
2523
  function readJsonFile(relativePath) {
2246
2524
  const absolutePath = path__namespace.resolve(relativePath);
2247
2525
  if (!fs__namespace.existsSync(absolutePath)) {
@@ -2254,10 +2532,10 @@ function readJsonFile(relativePath) {
2254
2532
  throw new Error(`Unable to parse JSON in ${absolutePath}`);
2255
2533
  }
2256
2534
  }
2257
- function resolveOutputPath(args2) {
2258
- const index = args2.findIndex((arg) => arg === "-o" || arg === "--output");
2535
+ function resolveOutputPath(args) {
2536
+ const index = args.findIndex((arg) => arg === "-o" || arg === "--output");
2259
2537
  if (index === -1) return void 0;
2260
- const target = args2[index + 1];
2538
+ const target = args[index + 1];
2261
2539
  if (!target) {
2262
2540
  throw new Error("Output flag provided without a path");
2263
2541
  }
@@ -2272,6 +2550,13 @@ function writeOutput(data, outputPath) {
2272
2550
  const absolutePath = path__namespace.resolve(outputPath);
2273
2551
  fs__namespace.writeFileSync(absolutePath, serialized, "utf-8");
2274
2552
  }
2275
- main();
2553
+ if (__require.main === module) {
2554
+ runCli(process.argv.slice(2)).catch((error) => {
2555
+ console.error(`\u274C ${error?.message ?? error}`);
2556
+ process.exit(1);
2557
+ });
2558
+ }
2559
+
2560
+ exports.runCli = runCli;
2276
2561
  //# sourceMappingURL=index.js.map
2277
2562
  //# sourceMappingURL=index.js.map