soustack 0.1.3 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,1222 +33,322 @@ 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.1",
167
- title: "Soustack Recipe Schema v0.1",
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
- type: "string",
198
- format: "uri"
199
- },
200
- dateAdded: {
201
- type: "string",
202
- format: "date-time"
203
- },
204
- source: {
205
- type: "object",
206
- properties: {
207
- author: { type: "string" },
208
- url: { type: "string", format: "uri" },
209
- name: { type: "string" },
210
- adapted: { type: "boolean" }
211
- }
212
- },
213
- yield: {
214
- $ref: "#/definitions/yield"
215
- },
216
- time: {
217
- $ref: "#/definitions/time"
218
- },
219
- equipment: {
220
- type: "array",
221
- items: { $ref: "#/definitions/equipment" }
222
- },
223
- ingredients: {
224
- type: "array",
225
- items: {
226
- anyOf: [
227
- { type: "string" },
228
- { $ref: "#/definitions/ingredient" },
229
- { $ref: "#/definitions/ingredientSubsection" }
230
- ]
231
- }
232
- },
233
- instructions: {
234
- type: "array",
235
- items: {
236
- anyOf: [
237
- { type: "string" },
238
- { $ref: "#/definitions/instruction" },
239
- { $ref: "#/definitions/instructionSubsection" }
240
- ]
241
- }
242
- },
243
- storage: {
244
- $ref: "#/definitions/storage"
245
- },
246
- substitutions: {
247
- type: "array",
248
- items: { $ref: "#/definitions/substitution" }
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
+ };
249
293
  }
