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.js
CHANGED
|
@@ -2,150 +2,269 @@
|
|
|
2
2
|
|
|
3
3
|
var Ajv = require('ajv');
|
|
4
4
|
var addFormats = require('ajv-formats');
|
|
5
|
-
var cheerio = require('cheerio');
|
|
6
5
|
|
|
7
6
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
8
7
|
|
|
9
8
|
var Ajv__default = /*#__PURE__*/_interopDefault(Ajv);
|
|
10
9
|
var addFormats__default = /*#__PURE__*/_interopDefault(addFormats);
|
|
11
10
|
|
|
11
|
+
// src/parsers/duration.ts
|
|
12
|
+
var ISO_DURATION_REGEX = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i;
|
|
13
|
+
var HUMAN_OVERNIGHT = 8 * 60;
|
|
14
|
+
function isFiniteNumber(value) {
|
|
15
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
16
|
+
}
|
|
17
|
+
function parseDuration(iso) {
|
|
18
|
+
if (typeof iso === "number" && Number.isFinite(iso)) {
|
|
19
|
+
return iso;
|
|
20
|
+
}
|
|
21
|
+
if (!iso || typeof iso !== "string") return null;
|
|
22
|
+
const trimmed = iso.trim();
|
|
23
|
+
if (!trimmed) return null;
|
|
24
|
+
const match = trimmed.match(ISO_DURATION_REGEX);
|
|
25
|
+
if (!match) return null;
|
|
26
|
+
const [, daysRaw, hoursRaw, minutesRaw, secondsRaw] = match;
|
|
27
|
+
if (!daysRaw && !hoursRaw && !minutesRaw && !secondsRaw) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
let total = 0;
|
|
31
|
+
if (daysRaw) total += parseFloat(daysRaw) * 24 * 60;
|
|
32
|
+
if (hoursRaw) total += parseFloat(hoursRaw) * 60;
|
|
33
|
+
if (minutesRaw) total += parseFloat(minutesRaw);
|
|
34
|
+
if (secondsRaw) total += Math.ceil(parseFloat(secondsRaw) / 60);
|
|
35
|
+
return Math.round(total);
|
|
36
|
+
}
|
|
37
|
+
function formatDuration(minutes) {
|
|
38
|
+
if (!isFiniteNumber(minutes) || minutes <= 0) {
|
|
39
|
+
return "PT0M";
|
|
40
|
+
}
|
|
41
|
+
const rounded = Math.round(minutes);
|
|
42
|
+
const days = Math.floor(rounded / (24 * 60));
|
|
43
|
+
const afterDays = rounded % (24 * 60);
|
|
44
|
+
const hours = Math.floor(afterDays / 60);
|
|
45
|
+
const mins = afterDays % 60;
|
|
46
|
+
let result = "P";
|
|
47
|
+
if (days > 0) {
|
|
48
|
+
result += `${days}D`;
|
|
49
|
+
}
|
|
50
|
+
if (hours > 0 || mins > 0) {
|
|
51
|
+
result += "T";
|
|
52
|
+
if (hours > 0) {
|
|
53
|
+
result += `${hours}H`;
|
|
54
|
+
}
|
|
55
|
+
if (mins > 0) {
|
|
56
|
+
result += `${mins}M`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (result === "P") {
|
|
60
|
+
return "PT0M";
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
function parseHumanDuration(text) {
|
|
65
|
+
if (!text || typeof text !== "string") return null;
|
|
66
|
+
const normalized = text.toLowerCase().trim();
|
|
67
|
+
if (!normalized) return null;
|
|
68
|
+
if (normalized === "overnight") {
|
|
69
|
+
return HUMAN_OVERNIGHT;
|
|
70
|
+
}
|
|
71
|
+
let total = 0;
|
|
72
|
+
const hourRegex = /(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|hr|h)\b/g;
|
|
73
|
+
let hourMatch;
|
|
74
|
+
while ((hourMatch = hourRegex.exec(normalized)) !== null) {
|
|
75
|
+
total += parseFloat(hourMatch[1]) * 60;
|
|
76
|
+
}
|
|
77
|
+
const minuteRegex = /(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|min|m)\b/g;
|
|
78
|
+
let minuteMatch;
|
|
79
|
+
while ((minuteMatch = minuteRegex.exec(normalized)) !== null) {
|
|
80
|
+
total += parseFloat(minuteMatch[1]);
|
|
81
|
+
}
|
|
82
|
+
if (total <= 0) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return Math.round(total);
|
|
86
|
+
}
|
|
87
|
+
function smartParseDuration(input) {
|
|
88
|
+
const iso = parseDuration(input);
|
|
89
|
+
if (iso !== null) {
|
|
90
|
+
return iso;
|
|
91
|
+
}
|
|
92
|
+
return parseHumanDuration(input);
|
|
93
|
+
}
|
|
94
|
+
|
|
12
95
|
// src/parser.ts
|
|
13
|
-
function scaleRecipe(recipe,
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
96
|
+
function scaleRecipe(recipe, options = {}) {
|
|
97
|
+
const multiplier = resolveMultiplier(recipe, options);
|
|
98
|
+
const scaled = deepClone(recipe);
|
|
99
|
+
applyYieldScaling(scaled, options, multiplier);
|
|
100
|
+
const baseAmounts = collectBaseIngredientAmounts(scaled.ingredients || []);
|
|
101
|
+
const scaledAmounts = /* @__PURE__ */ new Map();
|
|
102
|
+
const orderedIngredients = [];
|
|
103
|
+
collectIngredients(scaled.ingredients || [], orderedIngredients);
|
|
104
|
+
orderedIngredients.filter((ing) => {
|
|
105
|
+
var _a2;
|
|
106
|
+
return (((_a2 = ing.scaling) == null ? void 0 : _a2.type) || "linear") !== "bakers_percentage";
|
|
107
|
+
}).forEach((ing) => {
|
|
108
|
+
const key = getIngredientKey(ing);
|
|
109
|
+
scaledAmounts.set(key, calculateIndependentIngredient(ing, multiplier));
|
|
110
|
+
});
|
|
111
|
+
orderedIngredients.filter((ing) => {
|
|
112
|
+
var _a2;
|
|
113
|
+
return ((_a2 = ing.scaling) == null ? void 0 : _a2.type) === "bakers_percentage";
|
|
114
|
+
}).forEach((ing) => {
|
|
115
|
+
var _a2, _b2;
|
|
116
|
+
const key = getIngredientKey(ing);
|
|
117
|
+
const scaling = ing.scaling;
|
|
118
|
+
if (!(scaling == null ? void 0 : scaling.referenceId)) {
|
|
119
|
+
throw new Error(`Baker's percentage ingredient "${key}" is missing a referenceId`);
|
|
22
120
|
}
|
|
121
|
+
const referenceAmount = scaledAmounts.get(scaling.referenceId);
|
|
122
|
+
if (referenceAmount === void 0) {
|
|
123
|
+
throw new Error(`Reference ingredient "${scaling.referenceId}" not found for baker's percentage item "${key}"`);
|
|
124
|
+
}
|
|
125
|
+
const baseAmount = ((_a2 = ing.quantity) == null ? void 0 : _a2.amount) || 0;
|
|
126
|
+
const referenceBase = baseAmounts.get(scaling.referenceId);
|
|
127
|
+
const factor = (_b2 = scaling.factor) != null ? _b2 : referenceBase ? baseAmount / referenceBase : void 0;
|
|
128
|
+
if (factor === void 0) {
|
|
129
|
+
throw new Error(`Unable to determine factor for baker's percentage ingredient "${key}"`);
|
|
130
|
+
}
|
|
131
|
+
scaledAmounts.set(key, referenceAmount * factor);
|
|
23
132
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
133
|
+
orderedIngredients.forEach((ing) => {
|
|
134
|
+
const key = getIngredientKey(ing);
|
|
135
|
+
const amount = scaledAmounts.get(key);
|
|
136
|
+
if (amount === void 0) return;
|
|
137
|
+
if (!ing.quantity) {
|
|
138
|
+
ing.quantity = { amount, unit: null };
|
|
139
|
+
} else {
|
|
140
|
+
ing.quantity.amount = amount;
|
|
32
141
|
}
|
|
33
142
|
});
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
143
|
+
scaleInstructionItems(scaled.instructions || [], multiplier);
|
|
144
|
+
return scaled;
|
|
145
|
+
}
|
|
146
|
+
function resolveMultiplier(recipe, options) {
|
|
147
|
+
var _a2, _b2;
|
|
148
|
+
if (options.multiplier && options.multiplier > 0) {
|
|
149
|
+
return options.multiplier;
|
|
150
|
+
}
|
|
151
|
+
if ((_a2 = options.targetYield) == null ? void 0 : _a2.amount) {
|
|
152
|
+
const base = ((_b2 = recipe.yield) == null ? void 0 : _b2.amount) || 1;
|
|
153
|
+
return options.targetYield.amount / base;
|
|
154
|
+
}
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
function applyYieldScaling(recipe, options, multiplier) {
|
|
158
|
+
var _a2, _b2, _c, _d, _e, _f, _g;
|
|
159
|
+
const baseAmount = (_b2 = (_a2 = recipe.yield) == null ? void 0 : _a2.amount) != null ? _b2 : 1;
|
|
160
|
+
const targetAmount = (_d = (_c = options.targetYield) == null ? void 0 : _c.amount) != null ? _d : baseAmount * multiplier;
|
|
161
|
+
const unit = (_g = (_e = options.targetYield) == null ? void 0 : _e.unit) != null ? _g : (_f = recipe.yield) == null ? void 0 : _f.unit;
|
|
162
|
+
if (!recipe.yield && !options.targetYield) return;
|
|
163
|
+
recipe.yield = {
|
|
164
|
+
amount: targetAmount,
|
|
165
|
+
unit: unit != null ? unit : ""
|
|
56
166
|
};
|
|
57
167
|
}
|
|
58
|
-
function
|
|
59
|
-
return
|
|
168
|
+
function getIngredientKey(ing) {
|
|
169
|
+
return ing.id || ing.item;
|
|
60
170
|
}
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
171
|
+
function calculateIndependentIngredient(ing, multiplier) {
|
|
172
|
+
var _a2, _b2, _c, _d, _e, _f;
|
|
173
|
+
const baseAmount = ((_a2 = ing.quantity) == null ? void 0 : _a2.amount) || 0;
|
|
174
|
+
const type = ((_b2 = ing.scaling) == null ? void 0 : _b2.type) || "linear";
|
|
65
175
|
switch (type) {
|
|
66
|
-
case "linear":
|
|
67
|
-
newAmount = baseAmount * multiplier;
|
|
68
|
-
break;
|
|
69
176
|
case "fixed":
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
case "
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const text = `${parseFloat(newAmount.toFixed(2))}${unit ? " " + unit : ""} ${ingredientName}`;
|
|
84
|
-
return {
|
|
85
|
-
id: ing.id || ing.item,
|
|
86
|
-
name: ingredientName,
|
|
87
|
-
amount: newAmount,
|
|
88
|
-
unit: ing.quantity?.unit || null,
|
|
89
|
-
text,
|
|
90
|
-
notes: ing.notes
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
function extractNameFromItem(item) {
|
|
94
|
-
const match = item.match(/^\s*\d+(?:\.\d+)?\s*\w*\s*(.+)$/);
|
|
95
|
-
return match ? match[1].trim() : item;
|
|
96
|
-
}
|
|
97
|
-
function calculateInstruction(inst, multiplier) {
|
|
98
|
-
const baseDuration = inst.timing?.duration || 0;
|
|
99
|
-
const scalingType = inst.timing?.scaling || "fixed";
|
|
100
|
-
let newDuration = baseDuration;
|
|
101
|
-
if (scalingType === "linear") {
|
|
102
|
-
newDuration = baseDuration * multiplier;
|
|
103
|
-
} else if (scalingType === "sqrt") {
|
|
104
|
-
newDuration = baseDuration * Math.sqrt(multiplier);
|
|
177
|
+
return baseAmount;
|
|
178
|
+
case "discrete": {
|
|
179
|
+
const scaled = baseAmount * multiplier;
|
|
180
|
+
const step = (_d = (_c = ing.scaling) == null ? void 0 : _c.roundTo) != null ? _d : 1;
|
|
181
|
+
const rounded = Math.round(scaled / step) * step;
|
|
182
|
+
return Math.round(rounded);
|
|
183
|
+
}
|
|
184
|
+
case "proportional": {
|
|
185
|
+
const factor = (_f = (_e = ing.scaling) == null ? void 0 : _e.factor) != null ? _f : 1;
|
|
186
|
+
return baseAmount * multiplier * factor;
|
|
187
|
+
}
|
|
188
|
+
default:
|
|
189
|
+
return baseAmount * multiplier;
|
|
105
190
|
}
|
|
106
|
-
return {
|
|
107
|
-
id: inst.id || "step",
|
|
108
|
-
text: inst.text,
|
|
109
|
-
durationMinutes: Math.ceil(newDuration),
|
|
110
|
-
type: inst.timing?.type || "active"
|
|
111
|
-
};
|
|
112
191
|
}
|
|
113
|
-
function
|
|
114
|
-
const result = [];
|
|
192
|
+
function collectIngredients(items, bucket) {
|
|
115
193
|
items.forEach((item) => {
|
|
116
|
-
if (typeof item === "string")
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
result.push(...flattenIngredients(item.items));
|
|
194
|
+
if (typeof item === "string") return;
|
|
195
|
+
if ("subsection" in item) {
|
|
196
|
+
collectIngredients(item.items, bucket);
|
|
120
197
|
} else {
|
|
121
|
-
|
|
198
|
+
bucket.push(item);
|
|
122
199
|
}
|
|
123
200
|
});
|
|
124
|
-
return result;
|
|
125
201
|
}
|
|
126
|
-
function
|
|
127
|
-
const result = [];
|
|
202
|
+
function collectBaseIngredientAmounts(items, map = /* @__PURE__ */ new Map()) {
|
|
128
203
|
items.forEach((item) => {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
204
|
+
var _a2, _b2;
|
|
205
|
+
if (typeof item === "string") return;
|
|
206
|
+
if ("subsection" in item) {
|
|
207
|
+
collectBaseIngredientAmounts(item.items, map);
|
|
133
208
|
} else {
|
|
134
|
-
|
|
209
|
+
map.set(getIngredientKey(item), (_b2 = (_a2 = item.quantity) == null ? void 0 : _a2.amount) != null ? _b2 : 0);
|
|
135
210
|
}
|
|
136
211
|
});
|
|
137
|
-
return
|
|
212
|
+
return map;
|
|
213
|
+
}
|
|
214
|
+
function scaleInstructionItems(items, multiplier) {
|
|
215
|
+
items.forEach((item) => {
|
|
216
|
+
if (typeof item === "string") return;
|
|
217
|
+
if ("subsection" in item) {
|
|
218
|
+
scaleInstructionItems(item.items, multiplier);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const timing = item.timing;
|
|
222
|
+
if (!timing) return;
|
|
223
|
+
const baseDuration = toDurationMinutes(timing.duration);
|
|
224
|
+
const scalingType = timing.scaling || "fixed";
|
|
225
|
+
let newDuration = baseDuration;
|
|
226
|
+
if (scalingType === "linear") {
|
|
227
|
+
newDuration = baseDuration * multiplier;
|
|
228
|
+
} else if (scalingType === "sqrt") {
|
|
229
|
+
newDuration = baseDuration * Math.sqrt(multiplier);
|
|
230
|
+
}
|
|
231
|
+
timing.duration = Math.ceil(newDuration);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function deepClone(value) {
|
|
235
|
+
return JSON.parse(JSON.stringify(value));
|
|
236
|
+
}
|
|
237
|
+
function toDurationMinutes(duration) {
|
|
238
|
+
if (typeof duration === "number" && Number.isFinite(duration)) {
|
|
239
|
+
return duration;
|
|
240
|
+
}
|
|
241
|
+
if (typeof duration === "string" && duration.trim().startsWith("P")) {
|
|
242
|
+
const parsed = parseDuration(duration.trim());
|
|
243
|
+
if (parsed !== null) {
|
|
244
|
+
return parsed;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return 0;
|
|
138
248
|
}
|
|
139
249
|
|
|
140
250
|
// src/schema.json
|
|
141
251
|
var schema_default = {
|
|
142
252
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
143
|
-
$id: "http://soustack.org/schema/v0.2",
|
|
144
|
-
title: "Soustack Recipe Schema v0.2",
|
|
253
|
+
$id: "http://soustack.org/schema/v0.2.1",
|
|
254
|
+
title: "Soustack Recipe Schema v0.2.1",
|
|
145
255
|
description: "A portable, scalable, interoperable recipe format.",
|
|
146
256
|
type: "object",
|
|
147
257
|
required: ["name", "ingredients", "instructions"],
|
|
258
|
+
additionalProperties: false,
|
|
259
|
+
patternProperties: {
|
|
260
|
+
"^x-": {}
|
|
261
|
+
},
|
|
148
262
|
properties: {
|
|
263
|
+
$schema: {
|
|
264
|
+
type: "string",
|
|
265
|
+
format: "uri",
|
|
266
|
+
description: "Optional schema hint for tooling compatibility"
|
|
267
|
+
},
|
|
149
268
|
id: {
|
|
150
269
|
type: "string",
|
|
151
270
|
description: "Unique identifier (slug or UUID)"
|
|
@@ -154,10 +273,19 @@ var schema_default = {
|
|
|
154
273
|
type: "string",
|
|
155
274
|
description: "The title of the recipe"
|
|
156
275
|
},
|
|
276
|
+
title: {
|
|
277
|
+
type: "string",
|
|
278
|
+
description: "Optional display title; alias for name"
|
|
279
|
+
},
|
|
157
280
|
version: {
|
|
158
281
|
type: "string",
|
|
159
282
|
pattern: "^\\d+\\.\\d+\\.\\d+$",
|
|
160
|
-
description: "
|
|
283
|
+
description: "DEPRECATED: use recipeVersion for authoring revisions"
|
|
284
|
+
},
|
|
285
|
+
recipeVersion: {
|
|
286
|
+
type: "string",
|
|
287
|
+
pattern: "^\\d+\\.\\d+\\.\\d+$",
|
|
288
|
+
description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
|
|
161
289
|
},
|
|
162
290
|
description: {
|
|
163
291
|
type: "string"
|
|
@@ -191,6 +319,11 @@ var schema_default = {
|
|
|
191
319
|
type: "string",
|
|
192
320
|
format: "date-time"
|
|
193
321
|
},
|
|
322
|
+
metadata: {
|
|
323
|
+
type: "object",
|
|
324
|
+
additionalProperties: true,
|
|
325
|
+
description: "Free-form vendor metadata"
|
|
326
|
+
},
|
|
194
327
|
source: {
|
|
195
328
|
type: "object",
|
|
196
329
|
properties: {
|
|
@@ -250,24 +383,16 @@ var schema_default = {
|
|
|
250
383
|
}
|
|
251
384
|
},
|
|
252
385
|
time: {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
{
|
|
264
|
-
type: "object",
|
|
265
|
-
properties: {
|
|
266
|
-
prepTime: { type: "string" },
|
|
267
|
-
cookTime: { type: "string" }
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
]
|
|
386
|
+
type: "object",
|
|
387
|
+
properties: {
|
|
388
|
+
prep: { type: "number" },
|
|
389
|
+
active: { type: "number" },
|
|
390
|
+
passive: { type: "number" },
|
|
391
|
+
total: { type: "number" },
|
|
392
|
+
prepTime: { type: "string", format: "duration" },
|
|
393
|
+
cookTime: { type: "string", format: "duration" }
|
|
394
|
+
},
|
|
395
|
+
minProperties: 1
|
|
271
396
|
},
|
|
272
397
|
quantity: {
|
|
273
398
|
type: "object",
|
|
@@ -368,7 +493,13 @@ var schema_default = {
|
|
|
368
493
|
type: "object",
|
|
369
494
|
required: ["duration", "type"],
|
|
370
495
|
properties: {
|
|
371
|
-
duration: {
|
|
496
|
+
duration: {
|
|
497
|
+
anyOf: [
|
|
498
|
+
{ type: "number" },
|
|
499
|
+
{ type: "string", pattern: "^P" }
|
|
500
|
+
],
|
|
501
|
+
description: "Minutes as a number or ISO8601 duration string"
|
|
502
|
+
},
|
|
372
503
|
type: { type: "string", enum: ["active", "passive"] },
|
|
373
504
|
scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
|
|
374
505
|
}
|
|
@@ -462,665 +593,756 @@ var schema_default = {
|
|
|
462
593
|
}
|
|
463
594
|
};
|
|
464
595
|
|
|
465
|
-
// src/
|
|
466
|
-
var
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
liter: "l",
|
|
550
|
-
liters: "l",
|
|
551
|
-
l: "l",
|
|
552
|
-
ounce: "oz",
|
|
553
|
-
ounces: "oz",
|
|
554
|
-
oz: "oz",
|
|
555
|
-
pound: "lb",
|
|
556
|
-
pounds: "lb",
|
|
557
|
-
lb: "lb",
|
|
558
|
-
lbs: "lb",
|
|
559
|
-
pint: "pint",
|
|
560
|
-
pints: "pint",
|
|
561
|
-
quart: "quart",
|
|
562
|
-
quarts: "quart",
|
|
563
|
-
stick: "stick",
|
|
564
|
-
sticks: "stick",
|
|
565
|
-
dash: "dash",
|
|
566
|
-
pinches: "pinch",
|
|
567
|
-
pinch: "pinch"
|
|
568
|
-
};
|
|
569
|
-
var PREP_PHRASES = [
|
|
570
|
-
"diced",
|
|
571
|
-
"finely diced",
|
|
572
|
-
"roughly diced",
|
|
573
|
-
"minced",
|
|
574
|
-
"finely minced",
|
|
575
|
-
"chopped",
|
|
576
|
-
"finely chopped",
|
|
577
|
-
"roughly chopped",
|
|
578
|
-
"sliced",
|
|
579
|
-
"thinly sliced",
|
|
580
|
-
"thickly sliced",
|
|
581
|
-
"grated",
|
|
582
|
-
"finely grated",
|
|
583
|
-
"zested",
|
|
584
|
-
"sifted",
|
|
585
|
-
"softened",
|
|
586
|
-
"at room temperature",
|
|
587
|
-
"room temperature",
|
|
588
|
-
"room temp",
|
|
589
|
-
"melted",
|
|
590
|
-
"toasted",
|
|
591
|
-
"drained",
|
|
592
|
-
"drained and rinsed",
|
|
593
|
-
"beaten",
|
|
594
|
-
"divided",
|
|
595
|
-
"cut into cubes",
|
|
596
|
-
"cut into pieces",
|
|
597
|
-
"cut into strips",
|
|
598
|
-
"cut into chunks",
|
|
599
|
-
"cut into bite-size pieces"
|
|
600
|
-
].map((value) => value.toLowerCase());
|
|
601
|
-
var COUNT_DESCRIPTORS = /* @__PURE__ */ new Set([
|
|
602
|
-
"clove",
|
|
603
|
-
"cloves",
|
|
604
|
-
"can",
|
|
605
|
-
"cans",
|
|
606
|
-
"stick",
|
|
607
|
-
"sticks",
|
|
608
|
-
"sprig",
|
|
609
|
-
"sprigs",
|
|
610
|
-
"bunch",
|
|
611
|
-
"bunches",
|
|
612
|
-
"slice",
|
|
613
|
-
"slices",
|
|
614
|
-
"package",
|
|
615
|
-
"packages"
|
|
616
|
-
]);
|
|
617
|
-
var DESCRIPTOR_NOTE_SET = /* @__PURE__ */ new Set(["can", "cans", "jar", "jars", "package", "packages", "bottle", "bottles"]);
|
|
618
|
-
var WEIGHT_PRIORITY_UNITS = /* @__PURE__ */ new Set(["g", "kg", "oz", "lb", "ml", "l"]);
|
|
619
|
-
var SPICE_KEYWORDS = [
|
|
620
|
-
"salt",
|
|
621
|
-
"pepper",
|
|
622
|
-
"paprika",
|
|
623
|
-
"cumin",
|
|
624
|
-
"coriander",
|
|
625
|
-
"turmeric",
|
|
626
|
-
"chili powder",
|
|
627
|
-
"garlic powder",
|
|
628
|
-
"onion powder",
|
|
629
|
-
"cayenne",
|
|
630
|
-
"cinnamon",
|
|
631
|
-
"nutmeg",
|
|
632
|
-
"allspice",
|
|
633
|
-
"ginger",
|
|
634
|
-
"oregano",
|
|
635
|
-
"thyme",
|
|
636
|
-
"rosemary",
|
|
637
|
-
"basil",
|
|
638
|
-
"sage",
|
|
639
|
-
"clove",
|
|
640
|
-
"spice",
|
|
641
|
-
"seasoning"
|
|
642
|
-
];
|
|
643
|
-
var PURPOSE_KEYWORDS = ["frying", "greasing", "drizzling", "garnish", "serving", "brushing"];
|
|
644
|
-
var RANGE_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)(?:\s*(?:-|to)\s*((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?))/i;
|
|
645
|
-
var NUMBER_REGEX = /^((?:\d+\s+)?\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)/i;
|
|
646
|
-
var QUALIFIER_REGEX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
|
|
647
|
-
var FLAVOR_NOTE_REGEX = /\b(to taste|as needed|as necessary)\b/gi;
|
|
648
|
-
var VAGUE_QUANTITY_PATTERNS = [
|
|
649
|
-
{ regex: /^(a\s+pinch|pinch)\b/i, note: "a pinch" },
|
|
650
|
-
{ regex: /^(a\s+handful|handful)\b/i, note: "a handful" },
|
|
651
|
-
{ regex: /^(a\s+dash|dash)\b/i, note: "a dash" },
|
|
652
|
-
{ regex: /^(a\s+sprinkle|sprinkle)\b/i, note: "a sprinkle" },
|
|
653
|
-
{ regex: /^(some)\b/i, note: "some" },
|
|
654
|
-
{ regex: /^(few\s+sprigs)/i, note: "few sprigs" },
|
|
655
|
-
{ regex: /^(a\s+few|few)\b/i, note: "a few" },
|
|
656
|
-
{ regex: /^(several)\b/i, note: "several" }
|
|
657
|
-
];
|
|
658
|
-
var JUICE_PREFIXES = ["juice of", "zest of"];
|
|
659
|
-
function normalizeIngredientInput(input) {
|
|
660
|
-
if (!input) return "";
|
|
661
|
-
let result = input.replace(/\u00A0/g, " ").trim();
|
|
662
|
-
result = replaceDashes(result);
|
|
663
|
-
result = replaceUnicodeFractions(result);
|
|
664
|
-
result = replaceNumberWords(result);
|
|
665
|
-
result = result.replace(/\s+/g, " ").trim();
|
|
666
|
-
return result;
|
|
667
|
-
}
|
|
668
|
-
function parseIngredient(text) {
|
|
669
|
-
const original = text ?? "";
|
|
670
|
-
const normalized = normalizeIngredientInput(original);
|
|
671
|
-
if (!normalized) {
|
|
672
|
-
return {
|
|
673
|
-
item: original,
|
|
674
|
-
scaling: { type: "linear" }
|
|
675
|
-
};
|
|
676
|
-
}
|
|
677
|
-
let working = normalized;
|
|
678
|
-
const notes = [];
|
|
679
|
-
let optional = false;
|
|
680
|
-
if (/\boptional\b/i.test(working)) {
|
|
681
|
-
optional = true;
|
|
682
|
-
working = working.replace(/\(?\s*optional\s*\)?/gi, "").trim();
|
|
683
|
-
working = working.replace(/\(\s*\)/g, " ").trim();
|
|
684
|
-
}
|
|
685
|
-
const flavorExtraction = extractFlavorNotes(working);
|
|
686
|
-
working = flavorExtraction.cleaned;
|
|
687
|
-
notes.push(...flavorExtraction.notes);
|
|
688
|
-
const parenthetical = extractParentheticals(working);
|
|
689
|
-
working = parenthetical.cleaned;
|
|
690
|
-
notes.push(...parenthetical.notes);
|
|
691
|
-
optional = optional || parenthetical.optional;
|
|
692
|
-
const purposeExtraction = extractPurposeNotes(working);
|
|
693
|
-
working = purposeExtraction.cleaned;
|
|
694
|
-
notes.push(...purposeExtraction.notes);
|
|
695
|
-
const juiceExtraction = extractJuicePhrase(working);
|
|
696
|
-
if (juiceExtraction) {
|
|
697
|
-
working = juiceExtraction.cleaned;
|
|
698
|
-
notes.push(juiceExtraction.note);
|
|
699
|
-
}
|
|
700
|
-
const vagueQuantity = extractVagueQuantity(working);
|
|
701
|
-
let quantityResult;
|
|
702
|
-
if (vagueQuantity) {
|
|
703
|
-
notes.push(vagueQuantity.note);
|
|
704
|
-
quantityResult = {
|
|
705
|
-
amount: null,
|
|
706
|
-
unit: null,
|
|
707
|
-
descriptor: void 0,
|
|
708
|
-
remainder: vagueQuantity.remainder,
|
|
709
|
-
notes: [],
|
|
710
|
-
originalAmount: null
|
|
711
|
-
};
|
|
712
|
-
} else {
|
|
713
|
-
quantityResult = extractQuantity(working);
|
|
714
|
-
}
|
|
715
|
-
working = quantityResult.remainder;
|
|
716
|
-
const { quantity, usedParenthetical } = mergeQuantities(quantityResult, parenthetical.measurement);
|
|
717
|
-
if (usedParenthetical && quantityResult.originalAmount !== null && quantityResult.originalAmount > 1 && quantityResult.descriptor && DESCRIPTOR_NOTE_SET.has(quantityResult.descriptor.toLowerCase())) {
|
|
718
|
-
notes.push(formatCountNote(quantityResult.originalAmount, quantityResult.descriptor));
|
|
719
|
-
}
|
|
720
|
-
notes.push(...quantityResult.notes);
|
|
721
|
-
working = working.replace(/^[,.\s-]+/, "").trim();
|
|
722
|
-
working = working.replace(/^of\s+/i, "").trim();
|
|
723
|
-
if (quantityResult.descriptor && /^cans?$/i.test(quantityResult.descriptor) && working && !/^canned\b/i.test(working)) {
|
|
724
|
-
working = `canned ${working}`.trim();
|
|
725
|
-
}
|
|
726
|
-
const nameExtraction = extractNameAndPrep(working);
|
|
727
|
-
notes.push(...nameExtraction.notes);
|
|
728
|
-
const name = nameExtraction.name || void 0;
|
|
729
|
-
const scaling = inferScaling(
|
|
730
|
-
name,
|
|
731
|
-
quantity.unit,
|
|
732
|
-
quantity.amount,
|
|
733
|
-
notes,
|
|
734
|
-
quantityResult.descriptor
|
|
735
|
-
);
|
|
736
|
-
const mergedNotes = formatNotes(notes);
|
|
737
|
-
const parsed = {
|
|
738
|
-
item: original,
|
|
739
|
-
quantity,
|
|
740
|
-
...name ? { name } : {},
|
|
741
|
-
...nameExtraction.prep ? { prep: nameExtraction.prep } : {},
|
|
742
|
-
...optional ? { optional: true } : {},
|
|
743
|
-
scaling
|
|
744
|
-
};
|
|
745
|
-
if (mergedNotes) {
|
|
746
|
-
parsed.notes = mergedNotes;
|
|
747
|
-
}
|
|
748
|
-
return parsed;
|
|
749
|
-
}
|
|
750
|
-
function parseIngredientLine(text) {
|
|
751
|
-
return parseIngredient(text);
|
|
752
|
-
}
|
|
753
|
-
function parseIngredients(texts) {
|
|
754
|
-
if (!Array.isArray(texts)) return [];
|
|
755
|
-
return texts.map((item) => typeof item === "string" ? item : String(item ?? "")).map((entry) => parseIngredient(entry));
|
|
756
|
-
}
|
|
757
|
-
function replaceDashes(value) {
|
|
758
|
-
return value.replace(/[\u2012\u2013\u2014\u2212]/g, "-");
|
|
759
|
-
}
|
|
760
|
-
function replaceUnicodeFractions(value) {
|
|
761
|
-
return value.replace(/(\d+)?(?:\s+)?([½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])/g, (_match, whole, fraction) => {
|
|
762
|
-
const fractionValue = FRACTION_DECIMALS[fraction];
|
|
763
|
-
if (fractionValue === void 0) return _match;
|
|
764
|
-
const base = whole ? parseInt(whole, 10) : 0;
|
|
765
|
-
const combined = base + fractionValue;
|
|
766
|
-
return formatDecimal(combined);
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
function replaceNumberWords(value) {
|
|
770
|
-
return value.replace(
|
|
771
|
-
/\b(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|half|quarter)(?:-(one|two|three|four|five|six|seven|eight|nine))?\b/gi,
|
|
772
|
-
(match, word, hyphenPart) => {
|
|
773
|
-
const lower = word.toLowerCase();
|
|
774
|
-
const baseValue = NUMBER_WORDS[lower];
|
|
775
|
-
if (baseValue === void 0) return match;
|
|
776
|
-
if (!hyphenPart) {
|
|
777
|
-
return formatDecimal(baseValue);
|
|
596
|
+
// src/soustack.schema.json
|
|
597
|
+
var soustack_schema_default = {
|
|
598
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
599
|
+
$id: "http://soustack.org/schema/v0.2.1",
|
|
600
|
+
title: "Soustack Recipe Schema v0.2.1",
|
|
601
|
+
description: "A portable, scalable, interoperable recipe format.",
|
|
602
|
+
type: "object",
|
|
603
|
+
required: ["name", "ingredients", "instructions"],
|
|
604
|
+
additionalProperties: false,
|
|
605
|
+
patternProperties: {
|
|
606
|
+
"^x-": {}
|
|
607
|
+
},
|
|
608
|
+
properties: {
|
|
609
|
+
$schema: {
|
|
610
|
+
type: "string",
|
|
611
|
+
format: "uri",
|
|
612
|
+
description: "Optional schema hint for tooling compatibility"
|
|
613
|
+
},
|
|
614
|
+
id: {
|
|
615
|
+
type: "string",
|
|
616
|
+
description: "Unique identifier (slug or UUID)"
|
|
617
|
+
},
|
|
618
|
+
name: {
|
|
619
|
+
type: "string",
|
|
620
|
+
description: "The title of the recipe"
|
|
621
|
+
},
|
|
622
|
+
title: {
|
|
623
|
+
type: "string",
|
|
624
|
+
description: "Optional display title; alias for name"
|
|
625
|
+
},
|
|
626
|
+
version: {
|
|
627
|
+
type: "string",
|
|
628
|
+
pattern: "^\\d+\\.\\d+\\.\\d+$",
|
|
629
|
+
description: "DEPRECATED: use recipeVersion for authoring revisions"
|
|
630
|
+
},
|
|
631
|
+
recipeVersion: {
|
|
632
|
+
type: "string",
|
|
633
|
+
pattern: "^\\d+\\.\\d+\\.\\d+$",
|
|
634
|
+
description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
|
|
635
|
+
},
|
|
636
|
+
description: {
|
|
637
|
+
type: "string"
|
|
638
|
+
},
|
|
639
|
+
category: {
|
|
640
|
+
type: "string",
|
|
641
|
+
examples: ["Main Course", "Dessert"]
|
|
642
|
+
},
|
|
643
|
+
tags: {
|
|
644
|
+
type: "array",
|
|
645
|
+
items: { type: "string" }
|
|
646
|
+
},
|
|
647
|
+
image: {
|
|
648
|
+
description: "Recipe-level hero image(s)",
|
|
649
|
+
anyOf: [
|
|
650
|
+
{
|
|
651
|
+
type: "string",
|
|
652
|
+
format: "uri"
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
type: "array",
|
|
656
|
+
minItems: 1,
|
|
657
|
+
items: {
|
|
658
|
+
type: "string",
|
|
659
|
+
format: "uri"
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
]
|
|
663
|
+
},
|
|
664
|
+
dateAdded: {
|
|
665
|
+
type: "string",
|
|
666
|
+
format: "date-time"
|
|
667
|
+
},
|
|
668
|
+
metadata: {
|
|
669
|
+
type: "object",
|
|
670
|
+
additionalProperties: true,
|
|
671
|
+
description: "Free-form vendor metadata"
|
|
672
|
+
},
|
|
673
|
+
source: {
|
|
674
|
+
type: "object",
|
|
675
|
+
properties: {
|
|
676
|
+
author: { type: "string" },
|
|
677
|
+
url: { type: "string", format: "uri" },
|
|
678
|
+
name: { type: "string" },
|
|
679
|
+
adapted: { type: "boolean" }
|
|
778
680
|
}
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
681
|
+
},
|
|
682
|
+
yield: {
|
|
683
|
+
$ref: "#/definitions/yield"
|
|
684
|
+
},
|
|
685
|
+
time: {
|
|
686
|
+
$ref: "#/definitions/time"
|
|
687
|
+
},
|
|
688
|
+
equipment: {
|
|
689
|
+
type: "array",
|
|
690
|
+
items: { $ref: "#/definitions/equipment" }
|
|
691
|
+
},
|
|
692
|
+
ingredients: {
|
|
693
|
+
type: "array",
|
|
694
|
+
items: {
|
|
695
|
+
anyOf: [
|
|
696
|
+
{ type: "string" },
|
|
697
|
+
{ $ref: "#/definitions/ingredient" },
|
|
698
|
+
{ $ref: "#/definitions/ingredientSubsection" }
|
|
699
|
+
]
|
|
782
700
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
cleaned: cleaned.replace(/\s+/g, " ").trim(),
|
|
801
|
-
notes
|
|
802
|
-
};
|
|
803
|
-
}
|
|
804
|
-
function extractPurposeNotes(value) {
|
|
805
|
-
const notes = [];
|
|
806
|
-
let working = value.trim();
|
|
807
|
-
let match = working.match(/\bfor\s+(frying|greasing|drizzling|garnish|serving|brushing)\b\.?$/i);
|
|
808
|
-
if (match) {
|
|
809
|
-
notes.push(`for ${match[1].toLowerCase()}`);
|
|
810
|
-
working = working.slice(0, match.index).trim();
|
|
811
|
-
}
|
|
812
|
-
return { cleaned: working, notes };
|
|
813
|
-
}
|
|
814
|
-
function extractJuicePhrase(value) {
|
|
815
|
-
const lower = value.toLowerCase();
|
|
816
|
-
for (const prefix of JUICE_PREFIXES) {
|
|
817
|
-
if (lower.startsWith(prefix)) {
|
|
818
|
-
const remainder = value.slice(prefix.length).trim();
|
|
819
|
-
if (!remainder) break;
|
|
820
|
-
const cleanedSource = remainder.replace(/^of\s+/i, "").trim();
|
|
821
|
-
if (!cleanedSource) break;
|
|
822
|
-
const sourceForName = cleanedSource.replace(
|
|
823
|
-
/^(?:\d+(?:\.\d+)?|\d+\s+\d+\/\d+|\d+\/\d+|one|two|three|four|five|six|seven|eight|nine|ten|a|an)\s+/i,
|
|
824
|
-
""
|
|
825
|
-
).replace(/^(?:large|small|medium)\s+/i, "").trim();
|
|
826
|
-
const baseName = sourceForName || cleanedSource;
|
|
827
|
-
const singular = singularize(baseName);
|
|
828
|
-
const suffix = prefix.startsWith("zest") ? "zest" : "juice";
|
|
829
|
-
return {
|
|
830
|
-
cleaned: `${singular} ${suffix}`.trim(),
|
|
831
|
-
note: `from ${cleanedSource}`
|
|
832
|
-
};
|
|
701
|
+
},
|
|
702
|
+
instructions: {
|
|
703
|
+
type: "array",
|
|
704
|
+
items: {
|
|
705
|
+
anyOf: [
|
|
706
|
+
{ type: "string" },
|
|
707
|
+
{ $ref: "#/definitions/instruction" },
|
|
708
|
+
{ $ref: "#/definitions/instructionSubsection" }
|
|
709
|
+
]
|
|
710
|
+
}
|
|
711
|
+
},
|
|
712
|
+
storage: {
|
|
713
|
+
$ref: "#/definitions/storage"
|
|
714
|
+
},
|
|
715
|
+
substitutions: {
|
|
716
|
+
type: "array",
|
|
717
|
+
items: { $ref: "#/definitions/substitution" }
|
|
833
718
|
}
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
719
|
+
},
|
|
720
|
+
definitions: {
|
|
721
|
+
yield: {
|
|
722
|
+
type: "object",
|
|
723
|
+
required: ["amount", "unit"],
|
|
724
|
+
properties: {
|
|
725
|
+
amount: { type: "number" },
|
|
726
|
+
unit: { type: "string" },
|
|
727
|
+
servings: { type: "number" },
|
|
728
|
+
description: { type: "string" }
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
time: {
|
|
732
|
+
type: "object",
|
|
733
|
+
properties: {
|
|
734
|
+
prep: { type: "number" },
|
|
735
|
+
active: { type: "number" },
|
|
736
|
+
passive: { type: "number" },
|
|
737
|
+
total: { type: "number" },
|
|
738
|
+
prepTime: { type: "string", format: "duration" },
|
|
739
|
+
cookTime: { type: "string", format: "duration" }
|
|
740
|
+
},
|
|
741
|
+
minProperties: 1
|
|
742
|
+
},
|
|
743
|
+
quantity: {
|
|
744
|
+
type: "object",
|
|
745
|
+
required: ["amount"],
|
|
746
|
+
properties: {
|
|
747
|
+
amount: { type: "number" },
|
|
748
|
+
unit: { type: ["string", "null"] }
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
scaling: {
|
|
752
|
+
type: "object",
|
|
753
|
+
required: ["type"],
|
|
754
|
+
properties: {
|
|
755
|
+
type: {
|
|
756
|
+
type: "string",
|
|
757
|
+
enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
|
|
758
|
+
},
|
|
759
|
+
factor: { type: "number" },
|
|
760
|
+
referenceId: { type: "string" },
|
|
761
|
+
roundTo: { type: "number" },
|
|
762
|
+
min: { type: "number" },
|
|
763
|
+
max: { type: "number" }
|
|
764
|
+
},
|
|
765
|
+
if: {
|
|
766
|
+
properties: { type: { const: "bakers_percentage" } }
|
|
767
|
+
},
|
|
768
|
+
then: {
|
|
769
|
+
required: ["referenceId"]
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
ingredient: {
|
|
773
|
+
type: "object",
|
|
774
|
+
required: ["item"],
|
|
775
|
+
properties: {
|
|
776
|
+
id: { type: "string" },
|
|
777
|
+
item: { type: "string" },
|
|
778
|
+
quantity: { $ref: "#/definitions/quantity" },
|
|
779
|
+
name: { type: "string" },
|
|
780
|
+
aisle: { type: "string" },
|
|
781
|
+
prep: { type: "string" },
|
|
782
|
+
prepAction: { type: "string" },
|
|
783
|
+
prepTime: { type: "number" },
|
|
784
|
+
destination: { type: "string" },
|
|
785
|
+
scaling: { $ref: "#/definitions/scaling" },
|
|
786
|
+
critical: { type: "boolean" },
|
|
787
|
+
optional: { type: "boolean" },
|
|
788
|
+
notes: { type: "string" }
|
|
789
|
+
}
|
|
790
|
+
},
|
|
791
|
+
ingredientSubsection: {
|
|
792
|
+
type: "object",
|
|
793
|
+
required: ["subsection", "items"],
|
|
794
|
+
properties: {
|
|
795
|
+
subsection: { type: "string" },
|
|
796
|
+
items: {
|
|
797
|
+
type: "array",
|
|
798
|
+
items: { $ref: "#/definitions/ingredient" }
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
},
|
|
802
|
+
equipment: {
|
|
803
|
+
type: "object",
|
|
804
|
+
required: ["name"],
|
|
805
|
+
properties: {
|
|
806
|
+
id: { type: "string" },
|
|
807
|
+
name: { type: "string" },
|
|
808
|
+
required: { type: "boolean" },
|
|
809
|
+
label: { type: "string" },
|
|
810
|
+
capacity: { $ref: "#/definitions/quantity" },
|
|
811
|
+
scalingLimit: { type: "number" },
|
|
812
|
+
alternatives: {
|
|
813
|
+
type: "array",
|
|
814
|
+
items: { type: "string" }
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
instruction: {
|
|
819
|
+
type: "object",
|
|
820
|
+
required: ["text"],
|
|
821
|
+
properties: {
|
|
822
|
+
id: { type: "string" },
|
|
823
|
+
text: { type: "string" },
|
|
824
|
+
image: {
|
|
825
|
+
type: "string",
|
|
826
|
+
format: "uri",
|
|
827
|
+
description: "Optional image that illustrates this instruction"
|
|
828
|
+
},
|
|
829
|
+
destination: { type: "string" },
|
|
830
|
+
dependsOn: {
|
|
831
|
+
type: "array",
|
|
832
|
+
items: { type: "string" }
|
|
833
|
+
},
|
|
834
|
+
inputs: {
|
|
835
|
+
type: "array",
|
|
836
|
+
items: { type: "string" }
|
|
837
|
+
},
|
|
838
|
+
timing: {
|
|
839
|
+
type: "object",
|
|
840
|
+
required: ["duration", "type"],
|
|
841
|
+
properties: {
|
|
842
|
+
duration: {
|
|
843
|
+
anyOf: [
|
|
844
|
+
{ type: "number" },
|
|
845
|
+
{ type: "string", pattern: "^P" }
|
|
846
|
+
],
|
|
847
|
+
description: "Minutes as a number or ISO8601 duration string"
|
|
848
|
+
},
|
|
849
|
+
type: { type: "string", enum: ["active", "passive"] },
|
|
850
|
+
scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
instructionSubsection: {
|
|
856
|
+
type: "object",
|
|
857
|
+
required: ["subsection", "items"],
|
|
858
|
+
properties: {
|
|
859
|
+
subsection: { type: "string" },
|
|
860
|
+
items: {
|
|
861
|
+
type: "array",
|
|
862
|
+
items: {
|
|
863
|
+
anyOf: [
|
|
864
|
+
{ type: "string" },
|
|
865
|
+
{ $ref: "#/definitions/instruction" }
|
|
866
|
+
]
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
storage: {
|
|
872
|
+
type: "object",
|
|
873
|
+
properties: {
|
|
874
|
+
roomTemp: { $ref: "#/definitions/storageMethod" },
|
|
875
|
+
refrigerated: { $ref: "#/definitions/storageMethod" },
|
|
876
|
+
frozen: {
|
|
877
|
+
allOf: [
|
|
878
|
+
{ $ref: "#/definitions/storageMethod" },
|
|
879
|
+
{
|
|
880
|
+
type: "object",
|
|
881
|
+
properties: { thawing: { type: "string" } }
|
|
882
|
+
}
|
|
883
|
+
]
|
|
884
|
+
},
|
|
885
|
+
reheating: { type: "string" },
|
|
886
|
+
makeAhead: {
|
|
887
|
+
type: "array",
|
|
888
|
+
items: {
|
|
889
|
+
allOf: [
|
|
890
|
+
{ $ref: "#/definitions/storageMethod" },
|
|
891
|
+
{
|
|
892
|
+
type: "object",
|
|
893
|
+
required: ["component", "storage"],
|
|
894
|
+
properties: {
|
|
895
|
+
component: { type: "string" },
|
|
896
|
+
storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
]
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
storageMethod: {
|
|
905
|
+
type: "object",
|
|
906
|
+
required: ["duration"],
|
|
907
|
+
properties: {
|
|
908
|
+
duration: { type: "string", pattern: "^P" },
|
|
909
|
+
method: { type: "string" },
|
|
910
|
+
notes: { type: "string" }
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
substitution: {
|
|
914
|
+
type: "object",
|
|
915
|
+
required: ["ingredient"],
|
|
916
|
+
properties: {
|
|
917
|
+
ingredient: { type: "string" },
|
|
918
|
+
critical: { type: "boolean" },
|
|
919
|
+
notes: { type: "string" },
|
|
920
|
+
alternatives: {
|
|
921
|
+
type: "array",
|
|
922
|
+
items: {
|
|
923
|
+
type: "object",
|
|
924
|
+
required: ["name", "ratio"],
|
|
925
|
+
properties: {
|
|
926
|
+
name: { type: "string" },
|
|
927
|
+
ratio: { type: "string" },
|
|
928
|
+
notes: { type: "string" },
|
|
929
|
+
impact: { type: "string" },
|
|
930
|
+
dietary: {
|
|
931
|
+
type: "array",
|
|
932
|
+
items: { type: "string" }
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
847
938
|
}
|
|
848
939
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
// src/profiles/base.schema.json
|
|
943
|
+
var base_schema_default = {
|
|
944
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
945
|
+
$id: "http://soustack.org/schema/v0.2.1/profiles/base",
|
|
946
|
+
title: "Soustack Base Profile Schema",
|
|
947
|
+
description: "Wrapper schema that exposes the unmodified Soustack base schema.",
|
|
948
|
+
allOf: [
|
|
949
|
+
{ $ref: "http://soustack.org/schema/v0.2.1" }
|
|
950
|
+
]
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
// src/profiles/cookable.schema.json
|
|
954
|
+
var cookable_schema_default = {
|
|
955
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
956
|
+
$id: "http://soustack.org/schema/v0.2.1/profiles/cookable",
|
|
957
|
+
title: "Soustack Cookable Profile Schema",
|
|
958
|
+
description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
|
|
959
|
+
allOf: [
|
|
960
|
+
{ $ref: "http://soustack.org/schema/v0.2.1" },
|
|
961
|
+
{
|
|
962
|
+
required: ["yield", "time", "ingredients", "instructions"],
|
|
963
|
+
properties: {
|
|
964
|
+
yield: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/yield" },
|
|
965
|
+
time: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/time" },
|
|
966
|
+
ingredients: { type: "array", minItems: 1 },
|
|
967
|
+
instructions: { type: "array", minItems: 1 }
|
|
968
|
+
}
|
|
861
969
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
970
|
+
]
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
// src/profiles/quantified.schema.json
|
|
974
|
+
var quantified_schema_default = {
|
|
975
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
976
|
+
$id: "http://soustack.org/schema/v0.2.1/profiles/quantified",
|
|
977
|
+
title: "Soustack Quantified Profile Schema",
|
|
978
|
+
description: "Extends the base schema to require quantified ingredient entries.",
|
|
979
|
+
allOf: [
|
|
980
|
+
{ $ref: "http://soustack.org/schema/v0.2.1" },
|
|
981
|
+
{
|
|
982
|
+
properties: {
|
|
983
|
+
ingredients: {
|
|
984
|
+
type: "array",
|
|
985
|
+
items: {
|
|
986
|
+
anyOf: [
|
|
987
|
+
{ $ref: "#/definitions/quantifiedIngredient" },
|
|
988
|
+
{ $ref: "#/definitions/quantifiedIngredientSubsection" }
|
|
989
|
+
]
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
866
993
|
}
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
}
|
|
888
|
-
function extractQuantity(value) {
|
|
889
|
-
let working = value.trim();
|
|
890
|
-
const notes = [];
|
|
891
|
-
let amount = null;
|
|
892
|
-
let originalAmount = null;
|
|
893
|
-
let unit = null;
|
|
894
|
-
let descriptor;
|
|
895
|
-
while (QUALIFIER_REGEX.test(working)) {
|
|
896
|
-
working = working.replace(QUALIFIER_REGEX, "").trim();
|
|
897
|
-
}
|
|
898
|
-
const rangeMatch = working.match(RANGE_REGEX);
|
|
899
|
-
if (rangeMatch) {
|
|
900
|
-
amount = parseNumber(rangeMatch[1]);
|
|
901
|
-
originalAmount = amount;
|
|
902
|
-
const rangeText = rangeMatch[0].trim();
|
|
903
|
-
const afterRange = working.slice(rangeMatch[0].length).trim();
|
|
904
|
-
const descriptorMatch = afterRange.match(/^([a-zA-Z]+)/);
|
|
905
|
-
if (descriptorMatch && COUNT_DESCRIPTORS.has(descriptorMatch[1].toLowerCase())) {
|
|
906
|
-
notes.push(`${rangeText} ${descriptorMatch[1]}`);
|
|
907
|
-
} else {
|
|
908
|
-
notes.push(rangeText);
|
|
994
|
+
],
|
|
995
|
+
definitions: {
|
|
996
|
+
quantifiedIngredient: {
|
|
997
|
+
allOf: [
|
|
998
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredient" },
|
|
999
|
+
{ required: ["item", "quantity"] }
|
|
1000
|
+
]
|
|
1001
|
+
},
|
|
1002
|
+
quantifiedIngredientSubsection: {
|
|
1003
|
+
allOf: [
|
|
1004
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredientSubsection" },
|
|
1005
|
+
{
|
|
1006
|
+
properties: {
|
|
1007
|
+
items: {
|
|
1008
|
+
type: "array",
|
|
1009
|
+
items: { $ref: "#/definitions/quantifiedIngredient" }
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
]
|
|
909
1014
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
// src/profiles/illustrated.schema.json
|
|
1019
|
+
var illustrated_schema_default = {
|
|
1020
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1021
|
+
$id: "http://soustack.org/schema/v0.2.1/profiles/illustrated",
|
|
1022
|
+
title: "Soustack Illustrated Profile Schema",
|
|
1023
|
+
description: "Extends the base schema to guarantee at least one illustrative image.",
|
|
1024
|
+
allOf: [
|
|
1025
|
+
{ $ref: "http://soustack.org/schema/v0.2.1" },
|
|
1026
|
+
{
|
|
1027
|
+
anyOf: [
|
|
1028
|
+
{ required: ["image"] },
|
|
1029
|
+
{
|
|
1030
|
+
properties: {
|
|
1031
|
+
instructions: {
|
|
1032
|
+
type: "array",
|
|
1033
|
+
contains: {
|
|
1034
|
+
anyOf: [
|
|
1035
|
+
{ $ref: "#/definitions/imageInstruction" },
|
|
1036
|
+
{ $ref: "#/definitions/instructionSubsectionWithImage" }
|
|
1037
|
+
]
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
]
|
|
1043
|
+
}
|
|
1044
|
+
],
|
|
1045
|
+
definitions: {
|
|
1046
|
+
imageInstruction: {
|
|
1047
|
+
allOf: [
|
|
1048
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
|
|
1049
|
+
{ required: ["image"] }
|
|
1050
|
+
]
|
|
1051
|
+
},
|
|
1052
|
+
instructionSubsectionWithImage: {
|
|
1053
|
+
allOf: [
|
|
1054
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
|
|
1055
|
+
{
|
|
1056
|
+
properties: {
|
|
1057
|
+
items: {
|
|
1058
|
+
type: "array",
|
|
1059
|
+
contains: { $ref: "#/definitions/imageInstruction" }
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
]
|
|
917
1064
|
}
|
|
918
1065
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
// src/profiles/schedulable.schema.json
|
|
1069
|
+
var schedulable_schema_default = {
|
|
1070
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1071
|
+
$id: "http://soustack.org/schema/v0.2.1/profiles/schedulable",
|
|
1072
|
+
title: "Soustack Schedulable Profile Schema",
|
|
1073
|
+
description: "Extends the base schema to ensure every instruction is fully scheduled.",
|
|
1074
|
+
allOf: [
|
|
1075
|
+
{ $ref: "http://soustack.org/schema/v0.2.1" },
|
|
1076
|
+
{
|
|
1077
|
+
properties: {
|
|
1078
|
+
instructions: {
|
|
1079
|
+
type: "array",
|
|
1080
|
+
items: {
|
|
1081
|
+
anyOf: [
|
|
1082
|
+
{ $ref: "#/definitions/schedulableInstruction" },
|
|
1083
|
+
{ $ref: "#/definitions/schedulableInstructionSubsection" }
|
|
1084
|
+
]
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
929
1087
|
}
|
|
930
1088
|
}
|
|
1089
|
+
],
|
|
1090
|
+
definitions: {
|
|
1091
|
+
schedulableInstruction: {
|
|
1092
|
+
allOf: [
|
|
1093
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
|
|
1094
|
+
{ required: ["id", "timing"] }
|
|
1095
|
+
]
|
|
1096
|
+
},
|
|
1097
|
+
schedulableInstructionSubsection: {
|
|
1098
|
+
allOf: [
|
|
1099
|
+
{ $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
|
|
1100
|
+
{
|
|
1101
|
+
properties: {
|
|
1102
|
+
items: {
|
|
1103
|
+
type: "array",
|
|
1104
|
+
items: { $ref: "#/definitions/schedulableInstruction" }
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
]
|
|
1109
|
+
}
|
|
931
1110
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
// src/validator.ts
|
|
1114
|
+
var profileSchemas = {
|
|
1115
|
+
base: base_schema_default,
|
|
1116
|
+
cookable: cookable_schema_default,
|
|
1117
|
+
scalable: base_schema_default,
|
|
1118
|
+
quantified: quantified_schema_default,
|
|
1119
|
+
illustrated: illustrated_schema_default,
|
|
1120
|
+
schedulable: schedulable_schema_default
|
|
1121
|
+
};
|
|
1122
|
+
var validationContexts = /* @__PURE__ */ new Map();
|
|
1123
|
+
function createContext(collectAllErrors) {
|
|
1124
|
+
const ajv = new Ajv__default.default({ strict: false, allErrors: collectAllErrors });
|
|
1125
|
+
addFormats__default.default(ajv);
|
|
1126
|
+
const loadedIds = /* @__PURE__ */ new Set();
|
|
1127
|
+
const addSchemaIfNew = (schema) => {
|
|
1128
|
+
if (!schema) return;
|
|
1129
|
+
const schemaId = schema == null ? void 0 : schema.$id;
|
|
1130
|
+
if (schemaId && loadedIds.has(schemaId)) return;
|
|
1131
|
+
ajv.addSchema(schema);
|
|
1132
|
+
if (schemaId) loadedIds.add(schemaId);
|
|
939
1133
|
};
|
|
1134
|
+
addSchemaIfNew(schema_default);
|
|
1135
|
+
addSchemaIfNew(soustack_schema_default);
|
|
1136
|
+
Object.values(profileSchemas).forEach(addSchemaIfNew);
|
|
1137
|
+
return { ajv, validators: {} };
|
|
940
1138
|
}
|
|
941
|
-
function
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
if (/^\d+\s+\d+\/\d+$/.test(trimmed)) {
|
|
945
|
-
const [whole, fraction] = trimmed.split(/\s+/);
|
|
946
|
-
return parseInt(whole, 10) + parseFraction(fraction);
|
|
947
|
-
}
|
|
948
|
-
if (/^\d+\/\d+$/.test(trimmed)) {
|
|
949
|
-
return parseFraction(trimmed);
|
|
1139
|
+
function getContext(collectAllErrors) {
|
|
1140
|
+
if (!validationContexts.has(collectAllErrors)) {
|
|
1141
|
+
validationContexts.set(collectAllErrors, createContext(collectAllErrors));
|
|
950
1142
|
}
|
|
951
|
-
|
|
952
|
-
return Number.isNaN(parsed) ? null : parsed;
|
|
1143
|
+
return validationContexts.get(collectAllErrors);
|
|
953
1144
|
}
|
|
954
|
-
function
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
return numerator / denominator;
|
|
958
|
-
}
|
|
959
|
-
function normalizeUnit(raw) {
|
|
960
|
-
const lower = raw.toLowerCase();
|
|
961
|
-
if (UNIT_SYNONYMS[lower]) {
|
|
962
|
-
return UNIT_SYNONYMS[lower];
|
|
1145
|
+
function cloneRecipe(recipe) {
|
|
1146
|
+
if (typeof structuredClone === "function") {
|
|
1147
|
+
return structuredClone(recipe);
|
|
963
1148
|
}
|
|
964
|
-
|
|
965
|
-
if (raw === "t") return "tsp";
|
|
966
|
-
if (raw === "C") return "cup";
|
|
967
|
-
return null;
|
|
1149
|
+
return JSON.parse(JSON.stringify(recipe));
|
|
968
1150
|
}
|
|
969
|
-
function
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
return { quantity, usedParenthetical: false };
|
|
976
|
-
}
|
|
977
|
-
const measurementUnit = measurement.unit?.toLowerCase() ?? null;
|
|
978
|
-
const shouldPrefer = !quantity.unit || measurementUnit !== null && WEIGHT_PRIORITY_UNITS.has(measurementUnit);
|
|
979
|
-
if (shouldPrefer) {
|
|
980
|
-
return {
|
|
981
|
-
quantity: {
|
|
982
|
-
amount: measurement.amount,
|
|
983
|
-
unit: measurement.unit ?? null
|
|
984
|
-
},
|
|
985
|
-
usedParenthetical: true
|
|
986
|
-
};
|
|
1151
|
+
function detectProfileFromSchema(schemaRef) {
|
|
1152
|
+
if (!schemaRef) return void 0;
|
|
1153
|
+
const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
|
|
1154
|
+
if (match) {
|
|
1155
|
+
const profile = match[1].toLowerCase();
|
|
1156
|
+
if (profile in profileSchemas) return profile;
|
|
987
1157
|
}
|
|
988
|
-
return
|
|
989
|
-
}
|
|
990
|
-
function
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1158
|
+
return void 0;
|
|
1159
|
+
}
|
|
1160
|
+
function getValidator(profile, context) {
|
|
1161
|
+
if (!profileSchemas[profile]) {
|
|
1162
|
+
throw new Error(`Unknown Soustack profile: ${profile}`);
|
|
1163
|
+
}
|
|
1164
|
+
if (!context.validators[profile]) {
|
|
1165
|
+
context.validators[profile] = context.ajv.compile(profileSchemas[profile]);
|
|
1166
|
+
}
|
|
1167
|
+
return context.validators[profile];
|
|
1168
|
+
}
|
|
1169
|
+
function normalizeRecipe(recipe) {
|
|
1170
|
+
const normalized = cloneRecipe(recipe);
|
|
1171
|
+
const warnings = [];
|
|
1172
|
+
normalizeTime(normalized);
|
|
1173
|
+
if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
|
|
1174
|
+
normalized.recipeVersion = normalized.version;
|
|
1175
|
+
warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
|
|
1176
|
+
}
|
|
1177
|
+
return { normalized, warnings };
|
|
1178
|
+
}
|
|
1179
|
+
function normalizeTime(recipe) {
|
|
1180
|
+
const time = recipe == null ? void 0 : recipe.time;
|
|
1181
|
+
if (!time || typeof time !== "object" || Array.isArray(time)) return;
|
|
1182
|
+
const structuredKeys = [
|
|
1183
|
+
"prep",
|
|
1184
|
+
"active",
|
|
1185
|
+
"passive",
|
|
1186
|
+
"total"
|
|
1187
|
+
];
|
|
1188
|
+
structuredKeys.forEach((key) => {
|
|
1189
|
+
const value = time[key];
|
|
1190
|
+
if (typeof value === "number") return;
|
|
1191
|
+
const parsed = parseDuration(value);
|
|
1192
|
+
if (parsed !== null) {
|
|
1193
|
+
time[key] = parsed;
|
|
1000
1194
|
}
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
var _a, _b;
|
|
1198
|
+
var allowedTopLevelProps = /* @__PURE__ */ new Set([
|
|
1199
|
+
...Object.keys((_b = (_a = soustack_schema_default) == null ? void 0 : _a.properties) != null ? _b : {}),
|
|
1200
|
+
"metadata",
|
|
1201
|
+
"$schema"
|
|
1202
|
+
]);
|
|
1203
|
+
function detectUnknownTopLevelKeys(recipe) {
|
|
1204
|
+
if (!recipe || typeof recipe !== "object") return [];
|
|
1205
|
+
const disallowedKeys = Object.keys(recipe).filter(
|
|
1206
|
+
(key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
|
|
1207
|
+
);
|
|
1208
|
+
return disallowedKeys.map((key) => ({
|
|
1209
|
+
path: `/${key}`,
|
|
1210
|
+
keyword: "additionalProperties",
|
|
1211
|
+
message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
|
|
1212
|
+
}));
|
|
1213
|
+
}
|
|
1214
|
+
function formatAjvError(error) {
|
|
1215
|
+
var _a2;
|
|
1216
|
+
let path = error.instancePath || "/";
|
|
1217
|
+
if (error.keyword === "additionalProperties" && ((_a2 = error.params) == null ? void 0 : _a2.additionalProperty)) {
|
|
1218
|
+
const extra = error.params.additionalProperty;
|
|
1219
|
+
path = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
|
|
1001
1220
|
}
|
|
1002
|
-
working = working.replace(/^[,.\s-]+/, "").trim();
|
|
1003
|
-
working = working.replace(/^of\s+/i, "").trim();
|
|
1004
|
-
if (!working) {
|
|
1005
|
-
return { name: void 0, prep, notes };
|
|
1006
|
-
}
|
|
1007
|
-
let name = cleanupIngredientName(working);
|
|
1008
1221
|
return {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1222
|
+
path,
|
|
1223
|
+
keyword: error.keyword,
|
|
1224
|
+
message: error.message || "Validation error"
|
|
1012
1225
|
};
|
|
1013
1226
|
}
|
|
1014
|
-
function
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1227
|
+
function runAjvValidation(data, profile, context, schemaRef) {
|
|
1228
|
+
const validator = schemaRef ? context.ajv.getSchema(schemaRef) : void 0;
|
|
1229
|
+
const validateFn = validator != null ? validator : getValidator(profile, context);
|
|
1230
|
+
const isValid = validateFn(data);
|
|
1231
|
+
return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
|
|
1232
|
+
}
|
|
1233
|
+
function isInstruction(item) {
|
|
1234
|
+
return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
|
|
1235
|
+
}
|
|
1236
|
+
function isInstructionSubsection(item) {
|
|
1237
|
+
return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
|
|
1238
|
+
}
|
|
1239
|
+
function checkInstructionGraph(recipe) {
|
|
1240
|
+
const instructions = recipe == null ? void 0 : recipe.instructions;
|
|
1241
|
+
if (!Array.isArray(instructions)) return [];
|
|
1242
|
+
const instructionIds = /* @__PURE__ */ new Set();
|
|
1243
|
+
const dependencyRefs = [];
|
|
1244
|
+
const collect = (items, basePath) => {
|
|
1245
|
+
items.forEach((item, index) => {
|
|
1246
|
+
const currentPath = `${basePath}/${index}`;
|
|
1247
|
+
if (isInstructionSubsection(item) && Array.isArray(item.items)) {
|
|
1248
|
+
collect(item.items, `${currentPath}/items`);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
if (isInstruction(item)) {
|
|
1252
|
+
const id = typeof item.id === "string" ? item.id : void 0;
|
|
1253
|
+
if (id) instructionIds.add(id);
|
|
1254
|
+
if (Array.isArray(item.dependsOn)) {
|
|
1255
|
+
item.dependsOn.forEach((depId, depIndex) => {
|
|
1256
|
+
if (typeof depId === "string") {
|
|
1257
|
+
dependencyRefs.push({
|
|
1258
|
+
fromId: id,
|
|
1259
|
+
toId: depId,
|
|
1260
|
+
path: `${currentPath}/dependsOn/${depIndex}`
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
};
|
|
1268
|
+
collect(instructions, "/instructions");
|
|
1269
|
+
const errors = [];
|
|
1270
|
+
dependencyRefs.forEach((ref) => {
|
|
1271
|
+
if (!instructionIds.has(ref.toId)) {
|
|
1272
|
+
errors.push({
|
|
1273
|
+
path: ref.path,
|
|
1274
|
+
message: `Instruction dependency references missing id '${ref.toId}'.`
|
|
1275
|
+
});
|
|
1026
1276
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1277
|
+
});
|
|
1278
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
1279
|
+
dependencyRefs.forEach((ref) => {
|
|
1280
|
+
var _a2;
|
|
1281
|
+
if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
|
|
1282
|
+
const list = (_a2 = adjacency.get(ref.fromId)) != null ? _a2 : [];
|
|
1283
|
+
list.push({ toId: ref.toId, path: ref.path });
|
|
1284
|
+
adjacency.set(ref.fromId, list);
|
|
1031
1285
|
}
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
const smallUnit = unit ? ["tsp", "tbsp", "dash", "pinch"].includes(unit) : false;
|
|
1054
|
-
if (normalizedNotes.some((note) => note.includes("to taste")) || isSpice && (smallUnit || amount !== null && amount <= 1)) {
|
|
1055
|
-
return { type: "proportional", factor: 0.7 };
|
|
1056
|
-
}
|
|
1057
|
-
return { type: "linear" };
|
|
1058
|
-
}
|
|
1059
|
-
function formatNotes(notes) {
|
|
1060
|
-
const cleaned = Array.from(
|
|
1061
|
-
new Set(
|
|
1062
|
-
notes.map((note) => note.trim()).filter(Boolean)
|
|
1063
|
-
)
|
|
1064
|
-
);
|
|
1065
|
-
return cleaned.length ? cleaned.join("; ") : void 0;
|
|
1066
|
-
}
|
|
1067
|
-
function formatCountNote(amount, descriptor) {
|
|
1068
|
-
const lower = descriptor.toLowerCase();
|
|
1069
|
-
const singular = lower.endsWith("s") ? lower.slice(0, -1) : lower;
|
|
1070
|
-
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`;
|
|
1071
|
-
return `${formatDecimal(amount)} ${word}`;
|
|
1072
|
-
}
|
|
1073
|
-
function singularize(value) {
|
|
1074
|
-
const trimmed = value.trim();
|
|
1075
|
-
if (trimmed.endsWith("ies")) {
|
|
1076
|
-
return `${trimmed.slice(0, -3)}y`;
|
|
1077
|
-
}
|
|
1078
|
-
if (/(ches|shes|sses|xes|zes)$/i.test(trimmed)) {
|
|
1079
|
-
return trimmed.slice(0, -2);
|
|
1080
|
-
}
|
|
1081
|
-
if (trimmed.endsWith("s")) {
|
|
1082
|
-
return trimmed.slice(0, -1);
|
|
1083
|
-
}
|
|
1084
|
-
return trimmed;
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
// src/converters/ingredient.ts
|
|
1088
|
-
function parseIngredientLine2(line) {
|
|
1089
|
-
const parsed = parseIngredient(line);
|
|
1090
|
-
const ingredient = {
|
|
1091
|
-
item: parsed.item,
|
|
1092
|
-
scaling: parsed.scaling ?? { type: "linear" }
|
|
1286
|
+
});
|
|
1287
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
1288
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1289
|
+
const detectCycles = (nodeId) => {
|
|
1290
|
+
var _a2;
|
|
1291
|
+
if (visiting.has(nodeId)) return;
|
|
1292
|
+
if (visited.has(nodeId)) return;
|
|
1293
|
+
visiting.add(nodeId);
|
|
1294
|
+
const neighbors = (_a2 = adjacency.get(nodeId)) != null ? _a2 : [];
|
|
1295
|
+
neighbors.forEach((edge) => {
|
|
1296
|
+
if (visiting.has(edge.toId)) {
|
|
1297
|
+
errors.push({
|
|
1298
|
+
path: edge.path,
|
|
1299
|
+
message: `Circular dependency detected involving instruction id '${edge.toId}'.`
|
|
1300
|
+
});
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
detectCycles(edge.toId);
|
|
1304
|
+
});
|
|
1305
|
+
visiting.delete(nodeId);
|
|
1306
|
+
visited.add(nodeId);
|
|
1093
1307
|
};
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
const
|
|
1107
|
-
|
|
1108
|
-
ingredient.quantity = quantity;
|
|
1109
|
-
}
|
|
1110
|
-
return ingredient;
|
|
1111
|
-
}
|
|
1112
|
-
function buildQuantity(parsedQuantity) {
|
|
1113
|
-
if (!parsedQuantity) {
|
|
1114
|
-
return void 0;
|
|
1115
|
-
}
|
|
1116
|
-
if (parsedQuantity.amount === null || Number.isNaN(parsedQuantity.amount)) {
|
|
1117
|
-
return void 0;
|
|
1118
|
-
}
|
|
1308
|
+
instructionIds.forEach((id) => detectCycles(id));
|
|
1309
|
+
return errors;
|
|
1310
|
+
}
|
|
1311
|
+
function validateRecipe(input, options = {}) {
|
|
1312
|
+
var _a2, _b2, _c, _d;
|
|
1313
|
+
const collectAllErrors = (_a2 = options.collectAllErrors) != null ? _a2 : true;
|
|
1314
|
+
const context = getContext(collectAllErrors);
|
|
1315
|
+
const schemaRef = (_b2 = options.schema) != null ? _b2 : typeof (input == null ? void 0 : input.$schema) === "string" ? input.$schema : void 0;
|
|
1316
|
+
const profile = (_d = (_c = options.profile) != null ? _c : detectProfileFromSchema(schemaRef)) != null ? _d : "base";
|
|
1317
|
+
const { normalized, warnings } = normalizeRecipe(input);
|
|
1318
|
+
const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
|
|
1319
|
+
const validationErrors = runAjvValidation(normalized, profile, context, schemaRef);
|
|
1320
|
+
const graphErrors = profile === "schedulable" && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
|
|
1321
|
+
const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
|
|
1119
1322
|
return {
|
|
1120
|
-
|
|
1121
|
-
|
|
1323
|
+
valid: errors.length === 0,
|
|
1324
|
+
errors,
|
|
1325
|
+
warnings,
|
|
1326
|
+
normalized: errors.length === 0 ? normalized : void 0
|
|
1122
1327
|
};
|
|
1123
1328
|
}
|
|
1329
|
+
function detectProfiles(recipe) {
|
|
1330
|
+
var _a2;
|
|
1331
|
+
const result = validateRecipe(recipe, { profile: "base", collectAllErrors: false });
|
|
1332
|
+
if (!result.valid) return [];
|
|
1333
|
+
const normalizedRecipe = (_a2 = result.normalized) != null ? _a2 : recipe;
|
|
1334
|
+
const profiles = ["base"];
|
|
1335
|
+
const context = getContext(false);
|
|
1336
|
+
Object.keys(profileSchemas).forEach((profile) => {
|
|
1337
|
+
if (profile === "base") return;
|
|
1338
|
+
if (!profileSchemas[profile]) return;
|
|
1339
|
+
const errors = runAjvValidation(normalizedRecipe, profile, context);
|
|
1340
|
+
if (errors.length === 0) {
|
|
1341
|
+
profiles.push(profile);
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
return profiles;
|
|
1345
|
+
}
|
|
1124
1346
|
|
|
1125
1347
|
// src/converters/yield.ts
|
|
1126
1348
|
function parseYield(value) {
|
|
@@ -1128,128 +1350,48 @@ function parseYield(value) {
|
|
|
1128
1350
|
return void 0;
|
|
1129
1351
|
}
|
|
1130
1352
|
if (typeof value === "number") {
|
|
1131
|
-
return {
|
|
1132
|
-
amount: value,
|
|
1133
|
-
unit: "servings"
|
|
1134
|
-
};
|
|
1135
|
-
}
|
|
1136
|
-
if (Array.isArray(value)) {
|
|
1137
|
-
return parseYield(value[0]);
|
|
1138
|
-
}
|
|
1139
|
-
if (typeof value === "object") {
|
|
1140
|
-
const maybeYield = value;
|
|
1141
|
-
if (typeof maybeYield.amount === "number") {
|
|
1142
|
-
return {
|
|
1143
|
-
amount: maybeYield.amount,
|
|
1144
|
-
unit: typeof maybeYield.unit === "string" ? maybeYield.unit : "servings",
|
|
1145
|
-
description: typeof maybeYield.description === "string" ? maybeYield.description : void 0
|
|
1146
|
-
};
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
if (typeof value === "string") {
|
|
1150
|
-
const trimmed = value.trim();
|
|
1151
|
-
const match = trimmed.match(/(\d+(?:\.\d+)?)/);
|
|
1152
|
-
if (match) {
|
|
1153
|
-
const amount = parseFloat(match[1]);
|
|
1154
|
-
const unit = trimmed.slice(match.index + match[1].length).trim();
|
|
1155
|
-
return {
|
|
1156
|
-
amount,
|
|
1157
|
-
unit: unit || "servings",
|
|
1158
|
-
description: trimmed
|
|
1159
|
-
};
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
return void 0;
|
|
1163
|
-
}
|
|
1164
|
-
function formatYield(yieldValue) {
|
|
1165
|
-
if (!yieldValue) return void 0;
|
|
1166
|
-
if (!yieldValue.amount && !yieldValue.unit) {
|
|
1167
|
-
return void 0;
|
|
1168
|
-
}
|
|
1169
|
-
const amount = yieldValue.amount ?? "";
|
|
1170
|
-
const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
|
|
1171
|
-
return `${amount}${unit}`.trim() || yieldValue.description;
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
// src/parsers/duration.ts
|
|
1175
|
-
var ISO_DURATION_REGEX = /^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i;
|
|
1176
|
-
var HUMAN_OVERNIGHT = 8 * 60;
|
|
1177
|
-
function isFiniteNumber(value) {
|
|
1178
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
1179
|
-
}
|
|
1180
|
-
function parseDuration(iso) {
|
|
1181
|
-
if (!iso || typeof iso !== "string") return null;
|
|
1182
|
-
const trimmed = iso.trim();
|
|
1183
|
-
if (!trimmed) return null;
|
|
1184
|
-
const match = trimmed.match(ISO_DURATION_REGEX);
|
|
1185
|
-
if (!match) return null;
|
|
1186
|
-
const [, daysRaw, hoursRaw, minutesRaw, secondsRaw] = match;
|
|
1187
|
-
if (!daysRaw && !hoursRaw && !minutesRaw && !secondsRaw) {
|
|
1188
|
-
return null;
|
|
1189
|
-
}
|
|
1190
|
-
let total = 0;
|
|
1191
|
-
if (daysRaw) total += parseFloat(daysRaw) * 24 * 60;
|
|
1192
|
-
if (hoursRaw) total += parseFloat(hoursRaw) * 60;
|
|
1193
|
-
if (minutesRaw) total += parseFloat(minutesRaw);
|
|
1194
|
-
if (secondsRaw) total += Math.ceil(parseFloat(secondsRaw) / 60);
|
|
1195
|
-
return Math.round(total);
|
|
1196
|
-
}
|
|
1197
|
-
function formatDuration(minutes) {
|
|
1198
|
-
if (!isFiniteNumber(minutes) || minutes <= 0) {
|
|
1199
|
-
return "PT0M";
|
|
1200
|
-
}
|
|
1201
|
-
const rounded = Math.round(minutes);
|
|
1202
|
-
const days = Math.floor(rounded / (24 * 60));
|
|
1203
|
-
const afterDays = rounded % (24 * 60);
|
|
1204
|
-
const hours = Math.floor(afterDays / 60);
|
|
1205
|
-
const mins = afterDays % 60;
|
|
1206
|
-
let result = "P";
|
|
1207
|
-
if (days > 0) {
|
|
1208
|
-
result += `${days}D`;
|
|
1209
|
-
}
|
|
1210
|
-
if (hours > 0 || mins > 0) {
|
|
1211
|
-
result += "T";
|
|
1212
|
-
if (hours > 0) {
|
|
1213
|
-
result += `${hours}H`;
|
|
1214
|
-
}
|
|
1215
|
-
if (mins > 0) {
|
|
1216
|
-
result += `${mins}M`;
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
if (result === "P") {
|
|
1220
|
-
return "PT0M";
|
|
1221
|
-
}
|
|
1222
|
-
return result;
|
|
1223
|
-
}
|
|
1224
|
-
function parseHumanDuration(text) {
|
|
1225
|
-
if (!text || typeof text !== "string") return null;
|
|
1226
|
-
const normalized = text.toLowerCase().trim();
|
|
1227
|
-
if (!normalized) return null;
|
|
1228
|
-
if (normalized === "overnight") {
|
|
1229
|
-
return HUMAN_OVERNIGHT;
|
|
1353
|
+
return {
|
|
1354
|
+
amount: value,
|
|
1355
|
+
unit: "servings"
|
|
1356
|
+
};
|
|
1230
1357
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
let hourMatch;
|
|
1234
|
-
while ((hourMatch = hourRegex.exec(normalized)) !== null) {
|
|
1235
|
-
total += parseFloat(hourMatch[1]) * 60;
|
|
1358
|
+
if (Array.isArray(value)) {
|
|
1359
|
+
return parseYield(value[0]);
|
|
1236
1360
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1361
|
+
if (typeof value === "object") {
|
|
1362
|
+
const maybeYield = value;
|
|
1363
|
+
if (typeof maybeYield.amount === "number") {
|
|
1364
|
+
return {
|
|
1365
|
+
amount: maybeYield.amount,
|
|
1366
|
+
unit: typeof maybeYield.unit === "string" ? maybeYield.unit : "servings",
|
|
1367
|
+
description: typeof maybeYield.description === "string" ? maybeYield.description : void 0
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1241
1370
|
}
|
|
1242
|
-
if (
|
|
1243
|
-
|
|
1371
|
+
if (typeof value === "string") {
|
|
1372
|
+
const trimmed = value.trim();
|
|
1373
|
+
const match = trimmed.match(/(\d+(?:\.\d+)?)/);
|
|
1374
|
+
if (match) {
|
|
1375
|
+
const amount = parseFloat(match[1]);
|
|
1376
|
+
const unit = trimmed.slice(match.index + match[1].length).trim();
|
|
1377
|
+
return {
|
|
1378
|
+
amount,
|
|
1379
|
+
unit: unit || "servings",
|
|
1380
|
+
description: trimmed
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1244
1383
|
}
|
|
1245
|
-
return
|
|
1384
|
+
return void 0;
|
|
1246
1385
|
}
|
|
1247
|
-
function
|
|
1248
|
-
|
|
1249
|
-
if (
|
|
1250
|
-
|
|
1386
|
+
function formatYield(yieldValue) {
|
|
1387
|
+
var _a2;
|
|
1388
|
+
if (!yieldValue) return void 0;
|
|
1389
|
+
if (!yieldValue.amount && !yieldValue.unit) {
|
|
1390
|
+
return void 0;
|
|
1251
1391
|
}
|
|
1252
|
-
|
|
1392
|
+
const amount = (_a2 = yieldValue.amount) != null ? _a2 : "";
|
|
1393
|
+
const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
|
|
1394
|
+
return `${amount}${unit}`.trim() || yieldValue.description;
|
|
1253
1395
|
}
|
|
1254
1396
|
|
|
1255
1397
|
// src/utils/image.ts
|
|
@@ -1288,6 +1430,7 @@ function extractUrl(value) {
|
|
|
1288
1430
|
|
|
1289
1431
|
// src/fromSchemaOrg.ts
|
|
1290
1432
|
function fromSchemaOrg(input) {
|
|
1433
|
+
var _a2;
|
|
1291
1434
|
const recipeNode = extractRecipeNode(input);
|
|
1292
1435
|
if (!recipeNode) {
|
|
1293
1436
|
return null;
|
|
@@ -1302,7 +1445,7 @@ function fromSchemaOrg(input) {
|
|
|
1302
1445
|
const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
|
|
1303
1446
|
return {
|
|
1304
1447
|
name: recipeNode.name.trim(),
|
|
1305
|
-
description: recipeNode.description
|
|
1448
|
+
description: ((_a2 = recipeNode.description) == null ? void 0 : _a2.trim()) || void 0,
|
|
1306
1449
|
image: normalizeImage(recipeNode.image),
|
|
1307
1450
|
category,
|
|
1308
1451
|
tags: tags.length ? tags : void 0,
|
|
@@ -1348,8 +1491,6 @@ function extractRecipeNode(input) {
|
|
|
1348
1491
|
function hasRecipeType(value) {
|
|
1349
1492
|
if (!value) return false;
|
|
1350
1493
|
const types = Array.isArray(value) ? value : [value];
|
|
1351
|
-
fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "fromSchemaOrg.ts:95", message: "hasRecipeType check", data: { types, typesLower: types.map((t) => typeof t === "string" ? t.toLowerCase() : t), isMatch: types.some((e) => typeof e === "string" && e.toLowerCase() === "recipe") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
|
|
1352
|
-
});
|
|
1353
1494
|
return types.some(
|
|
1354
1495
|
(entry) => typeof entry === "string" && entry.toLowerCase() === "recipe"
|
|
1355
1496
|
);
|
|
@@ -1360,9 +1501,10 @@ function isValidName(name) {
|
|
|
1360
1501
|
function convertIngredients(value) {
|
|
1361
1502
|
if (!value) return [];
|
|
1362
1503
|
const normalized = Array.isArray(value) ? value : [value];
|
|
1363
|
-
return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean)
|
|
1504
|
+
return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean);
|
|
1364
1505
|
}
|
|
1365
1506
|
function convertInstructions(value) {
|
|
1507
|
+
var _a2;
|
|
1366
1508
|
if (!value) return [];
|
|
1367
1509
|
const normalized = Array.isArray(value) ? value : [value];
|
|
1368
1510
|
const result = [];
|
|
@@ -1379,7 +1521,7 @@ function convertInstructions(value) {
|
|
|
1379
1521
|
const subsectionItems = extractSectionItems(entry.itemListElement);
|
|
1380
1522
|
if (subsectionItems.length) {
|
|
1381
1523
|
result.push({
|
|
1382
|
-
subsection: entry.name
|
|
1524
|
+
subsection: ((_a2 = entry.name) == null ? void 0 : _a2.trim()) || "Section",
|
|
1383
1525
|
items: subsectionItems
|
|
1384
1526
|
});
|
|
1385
1527
|
}
|
|
@@ -1428,10 +1570,33 @@ function convertHowToStep(step) {
|
|
|
1428
1570
|
return void 0;
|
|
1429
1571
|
}
|
|
1430
1572
|
const normalizedImage = normalizeImage(step.image);
|
|
1431
|
-
|
|
1432
|
-
|
|
1573
|
+
const image = Array.isArray(normalizedImage) ? normalizedImage[0] : normalizedImage;
|
|
1574
|
+
const id = extractInstructionId(step);
|
|
1575
|
+
const timing = extractInstructionTiming(step);
|
|
1576
|
+
if (!image && !id && !timing) {
|
|
1577
|
+
return text;
|
|
1578
|
+
}
|
|
1579
|
+
const instruction = { text };
|
|
1580
|
+
if (id) instruction.id = id;
|
|
1581
|
+
if (image) instruction.image = image;
|
|
1582
|
+
if (timing) instruction.timing = timing;
|
|
1583
|
+
return instruction;
|
|
1584
|
+
}
|
|
1585
|
+
function extractInstructionTiming(step) {
|
|
1586
|
+
const duration = step.totalTime || step.performTime || step.prepTime || step.duration;
|
|
1587
|
+
if (!duration || typeof duration !== "string") {
|
|
1588
|
+
return void 0;
|
|
1589
|
+
}
|
|
1590
|
+
const parsed = smartParseDuration(duration);
|
|
1591
|
+
return { duration: parsed != null ? parsed : duration, type: "active" };
|
|
1592
|
+
}
|
|
1593
|
+
function extractInstructionId(step) {
|
|
1594
|
+
const raw = step["@id"] || step.id || step.url;
|
|
1595
|
+
if (typeof raw !== "string") {
|
|
1596
|
+
return void 0;
|
|
1433
1597
|
}
|
|
1434
|
-
|
|
1598
|
+
const trimmed = raw.trim();
|
|
1599
|
+
return trimmed || void 0;
|
|
1435
1600
|
}
|
|
1436
1601
|
function isHowToStep(value) {
|
|
1437
1602
|
return Boolean(value) && typeof value === "object" && value["@type"] === "HowToStep";
|
|
@@ -1440,9 +1605,10 @@ function isHowToSection(value) {
|
|
|
1440
1605
|
return Boolean(value) && typeof value === "object" && value["@type"] === "HowToSection" && Array.isArray(value.itemListElement);
|
|
1441
1606
|
}
|
|
1442
1607
|
function convertTime(recipe) {
|
|
1443
|
-
|
|
1444
|
-
const
|
|
1445
|
-
const
|
|
1608
|
+
var _a2, _b2, _c;
|
|
1609
|
+
const prep = smartParseDuration((_a2 = recipe.prepTime) != null ? _a2 : "");
|
|
1610
|
+
const cook = smartParseDuration((_b2 = recipe.cookTime) != null ? _b2 : "");
|
|
1611
|
+
const total = smartParseDuration((_c = recipe.totalTime) != null ? _c : "");
|
|
1446
1612
|
const structured = {};
|
|
1447
1613
|
if (prep !== null && prep !== void 0) structured.prep = prep;
|
|
1448
1614
|
if (cook !== null && cook !== void 0) structured.active = cook;
|
|
@@ -1475,9 +1641,10 @@ function extractFirst(value) {
|
|
|
1475
1641
|
return arr.length ? arr[0] : void 0;
|
|
1476
1642
|
}
|
|
1477
1643
|
function convertSource(recipe) {
|
|
1644
|
+
var _a2;
|
|
1478
1645
|
const author = extractEntityName(recipe.author);
|
|
1479
1646
|
const publisher = extractEntityName(recipe.publisher);
|
|
1480
|
-
const url = (recipe.url || recipe.mainEntityOfPage)
|
|
1647
|
+
const url = (_a2 = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a2.trim();
|
|
1481
1648
|
const source = {};
|
|
1482
1649
|
if (author) source.author = author;
|
|
1483
1650
|
if (publisher) source.name = publisher;
|
|
@@ -1508,13 +1675,14 @@ function extractEntityName(value) {
|
|
|
1508
1675
|
|
|
1509
1676
|
// src/converters/toSchemaOrg.ts
|
|
1510
1677
|
function convertBasicMetadata(recipe) {
|
|
1678
|
+
var _a2;
|
|
1511
1679
|
return cleanOutput({
|
|
1512
1680
|
"@context": "https://schema.org",
|
|
1513
1681
|
"@type": "Recipe",
|
|
1514
1682
|
name: recipe.name,
|
|
1515
1683
|
description: recipe.description,
|
|
1516
1684
|
image: recipe.image,
|
|
1517
|
-
url: recipe.source
|
|
1685
|
+
url: (_a2 = recipe.source) == null ? void 0 : _a2.url,
|
|
1518
1686
|
datePublished: recipe.dateAdded,
|
|
1519
1687
|
dateModified: recipe.dateModified
|
|
1520
1688
|
});
|
|
@@ -1522,6 +1690,7 @@ function convertBasicMetadata(recipe) {
|
|
|
1522
1690
|
function convertIngredients2(ingredients = []) {
|
|
1523
1691
|
const result = [];
|
|
1524
1692
|
ingredients.forEach((ingredient) => {
|
|
1693
|
+
var _a2;
|
|
1525
1694
|
if (!ingredient) {
|
|
1526
1695
|
return;
|
|
1527
1696
|
}
|
|
@@ -1551,7 +1720,7 @@ function convertIngredients2(ingredients = []) {
|
|
|
1551
1720
|
});
|
|
1552
1721
|
return;
|
|
1553
1722
|
}
|
|
1554
|
-
const value = ingredient.item
|
|
1723
|
+
const value = (_a2 = ingredient.item) == null ? void 0 : _a2.trim();
|
|
1555
1724
|
if (value) {
|
|
1556
1725
|
result.push(value);
|
|
1557
1726
|
}
|
|
@@ -1566,10 +1735,11 @@ function convertInstruction(entry) {
|
|
|
1566
1735
|
return null;
|
|
1567
1736
|
}
|
|
1568
1737
|
if (typeof entry === "string") {
|
|
1569
|
-
|
|
1738
|
+
const value = entry.trim();
|
|
1739
|
+
return value || null;
|
|
1570
1740
|
}
|
|
1571
1741
|
if ("subsection" in entry) {
|
|
1572
|
-
const steps = entry.items.map((item) =>
|
|
1742
|
+
const steps = entry.items.map((item) => convertInstruction(item)).filter((step) => Boolean(step));
|
|
1573
1743
|
if (!steps.length) {
|
|
1574
1744
|
return null;
|
|
1575
1745
|
}
|
|
@@ -1585,18 +1755,13 @@ function convertInstruction(entry) {
|
|
|
1585
1755
|
return createHowToStep(String(entry));
|
|
1586
1756
|
}
|
|
1587
1757
|
function createHowToStep(entry) {
|
|
1758
|
+
var _a2;
|
|
1588
1759
|
if (!entry) return null;
|
|
1589
1760
|
if (typeof entry === "string") {
|
|
1590
1761
|
const trimmed2 = entry.trim();
|
|
1591
|
-
|
|
1592
|
-
return null;
|
|
1593
|
-
}
|
|
1594
|
-
return {
|
|
1595
|
-
"@type": "HowToStep",
|
|
1596
|
-
text: trimmed2
|
|
1597
|
-
};
|
|
1762
|
+
return trimmed2 || null;
|
|
1598
1763
|
}
|
|
1599
|
-
const trimmed = entry.text
|
|
1764
|
+
const trimmed = (_a2 = entry.text) == null ? void 0 : _a2.trim();
|
|
1600
1765
|
if (!trimmed) {
|
|
1601
1766
|
return null;
|
|
1602
1767
|
}
|
|
@@ -1604,10 +1769,23 @@ function createHowToStep(entry) {
|
|
|
1604
1769
|
"@type": "HowToStep",
|
|
1605
1770
|
text: trimmed
|
|
1606
1771
|
};
|
|
1772
|
+
if (entry.id) {
|
|
1773
|
+
step["@id"] = entry.id;
|
|
1774
|
+
}
|
|
1775
|
+
if (entry.timing) {
|
|
1776
|
+
if (typeof entry.timing.duration === "number") {
|
|
1777
|
+
step.performTime = formatDuration(entry.timing.duration);
|
|
1778
|
+
} else if (entry.timing.duration) {
|
|
1779
|
+
step.performTime = entry.timing.duration;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1607
1782
|
if (entry.image) {
|
|
1608
1783
|
step.image = entry.image;
|
|
1609
1784
|
}
|
|
1610
|
-
|
|
1785
|
+
if (step["@id"] || step.performTime || step.image) {
|
|
1786
|
+
return step;
|
|
1787
|
+
}
|
|
1788
|
+
return trimmed;
|
|
1611
1789
|
}
|
|
1612
1790
|
function convertTime2(time) {
|
|
1613
1791
|
if (!time) {
|
|
@@ -1707,113 +1885,6 @@ function isStructuredTime(time) {
|
|
|
1707
1885
|
return typeof time.prep !== "undefined" || typeof time.active !== "undefined" || typeof time.passive !== "undefined" || typeof time.total !== "undefined";
|
|
1708
1886
|
}
|
|
1709
1887
|
|
|
1710
|
-
// src/scraper/fetch.ts
|
|
1711
|
-
var DEFAULT_USER_AGENTS = [
|
|
1712
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
1713
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
1714
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
|
|
1715
|
-
];
|
|
1716
|
-
function chooseUserAgent(provided) {
|
|
1717
|
-
if (provided) return provided;
|
|
1718
|
-
const index = Math.floor(Math.random() * DEFAULT_USER_AGENTS.length);
|
|
1719
|
-
return DEFAULT_USER_AGENTS[index];
|
|
1720
|
-
}
|
|
1721
|
-
function resolveFetch(fetchFn) {
|
|
1722
|
-
if (fetchFn) {
|
|
1723
|
-
return fetchFn;
|
|
1724
|
-
}
|
|
1725
|
-
const globalFetch = globalThis.fetch;
|
|
1726
|
-
if (!globalFetch) {
|
|
1727
|
-
throw new Error(
|
|
1728
|
-
"A global fetch implementation is not available. Provide window.fetch in browsers or upgrade to Node 18+."
|
|
1729
|
-
);
|
|
1730
|
-
}
|
|
1731
|
-
return globalFetch;
|
|
1732
|
-
}
|
|
1733
|
-
function isBrowserEnvironment() {
|
|
1734
|
-
return typeof globalThis.document !== "undefined";
|
|
1735
|
-
}
|
|
1736
|
-
function isClientError(error) {
|
|
1737
|
-
if (typeof error.status === "number") {
|
|
1738
|
-
return error.status >= 400 && error.status < 500;
|
|
1739
|
-
}
|
|
1740
|
-
return error.message.includes("HTTP 4");
|
|
1741
|
-
}
|
|
1742
|
-
async function wait(ms) {
|
|
1743
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1744
|
-
}
|
|
1745
|
-
async function fetchPage(url, options = {}) {
|
|
1746
|
-
const {
|
|
1747
|
-
timeout = 1e4,
|
|
1748
|
-
userAgent,
|
|
1749
|
-
maxRetries = 2,
|
|
1750
|
-
fetchFn
|
|
1751
|
-
} = options;
|
|
1752
|
-
let lastError = null;
|
|
1753
|
-
const resolvedFetch = resolveFetch(fetchFn);
|
|
1754
|
-
const isBrowser2 = isBrowserEnvironment();
|
|
1755
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1756
|
-
const controller = new AbortController();
|
|
1757
|
-
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1758
|
-
try {
|
|
1759
|
-
const headers = {
|
|
1760
|
-
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
1761
|
-
"Accept-Language": "en-US,en;q=0.5"
|
|
1762
|
-
};
|
|
1763
|
-
if (!isBrowser2) {
|
|
1764
|
-
headers["User-Agent"] = chooseUserAgent(userAgent);
|
|
1765
|
-
}
|
|
1766
|
-
const requestInit = {
|
|
1767
|
-
headers,
|
|
1768
|
-
signal: controller.signal,
|
|
1769
|
-
redirect: "follow"
|
|
1770
|
-
};
|
|
1771
|
-
const response = await resolvedFetch(url, requestInit);
|
|
1772
|
-
clearTimeout(timeoutId);
|
|
1773
|
-
if (response && typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
1774
|
-
try {
|
|
1775
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1776
|
-
if (globalFetch) {
|
|
1777
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:63", message: "fetch response", data: { url, status: response.status, statusText: response.statusText, ok: response.ok, isNYTimes: url.includes("nytimes.com") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
|
|
1778
|
-
});
|
|
1779
|
-
}
|
|
1780
|
-
} catch {
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
if (!response.ok) {
|
|
1784
|
-
const error = new Error(
|
|
1785
|
-
`HTTP ${response.status}: ${response.statusText}`
|
|
1786
|
-
);
|
|
1787
|
-
error.status = response.status;
|
|
1788
|
-
throw error;
|
|
1789
|
-
}
|
|
1790
|
-
const html = await response.text();
|
|
1791
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
1792
|
-
try {
|
|
1793
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1794
|
-
if (globalFetch) {
|
|
1795
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:75", message: "HTML received", data: { htmlLength: html.length, hasLoginPage: html.toLowerCase().includes("login") || html.toLowerCase().includes("sign in"), hasRecipeData: html.includes("application/ld+json") || html.includes("schema.org/Recipe") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B,D" }) }).catch(() => {
|
|
1796
|
-
});
|
|
1797
|
-
}
|
|
1798
|
-
} catch {
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
return html;
|
|
1802
|
-
} catch (err) {
|
|
1803
|
-
clearTimeout(timeoutId);
|
|
1804
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
1805
|
-
if (isClientError(lastError)) {
|
|
1806
|
-
throw lastError;
|
|
1807
|
-
}
|
|
1808
|
-
if (attempt < maxRetries) {
|
|
1809
|
-
await wait(1e3 * (attempt + 1));
|
|
1810
|
-
continue;
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
throw lastError ?? new Error("Failed to fetch page");
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
1888
|
// src/scraper/extractors/utils.ts
|
|
1818
1889
|
var RECIPE_TYPES = /* @__PURE__ */ new Set([
|
|
1819
1890
|
"recipe",
|
|
@@ -1850,97 +1921,8 @@ function normalizeText(value) {
|
|
|
1850
1921
|
return trimmed || void 0;
|
|
1851
1922
|
}
|
|
1852
1923
|
|
|
1853
|
-
// src/scraper/extractors/jsonld.ts
|
|
1854
|
-
function extractJsonLd(html) {
|
|
1855
|
-
const $ = cheerio.load(html);
|
|
1856
|
-
const scripts = $('script[type="application/ld+json"]');
|
|
1857
|
-
fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:8", message: "JSON-LD scripts found", data: { scriptCount: scripts.length }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "C,D" }) }).catch(() => {
|
|
1858
|
-
});
|
|
1859
|
-
const candidates = [];
|
|
1860
|
-
scripts.each((_, element) => {
|
|
1861
|
-
const content = $(element).html();
|
|
1862
|
-
if (!content) return;
|
|
1863
|
-
const parsed = safeJsonParse(content);
|
|
1864
|
-
if (!parsed) return;
|
|
1865
|
-
fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:18", message: "JSON-LD parsed", data: { hasGraph: !!(parsed && typeof parsed === "object" && "@graph" in parsed), type: parsed && typeof parsed === "object" && "@type" in parsed ? parsed["@type"] : void 0 }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C" }) }).catch(() => {
|
|
1866
|
-
});
|
|
1867
|
-
collectCandidates(parsed, candidates);
|
|
1868
|
-
});
|
|
1869
|
-
fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/jsonld.ts:22", message: "JSON-LD candidates", data: { candidateCount: candidates.length, candidateTypes: candidates.map((c) => c["@type"]) }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C" }) }).catch(() => {
|
|
1870
|
-
});
|
|
1871
|
-
return candidates[0] ?? null;
|
|
1872
|
-
}
|
|
1873
|
-
function collectCandidates(payload, bucket) {
|
|
1874
|
-
if (!payload) return;
|
|
1875
|
-
if (Array.isArray(payload)) {
|
|
1876
|
-
payload.forEach((entry) => collectCandidates(entry, bucket));
|
|
1877
|
-
return;
|
|
1878
|
-
}
|
|
1879
|
-
if (typeof payload !== "object") {
|
|
1880
|
-
return;
|
|
1881
|
-
}
|
|
1882
|
-
if (isRecipeNode(payload)) {
|
|
1883
|
-
bucket.push(payload);
|
|
1884
|
-
return;
|
|
1885
|
-
}
|
|
1886
|
-
const graph = payload["@graph"];
|
|
1887
|
-
if (Array.isArray(graph)) {
|
|
1888
|
-
graph.forEach((entry) => collectCandidates(entry, bucket));
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
var SIMPLE_PROPS = [
|
|
1892
|
-
"name",
|
|
1893
|
-
"description",
|
|
1894
|
-
"image",
|
|
1895
|
-
"recipeYield",
|
|
1896
|
-
"prepTime",
|
|
1897
|
-
"cookTime",
|
|
1898
|
-
"totalTime"
|
|
1899
|
-
];
|
|
1900
|
-
function extractMicrodata(html) {
|
|
1901
|
-
const $ = cheerio.load(html);
|
|
1902
|
-
const recipeEl = $('[itemscope][itemtype*="schema.org/Recipe"]').first();
|
|
1903
|
-
if (!recipeEl.length) {
|
|
1904
|
-
return null;
|
|
1905
|
-
}
|
|
1906
|
-
const recipe = {
|
|
1907
|
-
"@type": "Recipe"
|
|
1908
|
-
};
|
|
1909
|
-
SIMPLE_PROPS.forEach((prop) => {
|
|
1910
|
-
const value = findPropertyValue($, recipeEl, prop);
|
|
1911
|
-
if (value) {
|
|
1912
|
-
recipe[prop] = value;
|
|
1913
|
-
}
|
|
1914
|
-
});
|
|
1915
|
-
const ingredients = [];
|
|
1916
|
-
recipeEl.find('[itemprop="recipeIngredient"]').each((_, el) => {
|
|
1917
|
-
const text = normalizeText($(el).attr("content") || $(el).text());
|
|
1918
|
-
if (text) ingredients.push(text);
|
|
1919
|
-
});
|
|
1920
|
-
if (ingredients.length) {
|
|
1921
|
-
recipe.recipeIngredient = ingredients;
|
|
1922
|
-
}
|
|
1923
|
-
const instructions = [];
|
|
1924
|
-
recipeEl.find('[itemprop="recipeInstructions"]').each((_, el) => {
|
|
1925
|
-
const text = normalizeText($(el).attr("content")) || normalizeText($(el).find('[itemprop="text"]').first().text()) || normalizeText($(el).text());
|
|
1926
|
-
if (text) instructions.push(text);
|
|
1927
|
-
});
|
|
1928
|
-
if (instructions.length) {
|
|
1929
|
-
recipe.recipeInstructions = instructions;
|
|
1930
|
-
}
|
|
1931
|
-
if (recipe.name || ingredients.length) {
|
|
1932
|
-
return recipe;
|
|
1933
|
-
}
|
|
1934
|
-
return null;
|
|
1935
|
-
}
|
|
1936
|
-
function findPropertyValue($, context, prop) {
|
|
1937
|
-
const node = context.find(`[itemprop="${prop}"]`).first();
|
|
1938
|
-
if (!node.length) return void 0;
|
|
1939
|
-
return normalizeText(node.attr("content")) || normalizeText(node.attr("href")) || normalizeText(node.attr("src")) || normalizeText(node.text());
|
|
1940
|
-
}
|
|
1941
|
-
|
|
1942
1924
|
// src/scraper/extractors/browser.ts
|
|
1943
|
-
var
|
|
1925
|
+
var SIMPLE_PROPS = ["name", "description", "image", "recipeYield", "prepTime", "cookTime", "totalTime"];
|
|
1944
1926
|
function extractRecipeBrowser(html) {
|
|
1945
1927
|
const jsonLdRecipe = extractJsonLdBrowser(html);
|
|
1946
1928
|
if (jsonLdRecipe) {
|
|
@@ -1953,6 +1935,7 @@ function extractRecipeBrowser(html) {
|
|
|
1953
1935
|
return { recipe: null, source: null };
|
|
1954
1936
|
}
|
|
1955
1937
|
function extractJsonLdBrowser(html) {
|
|
1938
|
+
var _a2;
|
|
1956
1939
|
if (typeof globalThis.DOMParser === "undefined") {
|
|
1957
1940
|
return null;
|
|
1958
1941
|
}
|
|
@@ -1965,9 +1948,9 @@ function extractJsonLdBrowser(html) {
|
|
|
1965
1948
|
if (!content) return;
|
|
1966
1949
|
const parsed = safeJsonParse(content);
|
|
1967
1950
|
if (!parsed) return;
|
|
1968
|
-
|
|
1951
|
+
collectCandidates(parsed, candidates);
|
|
1969
1952
|
});
|
|
1970
|
-
return candidates[0]
|
|
1953
|
+
return (_a2 = candidates[0]) != null ? _a2 : null;
|
|
1971
1954
|
}
|
|
1972
1955
|
function extractMicrodataBrowser(html) {
|
|
1973
1956
|
if (typeof globalThis.DOMParser === "undefined") {
|
|
@@ -1982,8 +1965,8 @@ function extractMicrodataBrowser(html) {
|
|
|
1982
1965
|
const recipe = {
|
|
1983
1966
|
"@type": "Recipe"
|
|
1984
1967
|
};
|
|
1985
|
-
|
|
1986
|
-
const value =
|
|
1968
|
+
SIMPLE_PROPS.forEach((prop) => {
|
|
1969
|
+
const value = findPropertyValue(recipeEl, prop);
|
|
1987
1970
|
if (value) {
|
|
1988
1971
|
recipe[prop] = value;
|
|
1989
1972
|
}
|
|
@@ -2000,7 +1983,8 @@ function extractMicrodataBrowser(html) {
|
|
|
2000
1983
|
}
|
|
2001
1984
|
const instructions = [];
|
|
2002
1985
|
recipeEl.querySelectorAll('[itemprop="recipeInstructions"]').forEach((el) => {
|
|
2003
|
-
|
|
1986
|
+
var _a2;
|
|
1987
|
+
const text = normalizeText(el.getAttribute("content")) || normalizeText(((_a2 = el.querySelector('[itemprop="text"]')) == null ? void 0 : _a2.textContent) || void 0) || normalizeText(el.textContent || void 0);
|
|
2004
1988
|
if (text) instructions.push(text);
|
|
2005
1989
|
});
|
|
2006
1990
|
if (instructions.length) {
|
|
@@ -2011,15 +1995,15 @@ function extractMicrodataBrowser(html) {
|
|
|
2011
1995
|
}
|
|
2012
1996
|
return null;
|
|
2013
1997
|
}
|
|
2014
|
-
function
|
|
1998
|
+
function findPropertyValue(context, prop) {
|
|
2015
1999
|
const node = context.querySelector(`[itemprop="${prop}"]`);
|
|
2016
2000
|
if (!node) return void 0;
|
|
2017
2001
|
return normalizeText(node.getAttribute("content")) || normalizeText(node.getAttribute("href")) || normalizeText(node.getAttribute("src")) || normalizeText(node.textContent || void 0);
|
|
2018
2002
|
}
|
|
2019
|
-
function
|
|
2003
|
+
function collectCandidates(payload, bucket) {
|
|
2020
2004
|
if (!payload) return;
|
|
2021
2005
|
if (Array.isArray(payload)) {
|
|
2022
|
-
payload.forEach((entry) =>
|
|
2006
|
+
payload.forEach((entry) => collectCandidates(entry, bucket));
|
|
2023
2007
|
return;
|
|
2024
2008
|
}
|
|
2025
2009
|
if (typeof payload !== "object") {
|
|
@@ -2031,381 +2015,24 @@ function collectCandidates2(payload, bucket) {
|
|
|
2031
2015
|
}
|
|
2032
2016
|
const graph = payload["@graph"];
|
|
2033
2017
|
if (Array.isArray(graph)) {
|
|
2034
|
-
graph.forEach((entry) =>
|
|
2035
|
-
}
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
// src/scraper/extractors/index.ts
|
|
2039
|
-
function isBrowser() {
|
|
2040
|
-
try {
|
|
2041
|
-
return typeof globalThis.DOMParser !== "undefined";
|
|
2042
|
-
} catch {
|
|
2043
|
-
return false;
|
|
2044
|
-
}
|
|
2045
|
-
}
|
|
2046
|
-
function extractRecipe(html) {
|
|
2047
|
-
if (isBrowser()) {
|
|
2048
|
-
return extractRecipeBrowser(html);
|
|
2049
|
-
}
|
|
2050
|
-
const jsonLdRecipe = extractJsonLd(html);
|
|
2051
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2052
|
-
try {
|
|
2053
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2054
|
-
if (globalFetch) {
|
|
2055
|
-
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(() => {
|
|
2056
|
-
});
|
|
2057
|
-
}
|
|
2058
|
-
} catch {
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
if (jsonLdRecipe) {
|
|
2062
|
-
return { recipe: jsonLdRecipe, source: "jsonld" };
|
|
2063
|
-
}
|
|
2064
|
-
const microdataRecipe = extractMicrodata(html);
|
|
2065
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2066
|
-
try {
|
|
2067
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2068
|
-
if (globalFetch) {
|
|
2069
|
-
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(() => {
|
|
2070
|
-
});
|
|
2071
|
-
}
|
|
2072
|
-
} catch {
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
if (microdataRecipe) {
|
|
2076
|
-
return { recipe: microdataRecipe, source: "microdata" };
|
|
2018
|
+
graph.forEach((entry) => collectCandidates(entry, bucket));
|
|
2077
2019
|
}
|
|
2078
|
-
return { recipe: null, source: null };
|
|
2079
2020
|
}
|
|
2080
2021
|
|
|
2081
|
-
// src/scraper/
|
|
2082
|
-
async function scrapeRecipe(url, options = {}) {
|
|
2083
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2084
|
-
try {
|
|
2085
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2086
|
-
if (globalFetch) {
|
|
2087
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:7", message: "scrapeRecipe entry", data: { url, hasOptions: !!options }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,B,C,D,E" }) }).catch(() => {
|
|
2088
|
-
});
|
|
2089
|
-
}
|
|
2090
|
-
} catch {
|
|
2091
|
-
}
|
|
2092
|
-
}
|
|
2093
|
-
const html = await fetchPage(url, options);
|
|
2094
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2095
|
-
try {
|
|
2096
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2097
|
-
if (globalFetch) {
|
|
2098
|
-
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(() => {
|
|
2099
|
-
});
|
|
2100
|
-
}
|
|
2101
|
-
} catch {
|
|
2102
|
-
}
|
|
2103
|
-
}
|
|
2104
|
-
const { recipe } = extractRecipe(html);
|
|
2105
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2106
|
-
try {
|
|
2107
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2108
|
-
if (globalFetch) {
|
|
2109
|
-
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(() => {
|
|
2110
|
-
});
|
|
2111
|
-
}
|
|
2112
|
-
} catch {
|
|
2113
|
-
}
|
|
2114
|
-
}
|
|
2115
|
-
if (!recipe) {
|
|
2116
|
-
throw new Error("No Schema.org recipe data found in page");
|
|
2117
|
-
}
|
|
2118
|
-
const soustackRecipe = fromSchemaOrg(recipe);
|
|
2119
|
-
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
2120
|
-
try {
|
|
2121
|
-
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
2122
|
-
if (globalFetch) {
|
|
2123
|
-
globalFetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:17", message: "fromSchemaOrg result", data: { hasSoustackRecipe: !!soustackRecipe, soustackRecipeName: soustackRecipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
|
|
2124
|
-
});
|
|
2125
|
-
}
|
|
2126
|
-
} catch {
|
|
2127
|
-
}
|
|
2128
|
-
}
|
|
2129
|
-
if (!soustackRecipe) {
|
|
2130
|
-
throw new Error("Schema.org data did not include a valid recipe");
|
|
2131
|
-
}
|
|
2132
|
-
return soustackRecipe;
|
|
2133
|
-
}
|
|
2134
|
-
function extractRecipeFromHTML(html) {
|
|
2135
|
-
const { recipe } = extractRecipe(html);
|
|
2136
|
-
if (!recipe) {
|
|
2137
|
-
throw new Error("No Schema.org recipe data found in HTML");
|
|
2138
|
-
}
|
|
2139
|
-
const soustackRecipe = fromSchemaOrg(recipe);
|
|
2140
|
-
if (!soustackRecipe) {
|
|
2141
|
-
throw new Error("Schema.org data did not include a valid recipe");
|
|
2142
|
-
}
|
|
2143
|
-
return soustackRecipe;
|
|
2144
|
-
}
|
|
2022
|
+
// src/scraper/browser.ts
|
|
2145
2023
|
function extractSchemaOrgRecipeFromHTML(html) {
|
|
2146
|
-
const { recipe } =
|
|
2024
|
+
const { recipe } = extractRecipeBrowser(html);
|
|
2147
2025
|
return recipe;
|
|
2148
2026
|
}
|
|
2149
2027
|
|
|
2150
|
-
// src/
|
|
2151
|
-
var
|
|
2152
|
-
var MAKES_PREFIX = /^(makes?|yields?)\s*:?\s*(.+)$/i;
|
|
2153
|
-
var APPROX_PREFIX = /^(about|around|approximately|approx\.?|roughly)\s+/i;
|
|
2154
|
-
var SERVING_UNITS = ["servings", "serving", "portions", "portion", "people", "persons"];
|
|
2155
|
-
var DEFAULT_DOZEN_UNIT = "cookies";
|
|
2156
|
-
var NUMBER_WORDS2 = {
|
|
2157
|
-
a: 1,
|
|
2158
|
-
an: 1,
|
|
2159
|
-
one: 1,
|
|
2160
|
-
two: 2,
|
|
2161
|
-
three: 3,
|
|
2162
|
-
four: 4,
|
|
2163
|
-
five: 5,
|
|
2164
|
-
six: 6,
|
|
2165
|
-
seven: 7,
|
|
2166
|
-
eight: 8,
|
|
2167
|
-
nine: 9,
|
|
2168
|
-
ten: 10,
|
|
2169
|
-
eleven: 11,
|
|
2170
|
-
twelve: 12
|
|
2171
|
-
};
|
|
2172
|
-
function normalizeYield(text) {
|
|
2173
|
-
if (!text || typeof text !== "string") return "";
|
|
2174
|
-
return text.normalize("NFKC").replace(/\u00A0/g, " ").replace(/[–—−]/g, "-").trim().replace(/\s+/g, " ");
|
|
2175
|
-
}
|
|
2176
|
-
function parseYield2(text) {
|
|
2177
|
-
const normalized = normalizeYield(text);
|
|
2178
|
-
if (!normalized) return null;
|
|
2179
|
-
const { main, paren } = extractParenthetical(normalized);
|
|
2180
|
-
const core = parseYieldCore(main, normalized);
|
|
2181
|
-
if (!core) return null;
|
|
2182
|
-
const servingsFromParen = paren ? extractServingsFromParen(paren) : null;
|
|
2183
|
-
if (servingsFromParen !== null) {
|
|
2184
|
-
core.servings = servingsFromParen;
|
|
2185
|
-
core.description = normalized;
|
|
2186
|
-
}
|
|
2187
|
-
if (core.servings === void 0) {
|
|
2188
|
-
const inferred = inferServings(core.amount, core.unit);
|
|
2189
|
-
if (inferred !== void 0) {
|
|
2190
|
-
core.servings = inferred;
|
|
2191
|
-
}
|
|
2192
|
-
}
|
|
2193
|
-
return core;
|
|
2194
|
-
}
|
|
2195
|
-
function formatYield2(value) {
|
|
2196
|
-
if (value.description) {
|
|
2197
|
-
return value.description;
|
|
2198
|
-
}
|
|
2199
|
-
if (value.servings && value.unit === "servings") {
|
|
2200
|
-
return `Serves ${value.amount}`;
|
|
2201
|
-
}
|
|
2202
|
-
let result = `${value.amount} ${value.unit}`.trim();
|
|
2203
|
-
if (value.servings && value.unit !== "servings") {
|
|
2204
|
-
result += ` (${value.servings} servings)`;
|
|
2205
|
-
}
|
|
2206
|
-
return result;
|
|
2207
|
-
}
|
|
2208
|
-
function parseYieldCore(text, original) {
|
|
2209
|
-
return parseServesPattern(text, original) ?? parseMakesPattern(text, original) ?? parseRangePattern(text, original) ?? parseNumberUnitPattern(text, original) ?? parsePlainNumberPattern(text);
|
|
2210
|
-
}
|
|
2211
|
-
function parseServesPattern(text, original) {
|
|
2212
|
-
const patterns = [
|
|
2213
|
-
/^serves?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
2214
|
-
/^servings?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
2215
|
-
/^serving\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?/i,
|
|
2216
|
-
/^makes?\s*[:\-]?\s*(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i,
|
|
2217
|
-
/^(\d+)\s+servings?$/i
|
|
2218
|
-
];
|
|
2219
|
-
for (const regex of patterns) {
|
|
2220
|
-
const match = text.match(regex);
|
|
2221
|
-
if (!match) continue;
|
|
2222
|
-
const amount = parseInt(match[1], 10);
|
|
2223
|
-
if (Number.isNaN(amount)) continue;
|
|
2224
|
-
const result = {
|
|
2225
|
-
amount,
|
|
2226
|
-
unit: "servings",
|
|
2227
|
-
servings: amount
|
|
2228
|
-
};
|
|
2229
|
-
if (match[2]) {
|
|
2230
|
-
result.description = original;
|
|
2231
|
-
}
|
|
2232
|
-
return result;
|
|
2233
|
-
}
|
|
2234
|
-
return null;
|
|
2235
|
-
}
|
|
2236
|
-
function parseMakesPattern(text, original) {
|
|
2237
|
-
const match = text.match(MAKES_PREFIX);
|
|
2238
|
-
if (!match) return null;
|
|
2239
|
-
const remainder = match[2].trim();
|
|
2240
|
-
if (!remainder) return null;
|
|
2241
|
-
const servingsMatch = remainder.match(/^(\d+)(?:\s*(?:[-–—]|to)\s*(\d+))?\s+servings?$/i);
|
|
2242
|
-
if (servingsMatch) {
|
|
2243
|
-
const amount = parseInt(servingsMatch[1], 10);
|
|
2244
|
-
const result = {
|
|
2245
|
-
amount,
|
|
2246
|
-
unit: "servings",
|
|
2247
|
-
servings: amount
|
|
2248
|
-
};
|
|
2249
|
-
if (servingsMatch[2]) {
|
|
2250
|
-
result.description = original;
|
|
2251
|
-
}
|
|
2252
|
-
return result;
|
|
2253
|
-
}
|
|
2254
|
-
return parseRangePattern(remainder, original) ?? parseNumberUnitPattern(remainder, original) ?? parsePlainNumberPattern(remainder);
|
|
2255
|
-
}
|
|
2256
|
-
function parseRangePattern(text, descriptionSource) {
|
|
2257
|
-
const match = text.match(RANGE_PATTERN);
|
|
2258
|
-
if (!match) return null;
|
|
2259
|
-
const amount = parseInt(match[1], 10);
|
|
2260
|
-
const unit = cleanupUnit(match[3]);
|
|
2261
|
-
if (!unit) return null;
|
|
2262
|
-
const result = {
|
|
2263
|
-
amount,
|
|
2264
|
-
unit,
|
|
2265
|
-
description: descriptionSource
|
|
2266
|
-
};
|
|
2267
|
-
return result;
|
|
2268
|
-
}
|
|
2269
|
-
function parseNumberUnitPattern(text, descriptionSource) {
|
|
2270
|
-
if (!text) return null;
|
|
2271
|
-
const { value, approximate } = stripApproximation(text);
|
|
2272
|
-
if (!value) return null;
|
|
2273
|
-
const dozenResult = handleDozen(value);
|
|
2274
|
-
if (dozenResult) {
|
|
2275
|
-
const unit = cleanupUnit(dozenResult.remainder || DEFAULT_DOZEN_UNIT);
|
|
2276
|
-
const parsed = {
|
|
2277
|
-
amount: dozenResult.amount,
|
|
2278
|
-
unit
|
|
2279
|
-
};
|
|
2280
|
-
if (approximate) {
|
|
2281
|
-
parsed.description = descriptionSource;
|
|
2282
|
-
}
|
|
2283
|
-
return parsed;
|
|
2284
|
-
}
|
|
2285
|
-
const numericMatch = value.match(/^(\d+(?:\.\d+)?)\s+(.+)$/);
|
|
2286
|
-
if (numericMatch) {
|
|
2287
|
-
const amount = parseFloat(numericMatch[1]);
|
|
2288
|
-
if (!Number.isNaN(amount)) {
|
|
2289
|
-
const unit = cleanupUnit(numericMatch[2]);
|
|
2290
|
-
if (unit) {
|
|
2291
|
-
const parsed = { amount, unit };
|
|
2292
|
-
if (approximate) {
|
|
2293
|
-
parsed.description = descriptionSource;
|
|
2294
|
-
}
|
|
2295
|
-
return parsed;
|
|
2296
|
-
}
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
const wordMatch = value.match(/^([a-zA-Z]+)\s+(.+)$/);
|
|
2300
|
-
if (wordMatch) {
|
|
2301
|
-
const amount = wordToNumber(wordMatch[1]);
|
|
2302
|
-
if (amount !== null) {
|
|
2303
|
-
const unit = cleanupUnit(wordMatch[2]);
|
|
2304
|
-
if (unit) {
|
|
2305
|
-
const parsed = { amount, unit };
|
|
2306
|
-
if (approximate) {
|
|
2307
|
-
parsed.description = descriptionSource;
|
|
2308
|
-
}
|
|
2309
|
-
return parsed;
|
|
2310
|
-
}
|
|
2311
|
-
}
|
|
2312
|
-
}
|
|
2313
|
-
return null;
|
|
2314
|
-
}
|
|
2315
|
-
function parsePlainNumberPattern(text) {
|
|
2316
|
-
const match = text.match(/^(\d+)$/);
|
|
2317
|
-
if (!match) return null;
|
|
2318
|
-
const amount = parseInt(match[1], 10);
|
|
2319
|
-
if (Number.isNaN(amount)) return null;
|
|
2320
|
-
return {
|
|
2321
|
-
amount,
|
|
2322
|
-
unit: "servings",
|
|
2323
|
-
servings: amount
|
|
2324
|
-
};
|
|
2325
|
-
}
|
|
2326
|
-
function stripApproximation(value) {
|
|
2327
|
-
const match = value.match(APPROX_PREFIX);
|
|
2328
|
-
if (!match) {
|
|
2329
|
-
return { value: value.trim(), approximate: false };
|
|
2330
|
-
}
|
|
2331
|
-
const stripped = value.slice(match[0].length).trim();
|
|
2332
|
-
return { value: stripped, approximate: true };
|
|
2333
|
-
}
|
|
2334
|
-
function handleDozen(text) {
|
|
2335
|
-
const match = text.match(
|
|
2336
|
-
/^((?:\d+(?:\.\d+)?)|(?:one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|a|an|half))\s+dozens?\b(.*)$/i
|
|
2337
|
-
);
|
|
2338
|
-
if (!match) return null;
|
|
2339
|
-
const token = match[1].toLowerCase();
|
|
2340
|
-
let multiplier = null;
|
|
2341
|
-
if (token === "half") {
|
|
2342
|
-
multiplier = 0.5;
|
|
2343
|
-
} else if (!Number.isNaN(Number(token))) {
|
|
2344
|
-
multiplier = parseFloat(token);
|
|
2345
|
-
} else {
|
|
2346
|
-
multiplier = wordToNumber(token);
|
|
2347
|
-
}
|
|
2348
|
-
if (multiplier === null) return null;
|
|
2349
|
-
const amount = multiplier * 12;
|
|
2350
|
-
return {
|
|
2351
|
-
amount,
|
|
2352
|
-
remainder: match[2].trim()
|
|
2353
|
-
};
|
|
2354
|
-
}
|
|
2355
|
-
function cleanupUnit(value) {
|
|
2356
|
-
let unit = value.trim();
|
|
2357
|
-
unit = unit.replace(/^[,.-]+/, "").trim();
|
|
2358
|
-
unit = unit.replace(/[.,]+$/, "").trim();
|
|
2359
|
-
unit = unit.replace(/^of\s+/i, "").trim();
|
|
2360
|
-
return unit;
|
|
2361
|
-
}
|
|
2362
|
-
function extractParenthetical(text) {
|
|
2363
|
-
const match = text.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
|
|
2364
|
-
if (!match) {
|
|
2365
|
-
return { main: text, paren: null };
|
|
2366
|
-
}
|
|
2367
|
-
return {
|
|
2368
|
-
main: match[1].trim(),
|
|
2369
|
-
paren: match[2].trim()
|
|
2370
|
-
};
|
|
2371
|
-
}
|
|
2372
|
-
function extractServingsFromParen(text) {
|
|
2373
|
-
const match = text.match(/(\d+)/);
|
|
2374
|
-
if (!match) return null;
|
|
2375
|
-
const value = parseInt(match[1], 10);
|
|
2376
|
-
return Number.isNaN(value) ? null : value;
|
|
2377
|
-
}
|
|
2378
|
-
function inferServings(amount, unit) {
|
|
2379
|
-
if (SERVING_UNITS.includes(unit.toLowerCase())) {
|
|
2380
|
-
return amount;
|
|
2381
|
-
}
|
|
2382
|
-
return void 0;
|
|
2383
|
-
}
|
|
2384
|
-
function wordToNumber(word) {
|
|
2385
|
-
const normalized = word.toLowerCase();
|
|
2386
|
-
if (NUMBER_WORDS2.hasOwnProperty(normalized)) {
|
|
2387
|
-
return NUMBER_WORDS2[normalized];
|
|
2388
|
-
}
|
|
2389
|
-
return null;
|
|
2390
|
-
}
|
|
2028
|
+
// src/specVersion.ts
|
|
2029
|
+
var SOUSTACK_SPEC_VERSION = "0.2.1";
|
|
2391
2030
|
|
|
2392
|
-
exports.
|
|
2031
|
+
exports.SOUSTACK_SPEC_VERSION = SOUSTACK_SPEC_VERSION;
|
|
2032
|
+
exports.detectProfiles = detectProfiles;
|
|
2393
2033
|
exports.extractSchemaOrgRecipeFromHTML = extractSchemaOrgRecipeFromHTML;
|
|
2394
|
-
exports.formatDuration = formatDuration;
|
|
2395
|
-
exports.formatYield = formatYield2;
|
|
2396
2034
|
exports.fromSchemaOrg = fromSchemaOrg;
|
|
2397
|
-
exports.normalizeImage = normalizeImage;
|
|
2398
|
-
exports.normalizeIngredientInput = normalizeIngredientInput;
|
|
2399
|
-
exports.normalizeYield = normalizeYield;
|
|
2400
|
-
exports.parseDuration = parseDuration;
|
|
2401
|
-
exports.parseHumanDuration = parseHumanDuration;
|
|
2402
|
-
exports.parseIngredient = parseIngredient;
|
|
2403
|
-
exports.parseIngredientLine = parseIngredientLine;
|
|
2404
|
-
exports.parseIngredients = parseIngredients;
|
|
2405
|
-
exports.parseYield = parseYield2;
|
|
2406
2035
|
exports.scaleRecipe = scaleRecipe;
|
|
2407
|
-
exports.scrapeRecipe = scrapeRecipe;
|
|
2408
|
-
exports.smartParseDuration = smartParseDuration;
|
|
2409
2036
|
exports.toSchemaOrg = toSchemaOrg;
|
|
2410
2037
|
exports.validateRecipe = validateRecipe;
|
|
2411
2038
|
//# sourceMappingURL=index.js.map
|