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