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