250
- },
251
- definitions: {
252
- yield: {
253
- type: "object",
254
- required: ["amount", "unit"],
255
- properties: {
256
- amount: { type: "number" },
257
- unit: { type: "string" },
258
- servings: { type: "number" },
259
- description: { type: "string" }
260
- }
261
- },
262
- time: {
263
- oneOf: [
264
- {
265
- type: "object",
266
- properties: {
267
- prep: { type: "number" },
268
- active: { type: "number" },
269
- passive: { type: "number" },
270
- total: { type: "number" }
271
- }
272
- },
273
- {
274
- type: "object",
275
- properties: {
276
- prepTime: { type: "string" },
277
- cookTime: { type: "string" }
278
- }
279
- }
280
- ]
281
- },
282
- quantity: {
283
- type: "object",
284
- required: ["amount"],
285
- properties: {
286
- amount: { type: "number" },
287
- unit: { type: ["string", "null"] }
288
- }
289
- },
290
- scaling: {
291
- type: "object",
292
- required: ["type"],
293
- properties: {
294
- type: {
295
- type: "string",
296
- enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
297
- },
298
- factor: { type: "number" },
299
- referenceId: { type: "string" },
300
- roundTo: { type: "number" },
301
- min: { type: "number" },
302
- max: { type: "number" }
303
- },
304
- if: {
305
- properties: { type: { const: "bakers_percentage" } }
306
- },
307
- then: {
308
- required: ["referenceId"]
309
- }
310
- },
311
- ingredient: {
312
- type: "object",
313
- required: ["item"],
314
- properties: {
315
- id: { type: "string" },
316
- item: { type: "string" },
317
- quantity: { $ref: "#/definitions/quantity" },
318
- name: { type: "string" },
319
- aisle: { type: "string" },
320
- prep: { type: "string" },
321
- prepAction: { type: "string" },
322
- prepTime: { type: "number" },
323
- destination: { type: "string" },
324
- scaling: { $ref: "#/definitions/scaling" },
325
- critical: { type: "boolean" },
326
- optional: { type: "boolean" },
327
- notes: { type: "string" }
328
- }
329
- },
330
- ingredientSubsection: {
331
- type: "object",
332
- required: ["subsection", "items"],
333
- properties: {
334
- subsection: { type: "string" },
335
- items: {
336
- type: "array",
337
- items: { $ref: "#/definitions/ingredient" }
338
- }
339
- }
340
- },
341
- equipment: {
342
- type: "object",
343
- required: ["name"],
344
- properties: {
345
- id: { type: "string" },
346
- name: { type: "string" },
347
- required: { type: "boolean" },
348
- label: { type: "string" },
349
- capacity: { $ref: "#/definitions/quantity" },
350
- scalingLimit: { type: "number" },
351
- alternatives: {
352
- type: "array",
353
- items: { type: "string" }
354
- }
355
- }
356
- },
357
- instruction: {
358
- type: "object",
359
- required: ["text"],
360
- properties: {
361
- id: { type: "string" },
362
- text: { type: "string" },
363
- destination: { type: "string" },
364
- dependsOn: {
365
- type: "array",
366
- items: { type: "string" }
367
- },
368
- inputs: {
369
- type: "array",
370
- items: { type: "string" }
371
- },
372
- timing: {
373
- type: "object",
374
- required: ["duration", "type"],
375
- properties: {
376
- duration: { type: "number" },
377
- type: { type: "string", enum: ["active", "passive"] },
378
- scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
379
- }
380
- }
381
- }
382
- },
383
- instructionSubsection: {
384
- type: "object",
385
- required: ["subsection", "items"],
386
- properties: {
387
- subsection: { type: "string" },
388
- items: {
389
- type: "array",
390
- items: {
391
- anyOf: [
392
- { type: "string" },
393
- { $ref: "#/definitions/instruction" }
394
- ]
395
- }
396
- }
397
- }
398
- },
399
- storage: {
400
- type: "object",
401
- properties: {
402
- roomTemp: { $ref: "#/definitions/storageMethod" },
403
- refrigerated: { $ref: "#/definitions/storageMethod" },
404
- frozen: {
405
- allOf: [
406
- { $ref: "#/definitions/storageMethod" },
407
- {
408
- type: "object",
409
- properties: { thawing: { type: "string" } }
410
- }
411
- ]
412
- },
413
- reheating: { type: "string" },
414
- makeAhead: {
415
- type: "array",
416
- items: {
417
- allOf: [
418
- { $ref: "#/definitions/storageMethod" },
419
- {
420
- type: "object",
421
- required: ["component", "storage"],
422
- properties: {
423
- component: { type: "string" },
424
- storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
425
- }
426
- }
427
- ]
428
- }
429
- }
430
- }
431
- },
432
- storageMethod: {
433
- type: "object",
434
- required: ["duration"],
435
- properties: {
436
- duration: { type: "string", pattern: "^P" },
437
- method: { type: "string" },
438
- notes: { type: "string" }
439
- }
440
- },
441
- substitution: {
442
- type: "object",
443
- required: ["ingredient"],
444
- properties: {
445
- ingredient: { type: "string" },
446
- critical: { type: "boolean" },
447
- notes: { type: "string" },
448
- alternatives: {
449
- type: "array",
450
- items: {
451
- type: "object",
452
- required: ["name", "ratio"],
453
- properties: {
454
- name: { type: "string" },
455
- ratio: { type: "string" },
456
- notes: { type: "string" },
457
- impact: { type: "string" },
458
- dietary: {
459
- type: "array",
460
- items: { type: "string" }
461
- }
462
- }
463
- }
464
- }
465
- }
466
- }
467
- }
468
- };
469
-
470
- // src/validator.ts
471
- var ajv = new Ajv__default.default();
472
- addFormats__default.default(ajv);
473
- var validate = ajv.compile(schema_default);
474
- function validateRecipe(data) {
475
- const isValid = validate(data);
476
- if (!isValid) {
477
- throw new Error(JSON.stringify(validate.errors, null, 2));
478
- }
479
- return true;
480
- }
481
-
482
- // src/parsers/ingredient.ts
483
- var FRACTION_DECIMALS = {
484
- "\xBD": 0.5,
485
- "\u2153": 1 / 3,
486
- "\u2154": 2 / 3,
487
- "\xBC": 0.25,
488
- "\xBE": 0.75,
489
- "\u2155": 0.2,
490
- "\u2156": 0.4,
491
- "\u2157": 0.6,
492
- "\u2158": 0.8,
493
- "\u2159": 1 / 6,
494
- "\u215A": 5 / 6,
495
- "\u215B": 0.125,
496
- "\u215C": 0.375,
497
- "\u215D": 0.625,
498
- "\u215E": 0.875
499
- };
500
- var NUMBER_WORDS = {
501
- zero: 0,
502
- one: 1,
503
- two: 2,
504
- three: 3,
505
- four: 4,
506
- five: 5,
507
- six: 6,
508
- seven: 7,
509
- eight: 8,
510
- nine: 9,
511
- ten: 10,
512
- eleven: 11,
513
- twelve: 12,
514
- thirteen: 13,
515
- fourteen: 14,
516
- fifteen: 15,
517
- sixteen: 16,
518
- seventeen: 17,
519
- eighteen: 18,
520
- nineteen: 19,
521
- twenty: 20,
522
- thirty: 30,
523
- forty: 40,
524
- fifty: 50,
525
- sixty: 60,
526
- seventy: 70,
527
- eighty: 80,
528
- ninety: 90,
529
- hundred: 100,
530
- half: 0.5,
531
- quarter: 0.25
532
- };
533
- var UNIT_SYNONYMS = {
534
- cup: "cup",
535
- cups: "cup",
536
- c: "cup",
537
- tbsp: "tbsp",
538
- tablespoon: "tbsp",
539
- tablespoons: "tbsp",
540
- tbs: "tbsp",
541
- tsp: "tsp",
542
- teaspoon: "tsp",
543
- teaspoons: "tsp",
544
- t: "tsp",
545
- gram: "g",
546
- grams: "g",
547
- g: "g",
548
- kilogram: "kg",
549
- kilograms: "kg",
550
- kg: "kg",
551
- milliliter: "ml",
552
- milliliters: "ml",
553
- ml: "ml",
554
- liter: "l",
555
- liters: "l",
556
- l: "l",
557
- ounce: "oz",
558
- ounces: "oz",
559
- oz: "oz",
560
- pound: "lb",
561
- pounds: "lb",
562
- lb: "lb",
563
- lbs: "lb",
564
- pint: "pint",
565
- pints: "pint",
566
- quart: "quart",
567
- quarts: "quart",
568
- stick: "stick",
569
- sticks: "stick",
570
- dash: "dash",
571
- pinches: "pinch",
572
- pinch: "pinch"
573
- };
574
- var PREP_PHRASES = [
575
- "diced",
576
- "finely diced",
577
- "roughly diced",
578
- "minced",
579
- "finely minced",
580
- "chopped",
581
- "finely chopped",
582
- "roughly chopped",
583
- "sliced",
584
- "thinly sliced",
585
- "thickly sliced",
586
- "grated",
587
- "finely grated",
588
- "zested",
589
- "sifted",
590
- "softened",
591
- "at room temperature",
592
- "room temperature",
593
- "room temp",
594
- "melted",
595
- "toasted",
596
- "drained",
597
- "drained and rinsed",
598
- "beaten",
599
- "divided",
600
- "cut into cubes",
601
- "cut into pieces",
602
- "cut into strips",
603
- "cut into chunks",
604
- "cut into bite-size pieces"
605
- ].map((value) => value.toLowerCase());
606
- var COUNT_DESCRIPTORS = /* @__PURE__ */ new Set([
607
- "clove",
608
- "cloves",
609
- "can",
610
- "cans",
611
- "stick",
612
- "sticks",
613
- "sprig",
614
- "sprigs",
615
- "bunch",
616
- "bunches",
617
- "slice",
618
- "slices",
619
- "package",
620
- "packages"
621
- ]);
622
- var DESCRIPTOR_NOTE_SET = /* @__PURE__ */ new Set(["can", "cans", "jar", "jars", "package", "packages", "bottle", "bottles"]);
623
- var WEIGHT_PRIORITY_UNITS = /* @__PURE__ */ new Set(["g", "kg", "oz", "lb", "ml", "l"]);
624
- var SPICE_KEYWORDS = [
625
- "salt",
626
- "pepper",
627
- "paprika",
628
- "cumin",
629
- "coriander",
630
- "turmeric",
631
- "chili powder",
632
- "garlic powder",
633
- "onion powder",
634
- "cayenne",
635
- "cinnamon",
636
- "nutmeg",
637
- "allspice",
638
- "ginger",
639
- "oregano",
640
- "thyme",
641
- "rosemary",
642
- "basil",
643
- "sage",
644
- "clove",
645
- "spice",
646
- "seasoning"
647
- ];
648
- var PURPOSE_KEYWORDS = ["frying", "greasing", "drizzling", "garnish", "serving", "brushing"];
649
- var RANGE_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(?:\s*(?:-|to)\s*((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?))/i;
650
- var NUMBER_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)/i;
651
- var QUALIFIER_REGEX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
652
- var FLAVOR_NOTE_REGEX = /\b(to taste|as needed|as necessary)\b/gi;
653
- var VAGUE_QUANTITY_PATTERNS = [
654
- { regex: /^(a\s+pinch|pinch)\b/i, note: "a pinch" },
655
- { regex: /^(a\s+handful|handful)\b/i, note: "a handful" },
656
- { regex: /^(a\s+dash|dash)\b/i, note: "a dash" },
657
- { regex: /^(a\s+sprinkle|sprinkle)\b/i, note: "a sprinkle" },
658
- { regex: /^(some)\b/i, note: "some" },
659
- { regex: /^(few\s+sprigs)/i, note: "few sprigs" },
660
- { regex: /^(a\s+few|few)\b/i, note: "a few" },
661
- { regex: /^(several)\b/i, note: "several" }
662
- ];
663
- var JUICE_PREFIXES = ["juice of", "zest of"];
664
- function normalizeIngredientInput(input) {
665
- if (!input) return "";
666
- let result = input.replace(/\u00A0/g, " ").trim();
667
- result = replaceDashes(result);
668
- result = replaceUnicodeFractions(result);
669
- result = replaceNumberWords(result);
670
- result = result.replace(/\s+/g, " ").trim();
671
- return result;
672
- }
673
- function parseIngredient(text) {
674
- const original = text ?? "";
675
- const normalized = normalizeIngredientInput(original);
676
- if (!normalized) {
677
- return {
678
- item: original,
679
- scaling: { type: "linear" }
680
- };
681
- }
682
- let working = normalized;
683
- const notes = [];
684
- let optional = false;
685
- if (/\boptional\b/i.test(working)) {
686
- optional = true;
687
- working = working.replace(/\(?\s*optional\s*\)?/gi, "").trim();
688
- working = working.replace(/\(\s*\)/g, " ").trim();
689
- }
690
- const flavorExtraction = extractFlavorNotes(working);
691
- working = flavorExtraction.cleaned;
692
- notes.push(...flavorExtraction.notes);
693
- const parenthetical = extractParentheticals(working);
694
- working = parenthetical.cleaned;
695
- notes.push(...parenthetical.notes);
696
- optional = optional || parenthetical.optional;
697
- const purposeExtraction = extractPurposeNotes(working);
698
- working = purposeExtraction.cleaned;
699
- notes.push(...purposeExtraction.notes);
700
- const juiceExtraction = extractJuicePhrase(working);
701
- if (juiceExtraction) {
702
- working = juiceExtraction.cleaned;
703
- notes.push(juiceExtraction.note);
704
- }
705
- const vagueQuantity = extractVagueQuantity(working);
706
- let quantityResult;
707
- if (vagueQuantity) {
708
- notes.push(vagueQuantity.note);
709
- quantityResult = {
710
- amount: null,
711
- unit: null,
712
- descriptor: void 0,
713
- remainder: vagueQuantity.remainder,
714
- notes: [],
715
- originalAmount: null
716
- };
717
- } else {
718
- quantityResult = extractQuantity(working);
719
- }
720
- working = quantityResult.remainder;
721
- const { quantity, usedParenthetical } = mergeQuantities(quantityResult, parenthetical.measurement);
722
- if (usedParenthetical && quantityResult.originalAmount !== null && quantityResult.originalAmount > 1 && quantityResult.descriptor && DESCRIPTOR_NOTE_SET.has(quantityResult.descriptor.toLowerCase())) {
723
- notes.push(formatCountNote(quantityResult.originalAmount, quantityResult.descriptor));
724
- }
725
- notes.push(...quantityResult.notes);
726
- working = working.replace(/^[,.\s-]+/, "").trim();
727
- working = working.replace(/^of\s+/i, "").trim();
728
- if (quantityResult.descriptor && /^cans?$/i.test(quantityResult.descriptor) && working && !/^canned\b/i.test(working)) {
729
- working = `canned ${working}`.trim();
730
- }
731
- const nameExtraction = extractNameAndPrep(working);
732
- notes.push(...nameExtraction.notes);
733
- const name = nameExtraction.name || void 0;
734
- const scaling = inferScaling(
735
- name,
736
- quantity.unit,
737
- quantity.amount,
738
- notes,
739
- quantityResult.descriptor
740
- );
741
- const mergedNotes = formatNotes(notes);
742
- const parsed = {
743
- item: original,
744
- quantity,
745
- ...name ? { name } : {},
746
- ...nameExtraction.prep ? { prep: nameExtraction.prep } : {},
747
- ...optional ? { optional: true } : {},
748
- scaling
749
- };
750
- if (mergedNotes) {
751
- parsed.notes = mergedNotes;
752
- }
753
- return parsed;
754
- }
755
- function replaceDashes(value) {
756
- return value.replace(/[\u2012\u2013\u2014\u2212]/g, "-");
757
- }
758
- function replaceUnicodeFractions(value) {
759
- return value.replace(/(\d+)?(?:\s+)?([½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])/g, (_match, whole, fraction) => {
760
- const fractionValue = FRACTION_DECIMALS[fraction];
761
- if (fractionValue === void 0) return _match;
762
- const base = whole ? parseInt(whole, 10) : 0;
763
- const combined = base + fractionValue;
764
- return formatDecimal(combined);
765
- });
766
- }
767
- function replaceNumberWords(value) {
768
- return value.replace(
769
- /\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,
770
- (match, word, hyphenPart) => {
771
- const lower = word.toLowerCase();
772
- const baseValue = NUMBER_WORDS[lower];
773
- if (baseValue === void 0) return match;
774
- if (!hyphenPart) {
775
- return formatDecimal(baseValue);
776
- }
777
- const hyphenValue = NUMBER_WORDS[hyphenPart.toLowerCase()];
778
- if (hyphenValue === void 0) {
779
- return formatDecimal(baseValue);
780
- }
781
- return formatDecimal(baseValue + hyphenValue);
782
- }
783
- );
784
- }
785
- function formatDecimal(value) {
786
- if (Number.isInteger(value)) {
787
- return value.toString();
788
294
  }
789
- return parseFloat(value.toFixed(3)).toString().replace(/\.0+$/, "");
790
- }
791
- function extractFlavorNotes(value) {
792
- const notes = [];
793
- const cleaned = value.replace(FLAVOR_NOTE_REGEX, (_, phrase) => {
794
- notes.push(phrase.toLowerCase());
795
- return "";
796
- });
797
- return {
798
- cleaned: cleaned.replace(/\s+/g, " ").trim(),
799
- notes
800
- };
801
- }
802
- function extractPurposeNotes(value) {
803
- const notes = [];
804
- let working = value.trim();
805
- let match = working.match(/\bfor\s+(frying|greasing|drizzling|garnish|serving|brushing)\b\.?$/i);
806
- if (match) {
807
- notes.push(`for ${match[1].toLowerCase()}`);
808
- working = working.slice(0, match.index).trim();
809
- }
810
- return { cleaned: working, notes };
811
- }
812
- function extractJuicePhrase(value) {
813
- const lower = value.toLowerCase();
814
- for (const prefix of JUICE_PREFIXES) {
815
- if (lower.startsWith(prefix)) {
816
- const remainder = value.slice(prefix.length).trim();
817
- if (!remainder) break;
818
- const cleanedSource = remainder.replace(/^of\s+/i, "").trim();
819
- if (!cleanedSource) break;
820
- const sourceForName = cleanedSource.replace(
821
- /^(?:\d+(?:\.\d+)?|\d+\s+\d+\/\d+|\d+\/\d+|one|two|three|four|five|six|seven|eight|nine|ten|a|an)\s+/i,
822
- ""
823
- ).replace(/^(?:large|small|medium)\s+/i, "").trim();
824
- const baseName = sourceForName || cleanedSource;
825
- const singular = singularize(baseName);
826
- const suffix = prefix.startsWith("zest") ? "zest" : "juice";
295
+ if (typeof value === "string") {
296
+ const trimmed = value.trim();
297
+ const match = trimmed.match(/(\d+(?:\.\d+)?)/);
298
+ if (match) {
299
+ const amount = parseFloat(match[1]);
300
+ const unit = trimmed.slice(match.index + match[1].length).trim();
827
301
  return {
828
- cleaned: `${singular} ${suffix}`.trim(),
829
- note: `from ${cleanedSource}`
302
+ amount,
303
+ unit: unit || "servings",
304
+ description: trimmed
830
305
  };
831
306
  }
832
307
  }
833
308
  return void 0;
834
309
  }
835
- function extractVagueQuantity(value) {
836
- for (const pattern of VAGUE_QUANTITY_PATTERNS) {
837
- const match = value.match(pattern.regex);
838
- if (match) {
839
- let remainder = value.slice(match[0].length).trim();
840
- remainder = remainder.replace(/^of\s+/i, "").trim();
841
- return {
842
- remainder,
843
- note: pattern.note
844
- };
845
- }
310
+ function formatYield(yieldValue) {
311
+ if (!yieldValue) return void 0;
312
+ if (!yieldValue.amount && !yieldValue.unit) {
313
+ return void 0;
846
314
  }
847
- return void 0;
315
+ const amount = yieldValue.amount ?? "";
316
+ const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
317
+ return `${amount}${unit}`.trim() || yieldValue.description;
848
318
  }
849
- function extractParentheticals(value) {
850
- let optional = false;
851
- let measurement;
852
- const notes = [];
853
- const cleaned = value.replace(/\(([^)]+)\)/g, (_match, group) => {
854
- const trimmed = String(group).trim();
855
- if (!trimmed) return "";
856
- if (/optional/i.test(trimmed)) {
857
- optional = true;
858
- return "";
319
+
320
+ // src/utils/image.ts
321
+ function normalizeImage(image) {
322
+ if (!image) {
323
+ return void 0;
324
+ }
325
+ if (typeof image === "string") {
326
+ const trimmed = image.trim();
327
+ return trimmed || void 0;
328
+ }
329
+ if (Array.isArray(image)) {
330
+ const urls = image.map((entry) => typeof entry === "string" ? entry.trim() : extractUrl(entry)).filter((url) => typeof url === "string" && Boolean(url));
331
+ if (urls.length === 0) {
332
+ return void 0;
859
333
  }
860
- const maybeMeasurement = parseMeasurement(trimmed);
861
- if (maybeMeasurement && !measurement) {
862
- measurement = maybeMeasurement;
863
- return "";
334
+ if (urls.length === 1) {
335
+ return urls[0];
864
336
  }
865
- notes.push(trimmed);
866
- return "";
867
- });
868
- return {
869
- cleaned: cleaned.replace(/\s+/g, " ").trim(),
870
- measurement,
871
- notes,
872
- optional
873
- };
337
+ return urls;
338
+ }
339
+ return extractUrl(image);
874
340
  }
875
- function parseMeasurement(value) {
876
- const stripped = value.replace(/^(about|around|approximately|approx\.?|roughly)\s+/i, "").trim();
877
- const match = stripped.match(
878
- /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(?:\s*)([a-zA-Z]+)?$/
879
- );
880
- if (!match) return void 0;
881
- const amount = parseNumber(match[1]);
882
- if (amount === null) return void 0;
883
- const unit = match[2] ? normalizeUnit(match[2]) ?? match[2].toLowerCase() : null;
884
- return { amount, unit };
885
- }
886
- function extractQuantity(value) {
887
- let working = value.trim();
888
- const notes = [];
889
- let amount = null;
890
- let originalAmount = null;
891
- let unit = null;
892
- let descriptor;
893
- while (QUALIFIER_REGEX.test(working)) {
894
- working = working.replace(QUALIFIER_REGEX, "").trim();
895
- }
896
- const rangeMatch = working.match(RANGE_REGEX);
897
- if (rangeMatch) {
898
- amount = parseNumber(rangeMatch[1]);
899
- originalAmount = amount;
900
- const rangeText = rangeMatch[0].trim();
901
- const afterRange = working.slice(rangeMatch[0].length).trim();
902
- const descriptorMatch = afterRange.match(/^([a-zA-Z]+)/);
903
- if (descriptorMatch && COUNT_DESCRIPTORS.has(descriptorMatch[1].toLowerCase())) {
904
- notes.push(`${rangeText} ${descriptorMatch[1]}`);
905
- } else {
906
- notes.push(rangeText);
907
- }
908
- working = afterRange;
909
- } else {
910
- const numberMatch = working.match(NUMBER_REGEX);
911
- if (numberMatch) {
912
- amount = parseNumber(numberMatch[1]);
913
- originalAmount = amount;
914
- working = working.slice(numberMatch[0].length).trim();
915
- }
341
+ function extractUrl(value) {
342
+ if (!value || typeof value !== "object") {
343
+ return void 0;
916
344
  }
917
- if (working) {
918
- const unitMatch = working.match(/^([a-zA-Z]+)\b/);
919
- if (unitMatch) {
920
- const normalized = normalizeUnit(unitMatch[1]);
921
- if (normalized) {
922
- unit = normalized;
923
- working = working.slice(unitMatch[0].length).trim();
924
- } else if (COUNT_DESCRIPTORS.has(unitMatch[1].toLowerCase())) {
925
- descriptor = unitMatch[1];
926
- working = working.slice(unitMatch[0].length).trim();
927
- }
928
- }
929
- }
930
- return {
931
- amount,
932
- unit,
933
- descriptor,
934
- remainder: working.trim(),
935
- notes,
936
- originalAmount
937
- };
938
- }
939
- function parseNumber(value) {
940
- const trimmed = value.trim();
941
- if (!trimmed) return null;
942
- if (/^\d+\s+\d+\/\d+$/.test(trimmed)) {
943
- const [whole, fraction] = trimmed.split(/\s+/);
944
- return parseInt(whole, 10) + parseFraction(fraction);
945
- }
946
- if (/^\d+\/\d+$/.test(trimmed)) {
947
- return parseFraction(trimmed);
948
- }
949
- const parsed = Number(trimmed);
950
- return Number.isNaN(parsed) ? null : parsed;
951
- }
952
- function parseFraction(value) {
953
- const [numerator, denominator] = value.split("/").map(Number);
954
- if (!denominator) return numerator;
955
- return numerator / denominator;
956
- }
957
- function normalizeUnit(raw) {
958
- const lower = raw.toLowerCase();
959
- if (UNIT_SYNONYMS[lower]) {
960
- return UNIT_SYNONYMS[lower];
961
- }
962
- if (raw === "T") return "tbsp";
963
- if (raw === "t") return "tsp";
964
- if (raw === "C") return "cup";
965
- return null;
966
- }
967
- function mergeQuantities(extracted, measurement) {
968
- const quantity = {
969
- amount: extracted.amount ?? null,
970
- unit: extracted.unit ?? null
971
- };
972
- if (!measurement) {
973
- return { quantity, usedParenthetical: false };
974
- }
975
- const measurementUnit = measurement.unit?.toLowerCase() ?? null;
976
- const shouldPrefer = !quantity.unit || measurementUnit !== null && WEIGHT_PRIORITY_UNITS.has(measurementUnit);
977
- if (shouldPrefer) {
978
- return {
979
- quantity: {
980
- amount: measurement.amount,
981
- unit: measurement.unit ?? null
982
- },
983
- usedParenthetical: true
984
- };
985
- }
986
- return { quantity, usedParenthetical: false };
987
- }
988
- function extractNameAndPrep(value) {
989
- let working = value.trim();
990
- const notes = [];
991
- let prep;
992
- const lastComma = working.lastIndexOf(",");
993
- if (lastComma >= 0) {
994
- const trailing = working.slice(lastComma + 1).trim();
995
- if (isPrepPhrase(trailing)) {
996
- prep = trailing;
997
- working = working.slice(0, lastComma).trim();
998
- }
999
- }
1000
- working = working.replace(/^[,.\s-]+/, "").trim();
1001
- working = working.replace(/^of\s+/i, "").trim();
1002
- if (!working) {
1003
- return { name: void 0, prep, notes };
1004
- }
1005
- let name = cleanupIngredientName(working);
1006
- return {
1007
- name: name || void 0,
1008
- prep,
1009
- notes
1010
- };
1011
- }
1012
- function cleanupIngredientName(value) {
1013
- let result = value.trim();
1014
- if (/^cans?\b/i.test(result)) {
1015
- result = result.replace(/^cans?\b/i, "canned").trim();
1016
- }
1017
- let changed = true;
1018
- while (changed) {
1019
- changed = false;
1020
- if (/^of\s+/i.test(result)) {
1021
- result = result.replace(/^of\s+/i, "").trim();
1022
- changed = true;
1023
- continue;
1024
- }
1025
- const match = result.match(/^(clove|cloves|sprig|sprigs|bunch|bunches|stick|sticks|slice|slices)\b/i);
1026
- if (match) {
1027
- result = result.slice(match[0].length).trim();
1028
- changed = true;
1029
- }
1030
- }
1031
- return result;
1032
- }
1033
- function isPrepPhrase(value) {
1034
- const normalized = value.toLowerCase();
1035
- return PREP_PHRASES.includes(normalized);
1036
- }
1037
- function inferScaling(name, unit, amount, notes, descriptor) {
1038
- const lowerName = name?.toLowerCase() ?? "";
1039
- const normalizedNotes = notes.map((note) => note.toLowerCase());
1040
- const descriptorLower = descriptor?.toLowerCase();
1041
- if (lowerName.includes("egg") || descriptorLower === "clove" || descriptorLower === "cloves" || normalizedNotes.some((note) => note.includes("clove"))) {
1042
- return { type: "discrete", roundTo: 1 };
1043
- }
1044
- if (descriptorLower === "stick" || descriptorLower === "sticks") {
1045
- return { type: "discrete", roundTo: 1 };
1046
- }
1047
- if (normalizedNotes.some((note) => PURPOSE_KEYWORDS.some((keyword) => note.includes(keyword)))) {
1048
- return { type: "fixed" };
1049
- }
1050
- const isSpice = SPICE_KEYWORDS.some((keyword) => lowerName.includes(keyword));
1051
- const smallUnit = unit ? ["tsp", "tbsp", "dash", "pinch"].includes(unit) : false;
1052
- if (normalizedNotes.some((note) => note.includes("to taste")) || isSpice && (smallUnit || amount !== null && amount <= 1)) {
1053
- return { type: "proportional", factor: 0.7 };
1054
- }
1055
- return { type: "linear" };
1056
- }
1057
- function formatNotes(notes) {
1058
- const cleaned = Array.from(
1059
- new Set(
1060
- notes.map((note) => note.trim()).filter(Boolean)
1061
- )
1062
- );
1063
- return cleaned.length ? cleaned.join("; ") : void 0;
1064
- }
1065
- function formatCountNote(amount, descriptor) {
1066
- const lower = descriptor.toLowerCase();
1067
- const singular = lower.endsWith("s") ? lower.slice(0, -1) : lower;
1068
- 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`;
1069
- return `${formatDecimal(amount)} ${word}`;
1070
- }
1071
- function singularize(value) {
1072
- const trimmed = value.trim();
1073
- if (trimmed.endsWith("ies")) {
1074
- return `${trimmed.slice(0, -3)}y`;
1075
- }
1076
- if (/(ches|shes|sses|xes|zes)$/i.test(trimmed)) {
1077
- return trimmed.slice(0, -2);
1078
- }
1079
- if (trimmed.endsWith("s")) {
1080
- return trimmed.slice(0, -1);
1081
- }
1082
- return trimmed;
1083
- }
1084
-
1085
- // src/converters/ingredient.ts
1086
- function parseIngredientLine(line) {
1087
- const parsed = parseIngredient(line);
1088
- const ingredient = {
1089
- item: parsed.item,
1090
- scaling: parsed.scaling ?? { type: "linear" }
1091
- };
1092
- if (parsed.name) {
1093
- ingredient.name = parsed.name;
1094
- }
1095
- if (parsed.prep) {
1096
- ingredient.prep = parsed.prep;
1097
- }
1098
- if (parsed.optional) {
1099
- ingredient.optional = true;
1100
- }
1101
- if (parsed.notes) {
1102
- ingredient.notes = parsed.notes;
1103
- }
1104
- const quantity = buildQuantity(parsed.quantity);
1105
- if (quantity) {
1106
- ingredient.quantity = quantity;
1107
- }
1108
- return ingredient;
1109
- }
1110
- function buildQuantity(parsedQuantity) {
1111
- if (!parsedQuantity) {
1112
- return void 0;
1113
- }
1114
- if (parsedQuantity.amount === null || Number.isNaN(parsedQuantity.amount)) {
1115
- return void 0;
1116
- }
1117
- return {
1118
- amount: parsedQuantity.amount,
1119
- unit: parsedQuantity.unit ?? null
1120
- };
1121
- }
1122
-
1123
- // src/converters/yield.ts
1124
- function parseYield(value) {
1125
- if (value === void 0 || value === null) {
1126
- return void 0;
1127
- }
1128
- if (typeof value === "number") {
1129
- return {
1130
- amount: value,
1131
- unit: "servings"
1132
- };
1133
- }
1134
- if (Array.isArray(value)) {
1135
- return parseYield(value[0]);
1136
- }
1137
- if (typeof value === "object") {
1138
- const maybeYield = value;
1139
- if (typeof maybeYield.amount === "number") {
1140
- return {
1141
- amount: maybeYield.amount,
1142
- unit: typeof maybeYield.unit === "string" ? maybeYield.unit : "servings",
1143
- description: typeof maybeYield.description === "string" ? maybeYield.description : void 0
1144
- };
1145
- }
1146
- }
1147
- if (typeof value === "string") {
1148
- const trimmed = value.trim();
1149
- const match = trimmed.match(/(\d+(?:\.\d+)?)/);
1150
- if (match) {
1151
- const amount = parseFloat(match[1]);
1152
- const unit = trimmed.slice(match.index + match[1].length).trim();
1153
- return {
1154
- amount,
1155
- unit: unit || "servings",
1156
- description: trimmed
1157
- };
1158
- }
1159
- }
1160
- return void 0;
1161
- }
1162
- function formatYield(yieldValue) {
1163
- if (!yieldValue) return void 0;
1164
- if (!yieldValue.amount && !yieldValue.unit) {
345
+ const record = value;
346
+ const candidate = typeof record.url === "string" ? record.url : typeof record.contentUrl === "string" ? record.contentUrl : void 0;
347
+ if (!candidate) {
1165
348
  return void 0;
1166
349
  }
1167
- const amount = yieldValue.amount ?? "";
1168
- const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
1169
- return `${amount}${unit}`.trim() || yieldValue.description;
1170
- }
1171
-
1172
- // src/parsers/duration.ts
1173
- var ISO_DURATION_REGEX = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i;
1174
- var HUMAN_OVERNIGHT = 8 * 60;
1175
- function isFiniteNumber(value) {
1176
- return typeof value === "number" && Number.isFinite(value);
1177
- }
1178
- function parseDuration(iso) {
1179
- if (!iso || typeof iso !== "string") return null;
1180
- const trimmed = iso.trim();
1181
- if (!trimmed) return null;
1182
- const match = trimmed.match(ISO_DURATION_REGEX);
1183
- if (!match) return null;
1184
- const [, daysRaw, hoursRaw, minutesRaw, secondsRaw] = match;
1185
- if (!daysRaw && !hoursRaw && !minutesRaw && !secondsRaw) {
1186
- return null;
1187
- }
1188
- let total = 0;
1189
- if (daysRaw) total += parseFloat(daysRaw) * 24 * 60;
1190
- if (hoursRaw) total += parseFloat(hoursRaw) * 60;
1191
- if (minutesRaw) total += parseFloat(minutesRaw);
1192
- if (secondsRaw) total += Math.ceil(parseFloat(secondsRaw) / 60);
1193
- return Math.round(total);
1194
- }
1195
- function formatDuration(minutes) {
1196
- if (!isFiniteNumber(minutes) || minutes <= 0) {
1197
- return "PT0M";
1198
- }
1199
- const rounded = Math.round(minutes);
1200
- const days = Math.floor(rounded / (24 * 60));
1201
- const afterDays = rounded % (24 * 60);
1202
- const hours = Math.floor(afterDays / 60);
1203
- const mins = afterDays % 60;
1204
- let result = "P";
1205
- if (days > 0) {
1206
- result += `${days}D`;
1207
- }
1208
- if (hours > 0 || mins > 0) {
1209
- result += "T";
1210
- if (hours > 0) {
1211
- result += `${hours}H`;
1212
- }
1213
- if (mins > 0) {
1214
- result += `${mins}M`;
1215
- }
1216
- }
1217
- if (result === "P") {
1218
- return "PT0M";
1219
- }
1220
- return result;
1221
- }
1222
- function parseHumanDuration(text) {
1223
- if (!text || typeof text !== "string") return null;
1224
- const normalized = text.toLowerCase().trim();
1225
- if (!normalized) return null;
1226
- if (normalized === "overnight") {
1227
- return HUMAN_OVERNIGHT;
1228
- }
1229
- let total = 0;
1230
- const hourRegex = /(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|hr|h)\b/g;
1231
- let hourMatch;
1232
- while ((hourMatch = hourRegex.exec(normalized)) !== null) {
1233
- total += parseFloat(hourMatch[1]) * 60;
1234
- }
1235
- const minuteRegex = /(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|min|m)\b/g;
1236
- let minuteMatch;
1237
- while ((minuteMatch = minuteRegex.exec(normalized)) !== null) {
1238
- total += parseFloat(minuteMatch[1]);
1239
- }
1240
- if (total <= 0) {
1241
- return null;
1242
- }
1243
- return Math.round(total);
1244
- }
1245
- function smartParseDuration(input) {
1246
- const iso = parseDuration(input);
1247
- if (iso !== null) {
1248
- return iso;
1249
- }
1250
- return parseHumanDuration(input);
350
+ const trimmed = candidate.trim();
351
+ return trimmed || void 0;
1251
352
  }
1252
353
 
1253
354
  // src/fromSchemaOrg.ts
@@ -1262,13 +363,12 @@ function fromSchemaOrg(input) {
1262
363
  const recipeYield = parseYield(recipeNode.recipeYield);
1263
364
  const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
1264
365
  const category = extractFirst(recipeNode.recipeCategory);
1265
- const image = convertImage(recipeNode.image);
1266
366
  const source = convertSource(recipeNode);
1267
367
  const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
1268
368
  return {
1269
369
  name: recipeNode.name.trim(),
1270
370
  description: recipeNode.description?.trim() || void 0,
1271
- image,
371
+ image: normalizeImage(recipeNode.image),
1272
372
  category,
1273
373
  tags: tags.length ? tags : void 0,
1274
374
  source,
@@ -1313,8 +413,6 @@ function extractRecipeNode(input) {
1313
413
  function hasRecipeType(value) {
1314
414
  if (!value) return false;
1315
415
  const types = Array.isArray(value) ? value : [value];
1316
- 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(() => {
1317
- });
1318
416
  return types.some(
1319
417
  (entry) => typeof entry === "string" && entry.toLowerCase() === "recipe"
1320
418
  );
@@ -1325,7 +423,7 @@ function isValidName(name) {
1325
423
  function convertIngredients(value) {
1326
424
  if (!value) return [];
1327
425
  const normalized = Array.isArray(value) ? value : [value];
1328
- 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);
1329
427
  }
1330
428
  function convertInstructions(value) {
1331
429
  if (!value) return [];
@@ -1351,9 +449,9 @@ function convertInstructions(value) {
1351
449
  continue;
1352
450
  }
1353
451
  if (isHowToStep(entry)) {
1354
- const text = extractInstructionText(entry);
1355
- if (text) {
1356
- result.push(text);
452
+ const parsed = convertHowToStep(entry);
453
+ if (parsed) {
454
+ result.push(parsed);
1357
455
  }
1358
456
  }
1359
457
  }
@@ -1371,9 +469,9 @@ function extractSectionItems(items = []) {
1371
469
  continue;
1372
470
  }
1373
471
  if (isHowToStep(item)) {
1374
- const text = extractInstructionText(item);
1375
- if (text) {
1376
- result.push(text);
472
+ const parsed = convertHowToStep(item);
473
+ if (parsed) {
474
+ result.push(parsed);
1377
475
  }
1378
476
  continue;
1379
477
  }
@@ -1387,6 +485,40 @@ function extractInstructionText(value) {
1387
485
  const text = typeof value.text === "string" ? value.text : value.name;
1388
486
  return typeof text === "string" ? text.trim() || void 0 : void 0;
1389
487
  }
488
+ function convertHowToStep(step) {
489
+ const text = extractInstructionText(step);
490
+ if (!text) {
491
+ return void 0;
492
+ }
493
+ const normalizedImage = normalizeImage(step.image);
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;
510
+ }
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;
521
+ }
1390
522
  function isHowToStep(value) {
1391
523
  return Boolean(value) && typeof value === "object" && value["@type"] === "HowToStep";
1392
524
  }
@@ -1428,26 +560,6 @@ function extractFirst(value) {
1428
560
  const arr = flattenStrings(value);
1429
561
  return arr.length ? arr[0] : void 0;
1430
562
  }
1431
- function convertImage(value) {
1432
- if (!value) return void 0;
1433
- if (typeof value === "string") {
1434
- return value;
1435
- }
1436
- if (Array.isArray(value)) {
1437
- for (const item of value) {
1438
- const url = typeof item === "string" ? item : extractImageUrl(item);
1439
- if (url) return url;
1440
- }
1441
- return void 0;
1442
- }
1443
- return extractImageUrl(value);
1444
- }
1445
- function extractImageUrl(value) {
1446
- if (!value || typeof value !== "object") return void 0;
1447
- const record = value;
1448
- const candidate = typeof record.url === "string" ? record.url : typeof record.contentUrl === "string" ? record.contentUrl : void 0;
1449
- return candidate?.trim() || void 0;
1450
- }
1451
563
  function convertSource(recipe) {
1452
564
  const author = extractEntityName(recipe.author);
1453
565
  const publisher = extractEntityName(recipe.publisher);
@@ -1540,10 +652,11 @@ function convertInstruction(entry) {
1540
652
  return null;
1541
653
  }
1542
654
  if (typeof entry === "string") {
1543
- return createHowToStep(entry);
655
+ const value = entry.trim();
656
+ return value || null;
1544
657
  }
1545
658
  if ("subsection" in entry) {
1546
- const steps = entry.items.map((item) => typeof item === "string" ? createHowToStep(item) : createHowToStep(item.text)).filter((step) => Boolean(step));
659
+ const steps = entry.items.map((item) => convertInstruction(item)).filter((step) => Boolean(step));
1547
660
  if (!steps.length) {
1548
661
  return null;
1549
662
  }
@@ -1554,18 +667,41 @@ function convertInstruction(entry) {
1554
667
  };
1555
668
  }
1556
669
  if ("text" in entry) {
1557
- return createHowToStep(entry.text);
670
+ return createHowToStep(entry);
1558
671
  }
1559
672
  return createHowToStep(String(entry));
1560
673
  }
1561
- function createHowToStep(text) {
1562
- if (!text) return null;
1563
- const trimmed = text.trim();
1564
- if (!trimmed) return null;
1565
- return {
674
+ function createHowToStep(entry) {
675
+ if (!entry) return null;
676
+ if (typeof entry === "string") {
677
+ const trimmed2 = entry.trim();
678
+ return trimmed2 || null;
679
+ }
680
+ const trimmed = entry.text?.trim();
681
+ if (!trimmed) {
682
+ return null;
683
+ }
684
+ const step = {
1566
685
  "@type": "HowToStep",
1567
686
  text: trimmed
1568
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
+ }
698
+ if (entry.image) {
699
+ step.image = entry.image;
700
+ }
701
+ if (step["@id"] || step.performTime || step.image) {
702
+ return step;
703
+ }
704
+ return trimmed;
1569
705
  }
1570
706
  function convertTime2(time) {
1571
707
  if (!time) {
@@ -1728,7 +864,7 @@ async function fetchPage(url, options = {}) {
1728
864
  };
1729
865
  const response = await resolvedFetch(url, requestInit);
1730
866
  clearTimeout(timeoutId);
1731
- if (response && (typeof process === "undefined" || process.env.NODE_ENV !== "test")) {
867
+ if (response && typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1732
868
  try {
1733
869
  const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1734
870
  if (globalFetch) {
@@ -1746,7 +882,7 @@ async function fetchPage(url, options = {}) {
1746
882
  throw error;
1747
883
  }
1748
884
  const html = await response.text();
1749
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
885
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
1750
886
  try {
1751
887
  const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
1752
888
  if (globalFetch) {
@@ -1991,199 +1127,1454 @@ function collectCandidates2(payload, bucket) {
1991
1127
  if (Array.isArray(graph)) {
1992
1128
  graph.forEach((entry) => collectCandidates2(entry, bucket));
1993
1129
  }
1994
- }
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
+ };
1995
1920
 
1996
- // src/scraper/extractors/index.ts
1997
- function isBrowser() {
1998
- try {
1999
- return typeof globalThis.DOMParser !== "undefined";
2000
- } catch {
2001
- return false;
2002
- }
2003
- }
2004
- function extractRecipe(html) {
2005
- if (isBrowser()) {
2006
- return extractRecipeBrowser(html);
2007
- }
2008
- const jsonLdRecipe = extractJsonLd(html);
2009
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2010
- try {
2011
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2012
- if (globalFetch) {
2013
- 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(() => {
2014
- });
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 }
2015
1947
  }
2016
- } catch {
2017
1948
  }
2018
- }
2019
- if (jsonLdRecipe) {
2020
- return { recipe: jsonLdRecipe, source: "jsonld" };
2021
- }
2022
- const microdataRecipe = extractMicrodata(html);
2023
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2024
- try {
2025
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2026
- if (globalFetch) {
2027
- 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(() => {
2028
- });
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
+ }
2029
1971
  }
2030
- } 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
+ ]
2031
1993
  }
2032
1994
  }
2033
- if (microdataRecipe) {
2034
- 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
+ }
2035
2044
  }
2036
- return { recipe: null, source: null };
2037
- }
2045
+ };
2038
2046
 
2039
- // src/scraper/index.ts
2040
- async function scrapeRecipe(url, options = {}) {
2041
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2042
- try {
2043
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2044
- if (globalFetch) {
2045
- 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(() => {
2046
- });
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
+ }
2047
2066
  }
2048
- } 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
+ ]
2049
2088
  }
2050
2089
  }
2051
- const html = await fetchPage(url, options);
2052
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2053
- try {
2054
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2055
- if (globalFetch) {
2056
- 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(() => {
2057
- });
2058
- }
2059
- } 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;
2060
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, "/") || "/";
2061
2197
  }
2062
- const { recipe } = extractRecipe(html);
2063
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2064
- try {
2065
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2066
- if (globalFetch) {
2067
- 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(() => {
2068
- });
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;
2069
2227
  }
2070
- } 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
+ });
2071
2253
  }
2072
- }
2073
- if (!recipe) {
2074
- throw new Error("No Schema.org recipe data found in page");
2075
- }
2076
- const soustackRecipe = fromSchemaOrg(recipe);
2077
- if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
2078
- try {
2079
- const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
2080
- if (globalFetch) {
2081
- 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}'.`
2082
2275
  });
2276
+ return;
2083
2277
  }
2084
- } catch {
2085
- }
2086
- }
2087
- if (!soustackRecipe) {
2088
- throw new Error("Schema.org data did not include a valid recipe");
2089
- }
2090
- 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
+ };
2091
2302
  }
2092
2303
 
2093
2304
  // bin/cli.ts
2094
- var [, , command, ...args] = process.argv;
2095
- async function main() {
2305
+ var supportedProfiles = ["base", "cookable", "scalable", "quantified", "illustrated", "schedulable"];
2306
+ async function runCli(argv) {
2307
+ const [command, ...args] = argv;
2096
2308
  try {
2097
2309
  switch (command) {
2098
2310
  case "validate":
2099
2311
  await handleValidate(args);
2100
- break;
2101
- case "scale":
2102
- await handleScale(args);
2103
- break;
2312
+ return;
2313
+ case "convert":
2314
+ await handleConvert(args);
2315
+ return;
2104
2316
  case "import":
2105
2317
  await handleImport(args);
2106
- break;
2107
- case "export":
2108
- await handleExport(args);
2109
- break;
2318
+ return;
2319
+ case "scale":
2320
+ await handleScale(args);
2321
+ return;
2110
2322
  case "scrape":
2111
2323
  await handleScrape(args);
2112
- break;
2324
+ return;
2325
+ case "test":
2326
+ await handleTest(args);
2327
+ return;
2113
2328
  default:
2114
2329
  printUsage();
2115
- process.exit(1);
2330
+ process.exitCode = 1;
2116
2331
  }
2117
2332
  } catch (error) {
2118
- console.error(`\u274C ${error.message}`);
2333
+ console.error(`\u274C ${error?.message ?? error}`);
2119
2334
  process.exit(1);
2120
2335
  }
2121
2336
  }
2122
2337
  function printUsage() {
2123
2338
  console.log("Usage:");
2124
- console.log(" npx soustack validate <soustack.json>");
2125
- console.log(" npx soustack scale <soustack.json> <multiplier>");
2126
- console.log(" npx soustack import <schema-org.jsonld> -o <soustack.json>");
2127
- console.log(" npx soustack export <soustack.json> -o <schema-org.jsonld>");
2128
- console.log(" npx soustack scrape <url> -o <soustack.json>");
2129
- }
2130
- async function handleValidate(args2) {
2131
- const filePath = args2[0];
2132
- if (!filePath) throw new Error("Path to recipe JSON is required");
2133
- const recipe = readJsonFile(filePath);
2134
- validateRecipe(recipe);
2135
- 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})` : ""}`);
2136
2395
  }
