soustack 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,1146 +33,275 @@ 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;
234
+ }
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;
241
+ }
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);
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;
261
+ }
262
+ if (typeof duration === "string" && duration.trim().startsWith("P")) {
263
+ const parsed = parseDuration(duration.trim());
264
+ if (parsed !== null) {
265
+ return parsed;
266
+ }
267
+ }
268
+ return 0;
161
269
  }
162
270
 
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" }
262
- }
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
- }
484
- }
485
- }
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));
496
- }
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
- };
271
+ // src/converters/yield.ts
272
+ function parseYield(value) {
273
+ if (value === void 0 || value === null) {
274
+ return void 0;
275
+ }
276
+ if (typeof value === "number") {
277
+ return {
278
+ amount: value,
279
+ unit: "servings"
280
+ };
281
+ }
282
+ if (Array.isArray(value)) {
283
+ return parseYield(value[0]);
284
+ }
285
+ if (typeof value === "object") {
286
+ const maybeYield = value;
287
+ if (typeof maybeYield.amount === "number") {
288
+ return {
289
+ amount: maybeYield.amount,
290
+ unit: typeof maybeYield.unit === "string" ? maybeYield.unit : "servings",
291
+ description: typeof maybeYield.description === "string" ? maybeYield.description : void 0
292
+ };
849
293
  }
850
294
  }
851
- return void 0;
852
- }
853
- function extractVagueQuantity(value) {
854
- for (const pattern of VAGUE_QUANTITY_PATTERNS) {
855
- const match = value.match(pattern.regex);
295
+ if (typeof value === "string") {
296
+ const trimmed = value.trim();
297
+ const match = trimmed.match(/(\d+(?:\.\d+)?)/);
856
298
  if (match) {
857
- let remainder = value.slice(match[0].length).trim();
858
- remainder = remainder.replace(/^of\s+/i, "").trim();
299
+ const amount = parseFloat(match[1]);
300
+ const unit = trimmed.slice(match.index + match[1].length).trim();
859
301
  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
- };
1139
- }
1140
-
1141
- // src/converters/yield.ts
1142
- function parseYield(value) {
1143
- if (value === void 0 || value === null) {
1144
- return void 0;
1145
- }
1146
- if (typeof value === "number") {
1147
- return {
1148
- amount: value,
1149
- unit: "servings"
1150
- };
1151
- }
1152
- if (Array.isArray(value)) {
1153
- return parseYield(value[0]);
1154
- }
1155
- if (typeof value === "object") {
1156
- const maybeYield = value;
1157
- if (typeof maybeYield.amount === "number") {
1158
- return {
1159
- amount: maybeYield.amount,
1160
- unit: typeof maybeYield.unit === "string" ? maybeYield.unit : "servings",
1161
- description: typeof maybeYield.description === "string" ? maybeYield.description : void 0
1162
- };
1163
- }
1164
- }
1165
- if (typeof value === "string") {
1166
- const trimmed = value.trim();
1167
- const match = trimmed.match(/(\d+(?:\.\d+)?)/);
1168
- if (match) {
1169
- const amount = parseFloat(match[1]);
1170
- const unit = trimmed.slice(match.index + match[1].length).trim();
1171
- return {
1172
- amount,
1173
- unit: unit || "servings",
1174
- description: trimmed
302
+ amount,
303
+ unit: unit || "servings",
304
+ description: trimmed
1175
305
  };
1176
306
  }
1177
307
  }
@@ -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) {
@@ -1364,8 +413,6 @@ function extractRecipeNode(input) {
1364
413
  function hasRecipeType(value) {
1365
414
  if (!value) return false;
1366
415
  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
416
  return types.some(
1370
417
  (entry) => typeof entry === "string" && entry.toLowerCase() === "recipe"
1371
418
  );
@@ -1376,7 +423,7 @@ function isValidName(name) {
1376
423
  function convertIngredients(value) {
1377
424
  if (!value) return [];
1378
425
  const normalized = Array.isArray(value) ? value : [value];
1379
- return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean).map((line) => parseIngredientLine(line));
426
+ return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean);
1380
427
  }
1381
428
  function convertInstructions(value) {
1382
429
  if (!value) return [];
@@ -1444,10 +491,33 @@ function convertHowToStep(step) {
1444
491
  return void 0;
1445
492
  }
1446
493
  const normalizedImage = normalizeImage(step.image);
1447
- if (typeof normalizedImage === "string") {
1448
- return { text, image: normalizedImage };
494
+ const image = Array.isArray(normalizedImage) ? normalizedImage[0] : normalizedImage;
495
+ const id = extractInstructionId(step);
496
+ const timing = extractInstructionTiming(step);
497
+ if (!image && !id && !timing) {
498
+ return text;
499
+ }
500
+ const instruction = { text };
501
+ if (id) instruction.id = id;
502
+ if (image) instruction.image = image;
503
+ if (timing) instruction.timing = timing;
504
+ return instruction;
505
+ }
506
+ function extractInstructionTiming(step) {
507
+ const duration = step.totalTime || step.performTime || step.prepTime || step.duration;
508
+ if (!duration || typeof duration !== "string") {
509
+ return void 0;
1449
510
  }
1450
- return text;
511
+ const parsed = smartParseDuration(duration);
512
+ return { duration: parsed ?? duration, type: "active" };
513
+ }
514
+ function extractInstructionId(step) {
515
+ const raw = step["@id"] || step.id || step.url;
516
+ if (typeof raw !== "string") {
517
+ return void 0;
518
+ }
519
+ const trimmed = raw.trim();
520
+ return trimmed || void 0;
1451
521
  }
1452
522
  function isHowToStep(value) {
1453
523
  return Boolean(value) && typeof value === "object" && value["@type"] === "HowToStep";
@@ -1582,10 +652,11 @@ function convertInstruction(entry) {
1582
652
  return null;
1583
653
  }
1584
654
  if (typeof entry === "string") {
1585
- return createHowToStep(entry);
655
+ const value = entry.trim();
656
+ return value || null;
1586
657
  }
1587
658
  if ("subsection" in entry) {
1588
- const steps = entry.items.map((item) => createHowToStep(item)).filter((step) => Boolean(step));
659
+ const steps = entry.items.map((item) => convertInstruction(item)).filter((step) => Boolean(step));
1589
660
  if (!steps.length) {
1590
661
  return null;
1591
662
  }
@@ -1604,13 +675,7 @@ function createHowToStep(entry) {
1604
675
  if (!entry) return null;
1605
676
  if (typeof entry === "string") {
1606
677
  const trimmed2 = entry.trim();
1607
- if (!trimmed2) {
1608
- return null;
1609
- }
1610
- return {
1611
- "@type": "HowToStep",
1612
- text: trimmed2
1613
- };
678
+ return trimmed2 || null;
1614
679
  }
1615
680
  const trimmed = entry.text?.trim();
1616
681
  if (!trimmed) {
@@ -1620,10 +685,23 @@ function createHowToStep(entry) {
1620
685
  "@type": "HowToStep",
1621
686
  text: trimmed
1622
687
  };
688
+ if (entry.id) {
689
+ step["@id"] = entry.id;
690
+ }
691
+ if (entry.timing) {
692
+ if (typeof entry.timing.duration === "number") {
693
+ step.performTime = formatDuration(entry.timing.duration);
694
+ } else if (entry.timing.duration) {
695
+ step.performTime = entry.timing.duration;
696
+ }
697
+ }
1623
698
  if (entry.image) {
1624
699
  step.image = entry.image;
1625
700
  }
1626
- return step;
701
+ if (step["@id"] || step.performTime || step.image) {
702
+ return step;
703
+ }
704
+ return trimmed;
1627
705
  }
1628
706
  function convertTime2(time) {
1629
707
  if (!time) {
@@ -2049,199 +1127,1454 @@ function collectCandidates2(payload, bucket) {
2049
1127
  if (Array.isArray(graph)) {
2050
1128
  graph.forEach((entry) => collectCandidates2(entry, bucket));
2051
1129
  }
2052
- }
1130
+ }
1131
+
1132
+ // src/scraper/extractors/index.ts
1133
+ function isBrowser() {
1134
+ try {
1135
+ return typeof globalThis.DOMParser !== "undefined";
1136
+ } catch {
1137
+ return false;
1138
+ }
1139
+ }
1140
+ function extractRecipe(html) {
1141
+ if (isBrowser()) {
1142
+ return extractRecipeBrowser(html);
1143
+ }
1144
+ const jsonLdRecipe = extractJsonLd(html);
1145
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1146
+ try {
1147
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1148
+ if (globalFetch) {
1149
+ 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(() => {
1150
+ });
1151
+ }
1152
+ } catch {
1153
+ }
1154
+ }
1155
+ if (jsonLdRecipe) {
1156
+ return { recipe: jsonLdRecipe, source: "jsonld" };
1157
+ }
1158
+ const microdataRecipe = extractMicrodata(html);
1159
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1160
+ try {
1161
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1162
+ if (globalFetch) {
1163
+ 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(() => {
1164
+ });
1165
+ }
1166
+ } catch {
1167
+ }
1168
+ }
1169
+ if (microdataRecipe) {
1170
+ return { recipe: microdataRecipe, source: "microdata" };
1171
+ }
1172
+ return { recipe: null, source: null };
1173
+ }
1174
+
1175
+ // src/scraper/index.ts
1176
+ async function scrapeRecipe(url, options = {}) {
1177
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1178
+ try {
1179
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1180
+ if (globalFetch) {
1181
+ 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(() => {
1182
+ });
1183
+ }
1184
+ } catch {
1185
+ }
1186
+ }
1187
+ const html = await fetchPage(url, options);
1188
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1189
+ try {
1190
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1191
+ if (globalFetch) {
1192
+ 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(() => {
1193
+ });
1194
+ }
1195
+ } catch {
1196
+ }
1197
+ }
1198
+ const { recipe } = extractRecipe(html);
1199
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1200
+ try {
1201
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1202
+ if (globalFetch) {
1203
+ 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(() => {
1204
+ });
1205
+ }
1206
+ } catch {
1207
+ }
1208
+ }
1209
+ if (!recipe) {
1210
+ throw new Error("No Schema.org recipe data found in page");
1211
+ }
1212
+ const soustackRecipe = fromSchemaOrg(recipe);
1213
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1214
+ try {
1215
+ const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1216
+ if (globalFetch) {
1217
+ 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(() => {
1218
+ });
1219
+ }
1220
+ } catch {
1221
+ }
1222
+ }
1223
+ if (!soustackRecipe) {
1224
+ throw new Error("Schema.org data did not include a valid recipe");
1225
+ }
1226
+ return soustackRecipe;
1227
+ }
1228
+
1229
+ // src/schema.json
1230
+ var schema_default = {
1231
+ $schema: "http://json-schema.org/draft-07/schema#",
1232
+ $id: "http://soustack.org/schema/v0.2.1",
1233
+ title: "Soustack Recipe Schema v0.2.1",
1234
+ description: "A portable, scalable, interoperable recipe format.",
1235
+ type: "object",
1236
+ required: ["name", "ingredients", "instructions"],
1237
+ additionalProperties: false,
1238
+ patternProperties: {
1239
+ "^x-": {}
1240
+ },
1241
+ properties: {
1242
+ $schema: {
1243
+ type: "string",
1244
+ format: "uri",
1245
+ description: "Optional schema hint for tooling compatibility"
1246
+ },
1247
+ id: {
1248
+ type: "string",
1249
+ description: "Unique identifier (slug or UUID)"
1250
+ },
1251
+ name: {
1252
+ type: "string",
1253
+ description: "The title of the recipe"
1254
+ },
1255
+ title: {
1256
+ type: "string",
1257
+ description: "Optional display title; alias for name"
1258
+ },
1259
+ version: {
1260
+ type: "string",
1261
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
1262
+ description: "DEPRECATED: use recipeVersion for authoring revisions"
1263
+ },
1264
+ recipeVersion: {
1265
+ type: "string",
1266
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
1267
+ description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
1268
+ },
1269
+ description: {
1270
+ type: "string"
1271
+ },
1272
+ category: {
1273
+ type: "string",
1274
+ examples: ["Main Course", "Dessert"]
1275
+ },
1276
+ tags: {
1277
+ type: "array",
1278
+ items: { type: "string" }
1279
+ },
1280
+ image: {
1281
+ description: "Recipe-level hero image(s)",
1282
+ anyOf: [
1283
+ {
1284
+ type: "string",
1285
+ format: "uri"
1286
+ },
1287
+ {
1288
+ type: "array",
1289
+ minItems: 1,
1290
+ items: {
1291
+ type: "string",
1292
+ format: "uri"
1293
+ }
1294
+ }
1295
+ ]
1296
+ },
1297
+ dateAdded: {
1298
+ type: "string",
1299
+ format: "date-time"
1300
+ },
1301
+ metadata: {
1302
+ type: "object",
1303
+ additionalProperties: true,
1304
+ description: "Free-form vendor metadata"
1305
+ },
1306
+ source: {
1307
+ type: "object",
1308
+ properties: {
1309
+ author: { type: "string" },
1310
+ url: { type: "string", format: "uri" },
1311
+ name: { type: "string" },
1312
+ adapted: { type: "boolean" }
1313
+ }
1314
+ },
1315
+ yield: {
1316
+ $ref: "#/definitions/yield"
1317
+ },
1318
+ time: {
1319
+ $ref: "#/definitions/time"
1320
+ },
1321
+ equipment: {
1322
+ type: "array",
1323
+ items: { $ref: "#/definitions/equipment" }
1324
+ },
1325
+ ingredients: {
1326
+ type: "array",
1327
+ items: {
1328
+ anyOf: [
1329
+ { type: "string" },
1330
+ { $ref: "#/definitions/ingredient" },
1331
+ { $ref: "#/definitions/ingredientSubsection" }
1332
+ ]
1333
+ }
1334
+ },
1335
+ instructions: {
1336
+ type: "array",
1337
+ items: {
1338
+ anyOf: [
1339
+ { type: "string" },
1340
+ { $ref: "#/definitions/instruction" },
1341
+ { $ref: "#/definitions/instructionSubsection" }
1342
+ ]
1343
+ }
1344
+ },
1345
+ storage: {
1346
+ $ref: "#/definitions/storage"
1347
+ },
1348
+ substitutions: {
1349
+ type: "array",
1350
+ items: { $ref: "#/definitions/substitution" }
1351
+ }
1352
+ },
1353
+ definitions: {
1354
+ yield: {
1355
+ type: "object",
1356
+ required: ["amount", "unit"],
1357
+ properties: {
1358
+ amount: { type: "number" },
1359
+ unit: { type: "string" },
1360
+ servings: { type: "number" },
1361
+ description: { type: "string" }
1362
+ }
1363
+ },
1364
+ time: {
1365
+ type: "object",
1366
+ properties: {
1367
+ prep: { type: "number" },
1368
+ active: { type: "number" },
1369
+ passive: { type: "number" },
1370
+ total: { type: "number" },
1371
+ prepTime: { type: "string", format: "duration" },
1372
+ cookTime: { type: "string", format: "duration" }
1373
+ },
1374
+ minProperties: 1
1375
+ },
1376
+ quantity: {
1377
+ type: "object",
1378
+ required: ["amount"],
1379
+ properties: {
1380
+ amount: { type: "number" },
1381
+ unit: { type: ["string", "null"] }
1382
+ }
1383
+ },
1384
+ scaling: {
1385
+ type: "object",
1386
+ required: ["type"],
1387
+ properties: {
1388
+ type: {
1389
+ type: "string",
1390
+ enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
1391
+ },
1392
+ factor: { type: "number" },
1393
+ referenceId: { type: "string" },
1394
+ roundTo: { type: "number" },
1395
+ min: { type: "number" },
1396
+ max: { type: "number" }
1397
+ },
1398
+ if: {
1399
+ properties: { type: { const: "bakers_percentage" } }
1400
+ },
1401
+ then: {
1402
+ required: ["referenceId"]
1403
+ }
1404
+ },
1405
+ ingredient: {
1406
+ type: "object",
1407
+ required: ["item"],
1408
+ properties: {
1409
+ id: { type: "string" },
1410
+ item: { type: "string" },
1411
+ quantity: { $ref: "#/definitions/quantity" },
1412
+ name: { type: "string" },
1413
+ aisle: { type: "string" },
1414
+ prep: { type: "string" },
1415
+ prepAction: { type: "string" },
1416
+ prepTime: { type: "number" },
1417
+ destination: { type: "string" },
1418
+ scaling: { $ref: "#/definitions/scaling" },
1419
+ critical: { type: "boolean" },
1420
+ optional: { type: "boolean" },
1421
+ notes: { type: "string" }
1422
+ }
1423
+ },
1424
+ ingredientSubsection: {
1425
+ type: "object",
1426
+ required: ["subsection", "items"],
1427
+ properties: {
1428
+ subsection: { type: "string" },
1429
+ items: {
1430
+ type: "array",
1431
+ items: { $ref: "#/definitions/ingredient" }
1432
+ }
1433
+ }
1434
+ },
1435
+ equipment: {
1436
+ type: "object",
1437
+ required: ["name"],
1438
+ properties: {
1439
+ id: { type: "string" },
1440
+ name: { type: "string" },
1441
+ required: { type: "boolean" },
1442
+ label: { type: "string" },
1443
+ capacity: { $ref: "#/definitions/quantity" },
1444
+ scalingLimit: { type: "number" },
1445
+ alternatives: {
1446
+ type: "array",
1447
+ items: { type: "string" }
1448
+ }
1449
+ }
1450
+ },
1451
+ instruction: {
1452
+ type: "object",
1453
+ required: ["text"],
1454
+ properties: {
1455
+ id: { type: "string" },
1456
+ text: { type: "string" },
1457
+ image: {
1458
+ type: "string",
1459
+ format: "uri",
1460
+ description: "Optional image that illustrates this instruction"
1461
+ },
1462
+ destination: { type: "string" },
1463
+ dependsOn: {
1464
+ type: "array",
1465
+ items: { type: "string" }
1466
+ },
1467
+ inputs: {
1468
+ type: "array",
1469
+ items: { type: "string" }
1470
+ },
1471
+ timing: {
1472
+ type: "object",
1473
+ required: ["duration", "type"],
1474
+ properties: {
1475
+ duration: {
1476
+ anyOf: [
1477
+ { type: "number" },
1478
+ { type: "string", pattern: "^P" }
1479
+ ],
1480
+ description: "Minutes as a number or ISO8601 duration string"
1481
+ },
1482
+ type: { type: "string", enum: ["active", "passive"] },
1483
+ scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
1484
+ }
1485
+ }
1486
+ }
1487
+ },
1488
+ instructionSubsection: {
1489
+ type: "object",
1490
+ required: ["subsection", "items"],
1491
+ properties: {
1492
+ subsection: { type: "string" },
1493
+ items: {
1494
+ type: "array",
1495
+ items: {
1496
+ anyOf: [
1497
+ { type: "string" },
1498
+ { $ref: "#/definitions/instruction" }
1499
+ ]
1500
+ }
1501
+ }
1502
+ }
1503
+ },
1504
+ storage: {
1505
+ type: "object",
1506
+ properties: {
1507
+ roomTemp: { $ref: "#/definitions/storageMethod" },
1508
+ refrigerated: { $ref: "#/definitions/storageMethod" },
1509
+ frozen: {
1510
+ allOf: [
1511
+ { $ref: "#/definitions/storageMethod" },
1512
+ {
1513
+ type: "object",
1514
+ properties: { thawing: { type: "string" } }
1515
+ }
1516
+ ]
1517
+ },
1518
+ reheating: { type: "string" },
1519
+ makeAhead: {
1520
+ type: "array",
1521
+ items: {
1522
+ allOf: [
1523
+ { $ref: "#/definitions/storageMethod" },
1524
+ {
1525
+ type: "object",
1526
+ required: ["component", "storage"],
1527
+ properties: {
1528
+ component: { type: "string" },
1529
+ storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
1530
+ }
1531
+ }
1532
+ ]
1533
+ }
1534
+ }
1535
+ }
1536
+ },
1537
+ storageMethod: {
1538
+ type: "object",
1539
+ required: ["duration"],
1540
+ properties: {
1541
+ duration: { type: "string", pattern: "^P" },
1542
+ method: { type: "string" },
1543
+ notes: { type: "string" }
1544
+ }
1545
+ },
1546
+ substitution: {
1547
+ type: "object",
1548
+ required: ["ingredient"],
1549
+ properties: {
1550
+ ingredient: { type: "string" },
1551
+ critical: { type: "boolean" },
1552
+ notes: { type: "string" },
1553
+ alternatives: {
1554
+ type: "array",
1555
+ items: {
1556
+ type: "object",
1557
+ required: ["name", "ratio"],
1558
+ properties: {
1559
+ name: { type: "string" },
1560
+ ratio: { type: "string" },
1561
+ notes: { type: "string" },
1562
+ impact: { type: "string" },
1563
+ dietary: {
1564
+ type: "array",
1565
+ items: { type: "string" }
1566
+ }
1567
+ }
1568
+ }
1569
+ }
1570
+ }
1571
+ }
1572
+ }
1573
+ };
1574
+
1575
+ // src/soustack.schema.json
1576
+ var soustack_schema_default = {
1577
+ $schema: "http://json-schema.org/draft-07/schema#",
1578
+ $id: "http://soustack.org/schema/v0.2.1",
1579
+ title: "Soustack Recipe Schema v0.2.1",
1580
+ description: "A portable, scalable, interoperable recipe format.",
1581
+ type: "object",
1582
+ required: ["name", "ingredients", "instructions"],
1583
+ additionalProperties: false,
1584
+ patternProperties: {
1585
+ "^x-": {}
1586
+ },
1587
+ properties: {
1588
+ $schema: {
1589
+ type: "string",
1590
+ format: "uri",
1591
+ description: "Optional schema hint for tooling compatibility"
1592
+ },
1593
+ id: {
1594
+ type: "string",
1595
+ description: "Unique identifier (slug or UUID)"
1596
+ },
1597
+ name: {
1598
+ type: "string",
1599
+ description: "The title of the recipe"
1600
+ },
1601
+ title: {
1602
+ type: "string",
1603
+ description: "Optional display title; alias for name"
1604
+ },
1605
+ version: {
1606
+ type: "string",
1607
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
1608
+ description: "DEPRECATED: use recipeVersion for authoring revisions"
1609
+ },
1610
+ recipeVersion: {
1611
+ type: "string",
1612
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
1613
+ description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
1614
+ },
1615
+ description: {
1616
+ type: "string"
1617
+ },
1618
+ category: {
1619
+ type: "string",
1620
+ examples: ["Main Course", "Dessert"]
1621
+ },
1622
+ tags: {
1623
+ type: "array",
1624
+ items: { type: "string" }
1625
+ },
1626
+ image: {
1627
+ description: "Recipe-level hero image(s)",
1628
+ anyOf: [
1629
+ {
1630
+ type: "string",
1631
+ format: "uri"
1632
+ },
1633
+ {
1634
+ type: "array",
1635
+ minItems: 1,
1636
+ items: {
1637
+ type: "string",
1638
+ format: "uri"
1639
+ }
1640
+ }
1641
+ ]
1642
+ },
1643
+ dateAdded: {
1644
+ type: "string",
1645
+ format: "date-time"
1646
+ },
1647
+ metadata: {
1648
+ type: "object",
1649
+ additionalProperties: true,
1650
+ description: "Free-form vendor metadata"
1651
+ },
1652
+ source: {
1653
+ type: "object",
1654
+ properties: {
1655
+ author: { type: "string" },
1656
+ url: { type: "string", format: "uri" },
1657
+ name: { type: "string" },
1658
+ adapted: { type: "boolean" }
1659
+ }
1660
+ },
1661
+ yield: {
1662
+ $ref: "#/definitions/yield"
1663
+ },
1664
+ time: {
1665
+ $ref: "#/definitions/time"
1666
+ },
1667
+ equipment: {
1668
+ type: "array",
1669
+ items: { $ref: "#/definitions/equipment" }
1670
+ },
1671
+ ingredients: {
1672
+ type: "array",
1673
+ items: {
1674
+ anyOf: [
1675
+ { type: "string" },
1676
+ { $ref: "#/definitions/ingredient" },
1677
+ { $ref: "#/definitions/ingredientSubsection" }
1678
+ ]
1679
+ }
1680
+ },
1681
+ instructions: {
1682
+ type: "array",
1683
+ items: {
1684
+ anyOf: [
1685
+ { type: "string" },
1686
+ { $ref: "#/definitions/instruction" },
1687
+ { $ref: "#/definitions/instructionSubsection" }
1688
+ ]
1689
+ }
1690
+ },
1691
+ storage: {
1692
+ $ref: "#/definitions/storage"
1693
+ },
1694
+ substitutions: {
1695
+ type: "array",
1696
+ items: { $ref: "#/definitions/substitution" }
1697
+ }
1698
+ },
1699
+ definitions: {
1700
+ yield: {
1701
+ type: "object",
1702
+ required: ["amount", "unit"],
1703
+ properties: {
1704
+ amount: { type: "number" },
1705
+ unit: { type: "string" },
1706
+ servings: { type: "number" },
1707
+ description: { type: "string" }
1708
+ }
1709
+ },
1710
+ time: {
1711
+ type: "object",
1712
+ properties: {
1713
+ prep: { type: "number" },
1714
+ active: { type: "number" },
1715
+ passive: { type: "number" },
1716
+ total: { type: "number" },
1717
+ prepTime: { type: "string", format: "duration" },
1718
+ cookTime: { type: "string", format: "duration" }
1719
+ },
1720
+ minProperties: 1
1721
+ },
1722
+ quantity: {
1723
+ type: "object",
1724
+ required: ["amount"],
1725
+ properties: {
1726
+ amount: { type: "number" },
1727
+ unit: { type: ["string", "null"] }
1728
+ }
1729
+ },
1730
+ scaling: {
1731
+ type: "object",
1732
+ required: ["type"],
1733
+ properties: {
1734
+ type: {
1735
+ type: "string",
1736
+ enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
1737
+ },
1738
+ factor: { type: "number" },
1739
+ referenceId: { type: "string" },
1740
+ roundTo: { type: "number" },
1741
+ min: { type: "number" },
1742
+ max: { type: "number" }
1743
+ },
1744
+ if: {
1745
+ properties: { type: { const: "bakers_percentage" } }
1746
+ },
1747
+ then: {
1748
+ required: ["referenceId"]
1749
+ }
1750
+ },
1751
+ ingredient: {
1752
+ type: "object",
1753
+ required: ["item"],
1754
+ properties: {
1755
+ id: { type: "string" },
1756
+ item: { type: "string" },
1757
+ quantity: { $ref: "#/definitions/quantity" },
1758
+ name: { type: "string" },
1759
+ aisle: { type: "string" },
1760
+ prep: { type: "string" },
1761
+ prepAction: { type: "string" },
1762
+ prepTime: { type: "number" },
1763
+ destination: { type: "string" },
1764
+ scaling: { $ref: "#/definitions/scaling" },
1765
+ critical: { type: "boolean" },
1766
+ optional: { type: "boolean" },
1767
+ notes: { type: "string" }
1768
+ }
1769
+ },
1770
+ ingredientSubsection: {
1771
+ type: "object",
1772
+ required: ["subsection", "items"],
1773
+ properties: {
1774
+ subsection: { type: "string" },
1775
+ items: {
1776
+ type: "array",
1777
+ items: { $ref: "#/definitions/ingredient" }
1778
+ }
1779
+ }
1780
+ },
1781
+ equipment: {
1782
+ type: "object",
1783
+ required: ["name"],
1784
+ properties: {
1785
+ id: { type: "string" },
1786
+ name: { type: "string" },
1787
+ required: { type: "boolean" },
1788
+ label: { type: "string" },
1789
+ capacity: { $ref: "#/definitions/quantity" },
1790
+ scalingLimit: { type: "number" },
1791
+ alternatives: {
1792
+ type: "array",
1793
+ items: { type: "string" }
1794
+ }
1795
+ }
1796
+ },
1797
+ instruction: {
1798
+ type: "object",
1799
+ required: ["text"],
1800
+ properties: {
1801
+ id: { type: "string" },
1802
+ text: { type: "string" },
1803
+ image: {
1804
+ type: "string",
1805
+ format: "uri",
1806
+ description: "Optional image that illustrates this instruction"
1807
+ },
1808
+ destination: { type: "string" },
1809
+ dependsOn: {
1810
+ type: "array",
1811
+ items: { type: "string" }
1812
+ },
1813
+ inputs: {
1814
+ type: "array",
1815
+ items: { type: "string" }
1816
+ },
1817
+ timing: {
1818
+ type: "object",
1819
+ required: ["duration", "type"],
1820
+ properties: {
1821
+ duration: {
1822
+ anyOf: [
1823
+ { type: "number" },
1824
+ { type: "string", pattern: "^P" }
1825
+ ],
1826
+ description: "Minutes as a number or ISO8601 duration string"
1827
+ },
1828
+ type: { type: "string", enum: ["active", "passive"] },
1829
+ scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
1830
+ }
1831
+ }
1832
+ }
1833
+ },
1834
+ instructionSubsection: {
1835
+ type: "object",
1836
+ required: ["subsection", "items"],
1837
+ properties: {
1838
+ subsection: { type: "string" },
1839
+ items: {
1840
+ type: "array",
1841
+ items: {
1842
+ anyOf: [
1843
+ { type: "string" },
1844
+ { $ref: "#/definitions/instruction" }
1845
+ ]
1846
+ }
1847
+ }
1848
+ }
1849
+ },
1850
+ storage: {
1851
+ type: "object",
1852
+ properties: {
1853
+ roomTemp: { $ref: "#/definitions/storageMethod" },
1854
+ refrigerated: { $ref: "#/definitions/storageMethod" },
1855
+ frozen: {
1856
+ allOf: [
1857
+ { $ref: "#/definitions/storageMethod" },
1858
+ {
1859
+ type: "object",
1860
+ properties: { thawing: { type: "string" } }
1861
+ }
1862
+ ]
1863
+ },
1864
+ reheating: { type: "string" },
1865
+ makeAhead: {
1866
+ type: "array",
1867
+ items: {
1868
+ allOf: [
1869
+ { $ref: "#/definitions/storageMethod" },
1870
+ {
1871
+ type: "object",
1872
+ required: ["component", "storage"],
1873
+ properties: {
1874
+ component: { type: "string" },
1875
+ storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
1876
+ }
1877
+ }
1878
+ ]
1879
+ }
1880
+ }
1881
+ }
1882
+ },
1883
+ storageMethod: {
1884
+ type: "object",
1885
+ required: ["duration"],
1886
+ properties: {
1887
+ duration: { type: "string", pattern: "^P" },
1888
+ method: { type: "string" },
1889
+ notes: { type: "string" }
1890
+ }
1891
+ },
1892
+ substitution: {
1893
+ type: "object",
1894
+ required: ["ingredient"],
1895
+ properties: {
1896
+ ingredient: { type: "string" },
1897
+ critical: { type: "boolean" },
1898
+ notes: { type: "string" },
1899
+ alternatives: {
1900
+ type: "array",
1901
+ items: {
1902
+ type: "object",
1903
+ required: ["name", "ratio"],
1904
+ properties: {
1905
+ name: { type: "string" },
1906
+ ratio: { type: "string" },
1907
+ notes: { type: "string" },
1908
+ impact: { type: "string" },
1909
+ dietary: {
1910
+ type: "array",
1911
+ items: { type: "string" }
1912
+ }
1913
+ }
1914
+ }
1915
+ }
1916
+ }
1917
+ }
1918
+ }
1919
+ };
2053
1920
 
2054
- // src/scraper/extractors/index.ts
2055
- function isBrowser() {
2056
- try {
2057
- return typeof globalThis.DOMParser !== "undefined";
2058
- } catch {
2059
- return false;
2060
- }
2061
- }
2062
- function extractRecipe(html) {
2063
- if (isBrowser()) {
2064
- return extractRecipeBrowser(html);
2065
- }
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
- });
1921
+ // src/profiles/base.schema.json
1922
+ var base_schema_default = {
1923
+ $schema: "http://json-schema.org/draft-07/schema#",
1924
+ $id: "http://soustack.org/schema/v0.2.1/profiles/base",
1925
+ title: "Soustack Base Profile Schema",
1926
+ description: "Wrapper schema that exposes the unmodified Soustack base schema.",
1927
+ allOf: [
1928
+ { $ref: "http://soustack.org/schema/v0.2.1" }
1929
+ ]
1930
+ };
1931
+
1932
+ // src/profiles/cookable.schema.json
1933
+ var cookable_schema_default = {
1934
+ $schema: "http://json-schema.org/draft-07/schema#",
1935
+ $id: "http://soustack.org/schema/v0.2.1/profiles/cookable",
1936
+ title: "Soustack Cookable Profile Schema",
1937
+ description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
1938
+ allOf: [
1939
+ { $ref: "http://soustack.org/schema/v0.2.1" },
1940
+ {
1941
+ required: ["yield", "time", "ingredients", "instructions"],
1942
+ properties: {
1943
+ yield: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/yield" },
1944
+ time: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/time" },
1945
+ ingredients: { type: "array", minItems: 1 },
1946
+ instructions: { type: "array", minItems: 1 }
2073
1947
  }
2074
- } catch {
2075
1948
  }
2076
- }
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
- });
1949
+ ]
1950
+ };
1951
+
1952
+ // src/profiles/quantified.schema.json
1953
+ var quantified_schema_default = {
1954
+ $schema: "http://json-schema.org/draft-07/schema#",
1955
+ $id: "http://soustack.org/schema/v0.2.1/profiles/quantified",
1956
+ title: "Soustack Quantified Profile Schema",
1957
+ description: "Extends the base schema to require quantified ingredient entries.",
1958
+ allOf: [
1959
+ { $ref: "http://soustack.org/schema/v0.2.1" },
1960
+ {
1961
+ properties: {
1962
+ ingredients: {
1963
+ type: "array",
1964
+ items: {
1965
+ anyOf: [
1966
+ { $ref: "#/definitions/quantifiedIngredient" },
1967
+ { $ref: "#/definitions/quantifiedIngredientSubsection" }
1968
+ ]
1969
+ }
1970
+ }
2087
1971
  }
2088
- } catch {
1972
+ }
1973
+ ],
1974
+ definitions: {
1975
+ quantifiedIngredient: {
1976
+ allOf: [
1977
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredient" },
1978
+ { required: ["item", "quantity"] }
1979
+ ]
1980
+ },
1981
+ quantifiedIngredientSubsection: {
1982
+ allOf: [
1983
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredientSubsection" },
1984
+ {
1985
+ properties: {
1986
+ items: {
1987
+ type: "array",
1988
+ items: { $ref: "#/definitions/quantifiedIngredient" }
1989
+ }
1990
+ }
1991
+ }
1992
+ ]
2089
1993
  }
2090
1994
  }
2091
- if (microdataRecipe) {
2092
- return { recipe: microdataRecipe, source: "microdata" };
1995
+ };
1996
+
1997
+ // src/profiles/illustrated.schema.json
1998
+ var illustrated_schema_default = {
1999
+ $schema: "http://json-schema.org/draft-07/schema#",
2000
+ $id: "http://soustack.org/schema/v0.2.1/profiles/illustrated",
2001
+ title: "Soustack Illustrated Profile Schema",
2002
+ description: "Extends the base schema to guarantee at least one illustrative image.",
2003
+ allOf: [
2004
+ { $ref: "http://soustack.org/schema/v0.2.1" },
2005
+ {
2006
+ anyOf: [
2007
+ { required: ["image"] },
2008
+ {
2009
+ properties: {
2010
+ instructions: {
2011
+ type: "array",
2012
+ contains: {
2013
+ anyOf: [
2014
+ { $ref: "#/definitions/imageInstruction" },
2015
+ { $ref: "#/definitions/instructionSubsectionWithImage" }
2016
+ ]
2017
+ }
2018
+ }
2019
+ }
2020
+ }
2021
+ ]
2022
+ }
2023
+ ],
2024
+ definitions: {
2025
+ imageInstruction: {
2026
+ allOf: [
2027
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
2028
+ { required: ["image"] }
2029
+ ]
2030
+ },
2031
+ instructionSubsectionWithImage: {
2032
+ allOf: [
2033
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
2034
+ {
2035
+ properties: {
2036
+ items: {
2037
+ type: "array",
2038
+ contains: { $ref: "#/definitions/imageInstruction" }
2039
+ }
2040
+ }
2041
+ }
2042
+ ]
2043
+ }
2093
2044
  }
2094
- return { recipe: null, source: null };
2095
- }
2045
+ };
2096
2046
 
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
- });
2047
+ // src/profiles/schedulable.schema.json
2048
+ var schedulable_schema_default = {
2049
+ $schema: "http://json-schema.org/draft-07/schema#",
2050
+ $id: "http://soustack.org/schema/v0.2.1/profiles/schedulable",
2051
+ title: "Soustack Schedulable Profile Schema",
2052
+ description: "Extends the base schema to ensure every instruction is fully scheduled.",
2053
+ allOf: [
2054
+ { $ref: "http://soustack.org/schema/v0.2.1" },
2055
+ {
2056
+ properties: {
2057
+ instructions: {
2058
+ type: "array",
2059
+ items: {
2060
+ anyOf: [
2061
+ { $ref: "#/definitions/schedulableInstruction" },
2062
+ { $ref: "#/definitions/schedulableInstructionSubsection" }
2063
+ ]
2064
+ }
2065
+ }
2105
2066
  }
2106
- } catch {
2067
+ }
2068
+ ],
2069
+ definitions: {
2070
+ schedulableInstruction: {
2071
+ allOf: [
2072
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
2073
+ { required: ["id", "timing"] }
2074
+ ]
2075
+ },
2076
+ schedulableInstructionSubsection: {
2077
+ allOf: [
2078
+ { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
2079
+ {
2080
+ properties: {
2081
+ items: {
2082
+ type: "array",
2083
+ items: { $ref: "#/definitions/schedulableInstruction" }
2084
+ }
2085
+ }
2086
+ }
2087
+ ]
2107
2088
  }
2108
2089
  }
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
- });
2116
- }
2117
- } catch {
2090
+ };
2091
+
2092
+ // src/validator.ts
2093
+ var profileSchemas = {
2094
+ base: base_schema_default,
2095
+ cookable: cookable_schema_default,
2096
+ scalable: base_schema_default,
2097
+ quantified: quantified_schema_default,
2098
+ illustrated: illustrated_schema_default,
2099
+ schedulable: schedulable_schema_default
2100
+ };
2101
+ var validationContexts = /* @__PURE__ */ new Map();
2102
+ function createContext(collectAllErrors) {
2103
+ const ajv = new Ajv__default.default({ strict: false, allErrors: collectAllErrors });
2104
+ addFormats__default.default(ajv);
2105
+ const loadedIds = /* @__PURE__ */ new Set();
2106
+ const addSchemaIfNew = (schema) => {
2107
+ if (!schema) return;
2108
+ const schemaId = schema?.$id;
2109
+ if (schemaId && loadedIds.has(schemaId)) return;
2110
+ ajv.addSchema(schema);
2111
+ if (schemaId) loadedIds.add(schemaId);
2112
+ };
2113
+ addSchemaIfNew(schema_default);
2114
+ addSchemaIfNew(soustack_schema_default);
2115
+ Object.values(profileSchemas).forEach(addSchemaIfNew);
2116
+ return { ajv, validators: {} };
2117
+ }
2118
+ function getContext(collectAllErrors) {
2119
+ if (!validationContexts.has(collectAllErrors)) {
2120
+ validationContexts.set(collectAllErrors, createContext(collectAllErrors));
2121
+ }
2122
+ return validationContexts.get(collectAllErrors);
2123
+ }
2124
+ function cloneRecipe(recipe) {
2125
+ if (typeof structuredClone === "function") {
2126
+ return structuredClone(recipe);
2127
+ }
2128
+ return JSON.parse(JSON.stringify(recipe));
2129
+ }
2130
+ function detectProfileFromSchema(schemaRef) {
2131
+ if (!schemaRef) return void 0;
2132
+ const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
2133
+ if (match) {
2134
+ const profile = match[1].toLowerCase();
2135
+ if (profile in profileSchemas) return profile;
2136
+ }
2137
+ return void 0;
2138
+ }
2139
+ function getValidator(profile, context) {
2140
+ if (!profileSchemas[profile]) {
2141
+ throw new Error(`Unknown Soustack profile: ${profile}`);
2142
+ }
2143
+ if (!context.validators[profile]) {
2144
+ context.validators[profile] = context.ajv.compile(profileSchemas[profile]);
2145
+ }
2146
+ return context.validators[profile];
2147
+ }
2148
+ function normalizeRecipe(recipe) {
2149
+ const normalized = cloneRecipe(recipe);
2150
+ const warnings = [];
2151
+ normalizeTime(normalized);
2152
+ if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
2153
+ normalized.recipeVersion = normalized.version;
2154
+ warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
2155
+ }
2156
+ return { normalized, warnings };
2157
+ }
2158
+ function normalizeTime(recipe) {
2159
+ const time = recipe?.time;
2160
+ if (!time || typeof time !== "object" || Array.isArray(time)) return;
2161
+ const structuredKeys = [
2162
+ "prep",
2163
+ "active",
2164
+ "passive",
2165
+ "total"
2166
+ ];
2167
+ structuredKeys.forEach((key) => {
2168
+ const value = time[key];
2169
+ if (typeof value === "number") return;
2170
+ const parsed = parseDuration(value);
2171
+ if (parsed !== null) {
2172
+ time[key] = parsed;
2118
2173
  }
2174
+ });
2175
+ }
2176
+ var allowedTopLevelProps = /* @__PURE__ */ new Set([
2177
+ ...Object.keys(soustack_schema_default?.properties ?? {}),
2178
+ "metadata",
2179
+ "$schema"
2180
+ ]);
2181
+ function detectUnknownTopLevelKeys(recipe) {
2182
+ if (!recipe || typeof recipe !== "object") return [];
2183
+ const disallowedKeys = Object.keys(recipe).filter(
2184
+ (key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
2185
+ );
2186
+ return disallowedKeys.map((key) => ({
2187
+ path: `/${key}`,
2188
+ keyword: "additionalProperties",
2189
+ message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
2190
+ }));
2191
+ }
2192
+ function formatAjvError(error) {
2193
+ let path2 = error.instancePath || "/";
2194
+ if (error.keyword === "additionalProperties" && error.params?.additionalProperty) {
2195
+ const extra = error.params.additionalProperty;
2196
+ path2 = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
2119
2197
  }
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
- });
2198
+ return {
2199
+ path: path2,
2200
+ keyword: error.keyword,
2201
+ message: error.message || "Validation error"
2202
+ };
2203
+ }
2204
+ function runAjvValidation(data, profile, context, schemaRef) {
2205
+ const validator = schemaRef ? context.ajv.getSchema(schemaRef) : void 0;
2206
+ const validateFn = validator ?? getValidator(profile, context);
2207
+ const isValid = validateFn(data);
2208
+ return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
2209
+ }
2210
+ function isInstruction(item) {
2211
+ return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
2212
+ }
2213
+ function isInstructionSubsection(item) {
2214
+ return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
2215
+ }
2216
+ function checkInstructionGraph(recipe) {
2217
+ const instructions = recipe?.instructions;
2218
+ if (!Array.isArray(instructions)) return [];
2219
+ const instructionIds = /* @__PURE__ */ new Set();
2220
+ const dependencyRefs = [];
2221
+ const collect = (items, basePath) => {
2222
+ items.forEach((item, index) => {
2223
+ const currentPath = `${basePath}/${index}`;
2224
+ if (isInstructionSubsection(item) && Array.isArray(item.items)) {
2225
+ collect(item.items, `${currentPath}/items`);
2226
+ return;
2127
2227
  }
2128
- } catch {
2228
+ if (isInstruction(item)) {
2229
+ const id = typeof item.id === "string" ? item.id : void 0;
2230
+ if (id) instructionIds.add(id);
2231
+ if (Array.isArray(item.dependsOn)) {
2232
+ item.dependsOn.forEach((depId, depIndex) => {
2233
+ if (typeof depId === "string") {
2234
+ dependencyRefs.push({
2235
+ fromId: id,
2236
+ toId: depId,
2237
+ path: `${currentPath}/dependsOn/${depIndex}`
2238
+ });
2239
+ }
2240
+ });
2241
+ }
2242
+ }
2243
+ });
2244
+ };
2245
+ collect(instructions, "/instructions");
2246
+ const errors = [];
2247
+ dependencyRefs.forEach((ref) => {
2248
+ if (!instructionIds.has(ref.toId)) {
2249
+ errors.push({
2250
+ path: ref.path,
2251
+ message: `Instruction dependency references missing id '${ref.toId}'.`
2252
+ });
2129
2253
  }
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(() => {
2254
+ });
2255
+ const adjacency = /* @__PURE__ */ new Map();
2256
+ dependencyRefs.forEach((ref) => {
2257
+ if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
2258
+ const list = adjacency.get(ref.fromId) ?? [];
2259
+ list.push({ toId: ref.toId, path: ref.path });
2260
+ adjacency.set(ref.fromId, list);
2261
+ }
2262
+ });
2263
+ const visiting = /* @__PURE__ */ new Set();
2264
+ const visited = /* @__PURE__ */ new Set();
2265
+ const detectCycles = (nodeId) => {
2266
+ if (visiting.has(nodeId)) return;
2267
+ if (visited.has(nodeId)) return;
2268
+ visiting.add(nodeId);
2269
+ const neighbors = adjacency.get(nodeId) ?? [];
2270
+ neighbors.forEach((edge) => {
2271
+ if (visiting.has(edge.toId)) {
2272
+ errors.push({
2273
+ path: edge.path,
2274
+ message: `Circular dependency detected involving instruction id '${edge.toId}'.`
2140
2275
  });
2276
+ return;
2141
2277
  }
2142
- } catch {
2143
- }
2144
- }
2145
- if (!soustackRecipe) {
2146
- throw new Error("Schema.org data did not include a valid recipe");
2147
- }
2148
- return soustackRecipe;
2278
+ detectCycles(edge.toId);
2279
+ });
2280
+ visiting.delete(nodeId);
2281
+ visited.add(nodeId);
2282
+ };
2283
+ instructionIds.forEach((id) => detectCycles(id));
2284
+ return errors;
2285
+ }
2286
+ function validateRecipe(input, options = {}) {
2287
+ const collectAllErrors = options.collectAllErrors ?? true;
2288
+ const context = getContext(collectAllErrors);
2289
+ const schemaRef = options.schema ?? (typeof input?.$schema === "string" ? input.$schema : void 0);
2290
+ const profile = options.profile ?? detectProfileFromSchema(schemaRef) ?? "base";
2291
+ const { normalized, warnings } = normalizeRecipe(input);
2292
+ const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
2293
+ const validationErrors = runAjvValidation(normalized, profile, context, schemaRef);
2294
+ const graphErrors = profile === "schedulable" && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
2295
+ const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
2296
+ return {
2297
+ valid: errors.length === 0,
2298
+ errors,
2299
+ warnings,
2300
+ normalized: errors.length === 0 ? normalized : void 0
2301
+ };
2149
2302
  }
