soustack 0.1.3 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +301 -203
- package/dist/cli/index.js +1763 -1365
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.mts +60 -135
- package/dist/index.d.ts +60 -135
- package/dist/index.js +1141 -1455
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1140 -1443
- package/dist/index.mjs.map +1 -1
- package/dist/scrape.d.mts +308 -0
- package/dist/scrape.d.ts +308 -0
- package/dist/scrape.js +819 -0
- package/dist/scrape.js.map +1 -0
- package/dist/scrape.mjs +814 -0
- package/dist/scrape.mjs.map +1 -0
- package/package.json +86 -75
- package/src/profiles/.gitkeep +0 -0
- package/src/profiles/base.schema.json +9 -0
- package/src/profiles/cookable.schema.json +18 -0
- package/src/profiles/illustrated.schema.json +48 -0
- package/src/profiles/quantified.schema.json +43 -0
- package/src/profiles/scalable.schema.json +75 -0
- package/src/profiles/schedulable.schema.json +43 -0
- package/src/schema.json +63 -24
- package/src/soustack.schema.json +344 -0
package/dist/index.mjs
CHANGED
|
@@ -1,144 +1,263 @@
|
|
|
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
243
|
// src/schema.json
|
|
134
244
|
var schema_default = {
|
|
135
245
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
136
|
-
$id: "http://soustack.org/schema/v0.1",
|
|
137
|
-
title: "Soustack Recipe Schema v0.1",
|
|
246
|
+
$id: "http://soustack.org/schema/v0.2.1",
|
|
247
|
+
title: "Soustack Recipe Schema v0.2.1",
|
|
138
248
|
description: "A portable, scalable, interoperable recipe format.",
|
|
139
249
|
type: "object",
|
|
140
250
|
required: ["name", "ingredients", "instructions"],
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
patternProperties: {
|
|
253
|
+
"^x-": {}
|
|
254
|
+
},
|
|
141
255
|
properties: {
|
|
256
|
+
$schema: {
|
|
257
|
+
type: "string",
|
|
258
|
+
format: "uri",
|
|
259
|
+
description: "Optional schema hint for tooling compatibility"
|
|
260
|
+
},
|
|
142
261
|
id: {
|
|
143
262
|
type: "string",
|
|
144
263
|
description: "Unique identifier (slug or UUID)"
|
|
@@ -147,10 +266,19 @@ var schema_default = {
|
|
|
147
266
|
type: "string",
|
|
148
267
|
description: "The title of the recipe"
|
|
149
268
|
},
|
|
269
|
+
title: {
|
|
270
|
+
type: "string",
|
|
271
|
+
description: "Optional display title; alias for name"
|
|
272
|
+
},
|
|
150
273
|
version: {
|
|
151
274
|
type: "string",
|
|
152
275
|
pattern: "^\\d+\\.\\d+\\.\\d+$",
|
|
153
|
-
description: "
|
|
276
|
+
description: "DEPRECATED: use recipeVersion for authoring revisions"
|
|
277
|
+
},
|
|
278
|
+
recipeVersion: {
|
|
279
|
+
type: "string",
|
|
280
|
+
pattern: "^\\d+\\.\\d+\\.\\d+$",
|
|
281
|
+
description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
|
|
154
282
|
},
|
|
155
283
|
description: {
|
|
156
284
|
type: "string"
|
|
@@ -164,13 +292,31 @@ var schema_default = {
|
|
|
164
292
|
items: { type: "string" }
|
|
165
293
|
},
|
|
166
294
|
image: {
|
|
167
|
-
|
|
168
|
-
|
|
295
|
+
description: "Recipe-level hero image(s)",
|
|
296
|
+
anyOf: [
|
|
297
|
+
{
|
|
298
|
+
type: "string",
|
|
299
|
+
format: "uri"
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
type: "array",
|
|
303
|
+
minItems: 1,
|
|
304
|
+
items: {
|
|
305
|
+
type: "string",
|
|
306
|
+
format: "uri"
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
]
|
|
169
310
|
},
|
|
170
311
|
dateAdded: {
|
|
171
312
|
type: "string",
|
|
172
313
|
format: "date-time"
|
|
173
314
|
},
|
|
315
|
+
metadata: {
|
|
316
|
+
type: "object",
|
|
317
|
+
additionalProperties: true,
|
|
318
|
+
description: "Free-form vendor metadata"
|
|
319
|
+
},
|
|
174
320
|
source: {
|
|
175
321
|
type: "object",
|
|
176
322
|
properties: {
|
|
@@ -230,24 +376,16 @@ var schema_default = {
|
|
|
230
376
|
}
|
|
231
377
|
},
|
|
232
378
|
time: {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
{
|
|
244
|
-
type: "object",
|
|
245
|
-
properties: {
|
|
246
|
-
prepTime: { type: "string" },
|
|
247
|
-
cookTime: { type: "string" }
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
]
|
|
379
|
+
type: "object",
|
|
380
|
+
properties: {
|
|
381
|
+
prep: { type: "number" },
|
|
382
|
+
active: { type: "number" },
|
|
383
|
+
passive: { type: "number" },
|
|
384
|
+
total: { type: "number" },
|
|
385
|
+
prepTime: { type: "string", format: "duration" },
|
|
386
|
+
cookTime: { type: "string", format: "duration" }
|
|
387
|
+
},
|
|
388
|
+
minProperties: 1
|
|
251
389
|
},
|
|
252
390
|
quantity: {
|
|
253
391
|
type: "object",
|
|
@@ -330,6 +468,11 @@ var schema_default = {
|
|
|
330
468
|
properties: {
|
|
331
469
|
id: { type: "string" },
|
|
332
470
|
text: { type: "string" },
|
|
471
|
+
image: {
|
|
472
|
+
type: "string",
|
|
473
|
+
format: "uri",
|
|
474
|
+
description: "Optional image that illustrates this instruction"
|
|
475
|
+
},
|
|
333
476
|
destination: { type: "string" },
|
|
334
477
|
dependsOn: {
|
|
335
478
|
type: "array",
|
|
@@ -343,7 +486,13 @@ var schema_default = {
|
|
|
343
486
|
type: "object",
|
|
344
487
|
required: ["duration", "type"],
|
|
345
488
|
properties: {
|
|
346
|
-
duration: {
|
|
489
|
+
duration: {
|
|
490
|
+
anyOf: [
|
|
491
|
+
{ type: "number" },
|
|
492
|
+
{ type: "string", pattern: "^P" }
|
|
493
|
+
],
|
|
494
|
+
description: "Minutes as a number or ISO8601 duration string"
|
|
495
|
+
},
|
|
347
496
|
type: { type: "string", enum: ["active", "passive"] },
|
|
348
497
|
scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
|
|
349
498
|
}
|
|
@@ -437,665 +586,756 @@ var schema_default = {
|
|
|
437
586
|
}
|
|
438
587
|
};
|
|
439
588
|
|
|
440
|
-
// src/
|
|
441
|
-
var
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
589
|
+
// src/soustack.schema.json
|
|
590
|
+
var soustack_schema_default = {
|
|
591
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
592
|
+
$id: "http://soustack.org/schema/v0.2.1",
|
|
593
|
+
title: "Soustack Recipe Schema v0.2.1",
|
|
594
|
+
description: "A portable, scalable, interoperable recipe format.",
|
|
595
|
+
type: "object",
|
|
596
|
+
required: ["name", "ingredients", "instructions"],
|
|
597
|
+
additionalProperties: false,
|
|
598
|
+
patternProperties: {
|
|
599
|
+
"^x-": {}
|
|
600
|
+
},
|
|
601
|
+
properties: {
|
|
602
|
+
$schema: {
|
|
603
|
+
type: "string",
|
|
604
|
+
format: "uri",
|
|
605
|
+
description: "Optional schema hint for tooling compatibility"
|
|
606
|
+
},
|
|
607
|
+
id: {
|
|
608
|
+
type: "string",
|
|
609
|
+
description: "Unique identifier (slug or UUID)"
|
|
610
|
+
},
|
|
611
|
+
name: {
|
|
612
|
+
type: "string",
|
|
613
|
+
description: "The title of the recipe"
|
|
614
|
+
},
|
|
615
|
+
title: {
|
|
616
|
+
type: "string",
|
|
617
|
+
description: "Optional display title; alias for name"
|
|
618
|
+
},
|
|
619
|
+
version: {
|
|
620
|
+
type: "string",
|
|
621
|
+
pattern: "^\\d+\\.\\d+\\.\\d+$",
|
|
622
|
+
description: "DEPRECATED: use recipeVersion for authoring revisions"
|
|
623
|
+
},
|
|
624
|
+
recipeVersion: {
|
|
625
|
+
type: "string",
|
|
626
|
+
pattern: "^\\d+\\.\\d+\\.\\d+$",
|
|
627
|
+
description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
|
|
628
|
+
},
|
|
629
|
+
description: {
|
|
630
|
+
type: "string"
|
|
631
|
+
},
|
|
632
|
+
category: {
|
|
633
|
+
type: "string",
|
|
634
|
+
examples: ["Main Course", "Dessert"]
|
|
635
|
+
},
|
|
636
|
+
tags: {
|
|
637
|
+
type: "array",
|
|
638
|
+
items: { type: "string" }
|
|
639
|
+
},
|
|
640
|
+
image: {
|
|
641
|
+
description: "Recipe-level hero image(s)",
|
|
642
|
+
anyOf: [
|
|
643
|
+
{
|
|
644
|
+
type: "string",
|
|
645
|
+
format: "uri"
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
type: "array",
|
|
649
|
+
minItems: 1,
|
|
650
|
+
items: {
|
|
651
|
+
type: "string",
|
|
652
|
+
format: "uri"
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
]
|
|
656
|
+
},
|
|
657
|
+
dateAdded: {
|
|
658
|
+
type: "string",
|
|
659
|
+
format: "date-time"
|
|
660
|
+
},
|
|
661
|
+
metadata: {
|
|
662
|
+
type: "object",
|
|
663
|
+
additionalProperties: true,
|
|
664
|
+
description: "Free-form vendor metadata"
|
|
665
|
+
},
|
|
666
|
+
source: {
|
|
667
|
+
type: "object",
|
|
668
|
+
properties: {
|
|
669
|
+
author: { type: "string" },
|
|
670
|
+
url: { type: "string", format: "uri" },
|
|
671
|
+
name: { type: "string" },
|
|
672
|
+
adapted: { type: "boolean" }
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
yield: {
|
|
676
|
+
$ref: "#/definitions/yield"
|
|
677
|
+
},
|
|
678
|
+
time: {
|
|
679
|
+
$ref: "#/definitions/time"
|
|
680
|
+
},
|
|
681
|
+
equipment: {
|
|
682
|
+
type: "array",
|
|
683
|
+
items: { $ref: "#/definitions/equipment" }
|
|
684
|
+
},
|
|
685
|
+
ingredients: {
|
|
686
|
+
type: "array",
|
|
687
|
+
items: {
|
|
688
|
+
anyOf: [
|
|
689
|
+
{ type: "string" },
|
|
690
|
+
{ $ref: "#/definitions/ingredient" },
|
|
691
|
+
{ $ref: "#/definitions/ingredientSubsection" }
|
|
692
|
+
]
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
instructions: {
|
|
696
|
+
type: "array",
|
|
697
|
+
items: {
|
|
698
|
+
anyOf: [
|
|
699
|
+
{ type: "string" },
|
|
700
|
+
{ $ref: "#/definitions/instruction" },
|
|
701
|
+
{ $ref: "#/definitions/instructionSubsection" }
|
|
702
|
+
]
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
storage: {
|
|
706
|
+
$ref: "#/definitions/storage"
|
|
707
|
+
},
|
|
708
|
+
substitutions: {
|
|
709
|
+
type: "array",
|
|
710
|
+
items: { $ref: "#/definitions/substitution" }
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
definitions: {
|
|
714
|
+
yield: {
|
|
715
|
+
type: "object",
|
|
716
|
+
required: ["amount", "unit"],
|
|
717
|
+
properties: {
|
|
718
|
+
amount: { type: "number" },
|
|
719
|
+
unit: { type: "string" },
|
|
720
|
+
servings: { type: "number" },
|
|
721
|
+
description: { type: "string" }
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
time: {
|
|
725
|
+
type: "object",
|
|
726
|
+
properties: {
|
|
727
|
+
prep: { type: "number" },
|
|
728
|
+
active: { type: "number" },
|
|
729
|
+
passive: { type: "number" },
|
|
730
|
+
total: { type: "number" },
|
|
731
|
+
prepTime: { type: "string", format: "duration" },
|
|
732
|
+
cookTime: { type: "string", format: "duration" }
|
|
733
|
+
},
|
|
734
|
+
minProperties: 1
|
|
735
|
+
},
|
|
736
|
+
quantity: {
|
|
737
|
+
type: "object",
|
|
738
|
+
required: ["amount"],
|
|
739
|
+
properties: {
|
|
740
|
+
amount: { type: "number" },
|
|
741
|
+
unit: { type: ["string", "null"] }
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
scaling: {
|
|
745
|
+
type: "object",
|
|
746
|
+
required: ["type"],
|
|
747
|
+
properties: {
|
|
748
|
+
type: {
|
|
749
|
+
type: "string",
|
|
750
|
+
enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
|
|
751
|
+
},
|
|
752
|
+
factor: { type: "number" },
|
|
753
|
+
referenceId: { type: "string" },
|
|
754
|
+
roundTo: { type: "number" },
|
|
755
|
+
min: { type: "number" },
|
|
756
|
+
max: { type: "number" }
|
|
757
|
+
},
|
|
758
|
+
if: {
|
|
759
|
+
properties: { type: { const: "bakers_percentage" } }
|
|
760
|
+
},
|
|
761
|
+
then: {
|
|
762
|
+
required: ["referenceId"]
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
ingredient: {
|
|
766
|
+
type: "object",
|
|
767
|
+
required: ["item"],
|
|
768
|
+
properties: {
|
|
769
|
+
id: { type: "string" },
|
|
770
|
+
item: { type: "string" },
|
|
771
|
+
quantity: { $ref: "#/definitions/quantity" },
|
|
772
|
+
name: { type: "string" },
|
|
773
|
+
aisle: { type: "string" },
|
|
774
|
+
prep: { type: "string" },
|
|
775
|
+
prepAction: { type: "string" },
|
|
776
|
+
prepTime: { type: "number" },
|
|
777
|
+
destination: { type: "string" },
|
|
778
|
+
scaling: { $ref: "#/definitions/scaling" },
|
|
779
|
+
critical: { type: "boolean" },
|
|
780
|
+
optional: { type: "boolean" },
|
|
781
|
+
notes: { type: "string" }
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
ingredientSubsection: {
|
|
785
|
+
type: "object",
|
|
786
|
+
required: ["subsection", "items"],
|
|
787
|
+
properties: {
|
|
788
|
+
subsection: { type: "string" },
|
|
789
|
+
items: {
|
|
790
|
+
type: "array",
|
|
791
|
+
items: { $ref: "#/definitions/ingredient" }
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
equipment: {
|
|
796
|
+
type: "object",
|
|
797
|
+
required: ["name"],
|
|
798
|
+
properties: {
|
|
799
|
+
id: { type: "string" },
|
|
800
|
+
name: { type: "string" },
|
|
801
|
+
required: { type: "boolean" },
|
|
802
|
+
label: { type: "string" },
|
|
803
|
+
capacity: { $ref: "#/definitions/quantity" },
|
|
804
|
+
scalingLimit: { type: "number" },
|
|
805
|
+
alternatives: {
|
|
806
|
+
type: "array",
|
|
807
|
+
items: { type: "string" }
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
instruction: {
|
|
812
|
+
type: "object",
|
|
813
|
+
required: ["text"],
|
|
814
|
+
properties: {
|
|
815
|
+
id: { type: "string" },
|
|
816
|
+
text: { type: "string" },
|
|
817
|
+
image: {
|
|
818
|
+
type: "string",
|
|
819
|
+
format: "uri",
|
|
820
|
+
description: "Optional image that illustrates this instruction"
|
|
821
|
+
},
|
|
822
|
+
destination: { type: "string" },
|
|
823
|
+
dependsOn: {
|
|
824
|
+
type: "array",
|
|
825
|
+
items: { type: "string" }
|
|
826
|
+
},
|
|
827
|
+
inputs: {
|
|
828
|
+
type: "array",
|
|
829
|
+
items: { type: "string" }
|
|
830
|
+
},
|
|
831
|
+
timing: {
|
|
832
|
+
type: "object",
|
|
833
|
+
required: ["duration", "type"],
|
|
834
|
+
properties: {
|
|
835
|
+
duration: {
|
|
836
|
+
anyOf: [
|
|
837
|
+
{ type: "number" },
|
|
838
|
+
{ type: "string", pattern: "^P" }
|
|
839
|
+
],
|
|
840
|
+
description: "Minutes as a number or ISO8601 duration string"
|
|
841
|
+
},
|
|
842
|
+
type: { type: "string", enum: ["active", "passive"] },
|
|
843
|
+
scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
},
|
|
848
|
+
instructionSubsection: {
|
|
849
|
+
type: "object",
|
|
850
|
+
required: ["subsection", "items"],
|
|
851
|
+
properties: {
|
|
852
|
+
subsection: { type: "string" },
|
|
853
|
+
items: {
|
|
854
|
+
type: "array",
|
|
855
|
+
items: {
|
|
856
|
+
anyOf: [
|
|
857
|
+
{ type: "string" },
|
|
858
|
+
{ $ref: "#/definitions/instruction" }
|
|
859
|
+
]
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
storage: {
|
|
865
|
+
type: "object",
|
|
866
|
+
properties: {
|
|
867
|
+
roomTemp: { $ref: "#/definitions/storageMethod" },
|
|
868
|
+
refrigerated: { $ref: "#/definitions/storageMethod" },
|
|
869
|
+
frozen: {
|
|
870
|
+
allOf: [
|
|
871
|
+
{ $ref: "#/definitions/storageMethod" },
|
|
872
|
+
{
|
|
873
|
+
type: "object",
|
|
874
|
+
properties: { thawing: { type: "string" } }
|
|
875
|
+
}
|
|
876
|
+
]
|
|
877
|
+
},
|
|
878
|
+
reheating: { type: "string" },
|
|
879
|
+
makeAhead: {
|
|
880
|
+
type: "array",
|
|
881
|
+
items: {
|
|
882
|
+
allOf: [
|
|
883
|
+
{ $ref: "#/definitions/storageMethod" },
|
|
884
|
+
{
|
|
885
|
+
type: "object",
|
|
886
|
+
required: ["component", "storage"],
|
|
887
|
+
properties: {
|
|
888
|
+
component: { type: "string" },
|
|
889
|
+
storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
]
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
},
|
|
897
|
+
storageMethod: {
|
|
898
|
+
type: "object",
|
|
899
|
+
required: ["duration"],
|
|
900
|
+
properties: {
|
|
901
|
+
duration: { type: "string", pattern: "^P" },
|
|
902
|
+
method: { type: "string" },
|
|
903
|
+
notes: { type: "string" }
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
substitution: {
|
|
907
|
+
type: "object",
|
|
908
|
+
required: ["ingredient"],
|
|
909
|
+
properties: {
|
|
910
|
+
ingredient: { type: "string" },
|
|
911
|
+
critical: { type: "boolean" },
|
|
912
|
+
notes: { type: "string" },
|
|
913
|
+
alternatives: {
|
|
914
|
+
type: "array",
|
|
915
|
+
items: {
|
|
916
|
+
type: "object",
|
|
917
|
+
required: ["name", "ratio"],
|
|
918
|
+
properties: {
|
|
919
|
+
name: { type: "string" },
|
|
920
|
+
ratio: { type: "string" },
|
|
921
|
+
notes: { type: "string" },
|
|
922
|
+
impact: { type: "string" },
|
|
923
|
+
dietary: {
|
|
924
|
+
type: "array",
|
|
925
|
+
items: { type: "string" }
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
757
930
|
}
|
|
758
|
-
return formatDecimal(baseValue + hyphenValue);
|
|
759
|
-
}
|
|
760
|
-
);
|
|
761
|
-
}
|
|
762
|
-
function formatDecimal(value) {
|
|
763
|
-
if (Number.isInteger(value)) {
|
|
764
|
-
return value.toString();
|
|
765
|
-
}
|
|
766
|
-
return parseFloat(value.toFixed(3)).toString().replace(/\.0+$/, "");
|
|
767
|
-
}
|
|
768
|
-
function extractFlavorNotes(value) {
|
|
769
|
-
const notes = [];
|
|
770
|
-
const cleaned = value.replace(FLAVOR_NOTE_REGEX, (_, phrase) => {
|
|
771
|
-
notes.push(phrase.toLowerCase());
|
|
772
|
-
return "";
|
|
773
|
-
});
|
|
774
|
-
return {
|
|
775
|
-
cleaned: cleaned.replace(/\s+/g, " ").trim(),
|
|
776
|
-
notes
|
|
777
|
-
};
|
|
778
|
-
}
|
|
779
|
-
function extractPurposeNotes(value) {
|
|
780
|
-
const notes = [];
|
|
781
|
-
let working = value.trim();
|
|
782
|
-
let match = working.match(/\bfor\s+(frying|greasing|drizzling|garnish|serving|brushing)\b\.?$/i);
|
|
783
|
-
if (match) {
|
|
784
|
-
notes.push(`for ${match[1].toLowerCase()}`);
|
|
785
|
-
working = working.slice(0, match.index).trim();
|
|
786
|
-
}
|
|
787
|
-
return { cleaned: working, notes };
|
|
788
|
-
}
|
|
789
|
-
function extractJuicePhrase(value) {
|
|
790
|
-
const lower = value.toLowerCase();
|
|
791
|
-
for (const prefix of JUICE_PREFIXES) {
|
|
792
|
-
if (lower.startsWith(prefix)) {
|
|
793
|
-
const remainder = value.slice(prefix.length).trim();
|
|
794
|
-
if (!remainder) break;
|
|
795
|
-
const cleanedSource = remainder.replace(/^of\s+/i, "").trim();
|
|
796
|
-
if (!cleanedSource) break;
|
|
797
|
-
const sourceForName = cleanedSource.replace(
|
|
798
|
-
/^(?:\d+(?:\.\d+)?|\d+\s+\d+\/\d+|\d+\/\d+|one|two|three|four|five|six|seven|eight|nine|ten|a|an)\s+/i,
|
|
799
|
-
""
|
|
800
|
-
).replace(/^(?:large|small|medium)\s+/i, "").trim();
|
|
801
|
-
const baseName = sourceForName || cleanedSource;
|
|
802
|
-
const singular = singularize(baseName);
|
|
803
|
-
const suffix = prefix.startsWith("zest") ? "zest" : "juice";
|
|
804
|
-
return {
|
|
805
|
-
cleaned: `${singular} ${suffix}`.trim(),
|
|
806
|
-
note: `from ${cleanedSource}`
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
return void 0;
|
|
811
|
-
}
|
|
812
|
-
function extractVagueQuantity(value) {
|
|
813
|
-
for (const pattern of VAGUE_QUANTITY_PATTERNS) {
|
|
814
|
-
const match = value.match(pattern.regex);
|
|
815
|
-
if (match) {
|
|
816
|
-
let remainder = value.slice(match[0].length).trim();
|
|
817
|
-
remainder = remainder.replace(/^of\s+/i, "").trim();
|
|
818
|
-
return {
|
|
819
|
-
remainder,
|
|
820
|
-
note: pattern.note
|
|
821
|
-
};
|
|
822
931
|
}
|
|
823
932
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
// src/profiles/base.schema.json
|
|
936
|
+
var base_schema_default = {
|
|
937
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
938
|
+
$id: "http://soustack.org/schema/v0.2.1/profiles/base",
|
|
939
|
+
title: "Soustack Base Profile Schema",
|
|
940
|
+
description: "Wrapper schema that exposes the unmodified Soustack base schema.",
|
|
941
|
+
allOf: [
|
|
942
|
+
{ $ref: "http://soustack.org/schema/v0.2.1" }
|
|
943
|
+
]
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
// src/profiles/cookable.schema.json
|
|
947
|
+
var cookable_schema_default = {
|
|
948
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
949
|
+
$id: "http://soustack.org/schema/v0.2.1/profiles/cookable",
|
|
950
|
+
title: "Soustack Cookable Profile Schema",
|
|
951
|
+
description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
|
|
952
|
+
allOf: [
|
|
953
|
+
{ $ref: "http://soustack.org/schema/v0.2.1" },
|
|
954
|
+
{
|
|
955
|
+
required: ["yield", "time", "ingredients", "instructions"],
|
|
956
|
+
properties: {
|
|
957
|
+
yield: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/yield" },
|
|
958
|
+
time: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/time" },
|
|
959
|
+
ingredients: { type: "array", minItems: 1 },
|
|
960
|
+
instructions: { type: "array", minItems: 1 }
|
|
961
|
+
}
|
|
836
962
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
963
|
+
]
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
// src/profiles/quantified.schema.json
|
|
967
|
+
var quantified_schema_default = {
|
|
968
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
969
|
+
$id: "http://soustack.org/schema/v0.2.1/profiles/quantified",
|
|
970
|
+
title: "Soustack Quantified Profile Schema",
|
|
971
|
+
description: "Extends the base schema to require quantified ingredient entries.",
|
|
972
|
+
allOf: [
|
|
973
|
+
{ $ref: "http://soustack.org/schema/v0.2.1" },
|
|
974
|
+
{
|
|
975
|
+
properties: {
|
|
976
|
+
ingredients: {
|
|
977
|
+
type: "array",
|
|
978
|
+
items: {
|
|
979
|
+
anyOf: [
|
|
980
|
+
{ $ref: "#/definitions/quantifiedIngredient" },
|
|
981
|
+
{ $ref: "#/definitions/quantifiedIngredientSubsection" }
|
|
982
|
+
]
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
841
986
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}
|
|
863
|
-
function extractQuantity(value) {
|
|
864
|
-
let working = value.trim();
|
|
865
|
-
const notes = [];
|
|
866
|
-
let amount = null;
|
|
867
|
-
let originalAmount = null;
|
|
868
|
-
let unit = null;
|
|
869
|
-
let descriptor;
|
|
870
|
-
while (QUALIFIER_REGEX.test(working)) {
|
|
871
|
-
working = working.replace(QUALIFIER_REGEX, "").trim();
|
|
872
|
-
}
|
|
873
|
-
const rangeMatch = working.match(RANGE_REGEX);
|
|
874
|
-
if (rangeMatch) {
|
|
875
|
-
amount = parseNumber(rangeMatch[1]);
|
|
876
|
-
originalAmount = amount;
|
|
877
|
-
const rangeText = rangeMatch[0].trim();
|
|
878
|
-
const afterRange = working.slice(rangeMatch[0].length).trim();
|
|
879
|
-
const descriptorMatch = afterRange.match(/^([a-zA-Z]+)/);
|
|
880
|
-
if (descriptorMatch && COUNT_DESCRIPTORS.has(descriptorMatch[1].toLowerCase())) {
|
|
881
|
-
notes.push(`${rangeText} ${descriptorMatch[1]}`);
|
|
882
|
-
} else {
|
|
883
|
-
notes.push(rangeText);
|
|
987
|
+
],
|
|
988
|
+
definitions: {
|
|
989
|
+
quantifiedIngredient: {
|
|
990
|
+
allOf: [
|
|
991
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredient" },
|
|
992
|
+
{ required: ["item", "quantity"] }
|
|
993
|
+
]
|
|
994
|
+
},
|
|
995
|
+
quantifiedIngredientSubsection: {
|
|
996
|
+
allOf: [
|
|
997
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredientSubsection" },
|
|
998
|
+
{
|
|
999
|
+
properties: {
|
|
1000
|
+
items: {
|
|
1001
|
+
type: "array",
|
|
1002
|
+
items: { $ref: "#/definitions/quantifiedIngredient" }
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
]
|
|
884
1007
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
// src/profiles/illustrated.schema.json
|
|
1012
|
+
var illustrated_schema_default = {
|
|
1013
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1014
|
+
$id: "http://soustack.org/schema/v0.2.1/profiles/illustrated",
|
|
1015
|
+
title: "Soustack Illustrated Profile Schema",
|
|
1016
|
+
description: "Extends the base schema to guarantee at least one illustrative image.",
|
|
1017
|
+
allOf: [
|
|
1018
|
+
{ $ref: "http://soustack.org/schema/v0.2.1" },
|
|
1019
|
+
{
|
|
1020
|
+
anyOf: [
|
|
1021
|
+
{ required: ["image"] },
|
|
1022
|
+
{
|
|
1023
|
+
properties: {
|
|
1024
|
+
instructions: {
|
|
1025
|
+
type: "array",
|
|
1026
|
+
contains: {
|
|
1027
|
+
anyOf: [
|
|
1028
|
+
{ $ref: "#/definitions/imageInstruction" },
|
|
1029
|
+
{ $ref: "#/definitions/instructionSubsectionWithImage" }
|
|
1030
|
+
]
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
]
|
|
1036
|
+
}
|
|
1037
|
+
],
|
|
1038
|
+
definitions: {
|
|
1039
|
+
imageInstruction: {
|
|
1040
|
+
allOf: [
|
|
1041
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
|
|
1042
|
+
{ required: ["image"] }
|
|
1043
|
+
]
|
|
1044
|
+
},
|
|
1045
|
+
instructionSubsectionWithImage: {
|
|
1046
|
+
allOf: [
|
|
1047
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
|
|
1048
|
+
{
|
|
1049
|
+
properties: {
|
|
1050
|
+
items: {
|
|
1051
|
+
type: "array",
|
|
1052
|
+
contains: { $ref: "#/definitions/imageInstruction" }
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
]
|
|
892
1057
|
}
|
|
893
1058
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
// src/profiles/schedulable.schema.json
|
|
1062
|
+
var schedulable_schema_default = {
|
|
1063
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1064
|
+
$id: "http://soustack.org/schema/v0.2.1/profiles/schedulable",
|
|
1065
|
+
title: "Soustack Schedulable Profile Schema",
|
|
1066
|
+
description: "Extends the base schema to ensure every instruction is fully scheduled.",
|
|
1067
|
+
allOf: [
|
|
1068
|
+
{ $ref: "http://soustack.org/schema/v0.2.1" },
|
|
1069
|
+
{
|
|
1070
|
+
properties: {
|
|
1071
|
+
instructions: {
|
|
1072
|
+
type: "array",
|
|
1073
|
+
items: {
|
|
1074
|
+
anyOf: [
|
|
1075
|
+
{ $ref: "#/definitions/schedulableInstruction" },
|
|
1076
|
+
{ $ref: "#/definitions/schedulableInstructionSubsection" }
|
|
1077
|
+
]
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
904
1080
|
}
|
|
905
1081
|
}
|
|
1082
|
+
],
|
|
1083
|
+
definitions: {
|
|
1084
|
+
schedulableInstruction: {
|
|
1085
|
+
allOf: [
|
|
1086
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
|
|
1087
|
+
{ required: ["id", "timing"] }
|
|
1088
|
+
]
|
|
1089
|
+
},
|
|
1090
|
+
schedulableInstructionSubsection: {
|
|
1091
|
+
allOf: [
|
|
1092
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
|
|
1093
|
+
{
|
|
1094
|
+
properties: {
|
|
1095
|
+
items: {
|
|
1096
|
+
type: "array",
|
|
1097
|
+
items: { $ref: "#/definitions/schedulableInstruction" }
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
]
|
|
1102
|
+
}
|
|
906
1103
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
// src/validator.ts
|
|
1107
|
+
var profileSchemas = {
|
|
1108
|
+
base: base_schema_default,
|
|
1109
|
+
cookable: cookable_schema_default,
|
|
1110
|
+
scalable: base_schema_default,
|
|
1111
|
+
quantified: quantified_schema_default,
|
|
1112
|
+
illustrated: illustrated_schema_default,
|
|
1113
|
+
schedulable: schedulable_schema_default
|
|
1114
|
+
};
|
|
1115
|
+
var validationContexts = /* @__PURE__ */ new Map();
|
|
1116
|
+
function createContext(collectAllErrors) {
|
|
1117
|
+
const ajv = new Ajv({ strict: false, allErrors: collectAllErrors });
|
|
1118
|
+
addFormats(ajv);
|
|
1119
|
+
const loadedIds = /* @__PURE__ */ new Set();
|
|
1120
|
+
const addSchemaIfNew = (schema) => {
|
|
1121
|
+
if (!schema) return;
|
|
1122
|
+
const schemaId = schema == null ? void 0 : schema.$id;
|
|
1123
|
+
if (schemaId && loadedIds.has(schemaId)) return;
|
|
1124
|
+
ajv.addSchema(schema);
|
|
1125
|
+
if (schemaId) loadedIds.add(schemaId);
|
|
914
1126
|
};
|
|
1127
|
+
addSchemaIfNew(schema_default);
|
|
1128
|
+
addSchemaIfNew(soustack_schema_default);
|
|
1129
|
+
Object.values(profileSchemas).forEach(addSchemaIfNew);
|
|
1130
|
+
return { ajv, validators: {} };
|
|
915
1131
|
}
|
|
916
|
-
function
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
if (/^\d+\s+\d+\/\d+$/.test(trimmed)) {
|
|
920
|
-
const [whole, fraction] = trimmed.split(/\s+/);
|
|
921
|
-
return parseInt(whole, 10) + parseFraction(fraction);
|
|
922
|
-
}
|
|
923
|
-
if (/^\d+\/\d+$/.test(trimmed)) {
|
|
924
|
-
return parseFraction(trimmed);
|
|
1132
|
+
function getContext(collectAllErrors) {
|
|
1133
|
+
if (!validationContexts.has(collectAllErrors)) {
|
|
1134
|
+
validationContexts.set(collectAllErrors, createContext(collectAllErrors));
|
|
925
1135
|
}
|
|
926
|
-
|
|
927
|
-
return Number.isNaN(parsed) ? null : parsed;
|
|
1136
|
+
return validationContexts.get(collectAllErrors);
|
|
928
1137
|
}
|
|
929
|
-
function
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
return numerator / denominator;
|
|
933
|
-
}
|
|
934
|
-
function normalizeUnit(raw) {
|
|
935
|
-
const lower = raw.toLowerCase();
|
|
936
|
-
if (UNIT_SYNONYMS[lower]) {
|
|
937
|
-
return UNIT_SYNONYMS[lower];
|
|
1138
|
+
function cloneRecipe(recipe) {
|
|
1139
|
+
if (typeof structuredClone === "function") {
|
|
1140
|
+
return structuredClone(recipe);
|
|
938
1141
|
}
|
|
939
|
-
|
|
940
|
-
if (raw === "t") return "tsp";
|
|
941
|
-
if (raw === "C") return "cup";
|
|
942
|
-
return null;
|
|
1142
|
+
return JSON.parse(JSON.stringify(recipe));
|
|
943
1143
|
}
|
|
944
|
-
function
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
return { quantity, usedParenthetical: false };
|
|
951
|
-
}
|
|
952
|
-
const measurementUnit = measurement.unit?.toLowerCase() ?? null;
|
|
953
|
-
const shouldPrefer = !quantity.unit || measurementUnit !== null && WEIGHT_PRIORITY_UNITS.has(measurementUnit);
|
|
954
|
-
if (shouldPrefer) {
|
|
955
|
-
return {
|
|
956
|
-
quantity: {
|
|
957
|
-
amount: measurement.amount,
|
|
958
|
-
unit: measurement.unit ?? null
|
|
959
|
-
},
|
|
960
|
-
usedParenthetical: true
|
|
961
|
-
};
|
|
1144
|
+
function detectProfileFromSchema(schemaRef) {
|
|
1145
|
+
if (!schemaRef) return void 0;
|
|
1146
|
+
const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
|
|
1147
|
+
if (match) {
|
|
1148
|
+
const profile = match[1].toLowerCase();
|
|
1149
|
+
if (profile in profileSchemas) return profile;
|
|
962
1150
|
}
|
|
963
|
-
return
|
|
964
|
-
}
|
|
965
|
-
function
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1151
|
+
return void 0;
|
|
1152
|
+
}
|
|
1153
|
+
function getValidator(profile, context) {
|
|
1154
|
+
if (!profileSchemas[profile]) {
|
|
1155
|
+
throw new Error(`Unknown Soustack profile: ${profile}`);
|
|
1156
|
+
}
|
|
1157
|
+
if (!context.validators[profile]) {
|
|
1158
|
+
context.validators[profile] = context.ajv.compile(profileSchemas[profile]);
|
|
1159
|
+
}
|
|
1160
|
+
return context.validators[profile];
|
|
1161
|
+
}
|
|
1162
|
+
function normalizeRecipe(recipe) {
|
|
1163
|
+
const normalized = cloneRecipe(recipe);
|
|
1164
|
+
const warnings = [];
|
|
1165
|
+
normalizeTime(normalized);
|
|
1166
|
+
if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
|
|
1167
|
+
normalized.recipeVersion = normalized.version;
|
|
1168
|
+
warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
|
|
1169
|
+
}
|
|
1170
|
+
return { normalized, warnings };
|
|
1171
|
+
}
|
|
1172
|
+
function normalizeTime(recipe) {
|
|
1173
|
+
const time = recipe == null ? void 0 : recipe.time;
|
|
1174
|
+
if (!time || typeof time !== "object" || Array.isArray(time)) return;
|
|
1175
|
+
const structuredKeys = [
|
|
1176
|
+
"prep",
|
|
1177
|
+
"active",
|
|
1178
|
+
"passive",
|
|
1179
|
+
"total"
|
|
1180
|
+
];
|
|
1181
|
+
structuredKeys.forEach((key) => {
|
|
1182
|
+
const value = time[key];
|
|
1183
|
+
if (typeof value === "number") return;
|
|
1184
|
+
const parsed = parseDuration(value);
|
|
1185
|
+
if (parsed !== null) {
|
|
1186
|
+
time[key] = parsed;
|
|
975
1187
|
}
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
var _a, _b;
|
|
1191
|
+
var allowedTopLevelProps = /* @__PURE__ */ new Set([
|
|
1192
|
+
...Object.keys((_b = (_a = soustack_schema_default) == null ? void 0 : _a.properties) != null ? _b : {}),
|
|
1193
|
+
"metadata",
|
|
1194
|
+
"$schema"
|
|
1195
|
+
]);
|
|
1196
|
+
function detectUnknownTopLevelKeys(recipe) {
|
|
1197
|
+
if (!recipe || typeof recipe !== "object") return [];
|
|
1198
|
+
const disallowedKeys = Object.keys(recipe).filter(
|
|
1199
|
+
(key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
|
|
1200
|
+
);
|
|
1201
|
+
return disallowedKeys.map((key) => ({
|
|
1202
|
+
path: `/${key}`,
|
|
1203
|
+
keyword: "additionalProperties",
|
|
1204
|
+
message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
|
|
1205
|
+
}));
|
|
1206
|
+
}
|
|
1207
|
+
function formatAjvError(error) {
|
|
1208
|
+
var _a2;
|
|
1209
|
+
let path = error.instancePath || "/";
|
|
1210
|
+
if (error.keyword === "additionalProperties" && ((_a2 = error.params) == null ? void 0 : _a2.additionalProperty)) {
|
|
1211
|
+
const extra = error.params.additionalProperty;
|
|
1212
|
+
path = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
|
|
976
1213
|
}
|
|
977
|
-
working = working.replace(/^[,.\s-]+/, "").trim();
|
|
978
|
-
working = working.replace(/^of\s+/i, "").trim();
|
|
979
|
-
if (!working) {
|
|
980
|
-
return { name: void 0, prep, notes };
|
|
981
|
-
}
|
|
982
|
-
let name = cleanupIngredientName(working);
|
|
983
1214
|
return {
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1215
|
+
path,
|
|
1216
|
+
keyword: error.keyword,
|
|
1217
|
+
message: error.message || "Validation error"
|
|
987
1218
|
};
|
|
988
1219
|
}
|
|
989
|
-
function
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1220
|
+
function runAjvValidation(data, profile, context, schemaRef) {
|
|
1221
|
+
const validator = schemaRef ? context.ajv.getSchema(schemaRef) : void 0;
|
|
1222
|
+
const validateFn = validator != null ? validator : getValidator(profile, context);
|
|
1223
|
+
const isValid = validateFn(data);
|
|
1224
|
+
return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
|
|
1225
|
+
}
|
|
1226
|
+
function isInstruction(item) {
|
|
1227
|
+
return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
|
|
1228
|
+
}
|
|
1229
|
+
function isInstructionSubsection(item) {
|
|
1230
|
+
return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
|
|
1231
|
+
}
|
|
1232
|
+
function checkInstructionGraph(recipe) {
|
|
1233
|
+
const instructions = recipe == null ? void 0 : recipe.instructions;
|
|
1234
|
+
if (!Array.isArray(instructions)) return [];
|
|
1235
|
+
const instructionIds = /* @__PURE__ */ new Set();
|
|
1236
|
+
const dependencyRefs = [];
|
|
1237
|
+
const collect = (items, basePath) => {
|
|
1238
|
+
items.forEach((item, index) => {
|
|
1239
|
+
const currentPath = `${basePath}/${index}`;
|
|
1240
|
+
if (isInstructionSubsection(item) && Array.isArray(item.items)) {
|
|
1241
|
+
collect(item.items, `${currentPath}/items`);
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
if (isInstruction(item)) {
|
|
1245
|
+
const id = typeof item.id === "string" ? item.id : void 0;
|
|
1246
|
+
if (id) instructionIds.add(id);
|
|
1247
|
+
if (Array.isArray(item.dependsOn)) {
|
|
1248
|
+
item.dependsOn.forEach((depId, depIndex) => {
|
|
1249
|
+
if (typeof depId === "string") {
|
|
1250
|
+
dependencyRefs.push({
|
|
1251
|
+
fromId: id,
|
|
1252
|
+
toId: depId,
|
|
1253
|
+
path: `${currentPath}/dependsOn/${depIndex}`
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
};
|
|
1261
|
+
collect(instructions, "/instructions");
|
|
1262
|
+
const errors = [];
|
|
1263
|
+
dependencyRefs.forEach((ref) => {
|
|
1264
|
+
if (!instructionIds.has(ref.toId)) {
|
|
1265
|
+
errors.push({
|
|
1266
|
+
path: ref.path,
|
|
1267
|
+
message: `Instruction dependency references missing id '${ref.toId}'.`
|
|
1268
|
+
});
|
|
1001
1269
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1270
|
+
});
|
|
1271
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
1272
|
+
dependencyRefs.forEach((ref) => {
|
|
1273
|
+
var _a2;
|
|
1274
|
+
if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
|
|
1275
|
+
const list = (_a2 = adjacency.get(ref.fromId)) != null ? _a2 : [];
|
|
1276
|
+
list.push({ toId: ref.toId, path: ref.path });
|
|
1277
|
+
adjacency.set(ref.fromId, list);
|
|
1006
1278
|
}
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
const smallUnit = unit ? ["tsp", "tbsp", "dash", "pinch"].includes(unit) : false;
|
|
1029
|
-
if (normalizedNotes.some((note) => note.includes("to taste")) || isSpice && (smallUnit || amount !== null && amount <= 1)) {
|
|
1030
|
-
return { type: "proportional", factor: 0.7 };
|
|
1031
|
-
}
|
|
1032
|
-
return { type: "linear" };
|
|
1033
|
-
}
|
|
1034
|
-
function formatNotes(notes) {
|
|
1035
|
-
const cleaned = Array.from(
|
|
1036
|
-
new Set(
|
|
1037
|
-
notes.map((note) => note.trim()).filter(Boolean)
|
|
1038
|
-
)
|
|
1039
|
-
);
|
|
1040
|
-
return cleaned.length ? cleaned.join("; ") : void 0;
|
|
1041
|
-
}
|
|
1042
|
-
function formatCountNote(amount, descriptor) {
|
|
1043
|
-
const lower = descriptor.toLowerCase();
|
|
1044
|
-
const singular = lower.endsWith("s") ? lower.slice(0, -1) : lower;
|
|
1045
|
-
const word = amount === 1 ? singular : singular.endsWith("ch") || singular.endsWith("sh") || singular.endsWith("s") || singular.endsWith("x") || singular.endsWith("z") ? `${singular}es` : singular.endsWith("y") && !/[aeiou]y$/.test(singular) ? `${singular.slice(0, -1)}ies` : `${singular}s`;
|
|
1046
|
-
return `${formatDecimal(amount)} ${word}`;
|
|
1047
|
-
}
|
|
1048
|
-
function singularize(value) {
|
|
1049
|
-
const trimmed = value.trim();
|
|
1050
|
-
if (trimmed.endsWith("ies")) {
|
|
1051
|
-
return `${trimmed.slice(0, -3)}y`;
|
|
1052
|
-
}
|
|
1053
|
-
if (/(ches|shes|sses|xes|zes)$/i.test(trimmed)) {
|
|
1054
|
-
return trimmed.slice(0, -2);
|
|
1055
|
-
}
|
|
1056
|
-
if (trimmed.endsWith("s")) {
|
|
1057
|
-
return trimmed.slice(0, -1);
|
|
1058
|
-
}
|
|
1059
|
-
return trimmed;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
// src/converters/ingredient.ts
|
|
1063
|
-
function parseIngredientLine2(line) {
|
|
1064
|
-
const parsed = parseIngredient(line);
|
|
1065
|
-
const ingredient = {
|
|
1066
|
-
item: parsed.item,
|
|
1067
|
-
scaling: parsed.scaling ?? { type: "linear" }
|
|
1279
|
+
});
|
|
1280
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
1281
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1282
|
+
const detectCycles = (nodeId) => {
|
|
1283
|
+
var _a2;
|
|
1284
|
+
if (visiting.has(nodeId)) return;
|
|
1285
|
+
if (visited.has(nodeId)) return;
|
|
1286
|
+
visiting.add(nodeId);
|
|
1287
|
+
const neighbors = (_a2 = adjacency.get(nodeId)) != null ? _a2 : [];
|
|
1288
|
+
neighbors.forEach((edge) => {
|
|
1289
|
+
if (visiting.has(edge.toId)) {
|
|
1290
|
+
errors.push({
|
|
1291
|
+
path: edge.path,
|
|
1292
|
+
message: `Circular dependency detected involving instruction id '${edge.toId}'.`
|
|
1293
|
+
});
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
detectCycles(edge.toId);
|
|
1297
|
+
});
|
|
1298
|
+
visiting.delete(nodeId);
|
|
1299
|
+
visited.add(nodeId);
|
|
1068
1300
|
};
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
const
|
|
1082
|
-
|
|
1083
|
-
ingredient.quantity = quantity;
|
|
1084
|
-
}
|
|
1085
|
-
return ingredient;
|
|
1086
|
-
}
|
|
1087
|
-
function buildQuantity(parsedQuantity) {
|
|
1088
|
-
if (!parsedQuantity) {
|
|
1089
|
-
return void 0;
|
|
1090
|
-
}
|
|
1091
|
-
if (parsedQuantity.amount === null || Number.isNaN(parsedQuantity.amount)) {
|
|
1092
|
-
return void 0;
|
|
1093
|
-
}
|
|
1301
|
+
instructionIds.forEach((id) => detectCycles(id));
|
|
1302
|
+
return errors;
|
|
1303
|
+
}
|
|
1304
|
+
function validateRecipe(input, options = {}) {
|
|
1305
|
+
var _a2, _b2, _c, _d;
|
|
1306
|
+
const collectAllErrors = (_a2 = options.collectAllErrors) != null ? _a2 : true;
|
|
1307
|
+
const context = getContext(collectAllErrors);
|
|
1308
|
+
const schemaRef = (_b2 = options.schema) != null ? _b2 : typeof (input == null ? void 0 : input.$schema) === "string" ? input.$schema : void 0;
|
|
1309
|
+
const profile = (_d = (_c = options.profile) != null ? _c : detectProfileFromSchema(schemaRef)) != null ? _d : "base";
|
|
1310
|
+
const { normalized, warnings } = normalizeRecipe(input);
|
|
1311
|
+
const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
|
|
1312
|
+
const validationErrors = runAjvValidation(normalized, profile, context, schemaRef);
|
|
1313
|
+
const graphErrors = profile === "schedulable" && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
|
|
1314
|
+
const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
|
|
1094
1315
|
return {
|
|
1095
|
-
|
|
1096
|
-
|
|
1316
|
+
valid: errors.length === 0,
|
|
1317
|
+
errors,
|
|
1318
|
+
warnings,
|
|
1319
|
+
normalized: errors.length === 0 ? normalized : void 0
|
|
1097
1320
|
};
|
|
1098
1321
|
}
|
|
1322
|
+
function detectProfiles(recipe) {
|
|
1323
|
+
var _a2;
|
|
1324
|
+
const result = validateRecipe(recipe, { profile: "base", collectAllErrors: false });
|
|
1325
|
+
if (!result.valid) return [];
|
|
1326
|
+
const normalizedRecipe = (_a2 = result.normalized) != null ? _a2 : recipe;
|
|
1327
|
+
const profiles = ["base"];
|
|
1328
|
+
const context = getContext(false);
|
|
1329
|
+
Object.keys(profileSchemas).forEach((profile) => {
|
|
1330
|
+
if (profile === "base") return;
|
|
1331
|
+
if (!profileSchemas[profile]) return;
|
|
1332
|
+
const errors = runAjvValidation(normalizedRecipe, profile, context);
|
|
1333
|
+
if (errors.length === 0) {
|
|
1334
|
+
profiles.push(profile);
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
return profiles;
|
|
1338
|
+
}
|
|
1099
1339
|
|
|
1100
1340
|
// src/converters/yield.ts
|
|
1101
1341
|
function parseYield(value) {
|
|
@@ -1137,98 +1377,53 @@ function parseYield(value) {
|
|
|
1137
1377
|
return void 0;
|
|
1138
1378
|
}
|
|
1139
1379
|
function formatYield(yieldValue) {
|
|
1380
|
+
var _a2;
|
|
1140
1381
|
if (!yieldValue) return void 0;
|
|
1141
1382
|
if (!yieldValue.amount && !yieldValue.unit) {
|
|
1142
1383
|
return void 0;
|
|
1143
1384
|
}
|
|
1144
|
-
const amount = yieldValue.amount
|
|
1145
|
-
const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
|
|
1146
|
-
return `${amount}${unit}`.trim() || yieldValue.description;
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
// src/
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
1154
|
-
}
|
|
1155
|
-
function parseDuration(iso) {
|
|
1156
|
-
if (!iso || typeof iso !== "string") return null;
|
|
1157
|
-
const trimmed = iso.trim();
|
|
1158
|
-
if (!trimmed) return null;
|
|
1159
|
-
const match = trimmed.match(ISO_DURATION_REGEX);
|
|
1160
|
-
if (!match) return null;
|
|
1161
|
-
const [, daysRaw, hoursRaw, minutesRaw, secondsRaw] = match;
|
|
1162
|
-
if (!daysRaw && !hoursRaw && !minutesRaw && !secondsRaw) {
|
|
1163
|
-
return null;
|
|
1164
|
-
}
|
|
1165
|
-
let total = 0;
|
|
1166
|
-
if (daysRaw) total += parseFloat(daysRaw) * 24 * 60;
|
|
1167
|
-
if (hoursRaw) total += parseFloat(hoursRaw) * 60;
|
|
1168
|
-
if (minutesRaw) total += parseFloat(minutesRaw);
|
|
1169
|
-
if (secondsRaw) total += Math.ceil(parseFloat(secondsRaw) / 60);
|
|
1170
|
-
return Math.round(total);
|
|
1171
|
-
}
|
|
1172
|
-
function formatDuration(minutes) {
|
|
1173
|
-
if (!isFiniteNumber(minutes) || minutes <= 0) {
|
|
1174
|
-
return "PT0M";
|
|
1175
|
-
}
|
|
1176
|
-
const rounded = Math.round(minutes);
|
|
1177
|
-
const days = Math.floor(rounded / (24 * 60));
|
|
1178
|
-
const afterDays = rounded % (24 * 60);
|
|
1179
|
-
const hours = Math.floor(afterDays / 60);
|
|
1180
|
-
const mins = afterDays % 60;
|
|
1181
|
-
let result = "P";
|
|
1182
|
-
if (days > 0) {
|
|
1183
|
-
result += `${days}D`;
|
|
1184
|
-
}
|
|
1185
|
-
if (hours > 0 || mins > 0) {
|
|
1186
|
-
result += "T";
|
|
1187
|
-
if (hours > 0) {
|
|
1188
|
-
result += `${hours}H`;
|
|
1189
|
-
}
|
|
1190
|
-
if (mins > 0) {
|
|
1191
|
-
result += `${mins}M`;
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
if (result === "P") {
|
|
1195
|
-
return "PT0M";
|
|
1196
|
-
}
|
|
1197
|
-
return result;
|
|
1198
|
-
}
|
|
1199
|
-
function parseHumanDuration(text) {
|
|
1200
|
-
if (!text || typeof text !== "string") return null;
|
|
1201
|
-
const normalized = text.toLowerCase().trim();
|
|
1202
|
-
if (!normalized) return null;
|
|
1203
|
-
if (normalized === "overnight") {
|
|
1204
|
-
return HUMAN_OVERNIGHT;
|
|
1205
|
-
}
|
|
1206
|
-
let total = 0;
|
|
1207
|
-
const hourRegex = /(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|hr|h)\b/g;
|
|
1208
|
-
let hourMatch;
|
|
1209
|
-
while ((hourMatch = hourRegex.exec(normalized)) !== null) {
|
|
1210
|
-
total += parseFloat(hourMatch[1]) * 60;
|
|
1211
|
-
}
|
|
1212
|
-
const minuteRegex = /(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|min|m)\b/g;
|
|
1213
|
-
let minuteMatch;
|
|
1214
|
-
while ((minuteMatch = minuteRegex.exec(normalized)) !== null) {
|
|
1215
|
-
total += parseFloat(minuteMatch[1]);
|
|
1385
|
+
const amount = (_a2 = yieldValue.amount) != null ? _a2 : "";
|
|
1386
|
+
const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
|
|
1387
|
+
return `${amount}${unit}`.trim() || yieldValue.description;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// src/utils/image.ts
|
|
1391
|
+
function normalizeImage(image) {
|
|
1392
|
+
if (!image) {
|
|
1393
|
+
return void 0;
|
|
1216
1394
|
}
|
|
1217
|
-
if (
|
|
1218
|
-
|
|
1395
|
+
if (typeof image === "string") {
|
|
1396
|
+
const trimmed = image.trim();
|
|
1397
|
+
return trimmed || void 0;
|
|
1219
1398
|
}
|
|
1220
|
-
|
|
1399
|
+
if (Array.isArray(image)) {
|
|
1400
|
+
const urls = image.map((entry) => typeof entry === "string" ? entry.trim() : extractUrl(entry)).filter((url) => typeof url === "string" && Boolean(url));
|
|
1401
|
+
if (urls.length === 0) {
|
|
1402
|
+
return void 0;
|
|
1403
|
+
}
|
|
1404
|
+
if (urls.length === 1) {
|
|
1405
|
+
return urls[0];
|
|
1406
|
+
}
|
|
1407
|
+
return urls;
|
|
1408
|
+
}
|
|
1409
|
+
return extractUrl(image);
|
|
1221
1410
|
}
|
|
1222
|
-
function
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
return iso;
|
|
1411
|
+
function extractUrl(value) {
|
|
1412
|
+
if (!value || typeof value !== "object") {
|
|
1413
|
+
return void 0;
|
|
1226
1414
|
}
|
|
1227
|
-
|
|
1415
|
+
const record = value;
|
|
1416
|
+
const candidate = typeof record.url === "string" ? record.url : typeof record.contentUrl === "string" ? record.contentUrl : void 0;
|
|
1417
|
+
if (!candidate) {
|
|
1418
|
+
return void 0;
|
|
1419
|
+
}
|
|
1420
|
+
const trimmed = candidate.trim();
|
|
1421
|
+
return trimmed || void 0;
|
|
1228
1422
|
}
|
|
1229
1423
|
|
|
1230
1424
|
// src/fromSchemaOrg.ts
|
|
1231
1425
|
function fromSchemaOrg(input) {
|
|
1426
|
+
var _a2;
|
|
1232
1427
|
const recipeNode = extractRecipeNode(input);
|
|
1233
1428
|
if (!recipeNode) {
|
|
1234
1429
|
return null;
|
|
@@ -1239,13 +1434,12 @@ function fromSchemaOrg(input) {
|
|
|
1239
1434
|
const recipeYield = parseYield(recipeNode.recipeYield);
|
|
1240
1435
|
const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
|
|
1241
1436
|
const category = extractFirst(recipeNode.recipeCategory);
|
|
1242
|
-
const image = convertImage(recipeNode.image);
|
|
1243
1437
|
const source = convertSource(recipeNode);
|
|
1244
1438
|
const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
|
|
1245
1439
|
return {
|
|
1246
1440
|
name: recipeNode.name.trim(),
|
|
1247
|
-
description: recipeNode.description
|
|
1248
|
-
image,
|
|
1441
|
+
description: ((_a2 = recipeNode.description) == null ? void 0 : _a2.trim()) || void 0,
|
|
1442
|
+
image: normalizeImage(recipeNode.image),
|
|
1249
1443
|
category,
|
|
1250
1444
|
tags: tags.length ? tags : void 0,
|
|
1251
1445
|
source,
|
|
@@ -1290,8 +1484,6 @@ function extractRecipeNode(input) {
|
|
|
1290
1484
|
function hasRecipeType(value) {
|
|
1291
1485
|
if (!value) return false;
|
|
1292
1486
|
const types = Array.isArray(value) ? value : [value];
|
|
1293
|
-
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(() => {
|
|
1294
|
-
});
|
|
1295
1487
|
return types.some(
|
|
1296
1488
|
(entry) => typeof entry === "string" && entry.toLowerCase() === "recipe"
|
|
1297
1489
|
);
|
|
@@ -1302,9 +1494,10 @@ function isValidName(name) {
|
|
|
1302
1494
|
function convertIngredients(value) {
|
|
1303
1495
|
if (!value) return [];
|
|
1304
1496
|
const normalized = Array.isArray(value) ? value : [value];
|
|
1305
|
-
return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean)
|
|
1497
|
+
return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean);
|
|
1306
1498
|
}
|
|
1307
1499
|
function convertInstructions(value) {
|
|
1500
|
+
var _a2;
|
|
1308
1501
|
if (!value) return [];
|
|
1309
1502
|
const normalized = Array.isArray(value) ? value : [value];
|
|
1310
1503
|
const result = [];
|
|
@@ -1321,16 +1514,16 @@ function convertInstructions(value) {
|
|
|
1321
1514
|
const subsectionItems = extractSectionItems(entry.itemListElement);
|
|
1322
1515
|
if (subsectionItems.length) {
|
|
1323
1516
|
result.push({
|
|
1324
|
-
subsection: entry.name
|
|
1517
|
+
subsection: ((_a2 = entry.name) == null ? void 0 : _a2.trim()) || "Section",
|
|
1325
1518
|
items: subsectionItems
|
|
1326
1519
|
});
|
|
1327
1520
|
}
|
|
1328
1521
|
continue;
|
|
1329
1522
|
}
|
|
1330
1523
|
if (isHowToStep(entry)) {
|
|
1331
|
-
const
|
|
1332
|
-
if (
|
|
1333
|
-
result.push(
|
|
1524
|
+
const parsed = convertHowToStep(entry);
|
|
1525
|
+
if (parsed) {
|
|
1526
|
+
result.push(parsed);
|
|
1334
1527
|
}
|
|
1335
1528
|
}
|
|
1336
1529
|
}
|
|
@@ -1348,9 +1541,9 @@ function extractSectionItems(items = []) {
|
|
|
1348
1541
|
continue;
|
|
1349
1542
|
}
|
|
1350
1543
|
if (isHowToStep(item)) {
|
|
1351
|
-
const
|
|
1352
|
-
if (
|
|
1353
|
-
result.push(
|
|
1544
|
+
const parsed = convertHowToStep(item);
|
|
1545
|
+
if (parsed) {
|
|
1546
|
+
result.push(parsed);
|
|
1354
1547
|
}
|
|
1355
1548
|
continue;
|
|
1356
1549
|
}
|
|
@@ -1364,6 +1557,40 @@ function extractInstructionText(value) {
|
|
|
1364
1557
|
const text = typeof value.text === "string" ? value.text : value.name;
|
|
1365
1558
|
return typeof text === "string" ? text.trim() || void 0 : void 0;
|
|
1366
1559
|
}
|
|
1560
|
+
function convertHowToStep(step) {
|
|
1561
|
+
const text = extractInstructionText(step);
|
|
1562
|
+
if (!text) {
|
|
1563
|
+
return void 0;
|
|
1564
|
+
}
|
|
1565
|
+
const normalizedImage = normalizeImage(step.image);
|
|
1566
|
+
const image = Array.isArray(normalizedImage) ? normalizedImage[0] : normalizedImage;
|
|
1567
|
+
const id = extractInstructionId(step);
|
|
1568
|
+
const timing = extractInstructionTiming(step);
|
|
1569
|
+
if (!image && !id && !timing) {
|
|
1570
|
+
return text;
|
|
1571
|
+
}
|
|
1572
|
+
const instruction = { text };
|
|
1573
|
+
if (id) instruction.id = id;
|
|
1574
|
+
if (image) instruction.image = image;
|
|
1575
|
+
if (timing) instruction.timing = timing;
|
|
1576
|
+
return instruction;
|
|
1577
|
+
}
|
|
1578
|
+
function extractInstructionTiming(step) {
|
|
1579
|
+
const duration = step.totalTime || step.performTime || step.prepTime || step.duration;
|
|
1580
|
+
if (!duration || typeof duration !== "string") {
|
|
1581
|
+
return void 0;
|
|
1582
|
+
}
|
|
1583
|
+
const parsed = smartParseDuration(duration);
|
|
1584
|
+
return { duration: parsed != null ? parsed : duration, type: "active" };
|
|
1585
|
+
}
|
|
1586
|
+
function extractInstructionId(step) {
|
|
1587
|
+
const raw = step["@id"] || step.id || step.url;
|
|
1588
|
+
if (typeof raw !== "string") {
|
|
1589
|
+
return void 0;
|
|
1590
|
+
}
|
|
1591
|
+
const trimmed = raw.trim();
|
|
1592
|
+
return trimmed || void 0;
|
|
1593
|
+
}
|
|
1367
1594
|
function isHowToStep(value) {
|
|
1368
1595
|
return Boolean(value) && typeof value === "object" && value["@type"] === "HowToStep";
|
|
1369
1596
|
}
|
|
@@ -1371,9 +1598,10 @@ function isHowToSection(value) {
|
|
|
1371
1598
|
return Boolean(value) && typeof value === "object" && value["@type"] === "HowToSection" && Array.isArray(value.itemListElement);
|
|
1372
1599
|
}
|
|
1373
1600
|
function convertTime(recipe) {
|
|
1374
|
-
|
|
1375
|
-
const
|
|
1376
|
-
const
|
|
1601
|
+
var _a2, _b2, _c;
|
|
1602
|
+
const prep = smartParseDuration((_a2 = recipe.prepTime) != null ? _a2 : "");
|
|
1603
|
+
const cook = smartParseDuration((_b2 = recipe.cookTime) != null ? _b2 : "");
|
|
1604
|
+
const total = smartParseDuration((_c = recipe.totalTime) != null ? _c : "");
|
|
1377
1605
|
const structured = {};
|
|
1378
1606
|
if (prep !== null && prep !== void 0) structured.prep = prep;
|
|
1379
1607
|
if (cook !== null && cook !== void 0) structured.active = cook;
|
|
@@ -1405,30 +1633,11 @@ function extractFirst(value) {
|
|
|
1405
1633
|
const arr = flattenStrings(value);
|
|
1406
1634
|
return arr.length ? arr[0] : void 0;
|
|
1407
1635
|
}
|
|
1408
|
-
function convertImage(value) {
|
|
1409
|
-
if (!value) return void 0;
|
|
1410
|
-
if (typeof value === "string") {
|
|
1411
|
-
return value;
|
|
1412
|
-
}
|
|
1413
|
-
if (Array.isArray(value)) {
|
|
1414
|
-
for (const item of value) {
|
|
1415
|
-
const url = typeof item === "string" ? item : extractImageUrl(item);
|
|
1416
|
-
if (url) return url;
|
|
1417
|
-
}
|
|
1418
|
-
return void 0;
|
|
1419
|
-
}
|
|
1420
|
-
return extractImageUrl(value);
|
|
1421
|
-
}
|
|
1422
|
-
function extractImageUrl(value) {
|
|
1423
|
-
if (!value || typeof value !== "object") return void 0;
|
|
1424
|
-
const record = value;
|
|
1425
|
-
const candidate = typeof record.url === "string" ? record.url : typeof record.contentUrl === "string" ? record.contentUrl : void 0;
|
|
1426
|
-
return candidate?.trim() || void 0;
|
|
1427
|
-
}
|
|
1428
1636
|
function convertSource(recipe) {
|
|
1637
|
+
var _a2;
|
|
1429
1638
|
const author = extractEntityName(recipe.author);
|
|
1430
1639
|
const publisher = extractEntityName(recipe.publisher);
|
|
1431
|
-
const url = (recipe.url || recipe.mainEntityOfPage)
|
|
1640
|
+
const url = (_a2 = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a2.trim();
|
|
1432
1641
|
const source = {};
|
|
1433
1642
|
if (author) source.author = author;
|
|
1434
1643
|
if (publisher) source.name = publisher;
|
|
@@ -1459,13 +1668,14 @@ function extractEntityName(value) {
|
|
|
1459
1668
|
|
|
1460
1669
|
// src/converters/toSchemaOrg.ts
|
|
1461
1670
|
function convertBasicMetadata(recipe) {
|
|
1671
|
+
var _a2;
|
|
1462
1672
|
return cleanOutput({
|
|
1463
1673
|
"@context": "https://schema.org",
|
|
1464
1674
|
"@type": "Recipe",
|
|
1465
1675
|
name: recipe.name,
|
|
1466
1676
|
description: recipe.description,
|
|
1467
1677
|
image: recipe.image,
|
|
1468
|
-
url: recipe.source
|
|
1678
|
+
url: (_a2 = recipe.source) == null ? void 0 : _a2.url,
|
|
1469
1679
|
datePublished: recipe.dateAdded,
|
|
1470
1680
|
dateModified: recipe.dateModified
|
|
1471
1681
|
});
|
|
@@ -1473,6 +1683,7 @@ function convertBasicMetadata(recipe) {
|
|
|
1473
1683
|
function convertIngredients2(ingredients = []) {
|
|
1474
1684
|
const result = [];
|
|
1475
1685
|
ingredients.forEach((ingredient) => {
|
|
1686
|
+
var _a2;
|
|
1476
1687
|
if (!ingredient) {
|
|
1477
1688
|
return;
|
|
1478
1689
|
}
|
|
@@ -1502,7 +1713,7 @@ function convertIngredients2(ingredients = []) {
|
|
|
1502
1713
|
});
|
|
1503
1714
|
return;
|
|
1504
1715
|
}
|
|
1505
|
-
const value = ingredient.item
|
|
1716
|
+
const value = (_a2 = ingredient.item) == null ? void 0 : _a2.trim();
|
|
1506
1717
|
if (value) {
|
|
1507
1718
|
result.push(value);
|
|
1508
1719
|
}
|
|
@@ -1517,10 +1728,11 @@ function convertInstruction(entry) {
|
|
|
1517
1728
|
return null;
|
|
1518
1729
|
}
|
|
1519
1730
|
if (typeof entry === "string") {
|
|
1520
|
-
|
|
1731
|
+
const value = entry.trim();
|
|
1732
|
+
return value || null;
|
|
1521
1733
|
}
|
|
1522
1734
|
if ("subsection" in entry) {
|
|
1523
|
-
const steps = entry.items.map((item) =>
|
|
1735
|
+
const steps = entry.items.map((item) => convertInstruction(item)).filter((step) => Boolean(step));
|
|
1524
1736
|
if (!steps.length) {
|
|
1525
1737
|
return null;
|
|
1526
1738
|
}
|
|
@@ -1531,18 +1743,42 @@ function convertInstruction(entry) {
|
|
|
1531
1743
|
};
|
|
1532
1744
|
}
|
|
1533
1745
|
if ("text" in entry) {
|
|
1534
|
-
return createHowToStep(entry
|
|
1746
|
+
return createHowToStep(entry);
|
|
1535
1747
|
}
|
|
1536
1748
|
return createHowToStep(String(entry));
|
|
1537
1749
|
}
|
|
1538
|
-
function createHowToStep(
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
if (
|
|
1542
|
-
|
|
1750
|
+
function createHowToStep(entry) {
|
|
1751
|
+
var _a2;
|
|
1752
|
+
if (!entry) return null;
|
|
1753
|
+
if (typeof entry === "string") {
|
|
1754
|
+
const trimmed2 = entry.trim();
|
|
1755
|
+
return trimmed2 || null;
|
|
1756
|
+
}
|
|
1757
|
+
const trimmed = (_a2 = entry.text) == null ? void 0 : _a2.trim();
|
|
1758
|
+
if (!trimmed) {
|
|
1759
|
+
return null;
|
|
1760
|
+
}
|
|
1761
|
+
const step = {
|
|
1543
1762
|
"@type": "HowToStep",
|
|
1544
1763
|
text: trimmed
|
|
1545
1764
|
};
|
|
1765
|
+
if (entry.id) {
|
|
1766
|
+
step["@id"] = entry.id;
|
|
1767
|
+
}
|
|
1768
|
+
if (entry.timing) {
|
|
1769
|
+
if (typeof entry.timing.duration === "number") {
|
|
1770
|
+
step.performTime = formatDuration(entry.timing.duration);
|
|
1771
|
+
} else if (entry.timing.duration) {
|
|
1772
|
+
step.performTime = entry.timing.duration;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
if (entry.image) {
|
|
1776
|
+
step.image = entry.image;
|
|
1777
|
+
}
|
|
1778
|
+
if (step["@id"] || step.performTime || step.image) {
|
|
1779
|
+
return step;
|
|
1780
|
+
}
|
|
1781
|
+
return trimmed;
|
|
1546
1782
|
}
|
|
1547
1783
|
function convertTime2(time) {
|
|
1548
1784
|
if (!time) {
|
|
@@ -1642,113 +1878,6 @@ function isStructuredTime(time) {
|
|
|
1642
1878
|
return typeof time.prep !== "undefined" || typeof time.active !== "undefined" || typeof time.passive !== "undefined" || typeof time.total !== "undefined";
|
|
1643
1879
|
}
|
|
1644
1880
|
|
|
1645
|
-
// src/scraper/fetch.ts
|
|
1646
|
-
var DEFAULT_USER_AGENTS = [
|
|
1647
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
1648
|
-
"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",
|
|
1649
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
|
|
1650
|
-
];
|
|
1651
|
-
function chooseUserAgent(provided) {
|
|
1652
|
-
if (provided) return provided;
|
|
1653
|
-
const index = Math.floor(Math.random() * DEFAULT_USER_AGENTS.length);
|
|
1654
|
-
return DEFAULT_USER_AGENTS[index];
|
|
1655
|
-
}
|
|
1656
|
-
function resolveFetch(fetchFn) {
|
|
1657
|
-
if (fetchFn) {
|
|
1658
|
-
return fetchFn;
|
|
1659
|
-
}
|
|
1660
|
-
const globalFetch = globalThis.fetch;
|
|
1661
|
-
if (!globalFetch) {
|
|
1662
|
-
throw new Error(
|
|
1663
|
-
"A global fetch implementation is not available. Provide window.fetch in browsers or upgrade to Node 18+."
|
|
1664
|
-
);
|
|
1665
|
-
}
|
|
1666
|
-
return globalFetch;
|
|
1667
|
-
}
|
|
1668
|
-
function isBrowserEnvironment() {
|
|
1669
|
-
return typeof globalThis.document !== "undefined";
|
|
1670
|
-
}
|
|
1671
|
-
function isClientError(error) {
|
|
1672
|
-
if (typeof error.status === "number") {
|
|
1673
|
-
return error.status >= 400 && error.status < 500;
|
|
1674
|
-
}
|
|
1675
|
-
return error.message.includes("HTTP 4");
|
|
1676
|
-
}
|
|
1677
|
-
async function wait(ms) {
|
|
1678
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1679
|
-
}
|
|
1680
|
-
async function fetchPage(url, options = {}) {
|
|
1681
|
-
const {
|
|
1682
|
-
timeout = 1e4,
|
|
1683
|
-
userAgent,
|
|
1684
|
-
maxRetries = 2,
|
|
1685
|
-
fetchFn
|
|
1686
|
-
} = options;
|
|
1687
|
-
let lastError = null;
|
|
1688
|
-
const resolvedFetch = resolveFetch(fetchFn);
|
|
1689
|
-
const isBrowser2 = isBrowserEnvironment();
|
|
1690
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1691
|
-
const controller = new AbortController();
|
|
1692
|
-
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1693
|
-
try {
|
|
1694
|
-
const headers = {
|
|
1695
|
-
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
1696
|
-
"Accept-Language": "en-US,en;q=0.5"
|
|
1697
|
-
};
|
|
1698
|
-
if (!isBrowser2) {
|
|
1699
|
-
headers["User-Agent"] = chooseUserAgent(userAgent);
|
|
1700
|
-
}
|
|
1701
|
-
const requestInit = {
|
|
1702
|
-
headers,
|
|
1703
|
-
signal: controller.signal,
|
|
1704
|
-
redirect: "follow"
|
|
1705
|
-
};
|
|
1706
|
-
const response = await resolvedFetch(url, requestInit);
|
|
1707
|
-
clearTimeout(timeoutId);
|
|
1708
|
-
if (response && (typeof process === "undefined" || process.env.NODE_ENV !== "test")) {
|
|
1709
|
-
try {
|
|
1710
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1711
|
-
if (globalFetch) {
|
|
1712
|
-
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(() => {
|
|
1713
|
-
});
|
|
1714
|
-
}
|
|
1715
|
-
} catch {
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
if (!response.ok) {
|
|
1719
|
-
const error = new Error(
|
|
1720
|
-
`HTTP ${response.status}: ${response.statusText}`
|
|
1721
|
-
);
|
|
1722
|
-
error.status = response.status;
|
|
1723
|
-
throw error;
|
|
1724
|
-
}
|
|
1725
|
-
const html = await response.text();
|
|
1726
|
-
if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
|
|
1727
|
-
try {
|
|
1728
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1729
|
-
if (globalFetch) {
|
|
1730
|
-
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(() => {
|
|
1731
|
-
});
|
|
1732
|
-
}
|
|
1733
|
-
} catch {
|
|
1734
|
-
}
|
|
1735
|
-
}
|
|
1736
|
-
return html;
|
|
1737
|
-
} catch (err) {
|
|
1738
|
-
clearTimeout(timeoutId);
|
|
1739
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1740
|
-
if (isClientError(lastError)) {
|
|
1741
|
-
throw lastError;
|
|
1742
|
-
}
|
|
1743
|
-
if (attempt < maxRetries) {
|
|
1744
|
-
await wait(1e3 * (attempt + 1));
|
|
1745
|
-
continue;
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
|
-
}
|
|
1749
|
-
throw lastError ?? new Error("Failed to fetch page");
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
1881
|
// src/scraper/extractors/utils.ts
|
|
1753
1882
|
var RECIPE_TYPES = /* @__PURE__ */ new Set([
|
|
1754
1883
|
"recipe",
|
|
@@ -1785,97 +1914,8 @@ function normalizeText(value) {
|
|
|
1785
1914
|
return trimmed || void 0;
|
|
1786
1915
|
}
|
|
1787
1916
|
|
|
1788
|
-
// src/scraper/extractors/jsonld.ts
|
|
1789
|
-
function extractJsonLd(html) {
|
|
1790
|
-
const $ = load(html);
|
|
1791
|
-
const scripts = $('script[type="application/ld+json"]');
|
|
1792
|
-
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(() => {
|
|
1793
|
-
});
|
|
1794
|
-
const candidates = [];
|
|
1795
|
-
scripts.each((_, element) => {
|
|
1796
|
-
const content = $(element).html();
|
|
1797
|
-
if (!content) return;
|
|
1798
|
-
const parsed = safeJsonParse(content);
|
|
1799
|
-
if (!parsed) return;
|
|
1800
|
-
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(() => {
|
|
1801
|
-
});
|
|
1802
|
-
collectCandidates(parsed, candidates);
|
|
1803
|
-
});
|
|
1804
|
-
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(() => {
|
|
1805
|
-
});
|
|
1806
|
-
return candidates[0] ?? null;
|
|
1807
|
-
}
|
|
1808
|
-
function collectCandidates(payload, bucket) {
|
|
1809
|
-
if (!payload) return;
|
|
1810
|
-
if (Array.isArray(payload)) {
|
|
1811
|
-
payload.forEach((entry) => collectCandidates(entry, bucket));
|
|
1812
|
-
return;
|
|
1813
|
-
}
|
|
1814
|
-
if (typeof payload !== "object") {
|
|
1815
|
-
return;
|
|
1816
|
-
}
|
|
1817
|
-
if (isRecipeNode(payload)) {
|
|
1818
|
-
bucket.push(payload);
|
|
1819
|
-
return;
|
|
1820
|
-
}
|
|
1821
|
-
const graph = payload["@graph"];
|
|
1822
|
-
if (Array.isArray(graph)) {
|
|
1823
|
-
graph.forEach((entry) => collectCandidates(entry, bucket));
|
|
1824
|
-
}
|
|
1825
|
-
}
|
|
1826
|
-
var SIMPLE_PROPS = [
|
|
1827
|
-
"name",
|
|
1828
|
-
"description",
|
|
1829
|
-
"image",
|
|
1830
|
-
"recipeYield",
|
|
1831
|
-
"prepTime",
|
|
1832
|
-
"cookTime",
|
|
1833
|
-
"totalTime"
|
|
1834
|
-
];
|
|
1835
|
-
function extractMicrodata(html) {
|
|
1836
|
-
const $ = load(html);
|
|
1837
|
-
const recipeEl = $('[itemscope][itemtype*="schema.org/Recipe"]').first();
|
|
1838
|
-
if (!recipeEl.length) {
|
|
1839
|
-
return null;
|
|
1840
|
-
}
|
|
1841
|
-
const recipe = {
|
|
1842
|
-
"@type": "Recipe"
|
|
1843
|
-
};
|
|
1844
|
-
SIMPLE_PROPS.forEach((prop) => {
|
|
1845
|
-
const value = findPropertyValue($, recipeEl, prop);
|
|
1846
|
-
if (value) {
|
|
1847
|
-
recipe[prop] = value;
|
|
1848
|
-
}
|
|
1849
|
-
});
|
|
1850
|
-
const ingredients = [];
|
|
1851
|
-
recipeEl.find('[itemprop="recipeIngredient"]').each((_, el) => {
|
|
1852
|
-
const text = normalizeText($(el).attr("content") || $(el).text());
|
|
1853
|
-
if (text) ingredients.push(text);
|
|
1854
|
-
});
|
|
1855
|
-
if (ingredients.length) {
|
|
1856
|
-
recipe.recipeIngredient = ingredients;
|
|
1857
|
-
}
|
|
1858
|
-
const instructions = [];
|
|
1859
|
-
recipeEl.find('[itemprop="recipeInstructions"]').each((_, el) => {
|
|
1860
|
-
const text = normalizeText($(el).attr("content")) || normalizeText($(el).find('[itemprop="text"]').first().text()) || normalizeText($(el).text());
|
|
1861
|
-
if (text) instructions.push(text);
|
|
1862
|
-
});
|
|
1863
|
-
if (instructions.length) {
|
|
1864
|
-
recipe.recipeInstructions = instructions;
|
|
1865
|
-
}
|
|
1866
|
-
if (recipe.name || ingredients.length) {
|
|
1867
|
-
return recipe;
|
|
1868
|
-
}
|
|
1869
|
-
return null;
|
|
1870
|
-
}
|
|
1871
|
-
function findPropertyValue($, context, prop) {
|
|
1872
|
-
const node = context.find(`[itemprop="${prop}"]`).first();
|
|
1873
|
-
if (!node.length) return void 0;
|
|
1874
|
-
return normalizeText(node.attr("content")) || normalizeText(node.attr("href")) || normalizeText(node.attr("src")) || normalizeText(node.text());
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
1917
|
// src/scraper/extractors/browser.ts
|
|
1878
|
-
var
|
|
1918
|
+
var SIMPLE_PROPS = ["name", "description", "image", "recipeYield", "prepTime", "cookTime", "totalTime"];
|
|
1879
1919
|
function extractRecipeBrowser(html) {
|
|
1880
1920
|
const jsonLdRecipe = extractJsonLdBrowser(html);
|
|
1881
1921
|
if (jsonLdRecipe) {
|
|
@@ -1888,6 +1928,7 @@ function extractRecipeBrowser(html) {
|
|
|
1888
1928
|
return { recipe: null, source: null };
|
|
1889
1929
|
}
|
|
1890
1930
|
function extractJsonLdBrowser(html) {
|
|
1931
|
+
var _a2;
|
|
1891
1932
|
if (typeof globalThis.DOMParser === "undefined") {
|
|
1892
1933
|
return null;
|
|
1893
1934
|
}
|
|
@@ -1900,9 +1941,9 @@ function extractJsonLdBrowser(html) {
|
|
|
1900
1941
|
if (!content) return;
|
|
1901
1942
|
const parsed = safeJsonParse(content);
|
|
1902
1943
|
if (!parsed) return;
|
|
1903
|
-
|
|
1944
|
+
collectCandidates(parsed, candidates);
|
|
1904
1945
|
});
|
|
1905
|
-
return candidates[0]
|
|
1946
|
+
return (_a2 = candidates[0]) != null ? _a2 : null;
|
|
1906
1947
|
}
|
|
1907
1948
|
function extractMicrodataBrowser(html) {
|
|
1908
1949
|
if (typeof globalThis.DOMParser === "undefined") {
|
|
@@ -1917,8 +1958,8 @@ function extractMicrodataBrowser(html) {
|
|
|
1917
1958
|
const recipe = {
|
|
1918
1959
|
"@type": "Recipe"
|
|
1919
1960
|
};
|
|
1920
|
-
|
|
1921
|
-
const value =
|
|
1961
|
+
SIMPLE_PROPS.forEach((prop) => {
|
|
1962
|
+
const value = findPropertyValue(recipeEl, prop);
|
|
1922
1963
|
if (value) {
|
|
1923
1964
|
recipe[prop] = value;
|
|
1924
1965
|
}
|
|
@@ -1935,7 +1976,8 @@ function extractMicrodataBrowser(html) {
|
|
|
1935
1976
|
}
|
|
1936
1977
|
const instructions = [];
|
|
1937
1978
|
recipeEl.querySelectorAll('[itemprop="recipeInstructions"]').forEach((el) => {
|
|
1938
|
-
|
|
1979
|
+
var _a2;
|
|
1980
|
+
const text = normalizeText(el.getAttribute("content")) || normalizeText(((_a2 = el.querySelector('[itemprop="text"]')) == null ? void 0 : _a2.textContent) || void 0) || normalizeText(el.textContent || void 0);
|
|
1939
1981
|
if (text) instructions.push(text);
|
|
1940
1982
|
});
|
|
1941
1983
|
if (instructions.length) {
|
|
@@ -1946,15 +1988,15 @@ function extractMicrodataBrowser(html) {
|
|
|
1946
1988
|
}
|
|
1947
1989
|
return null;
|
|
1948
1990
|
}
|
|
1949
|
-
function
|
|
1991
|
+
function findPropertyValue(context, prop) {
|
|
1950
1992
|
const node = context.querySelector(`[itemprop="${prop}"]`);
|
|
1951
1993
|
if (!node) return void 0;
|
|
1952
1994
|
return normalizeText(node.getAttribute("content")) || normalizeText(node.getAttribute("href")) || normalizeText(node.getAttribute("src")) || normalizeText(node.textContent || void 0);
|
|
1953
1995
|
}
|
|
1954
|
-
function
|
|
1996
|
+
function collectCandidates(payload, bucket) {
|
|
1955
1997
|
if (!payload) return;
|
|
1956
1998
|
if (Array.isArray(payload)) {
|
|
1957
|
-
payload.forEach((entry) =>
|
|
1999
|
+
payload.forEach((entry) => collectCandidates(entry, bucket));
|
|
1958
2000
|
return;
|
|
1959
2001
|
}
|
|
1960
2002
|
if (typeof payload !== "object") {
|
|
@@ -1966,364 +2008,19 @@ function collectCandidates2(payload, bucket) {
|
|
|
1966
2008
|
}
|
|
1967
2009
|
const graph = payload["@graph"];
|
|
1968
2010
|
if (Array.isArray(graph)) {
|
|
1969
|
-
graph.forEach((entry) =>
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
// src/scraper/extractors/index.ts
|
|
1974
|
-
function isBrowser() {
|
|
1975
|
-
try {
|
|
1976
|
-
return typeof globalThis.DOMParser !== "undefined";
|
|
1977
|
-
} catch {
|
|
1978
|
-
return false;
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
1981
|
-
function extractRecipe(html) {
|
|
1982
|
-
if (isBrowser()) {
|
|
1983
|
-
return extractRecipeBrowser(html);
|
|
1984
|
-
}
|
|
1985
|
-
const jsonLdRecipe = extractJsonLd(html);
|
|
1986
|
-
if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
|
|
1987
|
-
try {
|
|
1988
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1989
|
-
if (globalFetch) {
|
|
1990
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/index.ts:6", message: "JSON-LD extraction result", data: { hasJsonLd: !!jsonLdRecipe }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "C,D" }) }).catch(() => {
|
|
1991
|
-
});
|
|
1992
|
-
}
|
|
1993
|
-
} catch {
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
if (jsonLdRecipe) {
|
|
1997
|
-
return { recipe: jsonLdRecipe, source: "jsonld" };
|
|
1998
|
-
}
|
|
1999
|
-
const microdataRecipe = extractMicrodata(html);
|
|
2000
|
-
if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
|
|
2001
|
-
try {
|
|
2002
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2003
|
-
if (globalFetch) {
|
|
2004
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/index.ts:12", message: "Microdata extraction result", data: { hasMicrodata: !!microdataRecipe }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "D" }) }).catch(() => {
|
|
2005
|
-
});
|
|
2006
|
-
}
|
|
2007
|
-
} catch {
|
|
2008
|
-
}
|
|
2009
|
-
}
|
|
2010
|
-
if (microdataRecipe) {
|
|
2011
|
-
return { recipe: microdataRecipe, source: "microdata" };
|
|
2011
|
+
graph.forEach((entry) => collectCandidates(entry, bucket));
|
|
2012
2012
|
}
|
|
2013
|
-
return { recipe: null, source: null };
|
|
2014
2013
|
}
|
|
2015
2014
|
|
|
2016
|
-
// src/scraper/
|
|
2017
|
-
async function scrapeRecipe(url, options = {}) {
|
|
2018
|
-
if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
|
|
2019
|
-
try {
|
|
2020
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2021
|
-
if (globalFetch) {
|
|
2022
|
-
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(() => {
|
|
2023
|
-
});
|
|
2024
|
-
}
|
|
2025
|
-
} catch {
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2028
|
-
const html = await fetchPage(url, options);
|
|
2029
|
-
if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
|
|
2030
|
-
try {
|
|
2031
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2032
|
-
if (globalFetch) {
|
|
2033
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:9", message: "HTML fetched", data: { htmlLength: html?.length, htmlPreview: html?.substring(0, 200) }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
|
|
2034
|
-
});
|
|
2035
|
-
}
|
|
2036
|
-
} catch {
|
|
2037
|
-
}
|
|
2038
|
-
}
|
|
2039
|
-
const { recipe } = extractRecipe(html);
|
|
2040
|
-
if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
|
|
2041
|
-
try {
|
|
2042
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2043
|
-
if (globalFetch) {
|
|
2044
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:11", message: "extractRecipe result", data: { hasRecipe: !!recipe, recipeType: recipe?.["@type"], recipeName: recipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C,D" }) }).catch(() => {
|
|
2045
|
-
});
|
|
2046
|
-
}
|
|
2047
|
-
} catch {
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
if (!recipe) {
|
|
2051
|
-
throw new Error("No Schema.org recipe data found in page");
|
|
2052
|
-
}
|
|
2053
|
-
const soustackRecipe = fromSchemaOrg(recipe);
|
|
2054
|
-
if (typeof process === "undefined" || process.env.NODE_ENV !== "test") {
|
|
2055
|
-
try {
|
|
2056
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2057
|
-
if (globalFetch) {
|
|
2058
|
-
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(() => {
|
|
2059
|
-
});
|
|
2060
|
-
}
|
|
2061
|
-
} catch {
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
if (!soustackRecipe) {
|
|
2065
|
-
throw new Error("Schema.org data did not include a valid recipe");
|
|
2066
|
-
}
|
|
2067
|
-
return soustackRecipe;
|
|
2068
|
-
}
|
|
2069
|
-
function extractRecipeFromHTML(html) {
|
|
2070
|
-
const { recipe } = extractRecipe(html);
|
|
2071
|
-
if (!recipe) {
|
|
2072
|
-
throw new Error("No Schema.org recipe data found in HTML");
|
|
2073
|
-
}
|
|
2074
|
-
const soustackRecipe = fromSchemaOrg(recipe);
|
|
2075
|
-
if (!soustackRecipe) {
|
|
2076
|
-
throw new Error("Schema.org data did not include a valid recipe");
|
|
2077
|
-
}
|
|
2078
|
-
return soustackRecipe;
|
|
2079
|
-
}
|
|
2015
|
+
// src/scraper/browser.ts
|
|
2080
2016
|
function extractSchemaOrgRecipeFromHTML(html) {
|
|
2081
|
-
const { recipe } =
|
|
2017
|
+
const { recipe } = extractRecipeBrowser(html);
|
|
2082
2018
|
return recipe;
|
|
2083
2019
|
}
|
|
2084
2020
|
|
|
2085
|
-
// src/
|
|
2086
|
-
var
|
|
2087
|
-
var MAKES_PREFIX = /^(makes?|yields?)\s*:?\s*(.+)$/i;
|
|
2088
|
-
var APPROX_PREFIX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
|
|
2089
|
-
var SERVING_UNITS = ["servings", "serving", "portions", "portion", "people", "persons"];
|
|
2090
|
-
var DEFAULT_DOZEN_UNIT = "cookies";
|
|
2091
|
-
var NUMBER_WORDS2 = {
|
|
2092
|
-
a: 1,
|
|
2093
|
-
an: 1,
|
|
2094
|
-
one: 1,
|
|
2095
|
-
two: 2,
|
|
2096
|
-
three: 3,
|
|
2097
|
-
four: 4,
|
|
2098
|
-
five: 5,
|
|
2099
|
-
six: 6,
|
|
2100
|
-
seven: 7,
|
|
2101
|
-
eight: 8,
|
|
2102
|
-
nine: 9,
|
|
2103
|
-
ten: 10,
|
|
2104
|
-
eleven: 11,
|
|
2105
|
-
twelve: 12
|
|
2106
|
-
};
|
|
2107
|
-
function normalizeYield(text) {
|
|
2108
|
-
if (!text || typeof text !== "string") return "";
|
|
2109
|
-
return text.normalize("NFKC").replace(/\u00A0/g, " ").replace(/[–—−]/g, "-").trim().replace(/\s+/g, " ");
|
|
2110
|
-
}
|
|
2111
|
-
function parseYield2(text) {
|
|
2112
|
-
const normalized = normalizeYield(text);
|
|
2113
|
-
if (!normalized) return null;
|
|
2114
|
-
const { main, paren } = extractParenthetical(normalized);
|
|
2115
|
-
const core = parseYieldCore(main, normalized);
|
|
2116
|
-
if (!core) return null;
|
|
2117
|
-
const servingsFromParen = paren ? extractServingsFromParen(paren) : null;
|
|
2118
|
-
if (servingsFromParen !== null) {
|
|
2119
|
-
core.servings = servingsFromParen;
|
|
2120
|
-
core.description = normalized;
|
|
2121
|
-
}
|
|
2122
|
-
if (core.servings === void 0) {
|
|
2123
|
-
const inferred = inferServings(core.amount, core.unit);
|
|
2124
|
-
if (inferred !== void 0) {
|
|
2125
|
-
core.servings = inferred;
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
return core;
|
|
2129
|
-
}
|
|
2130
|
-
function formatYield2(value) {
|
|
2131
|
-
if (value.description) {
|
|
2132
|
-
return value.description;
|
|
2133
|
-
}
|
|
2134
|
-
if (value.servings && value.unit === "servings") {
|
|
2135
|
-
return `Serves ${value.amount}`;
|
|
2136
|
-
}
|
|
2137
|
-
let result = `${value.amount} ${value.unit}`.trim();
|
|
2138
|
-
if (value.servings && value.unit !== "servings") {
|
|
2139
|
-
result += ` (${value.servings} servings)`;
|
|
2140
|
-
}
|
|
2141
|
-
return result;
|
|
2142
|
-
}
|
|
2143
|
-
function parseYieldCore(text, original) {
|
|
2144
|
-
return parseServesPattern(text, original) ?? parseMakesPattern(text, original) ?? parseRangePattern(text, original) ?? parseNumberUnitPattern(text, original) ?? parsePlainNumberPattern(text);
|
|
2145
|
-
}
|
|
2146
|
-
function parseServesPattern(text, original) {
|
|
2147
|
-
const patterns = [
|
|
2148
|
-
/^serves?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
2149
|
-
/^servings?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
2150
|
-
/^serving\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
2151
|
-
/^makes?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i,
|
|
2152
|
-
/^(\d+)\s+servings?$/i
|
|
2153
|
-
];
|
|
2154
|
-
for (const regex of patterns) {
|
|
2155
|
-
const match = text.match(regex);
|
|
2156
|
-
if (!match) continue;
|
|
2157
|
-
const amount = parseInt(match[1], 10);
|
|
2158
|
-
if (Number.isNaN(amount)) continue;
|
|
2159
|
-
const result = {
|
|
2160
|
-
amount,
|
|
2161
|
-
unit: "servings",
|
|
2162
|
-
servings: amount
|
|
2163
|
-
};
|
|
2164
|
-
if (match[2]) {
|
|
2165
|
-
result.description = original;
|
|
2166
|
-
}
|
|
2167
|
-
return result;
|
|
2168
|
-
}
|
|
2169
|
-
return null;
|
|
2170
|
-
}
|
|
2171
|
-
function parseMakesPattern(text, original) {
|
|
2172
|
-
const match = text.match(MAKES_PREFIX);
|
|
2173
|
-
if (!match) return null;
|
|
2174
|
-
const remainder = match[2].trim();
|
|
2175
|
-
if (!remainder) return null;
|
|
2176
|
-
const servingsMatch = remainder.match(/^(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i);
|
|
2177
|
-
if (servingsMatch) {
|
|
2178
|
-
const amount = parseInt(servingsMatch[1], 10);
|
|
2179
|
-
const result = {
|
|
2180
|
-
amount,
|
|
2181
|
-
unit: "servings",
|
|
2182
|
-
servings: amount
|
|
2183
|
-
};
|
|
2184
|
-
if (servingsMatch[2]) {
|
|
2185
|
-
result.description = original;
|
|
2186
|
-
}
|
|
2187
|
-
return result;
|
|
2188
|
-
}
|
|
2189
|
-
return parseRangePattern(remainder, original) ?? parseNumberUnitPattern(remainder, original) ?? parsePlainNumberPattern(remainder);
|
|
2190
|
-
}
|
|
2191
|
-
function parseRangePattern(text, descriptionSource) {
|
|
2192
|
-
const match = text.match(RANGE_PATTERN);
|
|
2193
|
-
if (!match) return null;
|
|
2194
|
-
const amount = parseInt(match[1], 10);
|
|
2195
|
-
const unit = cleanupUnit(match[3]);
|
|
2196
|
-
if (!unit) return null;
|
|
2197
|
-
const result = {
|
|
2198
|
-
amount,
|
|
2199
|
-
unit,
|
|
2200
|
-
description: descriptionSource
|
|
2201
|
-
};
|
|
2202
|
-
return result;
|
|
2203
|
-
}
|
|
2204
|
-
function parseNumberUnitPattern(text, descriptionSource) {
|
|
2205
|
-
if (!text) return null;
|
|
2206
|
-
const { value, approximate } = stripApproximation(text);
|
|
2207
|
-
if (!value) return null;
|
|
2208
|
-
const dozenResult = handleDozen(value);
|
|
2209
|
-
if (dozenResult) {
|
|
2210
|
-
const unit = cleanupUnit(dozenResult.remainder || DEFAULT_DOZEN_UNIT);
|
|
2211
|
-
const parsed = {
|
|
2212
|
-
amount: dozenResult.amount,
|
|
2213
|
-
unit
|
|
2214
|
-
};
|
|
2215
|
-
if (approximate) {
|
|
2216
|
-
parsed.description = descriptionSource;
|
|
2217
|
-
}
|
|
2218
|
-
return parsed;
|
|
2219
|
-
}
|
|
2220
|
-
const numericMatch = value.match(/^(\d+(?:\.\d+)?)\s+(.+)$/);
|
|
2221
|
-
if (numericMatch) {
|
|
2222
|
-
const amount = parseFloat(numericMatch[1]);
|
|
2223
|
-
if (!Number.isNaN(amount)) {
|
|
2224
|
-
const unit = cleanupUnit(numericMatch[2]);
|
|
2225
|
-
if (unit) {
|
|
2226
|
-
const parsed = { amount, unit };
|
|
2227
|
-
if (approximate) {
|
|
2228
|
-
parsed.description = descriptionSource;
|
|
2229
|
-
}
|
|
2230
|
-
return parsed;
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2233
|
-
}
|
|
2234
|
-
const wordMatch = value.match(/^([a-zA-Z]+)\s+(.+)$/);
|
|
2235
|
-
if (wordMatch) {
|
|
2236
|
-
const amount = wordToNumber(wordMatch[1]);
|
|
2237
|
-
if (amount !== null) {
|
|
2238
|
-
const unit = cleanupUnit(wordMatch[2]);
|
|
2239
|
-
if (unit) {
|
|
2240
|
-
const parsed = { amount, unit };
|
|
2241
|
-
if (approximate) {
|
|
2242
|
-
parsed.description = descriptionSource;
|
|
2243
|
-
}
|
|
2244
|
-
return parsed;
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
2247
|
-
}
|
|
2248
|
-
return null;
|
|
2249
|
-
}
|
|
2250
|
-
function parsePlainNumberPattern(text) {
|
|
2251
|
-
const match = text.match(/^(\d+)$/);
|
|
2252
|
-
if (!match) return null;
|
|
2253
|
-
const amount = parseInt(match[1], 10);
|
|
2254
|
-
if (Number.isNaN(amount)) return null;
|
|
2255
|
-
return {
|
|
2256
|
-
amount,
|
|
2257
|
-
unit: "servings",
|
|
2258
|
-
servings: amount
|
|
2259
|
-
};
|
|
2260
|
-
}
|
|
2261
|
-
function stripApproximation(value) {
|
|
2262
|
-
const match = value.match(APPROX_PREFIX);
|
|
2263
|
-
if (!match) {
|
|
2264
|
-
return { value: value.trim(), approximate: false };
|
|
2265
|
-
}
|
|
2266
|
-
const stripped = value.slice(match[0].length).trim();
|
|
2267
|
-
return { value: stripped, approximate: true };
|
|
2268
|
-
}
|
|
2269
|
-
function handleDozen(text) {
|
|
2270
|
-
const match = text.match(
|
|
2271
|
-
/^((?:\d+(?:\.\d+)?)|(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|a|an|half))\s+dozens?\b(.*)$/i
|
|
2272
|
-
);
|
|
2273
|
-
if (!match) return null;
|
|
2274
|
-
const token = match[1].toLowerCase();
|
|
2275
|
-
let multiplier = null;
|
|
2276
|
-
if (token === "half") {
|
|
2277
|
-
multiplier = 0.5;
|
|
2278
|
-
} else if (!Number.isNaN(Number(token))) {
|
|
2279
|
-
multiplier = parseFloat(token);
|
|
2280
|
-
} else {
|
|
2281
|
-
multiplier = wordToNumber(token);
|
|
2282
|
-
}
|
|
2283
|
-
if (multiplier === null) return null;
|
|
2284
|
-
const amount = multiplier * 12;
|
|
2285
|
-
return {
|
|
2286
|
-
amount,
|
|
2287
|
-
remainder: match[2].trim()
|
|
2288
|
-
};
|
|
2289
|
-
}
|
|
2290
|
-
function cleanupUnit(value) {
|
|
2291
|
-
let unit = value.trim();
|
|
2292
|
-
unit = unit.replace(/^[,.-]+/, "").trim();
|
|
2293
|
-
unit = unit.replace(/[.,]+$/, "").trim();
|
|
2294
|
-
unit = unit.replace(/^of\s+/i, "").trim();
|
|
2295
|
-
return unit;
|
|
2296
|
-
}
|
|
2297
|
-
function extractParenthetical(text) {
|
|
2298
|
-
const match = text.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
|
|
2299
|
-
if (!match) {
|
|
2300
|
-
return { main: text, paren: null };
|
|
2301
|
-
}
|
|
2302
|
-
return {
|
|
2303
|
-
main: match[1].trim(),
|
|
2304
|
-
paren: match[2].trim()
|
|
2305
|
-
};
|
|
2306
|
-
}
|
|
2307
|
-
function extractServingsFromParen(text) {
|
|
2308
|
-
const match = text.match(/(\d+)/);
|
|
2309
|
-
if (!match) return null;
|
|
2310
|
-
const value = parseInt(match[1], 10);
|
|
2311
|
-
return Number.isNaN(value) ? null : value;
|
|
2312
|
-
}
|
|
2313
|
-
function inferServings(amount, unit) {
|
|
2314
|
-
if (SERVING_UNITS.includes(unit.toLowerCase())) {
|
|
2315
|
-
return amount;
|
|
2316
|
-
}
|
|
2317
|
-
return void 0;
|
|
2318
|
-
}
|
|
2319
|
-
function wordToNumber(word) {
|
|
2320
|
-
const normalized = word.toLowerCase();
|
|
2321
|
-
if (NUMBER_WORDS2.hasOwnProperty(normalized)) {
|
|
2322
|
-
return NUMBER_WORDS2[normalized];
|
|
2323
|
-
}
|
|
2324
|
-
return null;
|
|
2325
|
-
}
|
|
2021
|
+
// src/specVersion.ts
|
|
2022
|
+
var SOUSTACK_SPEC_VERSION = "0.2.1";
|
|
2326
2023
|
|
|
2327
|
-
export {
|
|
2024
|
+
export { SOUSTACK_SPEC_VERSION, detectProfiles, extractSchemaOrgRecipeFromHTML, fromSchemaOrg, scaleRecipe, toSchemaOrg, validateRecipe };
|
|
2328
2025
|
//# sourceMappingURL=index.mjs.map
|
|
2329
2026
|
//# sourceMappingURL=index.mjs.map
|