2137
- async function handleScale(args2) {
2138
- const filePath = args2[0];
2139
- 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;
2140
2399
  if (!filePath || Number.isNaN(multiplier)) {
2141
2400
  throw new Error("Scale usage: scale <soustack.json> <multiplier>");
2142
2401
  }
2143
2402
  const recipe = readJsonFile(filePath);
2144
2403
  console.log(`
2145
- \u2696\uFE0F Scaling "${recipe.name}" by ${multiplier}x...
2404
+ \u2696\uFE0F Scaling "${recipe?.name ?? filePath}" by ${multiplier}x...
2146
2405
  `);
2147
- const baseYield = recipe.yield?.amount || 1;
2148
- const targetYield = baseYield * multiplier;
2149
- const result = scaleRecipe(recipe, targetYield);
2150
- console.log("--- INGREDIENTS ---");
2151
- result.ingredients.forEach((ing) => {
2152
- console.log(`\u2022 ${ing.text}`);
2153
- });
2154
- console.log("\n--- TIMING ---");
2155
- console.log(`Total Time: ${result.timing.total} minutes`);
2156
- console.log(`(Active: ${result.timing.active}m | Passive: ${result.timing.passive}m)`);
2157
- }
2158
- async function handleImport(args2) {
2159
- const filePath = args2[0];
2160
- const outputPath = resolveOutputPath(args2.slice(1));
2161
- if (!filePath) throw new Error("Import usage: import <schema-org.json> -o <soustack.json>");
2162
- const schemaOrg = readJsonFile(filePath);
2163
- const soustack = fromSchemaOrg(schemaOrg);
2164
- if (!soustack) {
2165
- throw new Error("No valid Schema.org recipe found in input");
2166
- }
2167
- writeOutput(soustack, outputPath);
2168
- console.log(`\u2705 Converted Schema.org \u2192 Soustack${outputPath ? ` (${outputPath})` : ""}`);
2169
- }
2170
- async function handleExport(args2) {
2171
- const filePath = args2[0];
2172
- const outputPath = resolveOutputPath(args2.slice(1));
2173
- if (!filePath) throw new Error("Export usage: export <soustack.json> -o <schema-org.jsonld>");
2174
- const soustack = readJsonFile(filePath);
2175
- const schemaOrg = toSchemaOrg(soustack);
2176
- writeOutput(schemaOrg, outputPath);
2177
- console.log(`\u2705 Converted Soustack \u2192 Schema.org${outputPath ? ` (${outputPath})` : ""}`);
2178
- }
2179
- async function handleScrape(args2) {
2180
- const url = args2[0];
2181
- 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));
2182
2412
  if (!url) throw new Error("Scrape usage: scrape <url> -o <soustack.json>");