2150
2303
 
2151
2304
  // bin/cli.ts
2152
- var [, , command, ...args] = process.argv;
2153
- async function main() {
2305
+ var supportedProfiles = ["base", "cookable", "scalable", "quantified", "illustrated", "schedulable"];
2306
+ async function runCli(argv) {
2307
+ const [command, ...args] = argv;
2154
2308
  try {
2155
2309
  switch (command) {
2156
2310
  case "validate":
2157
2311
  await handleValidate(args);
2158
- break;
2159
- case "scale":
2160
- await handleScale(args);
2161
- break;
2312
+ return;
2313
+ case "convert":
2314
+ await handleConvert(args);
2315
+ return;
2162
2316
  case "import":
2163
2317
  await handleImport(args);
2164
- break;
2165
- case "export":
2166
- await handleExport(args);
2167
- break;
2318
+ return;
2319
+ case "scale":
2320
+ await handleScale(args);
2321
+ return;
2168
2322
  case "scrape":
2169
2323
  await handleScrape(args);
2170
- break;
2324
+ return;
2325
+ case "test":
2326
+ await handleTest(args);
2327
+ return;
2171
2328
  default:
2172
2329
  printUsage();
2173
- process.exit(1);
2330
+ process.exitCode = 1;
2174
2331
  }
2175
2332
  } catch (error) {
2176
- console.error(`\u274C ${error.message}`);
2333
+ console.error(`\u274C ${error?.message ?? error}`);
2177
2334
  process.exit(1);
2178
2335
  }
2179
2336
  }
2180
2337
  function printUsage() {
2181
2338
  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");
2339
+ console.log(" soustack validate <fileOrGlob> [--profile <name>] [--strict] [--json]");
2340
+ console.log(" soustack convert --from <schemaorg|soustack> --to <schemaorg|soustack> <input> [-o <output>]");
2341
+ console.log(" soustack import --url <url> [-o <soustack.json>]");
2342
+ console.log(" soustack test [--profile <name>] [--strict] [--json]");
2343
+ console.log(" soustack scale <soustack.json> <multiplier>");
2344
+ console.log(" soustack scrape <url> -o <soustack.json>");
2345
+ }
2346
+ async function handleValidate(args) {
2347
+ const { target, profile, strict, json } = parseValidateArgs(args);
2348
+ if (!target) throw new Error("Path or glob to Soustack recipe JSON is required");
2349
+ const files = expandTargets(target);
2350
+ if (files.length === 0) throw new Error(`No files matched pattern: ${target}`);
2351
+ const results = files.map((file) => validateFile(file, profile));
2352
+ reportValidation(results, { strict, json });
2353
+ }
2354
+ async function handleTest(args) {
2355
+ const { profile, strict, json } = parseValidationFlags(args);
2356
+ const cwd = process.cwd();
2357
+ const files = glob.globSync("**/*.soustack.json", {
2358
+ cwd,
2359
+ absolute: true,
2360
+ nodir: true,
2361
+ ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**"]
2362
+ }).map((file) => path__namespace.resolve(cwd, file));
2363
+ if (files.length === 0) {
2364
+ console.log("No *.soustack.json files found in the current repository.");
2365
+ return;
2366
+ }
2367
+ const results = files.map((file) => validateFile(file, profile));
2368
+ reportValidation(results, { strict, json, context: "test" });
2369
+ }
2370
+ async function handleConvert(args) {
2371
+ const { from, to, inputPath, outputPath } = parseConvertArgs(args);
2372
+ const fromKey = from?.toLowerCase();
2373
+ const toKey = to?.toLowerCase();
2374
+ if (!inputPath || !fromKey || !toKey) {
2375
+ throw new Error("Convert usage: convert --from <schemaorg|soustack> --to <schemaorg|soustack> <input> [-o <output>]");
2376
+ }
2377
+ const direction = resolveConvertDirection(fromKey, toKey);
2378
+ if (!direction) {
2379
+ throw new Error(`Unsupported conversion from "${from}" to "${to}"`);
2380
+ }
2381
+ const input = readJsonFile(inputPath);
2382
+ const result = direction === "schemaorg-to-soustack" ? fromSchemaOrg(input) : toSchemaOrg(input);
2383
+ if (!result) {
2384
+ throw new Error("Unable to convert input with the provided formats.");
2385
+ }
2386
+ writeOutput(result, outputPath);
2387
+ console.log(`\u2705 Converted ${fromKey} \u2192 ${toKey}${outputPath ? ` (${outputPath})` : ""}`);
2388
+ }
2389
+ async function handleImport(args) {
2390
+ const { url, outputPath } = parseImportArgs(args);
2391
+ if (!url) throw new Error("Import usage: import --url <url> [-o <soustack.json>]");
2392
+ const recipe = await scrapeRecipe(url);
2393
+ writeOutput(recipe, outputPath);
2394
+ console.log(`\u2705 Imported recipe from ${url}${outputPath ? ` (${outputPath})` : ""}`);
2194
2395
  }
2195
- async function handleScale(args2) {
2196
- const filePath = args2[0];
2197
- const multiplier = args2[1] ? parseFloat(args2[1]) : 1;
2396
+ async function handleScale(args) {
2397
+ const filePath = args[0];
2398
+ const multiplier = args[1] ? parseFloat(args[1]) : 1;
2198
2399
  if (!filePath || Number.isNaN(multiplier)) {
2199
2400
  throw new Error("Scale usage: scale <soustack.json> <multiplier>");
2200
2401
  }
2201
2402
  const recipe = readJsonFile(filePath);
2202
2403
  console.log(`
2203
- \u2696\uFE0F Scaling "${recipe.name}" by ${multiplier}x...
2404
+ \u2696\uFE0F Scaling "${recipe?.name ?? filePath}" by ${multiplier}x...
2204
2405
  `);
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));
2406
+ const result = scaleRecipe(recipe, { multiplier });
2407
+ console.log(JSON.stringify(result, null, 2));
2408
+ }
2409
+ async function handleScrape(args) {
2410
+ const url = args[0];
2411
+ const outputPath = resolveOutputPath(args.slice(1));
2240
2412
  if (!url) throw new Error("Scrape usage: scrape <url> -o <soustack.json>");
2241
2413
  const recipe = await scrapeRecipe(url);
2242
2414
  writeOutput(recipe, outputPath);
2243
2415
  console.log(`\u2705 Scraped recipe from ${url}${outputPath ? ` (${outputPath})` : ""}`);
2244
2416
  }
