soustack 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +301 -244
- package/dist/cli/index.js +1697 -1357
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.mts +48 -138
- package/dist/index.d.ts +48 -138
- package/dist/index.js +1093 -1466
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1092 -1453
- package/dist/index.mjs.map +1 -1
- package/dist/scrape.d.mts +308 -0
- package/dist/scrape.d.ts +308 -0
- package/dist/scrape.js +819 -0
- package/dist/scrape.js.map +1 -0
- package/dist/scrape.mjs +814 -0
- package/dist/scrape.mjs.map +1 -0
- package/package.json +86 -75
- package/src/profiles/.gitkeep +0 -0
- package/src/profiles/base.schema.json +9 -0
- package/src/profiles/cookable.schema.json +18 -0
- package/src/profiles/illustrated.schema.json +48 -0
- package/src/profiles/quantified.schema.json +43 -0
- package/src/profiles/scalable.schema.json +75 -0
- package/src/profiles/schedulable.schema.json +43 -0
- package/src/schema.json +43 -22
- package/src/soustack.schema.json +344 -0
package/dist/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.2",
|
|
137
|
-
title: "Soustack Recipe Schema v0.2",
|
|
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"
|
|
@@ -184,6 +312,11 @@ var schema_default = {
|
|
|
184
312
|
type: "string",
|
|
185
313
|
format: "date-time"
|
|
186
314
|
},
|
|
315
|
+
metadata: {
|
|
316
|
+
type: "object",
|
|
317
|
+
additionalProperties: true,
|
|
318
|
+
description: "Free-form vendor metadata"
|
|
319
|
+
},
|
|
187
320
|
source: {
|
|
188
321
|
type: "object",
|
|
189
322
|
properties: {
|
|
@@ -243,24 +376,16 @@ var schema_default = {
|
|
|
243
376
|
}
|
|
244
377
|
},
|
|
245
378
|
time: {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
{
|
|
257
|
-
type: "object",
|
|
258
|
-
properties: {
|
|
259
|
-
prepTime: { type: "string" },
|
|
260
|
-
cookTime: { type: "string" }
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
]
|
|
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
|
|
264
389
|
},
|
|
265
390
|
quantity: {
|
|
266
391
|
type: "object",
|
|
@@ -361,7 +486,13 @@ var schema_default = {
|
|
|
361
486
|
type: "object",
|
|
362
487
|
required: ["duration", "type"],
|
|
363
488
|
properties: {
|
|
364
|
-
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
|
+
},
|
|
365
496
|
type: { type: "string", enum: ["active", "passive"] },
|
|
366
497
|
scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
|
|
367
498
|
}
|
|
@@ -455,665 +586,756 @@ var schema_default = {
|
|
|
455
586
|
}
|
|
456
587
|
};
|
|
457
588
|
|
|
458
|
-
// src/
|
|
459
|
-
var
|
|
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
|
-
liter: "l",
|
|
543
|
-
liters: "l",
|
|
544
|
-
l: "l",
|
|
545
|
-
ounce: "oz",
|
|
546
|
-
ounces: "oz",
|
|
547
|
-
oz: "oz",
|
|
548
|
-
pound: "lb",
|
|
549
|
-
pounds: "lb",
|
|
550
|
-
lb: "lb",
|
|
551
|
-
lbs: "lb",
|
|
552
|
-
pint: "pint",
|
|
553
|
-
pints: "pint",
|
|
554
|
-
quart: "quart",
|
|
555
|
-
quarts: "quart",
|
|
556
|
-
stick: "stick",
|
|
557
|
-
sticks: "stick",
|
|
558
|
-
dash: "dash",
|
|
559
|
-
pinches: "pinch",
|
|
560
|
-
pinch: "pinch"
|
|
561
|
-
};
|
|
562
|
-
var PREP_PHRASES = [
|
|
563
|
-
"diced",
|
|
564
|
-
"finely diced",
|
|
565
|
-
"roughly diced",
|
|
566
|
-
"minced",
|
|
567
|
-
"finely minced",
|
|
568
|
-
"chopped",
|
|
569
|
-
"finely chopped",
|
|
570
|
-
"roughly chopped",
|
|
571
|
-
"sliced",
|
|
572
|
-
"thinly sliced",
|
|
573
|
-
"thickly sliced",
|
|
574
|
-
"grated",
|
|
575
|
-
"finely grated",
|
|
576
|
-
"zested",
|
|
577
|
-
"sifted",
|
|
578
|
-
"softened",
|
|
579
|
-
"at room temperature",
|
|
580
|
-
"room temperature",
|
|
581
|
-
"room temp",
|
|
582
|
-
"melted",
|
|
583
|
-
"toasted",
|
|
584
|
-
"drained",
|
|
585
|
-
"drained and rinsed",
|
|
586
|
-
"beaten",
|
|
587
|
-
"divided",
|
|
588
|
-
"cut into cubes",
|
|
589
|
-
"cut into pieces",
|
|
590
|
-
"cut into strips",
|
|
591
|
-
"cut into chunks",
|
|
592
|
-
"cut into bite-size pieces"
|
|
593
|
-
].map((value) => value.toLowerCase());
|
|
594
|
-
var COUNT_DESCRIPTORS = /* @__PURE__ */ new Set([
|
|
595
|
-
"clove",
|
|
596
|
-
"cloves",
|
|
597
|
-
"can",
|
|
598
|
-
"cans",
|
|
599
|
-
"stick",
|
|
600
|
-
"sticks",
|
|
601
|
-
"sprig",
|
|
602
|
-
"sprigs",
|
|
603
|
-
"bunch",
|
|
604
|
-
"bunches",
|
|
605
|
-
"slice",
|
|
606
|
-
"slices",
|
|
607
|
-
"package",
|
|
608
|
-
"packages"
|
|
609
|
-
]);
|
|
610
|
-
var DESCRIPTOR_NOTE_SET = /* @__PURE__ */ new Set(["can", "cans", "jar", "jars", "package", "packages", "bottle", "bottles"]);
|
|
611
|
-
var WEIGHT_PRIORITY_UNITS = /* @__PURE__ */ new Set(["g", "kg", "oz", "lb", "ml", "l"]);
|
|
612
|
-
var SPICE_KEYWORDS = [
|
|
613
|
-
"salt",
|
|
614
|
-
"pepper",
|
|
615
|
-
"paprika",
|
|
616
|
-
"cumin",
|
|
617
|
-
"coriander",
|
|
618
|
-
"turmeric",
|
|
619
|
-
"chili powder",
|
|
620
|
-
"garlic powder",
|
|
621
|
-
"onion powder",
|
|
622
|
-
"cayenne",
|
|
623
|
-
"cinnamon",
|
|
624
|
-
"nutmeg",
|
|
625
|
-
"allspice",
|
|
626
|
-
"ginger",
|
|
627
|
-
"oregano",
|
|
628
|
-
"thyme",
|
|
629
|
-
"rosemary",
|
|
630
|
-
"basil",
|
|
631
|
-
"sage",
|
|
632
|
-
"clove",
|
|
633
|
-
"spice",
|
|
634
|
-
"seasoning"
|
|
635
|
-
];
|
|
636
|
-
var PURPOSE_KEYWORDS = ["frying", "greasing", "drizzling", "garnish", "serving", "brushing"];
|
|
637
|
-
var RANGE_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(?:\s*(?:-|to)\s*((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?))/i;
|
|
638
|
-
var NUMBER_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)/i;
|
|
639
|
-
var QUALIFIER_REGEX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
|
|
640
|
-
var FLAVOR_NOTE_REGEX = /\b(to taste|as needed|as necessary)\b/gi;
|
|
641
|
-
var VAGUE_QUANTITY_PATTERNS = [
|
|
642
|
-
{ regex: /^(a\s+pinch|pinch)\b/i, note: "a pinch" },
|
|
643
|
-
{ regex: /^(a\s+handful|handful)\b/i, note: "a handful" },
|
|
644
|
-
{ regex: /^(a\s+dash|dash)\b/i, note: "a dash" },
|
|
645
|
-
{ regex: /^(a\s+sprinkle|sprinkle)\b/i, note: "a sprinkle" },
|
|
646
|
-
{ regex: /^(some)\b/i, note: "some" },
|
|
647
|
-
{ regex: /^(few\s+sprigs)/i, note: "few sprigs" },
|
|
648
|
-
{ regex: /^(a\s+few|few)\b/i, note: "a few" },
|
|
649
|
-
{ regex: /^(several)\b/i, note: "several" }
|
|
650
|
-
];
|
|
651
|
-
var JUICE_PREFIXES = ["juice of", "zest of"];
|
|
652
|
-
function normalizeIngredientInput(input) {
|
|
653
|
-
if (!input) return "";
|
|
654
|
-
let result = input.replace(/\u00A0/g, " ").trim();
|
|
655
|
-
result = replaceDashes(result);
|
|
656
|
-
result = replaceUnicodeFractions(result);
|
|
657
|
-
result = replaceNumberWords(result);
|
|
658
|
-
result = result.replace(/\s+/g, " ").trim();
|
|
659
|
-
return result;
|
|
660
|
-
}
|
|
661
|
-
function parseIngredient(text) {
|
|
662
|
-
const original = text ?? "";
|
|
663
|
-
const normalized = normalizeIngredientInput(original);
|
|
664
|
-
if (!normalized) {
|
|
665
|
-
return {
|
|
666
|
-
item: original,
|
|
667
|
-
scaling: { type: "linear" }
|
|
668
|
-
};
|
|
669
|
-
}
|
|
670
|
-
let working = normalized;
|
|
671
|
-
const notes = [];
|
|
672
|
-
let optional = false;
|
|
673
|
-
if (/\boptional\b/i.test(working)) {
|
|
674
|
-
optional = true;
|
|
675
|
-
working = working.replace(/\(?\s*optional\s*\)?/gi, "").trim();
|
|
676
|
-
working = working.replace(/\(\s*\)/g, " ").trim();
|
|
677
|
-
}
|
|
678
|
-
const flavorExtraction = extractFlavorNotes(working);
|
|
679
|
-
working = flavorExtraction.cleaned;
|
|
680
|
-
notes.push(...flavorExtraction.notes);
|
|
681
|
-
const parenthetical = extractParentheticals(working);
|
|
682
|
-
working = parenthetical.cleaned;
|
|
683
|
-
notes.push(...parenthetical.notes);
|
|
684
|
-
optional = optional || parenthetical.optional;
|
|
685
|
-
const purposeExtraction = extractPurposeNotes(working);
|
|
686
|
-
working = purposeExtraction.cleaned;
|
|
687
|
-
notes.push(...purposeExtraction.notes);
|
|
688
|
-
const juiceExtraction = extractJuicePhrase(working);
|
|
689
|
-
if (juiceExtraction) {
|
|
690
|
-
working = juiceExtraction.cleaned;
|
|
691
|
-
notes.push(juiceExtraction.note);
|
|
692
|
-
}
|
|
693
|
-
const vagueQuantity = extractVagueQuantity(working);
|
|
694
|
-
let quantityResult;
|
|
695
|
-
if (vagueQuantity) {
|
|
696
|
-
notes.push(vagueQuantity.note);
|
|
697
|
-
quantityResult = {
|
|
698
|
-
amount: null,
|
|
699
|
-
unit: null,
|
|
700
|
-
descriptor: void 0,
|
|
701
|
-
remainder: vagueQuantity.remainder,
|
|
702
|
-
notes: [],
|
|
703
|
-
originalAmount: null
|
|
704
|
-
};
|
|
705
|
-
} else {
|
|
706
|
-
quantityResult = extractQuantity(working);
|
|
707
|
-
}
|
|
708
|
-
working = quantityResult.remainder;
|
|
709
|
-
const { quantity, usedParenthetical } = mergeQuantities(quantityResult, parenthetical.measurement);
|
|
710
|
-
if (usedParenthetical && quantityResult.originalAmount !== null && quantityResult.originalAmount > 1 && quantityResult.descriptor && DESCRIPTOR_NOTE_SET.has(quantityResult.descriptor.toLowerCase())) {
|
|
711
|
-
notes.push(formatCountNote(quantityResult.originalAmount, quantityResult.descriptor));
|
|
712
|
-
}
|
|
713
|
-
notes.push(...quantityResult.notes);
|
|
714
|
-
working = working.replace(/^[,.\s-]+/, "").trim();
|
|
715
|
-
working = working.replace(/^of\s+/i, "").trim();
|
|
716
|
-
if (quantityResult.descriptor && /^cans?$/i.test(quantityResult.descriptor) && working && !/^canned\b/i.test(working)) {
|
|
717
|
-
working = `canned ${working}`.trim();
|
|
718
|
-
}
|
|
719
|
-
const nameExtraction = extractNameAndPrep(working);
|
|
720
|
-
notes.push(...nameExtraction.notes);
|
|
721
|
-
const name = nameExtraction.name || void 0;
|
|
722
|
-
const scaling = inferScaling(
|
|
723
|
-
name,
|
|
724
|
-
quantity.unit,
|
|
725
|
-
quantity.amount,
|
|
726
|
-
notes,
|
|
727
|
-
quantityResult.descriptor
|
|
728
|
-
);
|
|
729
|
-
const mergedNotes = formatNotes(notes);
|
|
730
|
-
const parsed = {
|
|
731
|
-
item: original,
|
|
732
|
-
quantity,
|
|
733
|
-
...name ? { name } : {},
|
|
734
|
-
...nameExtraction.prep ? { prep: nameExtraction.prep } : {},
|
|
735
|
-
...optional ? { optional: true } : {},
|
|
736
|
-
scaling
|
|
737
|
-
};
|
|
738
|
-
if (mergedNotes) {
|
|
739
|
-
parsed.notes = mergedNotes;
|
|
740
|
-
}
|
|
741
|
-
return parsed;
|
|
742
|
-
}
|
|
743
|
-
function parseIngredientLine(text) {
|
|
744
|
-
return parseIngredient(text);
|
|
745
|
-
}
|
|
746
|
-
function parseIngredients(texts) {
|
|
747
|
-
if (!Array.isArray(texts)) return [];
|
|
748
|
-
return texts.map((item) => typeof item === "string" ? item : String(item ?? "")).map((entry) => parseIngredient(entry));
|
|
749
|
-
}
|
|
750
|
-
function replaceDashes(value) {
|
|
751
|
-
return value.replace(/[\u2012\u2013\u2014\u2212]/g, "-");
|
|
752
|
-
}
|
|
753
|
-
function replaceUnicodeFractions(value) {
|
|
754
|
-
return value.replace(/(\d+)?(?:\s+)?([½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])/g, (_match, whole, fraction) => {
|
|
755
|
-
const fractionValue = FRACTION_DECIMALS[fraction];
|
|
756
|
-
if (fractionValue === void 0) return _match;
|
|
757
|
-
const base = whole ? parseInt(whole, 10) : 0;
|
|
758
|
-
const combined = base + fractionValue;
|
|
759
|
-
return formatDecimal(combined);
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
function replaceNumberWords(value) {
|
|
763
|
-
return value.replace(
|
|
764
|
-
/\b(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|half|quarter)(?:-(one|two|three|four|five|six|seven|eight|nine))?\b/gi,
|
|
765
|
-
(match, word, hyphenPart) => {
|
|
766
|
-
const lower = word.toLowerCase();
|
|
767
|
-
const baseValue = NUMBER_WORDS[lower];
|
|
768
|
-
if (baseValue === void 0) return match;
|
|
769
|
-
if (!hyphenPart) {
|
|
770
|
-
return formatDecimal(baseValue);
|
|
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" }
|
|
771
673
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
+
]
|
|
775
693
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
cleaned: cleaned.replace(/\s+/g, " ").trim(),
|
|
794
|
-
notes
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
function extractPurposeNotes(value) {
|
|
798
|
-
const notes = [];
|
|
799
|
-
let working = value.trim();
|
|
800
|
-
let match = working.match(/\bfor\s+(frying|greasing|drizzling|garnish|serving|brushing)\b\.?$/i);
|
|
801
|
-
if (match) {
|
|
802
|
-
notes.push(`for ${match[1].toLowerCase()}`);
|
|
803
|
-
working = working.slice(0, match.index).trim();
|
|
804
|
-
}
|
|
805
|
-
return { cleaned: working, notes };
|
|
806
|
-
}
|
|
807
|
-
function extractJuicePhrase(value) {
|
|
808
|
-
const lower = value.toLowerCase();
|
|
809
|
-
for (const prefix of JUICE_PREFIXES) {
|
|
810
|
-
if (lower.startsWith(prefix)) {
|
|
811
|
-
const remainder = value.slice(prefix.length).trim();
|
|
812
|
-
if (!remainder) break;
|
|
813
|
-
const cleanedSource = remainder.replace(/^of\s+/i, "").trim();
|
|
814
|
-
if (!cleanedSource) break;
|
|
815
|
-
const sourceForName = cleanedSource.replace(
|
|
816
|
-
/^(?:\d+(?:\.\d+)?|\d+\s+\d+\/\d+|\d+\/\d+|one|two|three|four|five|six|seven|eight|nine|ten|a|an)\s+/i,
|
|
817
|
-
""
|
|
818
|
-
).replace(/^(?:large|small|medium)\s+/i, "").trim();
|
|
819
|
-
const baseName = sourceForName || cleanedSource;
|
|
820
|
-
const singular = singularize(baseName);
|
|
821
|
-
const suffix = prefix.startsWith("zest") ? "zest" : "juice";
|
|
822
|
-
return {
|
|
823
|
-
cleaned: `${singular} ${suffix}`.trim(),
|
|
824
|
-
note: `from ${cleanedSource}`
|
|
825
|
-
};
|
|
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" }
|
|
826
711
|
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
+
}
|
|
930
|
+
}
|
|
840
931
|
}
|
|
841
932
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
+
}
|
|
854
962
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
+
}
|
|
859
986
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
}
|
|
881
|
-
function extractQuantity(value) {
|
|
882
|
-
let working = value.trim();
|
|
883
|
-
const notes = [];
|
|
884
|
-
let amount = null;
|
|
885
|
-
let originalAmount = null;
|
|
886
|
-
let unit = null;
|
|
887
|
-
let descriptor;
|
|
888
|
-
while (QUALIFIER_REGEX.test(working)) {
|
|
889
|
-
working = working.replace(QUALIFIER_REGEX, "").trim();
|
|
890
|
-
}
|
|
891
|
-
const rangeMatch = working.match(RANGE_REGEX);
|
|
892
|
-
if (rangeMatch) {
|
|
893
|
-
amount = parseNumber(rangeMatch[1]);
|
|
894
|
-
originalAmount = amount;
|
|
895
|
-
const rangeText = rangeMatch[0].trim();
|
|
896
|
-
const afterRange = working.slice(rangeMatch[0].length).trim();
|
|
897
|
-
const descriptorMatch = afterRange.match(/^([a-zA-Z]+)/);
|
|
898
|
-
if (descriptorMatch && COUNT_DESCRIPTORS.has(descriptorMatch[1].toLowerCase())) {
|
|
899
|
-
notes.push(`${rangeText} ${descriptorMatch[1]}`);
|
|
900
|
-
} else {
|
|
901
|
-
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
|
+
]
|
|
902
1007
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
+
]
|
|
910
1057
|
}
|
|
911
1058
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
+
}
|
|
922
1080
|
}
|
|
923
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
|
+
}
|
|
924
1103
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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);
|
|
932
1126
|
};
|
|
1127
|
+
addSchemaIfNew(schema_default);
|
|
1128
|
+
addSchemaIfNew(soustack_schema_default);
|
|
1129
|
+
Object.values(profileSchemas).forEach(addSchemaIfNew);
|
|
1130
|
+
return { ajv, validators: {} };
|
|
933
1131
|
}
|
|
934
|
-
function
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
if (/^\d+\s+\d+\/\d+$/.test(trimmed)) {
|
|
938
|
-
const [whole, fraction] = trimmed.split(/\s+/);
|
|
939
|
-
return parseInt(whole, 10) + parseFraction(fraction);
|
|
940
|
-
}
|
|
941
|
-
if (/^\d+\/\d+$/.test(trimmed)) {
|
|
942
|
-
return parseFraction(trimmed);
|
|
1132
|
+
function getContext(collectAllErrors) {
|
|
1133
|
+
if (!validationContexts.has(collectAllErrors)) {
|
|
1134
|
+
validationContexts.set(collectAllErrors, createContext(collectAllErrors));
|
|
943
1135
|
}
|
|
944
|
-
|
|
945
|
-
return Number.isNaN(parsed) ? null : parsed;
|
|
1136
|
+
return validationContexts.get(collectAllErrors);
|
|
946
1137
|
}
|
|
947
|
-
function
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
return numerator / denominator;
|
|
951
|
-
}
|
|
952
|
-
function normalizeUnit(raw) {
|
|
953
|
-
const lower = raw.toLowerCase();
|
|
954
|
-
if (UNIT_SYNONYMS[lower]) {
|
|
955
|
-
return UNIT_SYNONYMS[lower];
|
|
1138
|
+
function cloneRecipe(recipe) {
|
|
1139
|
+
if (typeof structuredClone === "function") {
|
|
1140
|
+
return structuredClone(recipe);
|
|
956
1141
|
}
|
|
957
|
-
|
|
958
|
-
if (raw === "t") return "tsp";
|
|
959
|
-
if (raw === "C") return "cup";
|
|
960
|
-
return null;
|
|
1142
|
+
return JSON.parse(JSON.stringify(recipe));
|
|
961
1143
|
}
|
|
962
|
-
function
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
return { quantity, usedParenthetical: false };
|
|
969
|
-
}
|
|
970
|
-
const measurementUnit = measurement.unit?.toLowerCase() ?? null;
|
|
971
|
-
const shouldPrefer = !quantity.unit || measurementUnit !== null && WEIGHT_PRIORITY_UNITS.has(measurementUnit);
|
|
972
|
-
if (shouldPrefer) {
|
|
973
|
-
return {
|
|
974
|
-
quantity: {
|
|
975
|
-
amount: measurement.amount,
|
|
976
|
-
unit: measurement.unit ?? null
|
|
977
|
-
},
|
|
978
|
-
usedParenthetical: true
|
|
979
|
-
};
|
|
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;
|
|
980
1150
|
}
|
|
981
|
-
return
|
|
982
|
-
}
|
|
983
|
-
function
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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;
|
|
993
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, "/") || "/";
|
|
994
1213
|
}
|
|
995
|
-
working = working.replace(/^[,.\s-]+/, "").trim();
|
|
996
|
-
working = working.replace(/^of\s+/i, "").trim();
|
|
997
|
-
if (!working) {
|
|
998
|
-
return { name: void 0, prep, notes };
|
|
999
|
-
}
|
|
1000
|
-
let name = cleanupIngredientName(working);
|
|
1001
1214
|
return {
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1215
|
+
path,
|
|
1216
|
+
keyword: error.keyword,
|
|
1217
|
+
message: error.message || "Validation error"
|
|
1005
1218
|
};
|
|
1006
1219
|
}
|
|
1007
|
-
function
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
+
});
|
|
1019
1269
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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);
|
|
1024
1278
|
}
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
const smallUnit = unit ? ["tsp", "tbsp", "dash", "pinch"].includes(unit) : false;
|
|
1047
|
-
if (normalizedNotes.some((note) => note.includes("to taste")) || isSpice && (smallUnit || amount !== null && amount <= 1)) {
|
|
1048
|
-
return { type: "proportional", factor: 0.7 };
|
|
1049
|
-
}
|
|
1050
|
-
return { type: "linear" };
|
|
1051
|
-
}
|
|
1052
|
-
function formatNotes(notes) {
|
|
1053
|
-
const cleaned = Array.from(
|
|
1054
|
-
new Set(
|
|
1055
|
-
notes.map((note) => note.trim()).filter(Boolean)
|
|
1056
|
-
)
|
|
1057
|
-
);
|
|
1058
|
-
return cleaned.length ? cleaned.join("; ") : void 0;
|
|
1059
|
-
}
|
|
1060
|
-
function formatCountNote(amount, descriptor) {
|
|
1061
|
-
const lower = descriptor.toLowerCase();
|
|
1062
|
-
const singular = lower.endsWith("s") ? lower.slice(0, -1) : lower;
|
|
1063
|
-
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`;
|
|
1064
|
-
return `${formatDecimal(amount)} ${word}`;
|
|
1065
|
-
}
|
|
1066
|
-
function singularize(value) {
|
|
1067
|
-
const trimmed = value.trim();
|
|
1068
|
-
if (trimmed.endsWith("ies")) {
|
|
1069
|
-
return `${trimmed.slice(0, -3)}y`;
|
|
1070
|
-
}
|
|
1071
|
-
if (/(ches|shes|sses|xes|zes)$/i.test(trimmed)) {
|
|
1072
|
-
return trimmed.slice(0, -2);
|
|
1073
|
-
}
|
|
1074
|
-
if (trimmed.endsWith("s")) {
|
|
1075
|
-
return trimmed.slice(0, -1);
|
|
1076
|
-
}
|
|
1077
|
-
return trimmed;
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
// src/converters/ingredient.ts
|
|
1081
|
-
function parseIngredientLine2(line) {
|
|
1082
|
-
const parsed = parseIngredient(line);
|
|
1083
|
-
const ingredient = {
|
|
1084
|
-
item: parsed.item,
|
|
1085
|
-
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);
|
|
1086
1300
|
};
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1101
|
-
ingredient.quantity = quantity;
|
|
1102
|
-
}
|
|
1103
|
-
return ingredient;
|
|
1104
|
-
}
|
|
1105
|
-
function buildQuantity(parsedQuantity) {
|
|
1106
|
-
if (!parsedQuantity) {
|
|
1107
|
-
return void 0;
|
|
1108
|
-
}
|
|
1109
|
-
if (parsedQuantity.amount === null || Number.isNaN(parsedQuantity.amount)) {
|
|
1110
|
-
return void 0;
|
|
1111
|
-
}
|
|
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];
|
|
1112
1315
|
return {
|
|
1113
|
-
|
|
1114
|
-
|
|
1316
|
+
valid: errors.length === 0,
|
|
1317
|
+
errors,
|
|
1318
|
+
warnings,
|
|
1319
|
+
normalized: errors.length === 0 ? normalized : void 0
|
|
1115
1320
|
};
|
|
1116
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
|
+
}
|
|
1117
1339
|
|
|
1118
1340
|
// src/converters/yield.ts
|
|
1119
1341
|
function parseYield(value) {
|
|
@@ -1121,128 +1343,48 @@ function parseYield(value) {
|
|
|
1121
1343
|
return void 0;
|
|
1122
1344
|
}
|
|
1123
1345
|
if (typeof value === "number") {
|
|
1124
|
-
return {
|
|
1125
|
-
amount: value,
|
|
1126
|
-
unit: "servings"
|
|
1127
|
-
};
|
|
1128
|
-
}
|
|
1129
|
-
if (Array.isArray(value)) {
|
|
1130
|
-
return parseYield(value[0]);
|
|
1131
|
-
}
|
|
1132
|
-
if (typeof value === "object") {
|
|
1133
|
-
const maybeYield = value;
|
|
1134
|
-
if (typeof maybeYield.amount === "number") {
|
|
1135
|
-
return {
|
|
1136
|
-
amount: maybeYield.amount,
|
|
1137
|
-
unit: typeof maybeYield.unit === "string" ? maybeYield.unit : "servings",
|
|
1138
|
-
description: typeof maybeYield.description === "string" ? maybeYield.description : void 0
|
|
1139
|
-
};
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
|
-
if (typeof value === "string") {
|
|
1143
|
-
const trimmed = value.trim();
|
|
1144
|
-
const match = trimmed.match(/(\d+(?:\.\d+)?)/);
|
|
1145
|
-
if (match) {
|
|
1146
|
-
const amount = parseFloat(match[1]);
|
|
1147
|
-
const unit = trimmed.slice(match.index + match[1].length).trim();
|
|
1148
|
-
return {
|
|
1149
|
-
amount,
|
|
1150
|
-
unit: unit || "servings",
|
|
1151
|
-
description: trimmed
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
return void 0;
|
|
1156
|
-
}
|
|
1157
|
-
function formatYield(yieldValue) {
|
|
1158
|
-
if (!yieldValue) return void 0;
|
|
1159
|
-
if (!yieldValue.amount && !yieldValue.unit) {
|
|
1160
|
-
return void 0;
|
|
1161
|
-
}
|
|
1162
|
-
const amount = yieldValue.amount ?? "";
|
|
1163
|
-
const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
|
|
1164
|
-
return `${amount}${unit}`.trim() || yieldValue.description;
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
// src/parsers/duration.ts
|
|
1168
|
-
var ISO_DURATION_REGEX = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i;
|
|
1169
|
-
var HUMAN_OVERNIGHT = 8 * 60;
|
|
1170
|
-
function isFiniteNumber(value) {
|
|
1171
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
1172
|
-
}
|
|
1173
|
-
function parseDuration(iso) {
|
|
1174
|
-
if (!iso || typeof iso !== "string") return null;
|
|
1175
|
-
const trimmed = iso.trim();
|
|
1176
|
-
if (!trimmed) return null;
|
|
1177
|
-
const match = trimmed.match(ISO_DURATION_REGEX);
|
|
1178
|
-
if (!match) return null;
|
|
1179
|
-
const [, daysRaw, hoursRaw, minutesRaw, secondsRaw] = match;
|
|
1180
|
-
if (!daysRaw && !hoursRaw && !minutesRaw && !secondsRaw) {
|
|
1181
|
-
return null;
|
|
1182
|
-
}
|
|
1183
|
-
let total = 0;
|
|
1184
|
-
if (daysRaw) total += parseFloat(daysRaw) * 24 * 60;
|
|
1185
|
-
if (hoursRaw) total += parseFloat(hoursRaw) * 60;
|
|
1186
|
-
if (minutesRaw) total += parseFloat(minutesRaw);
|
|
1187
|
-
if (secondsRaw) total += Math.ceil(parseFloat(secondsRaw) / 60);
|
|
1188
|
-
return Math.round(total);
|
|
1189
|
-
}
|
|
1190
|
-
function formatDuration(minutes) {
|
|
1191
|
-
if (!isFiniteNumber(minutes) || minutes <= 0) {
|
|
1192
|
-
return "PT0M";
|
|
1193
|
-
}
|
|
1194
|
-
const rounded = Math.round(minutes);
|
|
1195
|
-
const days = Math.floor(rounded / (24 * 60));
|
|
1196
|
-
const afterDays = rounded % (24 * 60);
|
|
1197
|
-
const hours = Math.floor(afterDays / 60);
|
|
1198
|
-
const mins = afterDays % 60;
|
|
1199
|
-
let result = "P";
|
|
1200
|
-
if (days > 0) {
|
|
1201
|
-
result += `${days}D`;
|
|
1202
|
-
}
|
|
1203
|
-
if (hours > 0 || mins > 0) {
|
|
1204
|
-
result += "T";
|
|
1205
|
-
if (hours > 0) {
|
|
1206
|
-
result += `${hours}H`;
|
|
1207
|
-
}
|
|
1208
|
-
if (mins > 0) {
|
|
1209
|
-
result += `${mins}M`;
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
if (result === "P") {
|
|
1213
|
-
return "PT0M";
|
|
1214
|
-
}
|
|
1215
|
-
return result;
|
|
1216
|
-
}
|
|
1217
|
-
function parseHumanDuration(text) {
|
|
1218
|
-
if (!text || typeof text !== "string") return null;
|
|
1219
|
-
const normalized = text.toLowerCase().trim();
|
|
1220
|
-
if (!normalized) return null;
|
|
1221
|
-
if (normalized === "overnight") {
|
|
1222
|
-
return HUMAN_OVERNIGHT;
|
|
1346
|
+
return {
|
|
1347
|
+
amount: value,
|
|
1348
|
+
unit: "servings"
|
|
1349
|
+
};
|
|
1223
1350
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
let hourMatch;
|
|
1227
|
-
while ((hourMatch = hourRegex.exec(normalized)) !== null) {
|
|
1228
|
-
total += parseFloat(hourMatch[1]) * 60;
|
|
1351
|
+
if (Array.isArray(value)) {
|
|
1352
|
+
return parseYield(value[0]);
|
|
1229
1353
|
}
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1354
|
+
if (typeof value === "object") {
|
|
1355
|
+
const maybeYield = value;
|
|
1356
|
+
if (typeof maybeYield.amount === "number") {
|
|
1357
|
+
return {
|
|
1358
|
+
amount: maybeYield.amount,
|
|
1359
|
+
unit: typeof maybeYield.unit === "string" ? maybeYield.unit : "servings",
|
|
1360
|
+
description: typeof maybeYield.description === "string" ? maybeYield.description : void 0
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1234
1363
|
}
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
1364
|
+
if (typeof value === "string") {
|
|
1365
|
+
const trimmed = value.trim();
|
|
1366
|
+
const match = trimmed.match(/(\d+(?:\.\d+)?)/);
|
|
1367
|
+
if (match) {
|
|
1368
|
+
const amount = parseFloat(match[1]);
|
|
1369
|
+
const unit = trimmed.slice(match.index + match[1].length).trim();
|
|
1370
|
+
return {
|
|
1371
|
+
amount,
|
|
1372
|
+
unit: unit || "servings",
|
|
1373
|
+
description: trimmed
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1237
1376
|
}
|
|
1238
|
-
return
|
|
1377
|
+
return void 0;
|
|
1239
1378
|
}
|
|
1240
|
-
function
|
|
1241
|
-
|
|
1242
|
-
if (
|
|
1243
|
-
|
|
1379
|
+
function formatYield(yieldValue) {
|
|
1380
|
+
var _a2;
|
|
1381
|
+
if (!yieldValue) return void 0;
|
|
1382
|
+
if (!yieldValue.amount && !yieldValue.unit) {
|
|
1383
|
+
return void 0;
|
|
1244
1384
|
}
|
|
1245
|
-
|
|
1385
|
+
const amount = (_a2 = yieldValue.amount) != null ? _a2 : "";
|
|
1386
|
+
const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
|
|
1387
|
+
return `${amount}${unit}`.trim() || yieldValue.description;
|
|
1246
1388
|
}
|
|
1247
1389
|
|
|
1248
1390
|
// src/utils/image.ts
|
|
@@ -1281,6 +1423,7 @@ function extractUrl(value) {
|
|
|
1281
1423
|
|
|
1282
1424
|
// src/fromSchemaOrg.ts
|
|
1283
1425
|
function fromSchemaOrg(input) {
|
|
1426
|
+
var _a2;
|
|
1284
1427
|
const recipeNode = extractRecipeNode(input);
|
|
1285
1428
|
if (!recipeNode) {
|
|
1286
1429
|
return null;
|
|
@@ -1295,7 +1438,7 @@ function fromSchemaOrg(input) {
|
|
|
1295
1438
|
const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
|
|
1296
1439
|
return {
|
|
1297
1440
|
name: recipeNode.name.trim(),
|
|
1298
|
-
description: recipeNode.description
|
|
1441
|
+
description: ((_a2 = recipeNode.description) == null ? void 0 : _a2.trim()) || void 0,
|
|
1299
1442
|
image: normalizeImage(recipeNode.image),
|
|
1300
1443
|
category,
|
|
1301
1444
|
tags: tags.length ? tags : void 0,
|
|
@@ -1341,8 +1484,6 @@ function extractRecipeNode(input) {
|
|
|
1341
1484
|
function hasRecipeType(value) {
|
|
1342
1485
|
if (!value) return false;
|
|
1343
1486
|
const types = Array.isArray(value) ? value : [value];
|
|
1344
|
-
fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "fromSchemaOrg.ts:95", message: "hasRecipeType check", data: { types, typesLower: types.map((t) => typeof t === "string" ? t.toLowerCase() : t), isMatch: types.some((e) => typeof e === "string" && e.toLowerCase() === "recipe") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
|
|
1345
|
-
});
|
|
1346
1487
|
return types.some(
|
|
1347
1488
|
(entry) => typeof entry === "string" && entry.toLowerCase() === "recipe"
|
|
1348
1489
|
);
|
|
@@ -1353,9 +1494,10 @@ function isValidName(name) {
|
|
|
1353
1494
|
function convertIngredients(value) {
|
|
1354
1495
|
if (!value) return [];
|
|
1355
1496
|
const normalized = Array.isArray(value) ? value : [value];
|
|
1356
|
-
return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean)
|
|
1497
|
+
return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean);
|
|
1357
1498
|
}
|
|
1358
1499
|
function convertInstructions(value) {
|
|
1500
|
+
var _a2;
|
|
1359
1501
|
if (!value) return [];
|
|
1360
1502
|
const normalized = Array.isArray(value) ? value : [value];
|
|
1361
1503
|
const result = [];
|
|
@@ -1372,7 +1514,7 @@ function convertInstructions(value) {
|
|
|
1372
1514
|
const subsectionItems = extractSectionItems(entry.itemListElement);
|
|
1373
1515
|
if (subsectionItems.length) {
|
|
1374
1516
|
result.push({
|
|
1375
|
-
subsection: entry.name
|
|
1517
|
+
subsection: ((_a2 = entry.name) == null ? void 0 : _a2.trim()) || "Section",
|
|
1376
1518
|
items: subsectionItems
|
|
1377
1519
|
});
|
|
1378
1520
|
}
|
|
@@ -1421,10 +1563,33 @@ function convertHowToStep(step) {
|
|
|
1421
1563
|
return void 0;
|
|
1422
1564
|
}
|
|
1423
1565
|
const normalizedImage = normalizeImage(step.image);
|
|
1424
|
-
|
|
1425
|
-
|
|
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;
|
|
1426
1590
|
}
|
|
1427
|
-
|
|
1591
|
+
const trimmed = raw.trim();
|
|
1592
|
+
return trimmed || void 0;
|
|
1428
1593
|
}
|
|
1429
1594
|
function isHowToStep(value) {
|
|
1430
1595
|
return Boolean(value) && typeof value === "object" && value["@type"] === "HowToStep";
|
|
@@ -1433,9 +1598,10 @@ function isHowToSection(value) {
|
|
|
1433
1598
|
return Boolean(value) && typeof value === "object" && value["@type"] === "HowToSection" && Array.isArray(value.itemListElement);
|
|
1434
1599
|
}
|
|
1435
1600
|
function convertTime(recipe) {
|
|
1436
|
-
|
|
1437
|
-
const
|
|
1438
|
-
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 : "");
|
|
1439
1605
|
const structured = {};
|
|
1440
1606
|
if (prep !== null && prep !== void 0) structured.prep = prep;
|
|
1441
1607
|
if (cook !== null && cook !== void 0) structured.active = cook;
|
|
@@ -1468,9 +1634,10 @@ function extractFirst(value) {
|
|
|
1468
1634
|
return arr.length ? arr[0] : void 0;
|
|
1469
1635
|
}
|
|
1470
1636
|
function convertSource(recipe) {
|
|
1637
|
+
var _a2;
|
|
1471
1638
|
const author = extractEntityName(recipe.author);
|
|
1472
1639
|
const publisher = extractEntityName(recipe.publisher);
|
|
1473
|
-
const url = (recipe.url || recipe.mainEntityOfPage)
|
|
1640
|
+
const url = (_a2 = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a2.trim();
|
|
1474
1641
|
const source = {};
|
|
1475
1642
|
if (author) source.author = author;
|
|
1476
1643
|
if (publisher) source.name = publisher;
|
|
@@ -1501,13 +1668,14 @@ function extractEntityName(value) {
|
|
|
1501
1668
|
|
|
1502
1669
|
// src/converters/toSchemaOrg.ts
|
|
1503
1670
|
function convertBasicMetadata(recipe) {
|
|
1671
|
+
var _a2;
|
|
1504
1672
|
return cleanOutput({
|
|
1505
1673
|
"@context": "https://schema.org",
|
|
1506
1674
|
"@type": "Recipe",
|
|
1507
1675
|
name: recipe.name,
|
|
1508
1676
|
description: recipe.description,
|
|
1509
1677
|
image: recipe.image,
|
|
1510
|
-
url: recipe.source
|
|
1678
|
+
url: (_a2 = recipe.source) == null ? void 0 : _a2.url,
|
|
1511
1679
|
datePublished: recipe.dateAdded,
|
|
1512
1680
|
dateModified: recipe.dateModified
|
|
1513
1681
|
});
|
|
@@ -1515,6 +1683,7 @@ function convertBasicMetadata(recipe) {
|
|
|
1515
1683
|
function convertIngredients2(ingredients = []) {
|
|
1516
1684
|
const result = [];
|
|
1517
1685
|
ingredients.forEach((ingredient) => {
|
|
1686
|
+
var _a2;
|
|
1518
1687
|
if (!ingredient) {
|
|
1519
1688
|
return;
|
|
1520
1689
|
}
|
|
@@ -1544,7 +1713,7 @@ function convertIngredients2(ingredients = []) {
|
|
|
1544
1713
|
});
|
|
1545
1714
|
return;
|
|
1546
1715
|
}
|
|
1547
|
-
const value = ingredient.item
|
|
1716
|
+
const value = (_a2 = ingredient.item) == null ? void 0 : _a2.trim();
|
|
1548
1717
|
if (value) {
|
|
1549
1718
|
result.push(value);
|
|
1550
1719
|
}
|
|
@@ -1559,10 +1728,11 @@ function convertInstruction(entry) {
|
|
|
1559
1728
|
return null;
|
|
1560
1729
|
}
|
|
1561
1730
|
if (typeof entry === "string") {
|
|
1562
|
-
|
|
1731
|
+
const value = entry.trim();
|
|
1732
|
+
return value || null;
|
|
1563
1733
|
}
|
|
1564
1734
|
if ("subsection" in entry) {
|
|
1565
|
-
const steps = entry.items.map((item) =>
|
|
1735
|
+
const steps = entry.items.map((item) => convertInstruction(item)).filter((step) => Boolean(step));
|
|
1566
1736
|
if (!steps.length) {
|
|
1567
1737
|
return null;
|
|
1568
1738
|
}
|
|
@@ -1578,18 +1748,13 @@ function convertInstruction(entry) {
|
|
|
1578
1748
|
return createHowToStep(String(entry));
|
|
1579
1749
|
}
|
|
1580
1750
|
function createHowToStep(entry) {
|
|
1751
|
+
var _a2;
|
|
1581
1752
|
if (!entry) return null;
|
|
1582
1753
|
if (typeof entry === "string") {
|
|
1583
1754
|
const trimmed2 = entry.trim();
|
|
1584
|
-
|
|
1585
|
-
return null;
|
|
1586
|
-
}
|
|
1587
|
-
return {
|
|
1588
|
-
"@type": "HowToStep",
|
|
1589
|
-
text: trimmed2
|
|
1590
|
-
};
|
|
1755
|
+
return trimmed2 || null;
|
|
1591
1756
|
}
|
|
1592
|
-
const trimmed = entry.text
|
|
1757
|
+
const trimmed = (_a2 = entry.text) == null ? void 0 : _a2.trim();
|
|
1593
1758
|
if (!trimmed) {
|
|
1594
1759
|
return null;
|
|
1595
1760
|
}
|
|
@@ -1597,10 +1762,23 @@ function createHowToStep(entry) {
|
|
|
1597
1762
|
"@type": "HowToStep",
|
|
1598
1763
|
text: trimmed
|
|
1599
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
|
+
}
|
|
1600
1775
|
if (entry.image) {
|
|
1601
1776
|
step.image = entry.image;
|
|
1602
1777
|
}
|
|
1603
|
-
|
|
1778
|
+
if (step["@id"] || step.performTime || step.image) {
|
|
1779
|
+
return step;
|
|
1780
|
+
}
|
|
1781
|
+
return trimmed;
|
|
1604
1782
|
}
|
|
1605
1783
|
function convertTime2(time) {
|
|
1606
1784
|
if (!time) {
|
|
@@ -1700,113 +1878,6 @@ function isStructuredTime(time) {
|
|
|
1700
1878
|
return typeof time.prep !== "undefined" || typeof time.active !== "undefined" || typeof time.passive !== "undefined" || typeof time.total !== "undefined";
|
|
1701
1879
|
}
|
|
1702
1880
|
|
|
1703
|
-
// src/scraper/fetch.ts
|
|
1704
|
-
var DEFAULT_USER_AGENTS = [
|
|
1705
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
1706
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
1707
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
|
|
1708
|
-
];
|
|
1709
|
-
function chooseUserAgent(provided) {
|
|
1710
|
-
if (provided) return provided;
|
|
1711
|
-
const index = Math.floor(Math.random() * DEFAULT_USER_AGENTS.length);
|
|
1712
|
-
return DEFAULT_USER_AGENTS[index];
|
|
1713
|
-
}
|
|
1714
|
-
function resolveFetch(fetchFn) {
|
|
1715
|
-
if (fetchFn) {
|
|
1716
|
-
return fetchFn;
|
|
1717
|
-
}
|
|
1718
|
-
const globalFetch = globalThis.fetch;
|
|
1719
|
-
if (!globalFetch) {
|
|
1720
|
-
throw new Error(
|
|
1721
|
-
"A global fetch implementation is not available. Provide window.fetch in browsers or upgrade to Node 18+."
|
|
1722
|
-
);
|
|
1723
|
-
}
|
|
1724
|
-
return globalFetch;
|
|
1725
|
-
}
|
|
1726
|
-
function isBrowserEnvironment() {
|
|
1727
|
-
return typeof globalThis.document !== "undefined";
|
|
1728
|
-
}
|
|
1729
|
-
function isClientError(error) {
|
|
1730
|
-
if (typeof error.status === "number") {
|
|
1731
|
-
return error.status >= 400 && error.status < 500;
|
|
1732
|
-
}
|
|
1733
|
-
return error.message.includes("HTTP 4");
|
|
1734
|
-
}
|
|
1735
|
-
async function wait(ms) {
|
|
1736
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1737
|
-
}
|
|
1738
|
-
async function fetchPage(url, options = {}) {
|
|
1739
|
-
const {
|
|
1740
|
-
timeout = 1e4,
|
|
1741
|
-
userAgent,
|
|
1742
|
-
maxRetries = 2,
|
|
1743
|
-
fetchFn
|
|
1744
|
-
} = options;
|
|
1745
|
-
let lastError = null;
|
|
1746
|
-
const resolvedFetch = resolveFetch(fetchFn);
|
|
1747
|
-
const isBrowser2 = isBrowserEnvironment();
|
|
1748
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1749
|
-
const controller = new AbortController();
|
|
1750
|
-
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1751
|
-
try {
|
|
1752
|
-
const headers = {
|
|
1753
|
-
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
1754
|
-
"Accept-Language": "en-US,en;q=0.5"
|
|
1755
|
-
};
|
|
1756
|
-
if (!isBrowser2) {
|
|
1757
|
-
headers["User-Agent"] = chooseUserAgent(userAgent);
|
|
1758
|
-
}
|
|
1759
|
-
const requestInit = {
|
|
1760
|
-
headers,
|
|
1761
|
-
signal: controller.signal,
|
|
1762
|
-
redirect: "follow"
|
|
1763
|
-
};
|
|
1764
|
-
const response = await resolvedFetch(url, requestInit);
|
|
1765
|
-
clearTimeout(timeoutId);
|
|
1766
|
-
if (response && typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
1767
|
-
try {
|
|
1768
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1769
|
-
if (globalFetch) {
|
|
1770
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:63", message: "fetch response", data: { url, status: response.status, statusText: response.statusText, ok: response.ok, isNYTimes: url.includes("nytimes.com") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
|
|
1771
|
-
});
|
|
1772
|
-
}
|
|
1773
|
-
} catch {
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
if (!response.ok) {
|
|
1777
|
-
const error = new Error(
|
|
1778
|
-
`HTTP ${response.status}: ${response.statusText}`
|
|
1779
|
-
);
|
|
1780
|
-
error.status = response.status;
|
|
1781
|
-
throw error;
|
|
1782
|
-
}
|
|
1783
|
-
const html = await response.text();
|
|
1784
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
1785
|
-
try {
|
|
1786
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1787
|
-
if (globalFetch) {
|
|
1788
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:75", message: "HTML received", data: { htmlLength: html.length, hasLoginPage: html.toLowerCase().includes("login") || html.toLowerCase().includes("sign in"), hasRecipeData: html.includes("application/ld+json") || html.includes("schema.org/Recipe") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B,D" }) }).catch(() => {
|
|
1789
|
-
});
|
|
1790
|
-
}
|
|
1791
|
-
} catch {
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
return html;
|
|
1795
|
-
} catch (err) {
|
|
1796
|
-
clearTimeout(timeoutId);
|
|
1797
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1798
|
-
if (isClientError(lastError)) {
|
|
1799
|
-
throw lastError;
|
|
1800
|
-
}
|
|
1801
|
-
if (attempt < maxRetries) {
|
|
1802
|
-
await wait(1e3 * (attempt + 1));
|
|
1803
|
-
continue;
|
|
1804
|
-
}
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
throw lastError ?? new Error("Failed to fetch page");
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
1881
|
// src/scraper/extractors/utils.ts
|
|
1811
1882
|
var RECIPE_TYPES = /* @__PURE__ */ new Set([
|
|
1812
1883
|
"recipe",
|
|
@@ -1843,97 +1914,8 @@ function normalizeText(value) {
|
|
|
1843
1914
|
return trimmed || void 0;
|
|
1844
1915
|
}
|
|
1845
1916
|
|
|
1846
|
-
// src/scraper/extractors/jsonld.ts
|
|
1847
|
-
function extractJsonLd(html) {
|
|
1848
|
-
const $ = load(html);
|
|
1849
|
-
const scripts = $('script[type="application/ld+json"]');
|
|
1850
|
-
fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:8", message: "JSON-LD scripts found", data: { scriptCount: scripts.length }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "C,D" }) }).catch(() => {
|
|
1851
|
-
});
|
|
1852
|
-
const candidates = [];
|
|
1853
|
-
scripts.each((_, element) => {
|
|
1854
|
-
const content = $(element).html();
|
|
1855
|
-
if (!content) return;
|
|
1856
|
-
const parsed = safeJsonParse(content);
|
|
1857
|
-
if (!parsed) return;
|
|
1858
|
-
fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:18", message: "JSON-LD parsed", data: { hasGraph: !!(parsed && typeof parsed === "object" && "@graph" in parsed), type: parsed && typeof parsed === "object" && "@type" in parsed ? parsed["@type"] : void 0 }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C" }) }).catch(() => {
|
|
1859
|
-
});
|
|
1860
|
-
collectCandidates(parsed, candidates);
|
|
1861
|
-
});
|
|
1862
|
-
fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:22", message: "JSON-LD candidates", data: { candidateCount: candidates.length, candidateTypes: candidates.map((c) => c["@type"]) }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C" }) }).catch(() => {
|
|
1863
|
-
});
|
|
1864
|
-
return candidates[0] ?? null;
|
|
1865
|
-
}
|
|
1866
|
-
function collectCandidates(payload, bucket) {
|
|
1867
|
-
if (!payload) return;
|
|
1868
|
-
if (Array.isArray(payload)) {
|
|
1869
|
-
payload.forEach((entry) => collectCandidates(entry, bucket));
|
|
1870
|
-
return;
|
|
1871
|
-
}
|
|
1872
|
-
if (typeof payload !== "object") {
|
|
1873
|
-
return;
|
|
1874
|
-
}
|
|
1875
|
-
if (isRecipeNode(payload)) {
|
|
1876
|
-
bucket.push(payload);
|
|
1877
|
-
return;
|
|
1878
|
-
}
|
|
1879
|
-
const graph = payload["@graph"];
|
|
1880
|
-
if (Array.isArray(graph)) {
|
|
1881
|
-
graph.forEach((entry) => collectCandidates(entry, bucket));
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
var SIMPLE_PROPS = [
|
|
1885
|
-
"name",
|
|
1886
|
-
"description",
|
|
1887
|
-
"image",
|
|
1888
|
-
"recipeYield",
|
|
1889
|
-
"prepTime",
|
|
1890
|
-
"cookTime",
|
|
1891
|
-
"totalTime"
|
|
1892
|
-
];
|
|
1893
|
-
function extractMicrodata(html) {
|
|
1894
|
-
const $ = load(html);
|
|
1895
|
-
const recipeEl = $('[itemscope][itemtype*="schema.org/Recipe"]').first();
|
|
1896
|
-
if (!recipeEl.length) {
|
|
1897
|
-
return null;
|
|
1898
|
-
}
|
|
1899
|
-
const recipe = {
|
|
1900
|
-
"@type": "Recipe"
|
|
1901
|
-
};
|
|
1902
|
-
SIMPLE_PROPS.forEach((prop) => {
|
|
1903
|
-
const value = findPropertyValue($, recipeEl, prop);
|
|
1904
|
-
if (value) {
|
|
1905
|
-
recipe[prop] = value;
|
|
1906
|
-
}
|
|
1907
|
-
});
|
|
1908
|
-
const ingredients = [];
|
|
1909
|
-
recipeEl.find('[itemprop="recipeIngredient"]').each((_, el) => {
|
|
1910
|
-
const text = normalizeText($(el).attr("content") || $(el).text());
|
|
1911
|
-
if (text) ingredients.push(text);
|
|
1912
|
-
});
|
|
1913
|
-
if (ingredients.length) {
|
|
1914
|
-
recipe.recipeIngredient = ingredients;
|
|
1915
|
-
}
|
|
1916
|
-
const instructions = [];
|
|
1917
|
-
recipeEl.find('[itemprop="recipeInstructions"]').each((_, el) => {
|
|
1918
|
-
const text = normalizeText($(el).attr("content")) || normalizeText($(el).find('[itemprop="text"]').first().text()) || normalizeText($(el).text());
|
|
1919
|
-
if (text) instructions.push(text);
|
|
1920
|
-
});
|
|
1921
|
-
if (instructions.length) {
|
|
1922
|
-
recipe.recipeInstructions = instructions;
|
|
1923
|
-
}
|
|
1924
|
-
if (recipe.name || ingredients.length) {
|
|
1925
|
-
return recipe;
|
|
1926
|
-
}
|
|
1927
|
-
return null;
|
|
1928
|
-
}
|
|
1929
|
-
function findPropertyValue($, context, prop) {
|
|
1930
|
-
const node = context.find(`[itemprop="${prop}"]`).first();
|
|
1931
|
-
if (!node.length) return void 0;
|
|
1932
|
-
return normalizeText(node.attr("content")) || normalizeText(node.attr("href")) || normalizeText(node.attr("src")) || normalizeText(node.text());
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
1917
|
// src/scraper/extractors/browser.ts
|
|
1936
|
-
var
|
|
1918
|
+
var SIMPLE_PROPS = ["name", "description", "image", "recipeYield", "prepTime", "cookTime", "totalTime"];
|
|
1937
1919
|
function extractRecipeBrowser(html) {
|
|
1938
1920
|
const jsonLdRecipe = extractJsonLdBrowser(html);
|
|
1939
1921
|
if (jsonLdRecipe) {
|
|
@@ -1946,6 +1928,7 @@ function extractRecipeBrowser(html) {
|
|
|
1946
1928
|
return { recipe: null, source: null };
|
|
1947
1929
|
}
|
|
1948
1930
|
function extractJsonLdBrowser(html) {
|
|
1931
|
+
var _a2;
|
|
1949
1932
|
if (typeof globalThis.DOMParser === "undefined") {
|
|
1950
1933
|
return null;
|
|
1951
1934
|
}
|
|
@@ -1958,9 +1941,9 @@ function extractJsonLdBrowser(html) {
|
|
|
1958
1941
|
if (!content) return;
|
|
1959
1942
|
const parsed = safeJsonParse(content);
|
|
1960
1943
|
if (!parsed) return;
|
|
1961
|
-
|
|
1944
|
+
collectCandidates(parsed, candidates);
|
|
1962
1945
|
});
|
|
1963
|
-
return candidates[0]
|
|
1946
|
+
return (_a2 = candidates[0]) != null ? _a2 : null;
|
|
1964
1947
|
}
|
|
1965
1948
|
function extractMicrodataBrowser(html) {
|
|
1966
1949
|
if (typeof globalThis.DOMParser === "undefined") {
|
|
@@ -1975,8 +1958,8 @@ function extractMicrodataBrowser(html) {
|
|
|
1975
1958
|
const recipe = {
|
|
1976
1959
|
"@type": "Recipe"
|
|
1977
1960
|
};
|
|
1978
|
-
|
|
1979
|
-
const value =
|
|
1961
|
+
SIMPLE_PROPS.forEach((prop) => {
|
|
1962
|
+
const value = findPropertyValue(recipeEl, prop);
|
|
1980
1963
|
if (value) {
|
|
1981
1964
|
recipe[prop] = value;
|
|
1982
1965
|
}
|
|
@@ -1993,7 +1976,8 @@ function extractMicrodataBrowser(html) {
|
|
|
1993
1976
|
}
|
|
1994
1977
|
const instructions = [];
|
|
1995
1978
|
recipeEl.querySelectorAll('[itemprop="recipeInstructions"]').forEach((el) => {
|
|
1996
|
-
|
|
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);
|
|
1997
1981
|
if (text) instructions.push(text);
|
|
1998
1982
|
});
|
|
1999
1983
|
if (instructions.length) {
|
|
@@ -2004,15 +1988,15 @@ function extractMicrodataBrowser(html) {
|
|
|
2004
1988
|
}
|
|
2005
1989
|
return null;
|
|
2006
1990
|
}
|
|
2007
|
-
function
|
|
1991
|
+
function findPropertyValue(context, prop) {
|
|
2008
1992
|
const node = context.querySelector(`[itemprop="${prop}"]`);
|
|
2009
1993
|
if (!node) return void 0;
|
|
2010
1994
|
return normalizeText(node.getAttribute("content")) || normalizeText(node.getAttribute("href")) || normalizeText(node.getAttribute("src")) || normalizeText(node.textContent || void 0);
|
|
2011
1995
|
}
|
|
2012
|
-
function
|
|
1996
|
+
function collectCandidates(payload, bucket) {
|
|
2013
1997
|
if (!payload) return;
|
|
2014
1998
|
if (Array.isArray(payload)) {
|
|
2015
|
-
payload.forEach((entry) =>
|
|
1999
|
+
payload.forEach((entry) => collectCandidates(entry, bucket));
|
|
2016
2000
|
return;
|
|
2017
2001
|
}
|
|
2018
2002
|
if (typeof payload !== "object") {
|
|
@@ -2024,364 +2008,19 @@ function collectCandidates2(payload, bucket) {
|
|
|
2024
2008
|
}
|
|
2025
2009
|
const graph = payload["@graph"];
|
|
2026
2010
|
if (Array.isArray(graph)) {
|
|
2027
|
-
graph.forEach((entry) =>
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2030
|
-
|
|
2031
|
-
// src/scraper/extractors/index.ts
|
|
2032
|
-
function isBrowser() {
|
|
2033
|
-
try {
|
|
2034
|
-
return typeof globalThis.DOMParser !== "undefined";
|
|
2035
|
-
} catch {
|
|
2036
|
-
return false;
|
|
2037
|
-
}
|
|
2038
|
-
}
|
|
2039
|
-
function extractRecipe(html) {
|
|
2040
|
-
if (isBrowser()) {
|
|
2041
|
-
return extractRecipeBrowser(html);
|
|
2042
|
-
}
|
|
2043
|
-
const jsonLdRecipe = extractJsonLd(html);
|
|
2044
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2045
|
-
try {
|
|
2046
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2047
|
-
if (globalFetch) {
|
|
2048
|
-
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(() => {
|
|
2049
|
-
});
|
|
2050
|
-
}
|
|
2051
|
-
} catch {
|
|
2052
|
-
}
|
|
2053
|
-
}
|
|
2054
|
-
if (jsonLdRecipe) {
|
|
2055
|
-
return { recipe: jsonLdRecipe, source: "jsonld" };
|
|
2056
|
-
}
|
|
2057
|
-
const microdataRecipe = extractMicrodata(html);
|
|
2058
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2059
|
-
try {
|
|
2060
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2061
|
-
if (globalFetch) {
|
|
2062
|
-
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(() => {
|
|
2063
|
-
});
|
|
2064
|
-
}
|
|
2065
|
-
} catch {
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
if (microdataRecipe) {
|
|
2069
|
-
return { recipe: microdataRecipe, source: "microdata" };
|
|
2011
|
+
graph.forEach((entry) => collectCandidates(entry, bucket));
|
|
2070
2012
|
}
|
|
2071
|
-
return { recipe: null, source: null };
|
|
2072
2013
|
}
|
|
2073
2014
|
|
|
2074
|
-
// src/scraper/
|
|
2075
|
-
async function scrapeRecipe(url, options = {}) {
|
|
2076
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2077
|
-
try {
|
|
2078
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2079
|
-
if (globalFetch) {
|
|
2080
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:7", message: "scrapeRecipe entry", data: { url, hasOptions: !!options }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,B,C,D,E" }) }).catch(() => {
|
|
2081
|
-
});
|
|
2082
|
-
}
|
|
2083
|
-
} catch {
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
const html = await fetchPage(url, options);
|
|
2087
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2088
|
-
try {
|
|
2089
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2090
|
-
if (globalFetch) {
|
|
2091
|
-
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(() => {
|
|
2092
|
-
});
|
|
2093
|
-
}
|
|
2094
|
-
} catch {
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
const { recipe } = extractRecipe(html);
|
|
2098
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2099
|
-
try {
|
|
2100
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2101
|
-
if (globalFetch) {
|
|
2102
|
-
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(() => {
|
|
2103
|
-
});
|
|
2104
|
-
}
|
|
2105
|
-
} catch {
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
|
-
if (!recipe) {
|
|
2109
|
-
throw new Error("No Schema.org recipe data found in page");
|
|
2110
|
-
}
|
|
2111
|
-
const soustackRecipe = fromSchemaOrg(recipe);
|
|
2112
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2113
|
-
try {
|
|
2114
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2115
|
-
if (globalFetch) {
|
|
2116
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:17", message: "fromSchemaOrg result", data: { hasSoustackRecipe: !!soustackRecipe, soustackRecipeName: soustackRecipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
|
|
2117
|
-
});
|
|
2118
|
-
}
|
|
2119
|
-
} catch {
|
|
2120
|
-
}
|
|
2121
|
-
}
|
|
2122
|
-
if (!soustackRecipe) {
|
|
2123
|
-
throw new Error("Schema.org data did not include a valid recipe");
|
|
2124
|
-
}
|
|
2125
|
-
return soustackRecipe;
|
|
2126
|
-
}
|
|
2127
|
-
function extractRecipeFromHTML(html) {
|
|
2128
|
-
const { recipe } = extractRecipe(html);
|
|
2129
|
-
if (!recipe) {
|
|
2130
|
-
throw new Error("No Schema.org recipe data found in HTML");
|
|
2131
|
-
}
|
|
2132
|
-
const soustackRecipe = fromSchemaOrg(recipe);
|
|
2133
|
-
if (!soustackRecipe) {
|
|
2134
|
-
throw new Error("Schema.org data did not include a valid recipe");
|
|
2135
|
-
}
|
|
2136
|
-
return soustackRecipe;
|
|
2137
|
-
}
|
|
2015
|
+
// src/scraper/browser.ts
|
|
2138
2016
|
function extractSchemaOrgRecipeFromHTML(html) {
|
|
2139
|
-
const { recipe } =
|
|
2017
|
+
const { recipe } = extractRecipeBrowser(html);
|
|
2140
2018
|
return recipe;
|
|
2141
2019
|
}
|
|
2142
2020
|
|
|
2143
|
-
// src/
|
|
2144
|
-
var
|
|
2145
|
-
var MAKES_PREFIX = /^(makes?|yields?)\s*:?\s*(.+)$/i;
|
|
2146
|
-
var APPROX_PREFIX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
|
|
2147
|
-
var SERVING_UNITS = ["servings", "serving", "portions", "portion", "people", "persons"];
|
|
2148
|
-
var DEFAULT_DOZEN_UNIT = "cookies";
|
|
2149
|
-
var NUMBER_WORDS2 = {
|
|
2150
|
-
a: 1,
|
|
2151
|
-
an: 1,
|
|
2152
|
-
one: 1,
|
|
2153
|
-
two: 2,
|
|
2154
|
-
three: 3,
|
|
2155
|
-
four: 4,
|
|
2156
|
-
five: 5,
|
|
2157
|
-
six: 6,
|
|
2158
|
-
seven: 7,
|
|
2159
|
-
eight: 8,
|
|
2160
|
-
nine: 9,
|
|
2161
|
-
ten: 10,
|
|
2162
|
-
eleven: 11,
|
|
2163
|
-
twelve: 12
|
|
2164
|
-
};
|
|
2165
|
-
function normalizeYield(text) {
|
|
2166
|
-
if (!text || typeof text !== "string") return "";
|
|
2167
|
-
return text.normalize("NFKC").replace(/\u00A0/g, " ").replace(/[–—−]/g, "-").trim().replace(/\s+/g, " ");
|
|
2168
|
-
}
|
|
2169
|
-
function parseYield2(text) {
|
|
2170
|
-
const normalized = normalizeYield(text);
|
|
2171
|
-
if (!normalized) return null;
|
|
2172
|
-
const { main, paren } = extractParenthetical(normalized);
|
|
2173
|
-
const core = parseYieldCore(main, normalized);
|
|
2174
|
-
if (!core) return null;
|
|
2175
|
-
const servingsFromParen = paren ? extractServingsFromParen(paren) : null;
|
|
2176
|
-
if (servingsFromParen !== null) {
|
|
2177
|
-
core.servings = servingsFromParen;
|
|
2178
|
-
core.description = normalized;
|
|
2179
|
-
}
|
|
2180
|
-
if (core.servings === void 0) {
|
|
2181
|
-
const inferred = inferServings(core.amount, core.unit);
|
|
2182
|
-
if (inferred !== void 0) {
|
|
2183
|
-
core.servings = inferred;
|
|
2184
|
-
}
|
|
2185
|
-
}
|
|
2186
|
-
return core;
|
|
2187
|
-
}
|
|
2188
|
-
function formatYield2(value) {
|
|
2189
|
-
if (value.description) {
|
|
2190
|
-
return value.description;
|
|
2191
|
-
}
|
|
2192
|
-
if (value.servings && value.unit === "servings") {
|
|
2193
|
-
return `Serves ${value.amount}`;
|
|
2194
|
-
}
|
|
2195
|
-
let result = `${value.amount} ${value.unit}`.trim();
|
|
2196
|
-
if (value.servings && value.unit !== "servings") {
|
|
2197
|
-
result += ` (${value.servings} servings)`;
|
|
2198
|
-
}
|
|
2199
|
-
return result;
|
|
2200
|
-
}
|
|
2201
|
-
function parseYieldCore(text, original) {
|
|
2202
|
-
return parseServesPattern(text, original) ?? parseMakesPattern(text, original) ?? parseRangePattern(text, original) ?? parseNumberUnitPattern(text, original) ?? parsePlainNumberPattern(text);
|
|
2203
|
-
}
|
|
2204
|
-
function parseServesPattern(text, original) {
|
|
2205
|
-
const patterns = [
|
|
2206
|
-
/^serves?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
2207
|
-
/^servings?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
2208
|
-
/^serving\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
2209
|
-
/^makes?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i,
|
|
2210
|
-
/^(\d+)\s+servings?$/i
|
|
2211
|
-
];
|
|
2212
|
-
for (const regex of patterns) {
|
|
2213
|
-
const match = text.match(regex);
|
|
2214
|
-
if (!match) continue;
|
|
2215
|
-
const amount = parseInt(match[1], 10);
|
|
2216
|
-
if (Number.isNaN(amount)) continue;
|
|
2217
|
-
const result = {
|
|
2218
|
-
amount,
|
|
2219
|
-
unit: "servings",
|
|
2220
|
-
servings: amount
|
|
2221
|
-
};
|
|
2222
|
-
if (match[2]) {
|
|
2223
|
-
result.description = original;
|
|
2224
|
-
}
|
|
2225
|
-
return result;
|
|
2226
|
-
}
|
|
2227
|
-
return null;
|
|
2228
|
-
}
|
|
2229
|
-
function parseMakesPattern(text, original) {
|
|
2230
|
-
const match = text.match(MAKES_PREFIX);
|
|
2231
|
-
if (!match) return null;
|
|
2232
|
-
const remainder = match[2].trim();
|
|
2233
|
-
if (!remainder) return null;
|
|
2234
|
-
const servingsMatch = remainder.match(/^(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i);
|
|
2235
|
-
if (servingsMatch) {
|
|
2236
|
-
const amount = parseInt(servingsMatch[1], 10);
|
|
2237
|
-
const result = {
|
|
2238
|
-
amount,
|
|
2239
|
-
unit: "servings",
|
|
2240
|
-
servings: amount
|
|
2241
|
-
};
|
|
2242
|
-
if (servingsMatch[2]) {
|
|
2243
|
-
result.description = original;
|
|
2244
|
-
}
|
|
2245
|
-
return result;
|
|
2246
|
-
}
|
|
2247
|
-
return parseRangePattern(remainder, original) ?? parseNumberUnitPattern(remainder, original) ?? parsePlainNumberPattern(remainder);
|
|
2248
|
-
}
|
|
2249
|
-
function parseRangePattern(text, descriptionSource) {
|
|
2250
|
-
const match = text.match(RANGE_PATTERN);
|
|
2251
|
-
if (!match) return null;
|
|
2252
|
-
const amount = parseInt(match[1], 10);
|
|
2253
|
-
const unit = cleanupUnit(match[3]);
|
|
2254
|
-
if (!unit) return null;
|
|
2255
|
-
const result = {
|
|
2256
|
-
amount,
|
|
2257
|
-
unit,
|
|
2258
|
-
description: descriptionSource
|
|
2259
|
-
};
|
|
2260
|
-
return result;
|
|
2261
|
-
}
|
|
2262
|
-
function parseNumberUnitPattern(text, descriptionSource) {
|
|
2263
|
-
if (!text) return null;
|
|
2264
|
-
const { value, approximate } = stripApproximation(text);
|
|
2265
|
-
if (!value) return null;
|
|
2266
|
-
const dozenResult = handleDozen(value);
|
|
2267
|
-
if (dozenResult) {
|
|
2268
|
-
const unit = cleanupUnit(dozenResult.remainder || DEFAULT_DOZEN_UNIT);
|
|
2269
|
-
const parsed = {
|
|
2270
|
-
amount: dozenResult.amount,
|
|
2271
|
-
unit
|
|
2272
|
-
};
|
|
2273
|
-
if (approximate) {
|
|
2274
|
-
parsed.description = descriptionSource;
|
|
2275
|
-
}
|
|
2276
|
-
return parsed;
|
|
2277
|
-
}
|
|
2278
|
-
const numericMatch = value.match(/^(\d+(?:\.\d+)?)\s+(.+)$/);
|
|
2279
|
-
if (numericMatch) {
|
|
2280
|
-
const amount = parseFloat(numericMatch[1]);
|
|
2281
|
-
if (!Number.isNaN(amount)) {
|
|
2282
|
-
const unit = cleanupUnit(numericMatch[2]);
|
|
2283
|
-
if (unit) {
|
|
2284
|
-
const parsed = { amount, unit };
|
|
2285
|
-
if (approximate) {
|
|
2286
|
-
parsed.description = descriptionSource;
|
|
2287
|
-
}
|
|
2288
|
-
return parsed;
|
|
2289
|
-
}
|
|
2290
|
-
}
|
|
2291
|
-
}
|
|
2292
|
-
const wordMatch = value.match(/^([a-zA-Z]+)\s+(.+)$/);
|
|
2293
|
-
if (wordMatch) {
|
|
2294
|
-
const amount = wordToNumber(wordMatch[1]);
|
|
2295
|
-
if (amount !== null) {
|
|
2296
|
-
const unit = cleanupUnit(wordMatch[2]);
|
|
2297
|
-
if (unit) {
|
|
2298
|
-
const parsed = { amount, unit };
|
|
2299
|
-
if (approximate) {
|
|
2300
|
-
parsed.description = descriptionSource;
|
|
2301
|
-
}
|
|
2302
|
-
return parsed;
|
|
2303
|
-
}
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
return null;
|
|
2307
|
-
}
|
|
2308
|
-
function parsePlainNumberPattern(text) {
|
|
2309
|
-
const match = text.match(/^(\d+)$/);
|
|
2310
|
-
if (!match) return null;
|
|
2311
|
-
const amount = parseInt(match[1], 10);
|
|
2312
|
-
if (Number.isNaN(amount)) return null;
|
|
2313
|
-
return {
|
|
2314
|
-
amount,
|
|
2315
|
-
unit: "servings",
|
|
2316
|
-
servings: amount
|
|
2317
|
-
};
|
|
2318
|
-
}
|
|
2319
|
-
function stripApproximation(value) {
|
|
2320
|
-
const match = value.match(APPROX_PREFIX);
|
|
2321
|
-
if (!match) {
|
|
2322
|
-
return { value: value.trim(), approximate: false };
|
|
2323
|
-
}
|
|
2324
|
-
const stripped = value.slice(match[0].length).trim();
|
|
2325
|
-
return { value: stripped, approximate: true };
|
|
2326
|
-
}
|
|
2327
|
-
function handleDozen(text) {
|
|
2328
|
-
const match = text.match(
|
|
2329
|
-
/^((?:\d+(?:\.\d+)?)|(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|a|an|half))\s+dozens?\b(.*)$/i
|
|
2330
|
-
);
|
|
2331
|
-
if (!match) return null;
|
|
2332
|
-
const token = match[1].toLowerCase();
|
|
2333
|
-
let multiplier = null;
|
|
2334
|
-
if (token === "half") {
|
|
2335
|
-
multiplier = 0.5;
|
|
2336
|
-
} else if (!Number.isNaN(Number(token))) {
|
|
2337
|
-
multiplier = parseFloat(token);
|
|
2338
|
-
} else {
|
|
2339
|
-
multiplier = wordToNumber(token);
|
|
2340
|
-
}
|
|
2341
|
-
if (multiplier === null) return null;
|
|
2342
|
-
const amount = multiplier * 12;
|
|
2343
|
-
return {
|
|
2344
|
-
amount,
|
|
2345
|
-
remainder: match[2].trim()
|
|
2346
|
-
};
|
|
2347
|
-
}
|
|
2348
|
-
function cleanupUnit(value) {
|
|
2349
|
-
let unit = value.trim();
|
|
2350
|
-
unit = unit.replace(/^[,.-]+/, "").trim();
|
|
2351
|
-
unit = unit.replace(/[.,]+$/, "").trim();
|
|
2352
|
-
unit = unit.replace(/^of\s+/i, "").trim();
|
|
2353
|
-
return unit;
|
|
2354
|
-
}
|
|
2355
|
-
function extractParenthetical(text) {
|
|
2356
|
-
const match = text.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
|
|
2357
|
-
if (!match) {
|
|
2358
|
-
return { main: text, paren: null };
|
|
2359
|
-
}
|
|
2360
|
-
return {
|
|
2361
|
-
main: match[1].trim(),
|
|
2362
|
-
paren: match[2].trim()
|
|
2363
|
-
};
|
|
2364
|
-
}
|
|
2365
|
-
function extractServingsFromParen(text) {
|
|
2366
|
-
const match = text.match(/(\d+)/);
|
|
2367
|
-
if (!match) return null;
|
|
2368
|
-
const value = parseInt(match[1], 10);
|
|
2369
|
-
return Number.isNaN(value) ? null : value;
|
|
2370
|
-
}
|
|
2371
|
-
function inferServings(amount, unit) {
|
|
2372
|
-
if (SERVING_UNITS.includes(unit.toLowerCase())) {
|
|
2373
|
-
return amount;
|
|
2374
|
-
}
|
|
2375
|
-
return void 0;
|
|
2376
|
-
}
|
|
2377
|
-
function wordToNumber(word) {
|
|
2378
|
-
const normalized = word.toLowerCase();
|
|
2379
|
-
if (NUMBER_WORDS2.hasOwnProperty(normalized)) {
|
|
2380
|
-
return NUMBER_WORDS2[normalized];
|
|
2381
|
-
}
|
|
2382
|
-
return null;
|
|
2383
|
-
}
|
|
2021
|
+
// src/specVersion.ts
|
|
2022
|
+
var SOUSTACK_SPEC_VERSION = "0.2.1";
|
|
2384
2023
|
|
|
2385
|
-
export {
|
|
2024
|
+
export { SOUSTACK_SPEC_VERSION, detectProfiles, extractSchemaOrgRecipeFromHTML, fromSchemaOrg, scaleRecipe, toSchemaOrg, validateRecipe };
|
|
2386
2025
|
//# sourceMappingURL=index.mjs.map
|
|
2387
2026
|
//# sourceMappingURL=index.mjs.map
|