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