2417
+ function parseValidateArgs(args) {
2418
+ let profile;
2419
+ let strict = false;
2420
+ let json = false;
2421
+ let target;
2422
+ for (let i = 0; i < args.length; i++) {
2423
+ const arg = args[i];
2424
+ switch (arg) {
2425
+ case "--profile":
2426
+ profile = normalizeProfile(args[i + 1]);
2427
+ i++;
2428
+ break;
2429
+ case "--strict":
2430
+ strict = true;
2431
+ break;
2432
+ case "--json":
2433
+ json = true;
2434
+ break;
2435
+ default:
2436
+ if (!arg.startsWith("--") && !target) {
2437
+ target = arg;
2438
+ }
2439
+ break;
2440
+ }
2441
+ }
2442
+ return { profile, strict, json, target };
2443
+ }
2444
+ function parseValidationFlags(args) {
2445
+ const { profile, strict, json } = parseValidateArgs(args);
2446
+ return { profile, strict, json };
2447
+ }
2448
+ function normalizeProfile(value) {
2449
+ if (!value) return void 0;
2450
+ const normalized = value.toLowerCase();
2451
+ if (supportedProfiles.includes(normalized)) {
2452
+ return normalized;
2453
+ }
2454
+ throw new Error(`Unknown Soustack profile: ${value}`);
2455
+ }
2456
+ function parseConvertArgs(args) {
2457
+ let from;
2458
+ let to;
2459
+ let outputPath;
2460
+ let inputPath;
2461
+ for (let i = 0; i < args.length; i++) {
2462
+ const arg = args[i];
2463
+ switch (arg) {
2464
+ case "--from":
2465
+ from = args[i + 1];
2466
+ if (!from) throw new Error("Missing value for --from");
2467
+ i++;
2468
+ break;
2469
+ case "--to":
2470
+ to = args[i + 1];
2471
+ if (!to) throw new Error("Missing value for --to");
2472
+ i++;
2473
+ break;
2474
+ case "-o":
2475
+ case "--output":
2476
+ outputPath = args[i + 1];
2477
+ if (!outputPath) throw new Error("Missing value for output");
2478
+ i++;
2479
+ break;
2480
+ default:
2481
+ if (!inputPath && !arg.startsWith("--")) {
2482
+ inputPath = arg;
2483
+ }
2484
+ break;
2485
+ }
2486
+ }
2487
+ return { from, to, inputPath, outputPath };
2488
+ }
2489
+ function parseImportArgs(args) {
2490
+ let url;
2491
+ let outputPath;
2492
+ for (let i = 0; i < args.length; i++) {
2493
+ const arg = args[i];
2494
+ if (arg === "--url") {
2495
+ url = args[i + 1];
2496
+ if (!url) {
2497
+ throw new Error("URL flag provided without a value");
2498
+ }
2499
+ i++;
2500
+ } else if (arg === "-o" || arg === "--output") {
2501
+ outputPath = args[i + 1];
2502
+ if (!outputPath) {
2503
+ throw new Error("Output flag provided without a path");
2504
+ }
2505
+ i++;
2506
+ }
2507
+ }
2508
+ return { url, outputPath };
2509
+ }
2510
+ function resolveConvertDirection(from, to) {
2511
+ if (from === "schemaorg" && to === "soustack") return "schemaorg-to-soustack";
2512
+ if (from === "soustack" && to === "schemaorg") return "soustack-to-schemaorg";
2513
+ return null;
2514
+ }
2515
+ function expandTargets(target) {
2516
+ const matches = glob.globSync(target, { absolute: true, nodir: true });
2517
+ const unique = Array.from(new Set(matches.map((match) => path__namespace.resolve(match))));
2518
+ return unique;
2519
+ }
2520
+ function validateFile(file, profile) {
2521
+ const recipe = readJsonFile(file);
2522
+ const result = validateRecipe(recipe, profile ? { profile } : {});
2523
+ return {
2524
+ file,
2525
+ profile,
2526
+ valid: result.valid,
2527
+ warnings: result.warnings,
2528
+ errors: result.errors
2529
+ };
2530
+ }
2531
+ function reportValidation(results, options) {
2532
+ const summary = {
2533
+ strict: options.strict,
2534
+ total: results.length,
2535
+ passed: 0,
2536
+ failed: 0
2537
+ };
2538
+ const serializable = results.map((result) => {
2539
+ const passed = isEffectivelyValid(result, options.strict);
2540
+ if (passed) summary.passed += 1;
2541
+ else summary.failed += 1;
2542
+ return {
2543
+ file: path__namespace.relative(process.cwd(), result.file),
2544
+ profile: result.profile,
2545
+ valid: result.valid,
2546
+ warnings: result.warnings,
2547
+ errors: result.errors,
2548
+ passed
2549
+ };
2550
+ });
2551
+ if (options.json) {
2552
+ console.log(JSON.stringify({ summary, results: serializable }, null, 2));
2553
+ } else {
2554
+ serializable.forEach((entry) => {
2555
+ const prefix = entry.passed ? "\u2705" : "\u274C";
2556
+ console.log(`${prefix} ${entry.file}`);
2557
+ if (!entry.passed && entry.errors.length) {
2558
+ entry.errors.forEach((error) => {
2559
+ console.log(` \u2022 [${error.path}] ${error.message}`);
2560
+ });
2561
+ }
2562
+ if (!entry.passed && options.strict && entry.warnings.length) {
2563
+ entry.warnings.forEach((warning) => {
2564
+ console.log(` \u2022 [${warning.path}] ${warning.message} (warning)`);
2565
+ });
2566
+ }
2567
+ });
2568
+ const contextLabel = options.context === "test" ? "Test summary" : "Validation summary";
2569
+ console.log(`${contextLabel}: ${summary.passed}/${summary.total} files valid${options.strict ? " (strict)" : ""}`);
2570
+ }
2571
+ if (summary.failed > 0) {
2572
+ process.exitCode = 1;
2573
+ }
2574
+ }
2575
+ function isEffectivelyValid(result, strict) {
2576
+ return result.valid && (!strict || result.warnings.length === 0);
2577
+ }
2245
2578
  function readJsonFile(relativePath) {
2246
2579
  const absolutePath = path__namespace.resolve(relativePath);
2247
2580
  if (!fs__namespace.existsSync(absolutePath)) {
@@ -2254,10 +2587,10 @@ function readJsonFile(relativePath) {
2254
2587
  throw new Error(`Unable to parse JSON in ${absolutePath}`);
2255
2588
  }
2256
2589
  }
2257
- function resolveOutputPath(args2) {
2258
- const index = args2.findIndex((arg) => arg === "-o" || arg === "--output");
2590
+ function resolveOutputPath(args) {
2591
+ const index = args.findIndex((arg) => arg === "-o" || arg === "--output");
2259
2592
  if (index === -1) return void 0;
2260
- const target = args2[index + 1];
2593
+ const target = args[index + 1];
2261
2594
  if (!target) {
2262
2595
  throw new Error("Output flag provided without a path");
2263
2596
  }
@@ -2272,6 +2605,13 @@ function writeOutput(data, outputPath) {
2272
2605
  const absolutePath = path__namespace.resolve(outputPath);
2273
2606
  fs__namespace.writeFileSync(absolutePath, serialized, "utf-8");
2274
2607
  }
2275
- main();
2608
+ if (__require.main === module) {
2609
+ runCli(process.argv.slice(2)).catch((error) => {
2610
+ console.error(`\u274C ${error?.message ?? error}`);
2611
+ process.exit(1);
2612
+ });
2613
+ }
2614
+
2615
+ exports.runCli = runCli;
2276
2616
  //# sourceMappingURL=index.js.map
2277
2617
  //# sourceMappingURL=index.js.map