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