2183
2413
  const recipe = await scrapeRecipe(url);
2184
2414
  writeOutput(recipe, outputPath);
2185
2415
  console.log(`\u2705 Scraped recipe from ${url}${outputPath ? ` (${outputPath})` : ""}`);
2186
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
+ }
2187
2578
  function readJsonFile(relativePath) {
2188
2579
  const absolutePath = path__namespace.resolve(relativePath);
2189
2580
  if (!fs__namespace.existsSync(absolutePath)) {
@@ -2196,10 +2587,10 @@ function readJsonFile(relativePath) {
2196
2587
  throw new Error(`Unable to parse JSON in ${absolutePath}`);
2197
2588
  }
2198
2589
  }
2199
- function resolveOutputPath(args2) {
2200
- const index = args2.findIndex((arg) => arg === "-o" || arg === "--output");
2590
+ function resolveOutputPath(args) {
2591
+ const index = args.findIndex((arg) => arg === "-o" || arg === "--output");
2201
2592
  if (index === -1) return void 0;
2202
- const target = args2[index + 1];
2593
+ const target = args[index + 1];
2203
2594
  if (!target) {
2204
2595
  throw new Error("Output flag provided without a path");
2205
2596
  }
@@ -2214,6 +2605,13 @@ function writeOutput(data, outputPath) {
2214
2605
  const absolutePath = path__namespace.resolve(outputPath);
2215
2606
  fs__namespace.writeFileSync(absolutePath, serialized, "utf-8");
2216
2607
  }
2217
- 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;
2218
2616
  //# sourceMappingURL=index.js.map
2219
2617
  //# sourceMappingURL=index.js.map