soustack 0.2.3 → 0.4.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/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import Ajv from 'ajv';
1
+ import Ajv2020 from 'ajv/dist/2020';
2
2
  import addFormats from 'ajv-formats';
3
3
 
4
4
  // src/parsers/duration.ts
@@ -95,17 +95,17 @@ function scaleRecipe(recipe, options = {}) {
95
95
  const orderedIngredients = [];
96
96
  collectIngredients(scaled.ingredients || [], orderedIngredients);
97
97
  orderedIngredients.filter((ing) => {
98
- var _a2;
99
- return (((_a2 = ing.scaling) == null ? void 0 : _a2.type) || "linear") !== "bakers_percentage";
98
+ var _a;
99
+ return (((_a = ing.scaling) == null ? void 0 : _a.type) || "linear") !== "bakers_percentage";
100
100
  }).forEach((ing) => {
101
101
  const key = getIngredientKey(ing);
102
102
  scaledAmounts.set(key, calculateIndependentIngredient(ing, multiplier));
103
103
  });
104
104
  orderedIngredients.filter((ing) => {
105
- var _a2;
106
- return ((_a2 = ing.scaling) == null ? void 0 : _a2.type) === "bakers_percentage";
105
+ var _a;
106
+ return ((_a = ing.scaling) == null ? void 0 : _a.type) === "bakers_percentage";
107
107
  }).forEach((ing) => {
108
- var _a2, _b2;
108
+ var _a, _b;
109
109
  const key = getIngredientKey(ing);
110
110
  const scaling = ing.scaling;
111
111
  if (!(scaling == null ? void 0 : scaling.referenceId)) {
@@ -115,9 +115,9 @@ function scaleRecipe(recipe, options = {}) {
115
115
  if (referenceAmount === void 0) {
116
116
  throw new Error(`Reference ingredient "${scaling.referenceId}" not found for baker's percentage item "${key}"`);
117
117
  }
118
- const baseAmount = ((_a2 = ing.quantity) == null ? void 0 : _a2.amount) || 0;
118
+ const baseAmount = ((_a = ing.quantity) == null ? void 0 : _a.amount) || 0;
119
119
  const referenceBase = baseAmounts.get(scaling.referenceId);
120
- const factor = (_b2 = scaling.factor) != null ? _b2 : referenceBase ? baseAmount / referenceBase : void 0;
120
+ const factor = (_b = scaling.factor) != null ? _b : referenceBase ? baseAmount / referenceBase : void 0;
121
121
  if (factor === void 0) {
122
122
  throw new Error(`Unable to determine factor for baker's percentage ingredient "${key}"`);
123
123
  }
@@ -137,19 +137,19 @@ function scaleRecipe(recipe, options = {}) {
137
137
  return scaled;
138
138
  }
139
139
  function resolveMultiplier(recipe, options) {
140
- var _a2, _b2;
140
+ var _a, _b;
141
141
  if (options.multiplier && options.multiplier > 0) {
142
142
  return options.multiplier;
143
143
  }
144
- if ((_a2 = options.targetYield) == null ? void 0 : _a2.amount) {
145
- const base = ((_b2 = recipe.yield) == null ? void 0 : _b2.amount) || 1;
144
+ if ((_a = options.targetYield) == null ? void 0 : _a.amount) {
145
+ const base = ((_b = recipe.yield) == null ? void 0 : _b.amount) || 1;
146
146
  return options.targetYield.amount / base;
147
147
  }
148
148
  return 1;
149
149
  }
150
150
  function applyYieldScaling(recipe, options, multiplier) {
151
- var _a2, _b2, _c, _d, _e, _f, _g;
152
- const baseAmount = (_b2 = (_a2 = recipe.yield) == null ? void 0 : _a2.amount) != null ? _b2 : 1;
151
+ var _a, _b, _c, _d, _e, _f, _g;
152
+ const baseAmount = (_b = (_a = recipe.yield) == null ? void 0 : _a.amount) != null ? _b : 1;
153
153
  const targetAmount = (_d = (_c = options.targetYield) == null ? void 0 : _c.amount) != null ? _d : baseAmount * multiplier;
154
154
  const unit = (_g = (_e = options.targetYield) == null ? void 0 : _e.unit) != null ? _g : (_f = recipe.yield) == null ? void 0 : _f.unit;
155
155
  if (!recipe.yield && !options.targetYield) return;
@@ -162,9 +162,9 @@ function getIngredientKey(ing) {
162
162
  return ing.id || ing.item;
163
163
  }
164
164
  function calculateIndependentIngredient(ing, multiplier) {
165
- var _a2, _b2, _c, _d, _e, _f;
166
- const baseAmount = ((_a2 = ing.quantity) == null ? void 0 : _a2.amount) || 0;
167
- const type = ((_b2 = ing.scaling) == null ? void 0 : _b2.type) || "linear";
165
+ var _a, _b, _c, _d, _e, _f;
166
+ const baseAmount = ((_a = ing.quantity) == null ? void 0 : _a.amount) || 0;
167
+ const type = ((_b = ing.scaling) == null ? void 0 : _b.type) || "linear";
168
168
  switch (type) {
169
169
  case "fixed":
170
170
  return baseAmount;
@@ -194,12 +194,12 @@ function collectIngredients(items, bucket) {
194
194
  }
195
195
  function collectBaseIngredientAmounts(items, map = /* @__PURE__ */ new Map()) {
196
196
  items.forEach((item) => {
197
- var _a2, _b2;
197
+ var _a, _b;
198
198
  if (typeof item === "string") return;
199
199
  if ("subsection" in item) {
200
200
  collectBaseIngredientAmounts(item.items, map);
201
201
  } else {
202
- map.set(getIngredientKey(item), (_b2 = (_a2 = item.quantity) == null ? void 0 : _a2.amount) != null ? _b2 : 0);
202
+ map.set(getIngredientKey(item), (_b = (_a = item.quantity) == null ? void 0 : _a.amount) != null ? _b : 0);
203
203
  }
204
204
  });
205
205
  return map;
@@ -240,11 +240,321 @@ function toDurationMinutes(duration) {
240
240
  return 0;
241
241
  }
242
242
 
243
- // src/schema.json
244
- var schema_default = {
243
+ // src/normalize.ts
244
+ function normalizeRecipe(input) {
245
+ if (!input || typeof input !== "object") {
246
+ throw new Error("Recipe input must be an object");
247
+ }
248
+ const recipe = JSON.parse(JSON.stringify(input));
249
+ const warnings = [];
250
+ const legacyField = ["mod", "ules"].join("");
251
+ if (legacyField in recipe) {
252
+ throw new Error("The legacy field is no longer supported. Use `stacks` instead.");
253
+ }
254
+ normalizeStacks(recipe, warnings);
255
+ if (!recipe.stacks) {
256
+ recipe.stacks = {};
257
+ }
258
+ if (recipe && typeof recipe === "object" && "version" in recipe && !recipe.recipeVersion && typeof recipe.version === "string") {
259
+ recipe.recipeVersion = recipe.version;
260
+ warnings.push("'version' is deprecated; mapped to 'recipeVersion'.");
261
+ }
262
+ normalizeTime(recipe);
263
+ return {
264
+ recipe,
265
+ warnings
266
+ };
267
+ }
268
+ function normalizeStacks(recipe, warnings) {
269
+ let stacks = {};
270
+ if (recipe.stacks && typeof recipe.stacks === "object" && !Array.isArray(recipe.stacks)) {
271
+ for (const [key, value] of Object.entries(recipe.stacks)) {
272
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1) {
273
+ stacks[key] = value;
274
+ } else {
275
+ warnings.push(`Invalid stack version for '${key}': expected positive integer, got ${value}`);
276
+ }
277
+ }
278
+ }
279
+ if (Array.isArray(recipe.stacks)) {
280
+ const stackIdentifiers = recipe.stacks.filter((s) => typeof s === "string");
281
+ for (const identifier of stackIdentifiers) {
282
+ const parsed = parseStackIdentifier(identifier);
283
+ if (parsed) {
284
+ const { name, version } = parsed;
285
+ if (!stacks[name] || stacks[name] < version) {
286
+ stacks[name] = version;
287
+ }
288
+ } else {
289
+ warnings.push(`Invalid stack identifier '${identifier}': expected format 'name@version' (e.g., 'scaling@1')`);
290
+ }
291
+ }
292
+ }
293
+ recipe.stacks = stacks;
294
+ }
295
+ function parseStackIdentifier(identifier) {
296
+ if (typeof identifier !== "string" || !identifier.trim()) {
297
+ return null;
298
+ }
299
+ const match = identifier.trim().match(/^([a-z0-9_-]+)@(\d+)$/i);
300
+ if (!match) {
301
+ return null;
302
+ }
303
+ const [, name, versionStr] = match;
304
+ const version = parseInt(versionStr, 10);
305
+ if (isNaN(version) || version < 1) {
306
+ return null;
307
+ }
308
+ return { name, version };
309
+ }
310
+ function normalizeTime(recipe) {
311
+ const time = recipe == null ? void 0 : recipe.time;
312
+ if (!time || typeof time !== "object" || Array.isArray(time)) return;
313
+ const structuredKeys = [
314
+ "prep",
315
+ "active",
316
+ "passive",
317
+ "total"
318
+ ];
319
+ structuredKeys.forEach((key) => {
320
+ const value = time[key];
321
+ if (typeof value === "number") return;
322
+ const parsed = parseDuration(value);
323
+ if (parsed !== null) {
324
+ time[key] = parsed;
325
+ }
326
+ });
327
+ }
328
+
329
+ // src/conformance/index.ts
330
+ function validateConformance(recipe) {
331
+ const issues = [];
332
+ issues.push(...checkDAGValidity(recipe));
333
+ if (hasSchedulableProfile(recipe)) {
334
+ issues.push(...checkTimingSchedulability(recipe));
335
+ }
336
+ issues.push(...checkScalingSanity(recipe));
337
+ const ok = issues.filter((i) => i.severity === "error").length === 0;
338
+ return { ok, issues };
339
+ }
340
+ function hasSchedulableProfile(recipe) {
341
+ const schema = recipe.$schema;
342
+ if (typeof schema === "string") {
343
+ return schema.includes("schedulable") || schema === "http://soustack.org/schema/v0.3.0/profiles/schedulable";
344
+ }
345
+ return false;
346
+ }
347
+ function checkDAGValidity(recipe) {
348
+ const issues = [];
349
+ const instructions = recipe.instructions;
350
+ if (!Array.isArray(instructions)) {
351
+ return issues;
352
+ }
353
+ const instructionIds = /* @__PURE__ */ new Set();
354
+ const dependencyRefs = [];
355
+ const collect = (items, basePath) => {
356
+ items.forEach((item, index) => {
357
+ const currentPath = `${basePath}/${index}`;
358
+ if (isInstructionSubsection(item)) {
359
+ if (Array.isArray(item.items)) {
360
+ collect(item.items, `${currentPath}/items`);
361
+ }
362
+ return;
363
+ }
364
+ if (isInstruction(item)) {
365
+ const id = typeof item.id === "string" ? item.id : void 0;
366
+ if (id) {
367
+ instructionIds.add(id);
368
+ }
369
+ if (Array.isArray(item.dependsOn)) {
370
+ item.dependsOn.forEach((depId, depIndex) => {
371
+ if (typeof depId === "string") {
372
+ dependencyRefs.push({
373
+ fromId: id,
374
+ toId: depId,
375
+ path: `${currentPath}/dependsOn/${depIndex}`
376
+ });
377
+ }
378
+ });
379
+ }
380
+ }
381
+ });
382
+ };
383
+ collect(instructions, "/instructions");
384
+ dependencyRefs.forEach((ref) => {
385
+ if (!instructionIds.has(ref.toId)) {
386
+ issues.push({
387
+ code: "DAG_MISSING_NODE",
388
+ path: ref.path,
389
+ message: `Instruction dependency references missing step id '${ref.toId}'.`,
390
+ severity: "error"
391
+ });
392
+ }
393
+ });
394
+ const adjacency = /* @__PURE__ */ new Map();
395
+ dependencyRefs.forEach((ref) => {
396
+ var _a;
397
+ if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
398
+ const list = (_a = adjacency.get(ref.fromId)) != null ? _a : [];
399
+ list.push({ toId: ref.toId, path: ref.path });
400
+ adjacency.set(ref.fromId, list);
401
+ }
402
+ });
403
+ const visiting = /* @__PURE__ */ new Set();
404
+ const visited = /* @__PURE__ */ new Set();
405
+ const detectCycles = (nodeId) => {
406
+ var _a;
407
+ if (visiting.has(nodeId)) {
408
+ return;
409
+ }
410
+ if (visited.has(nodeId)) {
411
+ return;
412
+ }
413
+ visiting.add(nodeId);
414
+ const neighbors = (_a = adjacency.get(nodeId)) != null ? _a : [];
415
+ neighbors.forEach((edge) => {
416
+ if (visiting.has(edge.toId)) {
417
+ issues.push({
418
+ code: "DAG_CYCLE",
419
+ path: edge.path,
420
+ message: `Circular dependency detected involving step id '${edge.toId}'.`,
421
+ severity: "error"
422
+ });
423
+ return;
424
+ }
425
+ detectCycles(edge.toId);
426
+ });
427
+ visiting.delete(nodeId);
428
+ visited.add(nodeId);
429
+ };
430
+ instructionIds.forEach((id) => detectCycles(id));
431
+ return issues;
432
+ }
433
+ function checkTimingSchedulability(recipe) {
434
+ const issues = [];
435
+ const instructions = recipe.instructions;
436
+ if (!Array.isArray(instructions)) {
437
+ return issues;
438
+ }
439
+ const checkInstruction = (item, path) => {
440
+ if (isInstructionSubsection(item)) {
441
+ if (Array.isArray(item.items)) {
442
+ item.items.forEach((subItem, index) => {
443
+ checkInstruction(subItem, `${path}/items/${index}`);
444
+ });
445
+ }
446
+ return;
447
+ }
448
+ if (isInstruction(item)) {
449
+ if (!item.id) {
450
+ issues.push({
451
+ code: "SCHEDULABLE_MISSING_ID",
452
+ path,
453
+ message: "Schedulable profile requires all instructions to have an id.",
454
+ severity: "error"
455
+ });
456
+ }
457
+ if (!item.timing) {
458
+ issues.push({
459
+ code: "SCHEDULABLE_MISSING_TIMING",
460
+ path,
461
+ message: "Schedulable profile requires all instructions to have timing information.",
462
+ severity: "error"
463
+ });
464
+ } else if (!item.timing.duration) {
465
+ issues.push({
466
+ code: "SCHEDULABLE_MISSING_DURATION",
467
+ path: `${path}/timing`,
468
+ message: "Schedulable profile requires timing.duration for all instructions.",
469
+ severity: "error"
470
+ });
471
+ }
472
+ }
473
+ };
474
+ instructions.forEach((item, index) => {
475
+ checkInstruction(item, `/instructions/${index}`);
476
+ });
477
+ return issues;
478
+ }
479
+ function checkScalingSanity(recipe) {
480
+ const issues = [];
481
+ const ingredients = recipe.ingredients;
482
+ if (!Array.isArray(ingredients)) {
483
+ return issues;
484
+ }
485
+ const ingredientIds = /* @__PURE__ */ new Set();
486
+ const collectIngredientIds = (items, basePath) => {
487
+ items.forEach((item, index) => {
488
+ if (isIngredientSubsection(item)) {
489
+ if (Array.isArray(item.items)) {
490
+ collectIngredientIds(item.items);
491
+ }
492
+ return;
493
+ }
494
+ if (isIngredient(item)) {
495
+ if (typeof item.id === "string") {
496
+ ingredientIds.add(item.id);
497
+ }
498
+ }
499
+ });
500
+ };
501
+ collectIngredientIds(ingredients);
502
+ const checkIngredient = (item, path) => {
503
+ if (isIngredientSubsection(item)) {
504
+ if (Array.isArray(item.items)) {
505
+ item.items.forEach((subItem, index) => {
506
+ checkIngredient(subItem, `${path}/items/${index}`);
507
+ });
508
+ }
509
+ return;
510
+ }
511
+ if (isIngredient(item)) {
512
+ const scaling = item.scaling;
513
+ if (scaling && typeof scaling === "object" && "type" in scaling && scaling.type === "bakers_percentage") {
514
+ const bakersScaling = scaling;
515
+ if (bakersScaling.referenceId) {
516
+ if (!ingredientIds.has(bakersScaling.referenceId)) {
517
+ issues.push({
518
+ code: "SCALING_INVALID_REFERENCE",
519
+ path: `${path}/scaling/referenceId`,
520
+ message: `Baker's percentage references missing ingredient id '${bakersScaling.referenceId}'.`,
521
+ severity: "error"
522
+ });
523
+ }
524
+ } else {
525
+ issues.push({
526
+ code: "SCALING_MISSING_REFERENCE",
527
+ path: `${path}/scaling`,
528
+ message: "Baker's percentage scaling requires a referenceId.",
529
+ severity: "error"
530
+ });
531
+ }
532
+ }
533
+ }
534
+ };
535
+ ingredients.forEach((item, index) => {
536
+ checkIngredient(item, `/ingredients/${index}`);
537
+ });
538
+ return issues;
539
+ }
540
+ function isInstruction(item) {
541
+ return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
542
+ }
543
+ function isInstructionSubsection(item) {
544
+ return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
545
+ }
546
+ function isIngredient(item) {
547
+ return item && typeof item === "object" && !Array.isArray(item) && "item" in item;
548
+ }
549
+ function isIngredientSubsection(item) {
550
+ return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
551
+ }
552
+
553
+ // src/soustack.schema.json
554
+ var soustack_schema_default = {
245
555
  $schema: "http://json-schema.org/draft-07/schema#",
246
- $id: "http://soustack.org/schema/v0.2.1",
247
- title: "Soustack Recipe Schema v0.2.1",
556
+ $id: "http://soustack.org/schema/v0.3.0",
557
+ title: "Soustack Recipe Schema v0.3.0",
248
558
  description: "A portable, scalable, interoperable recipe format.",
249
559
  type: "object",
250
560
  required: ["name", "ingredients", "instructions"],
@@ -392,7 +702,10 @@ var schema_default = {
392
702
  required: ["amount"],
393
703
  properties: {
394
704
  amount: { type: "number" },
395
- unit: { type: ["string", "null"] }
705
+ unit: {
706
+ type: ["string", "null"],
707
+ description: "Display-friendly unit text; implementations may normalize or canonicalize units separately."
708
+ }
396
709
  }
397
710
  },
398
711
  scaling: {
@@ -427,7 +740,16 @@ var schema_default = {
427
740
  aisle: { type: "string" },
428
741
  prep: { type: "string" },
429
742
  prepAction: { type: "string" },
743
+ prepActions: {
744
+ type: "array",
745
+ items: { type: "string" },
746
+ description: "Structured prep verbs (e.g., peel, dice) for mise en place workflows."
747
+ },
430
748
  prepTime: { type: "number" },
749
+ form: {
750
+ type: "string",
751
+ description: "State of the ingredient as used (packed, sifted, melted, room_temperature, etc.)."
752
+ },
431
753
  destination: { type: "string" },
432
754
  scaling: { $ref: "#/definitions/scaling" },
433
755
  critical: { type: "boolean" },
@@ -586,548 +908,823 @@ var schema_default = {
586
908
  }
587
909
  };
588
910
 
589
- // src/soustack.schema.json
590
- var soustack_schema_default = {
911
+ // src/schemas/recipe/base.schema.json
912
+ var base_schema_default = {
591
913
  $schema: "http://json-schema.org/draft-07/schema#",
592
- $id: "http://soustack.org/schema/v0.2.1",
593
- title: "Soustack Recipe Schema v0.2.1",
594
- description: "A portable, scalable, interoperable recipe format.",
914
+ $id: "http://soustack.org/schema/recipe/base.schema.json",
915
+ title: "Soustack Recipe Base Schema",
916
+ description: "Base document shape for Soustack recipe documents. Profiles and stacks build on this baseline.",
595
917
  type: "object",
596
- required: ["name", "ingredients", "instructions"],
597
- additionalProperties: false,
598
- patternProperties: {
599
- "^x-": {}
600
- },
918
+ additionalProperties: true,
601
919
  properties: {
602
- $schema: {
603
- type: "string",
604
- format: "uri",
605
- description: "Optional schema hint for tooling compatibility"
606
- },
607
- id: {
608
- type: "string",
609
- description: "Unique identifier (slug or UUID)"
610
- },
611
- name: {
612
- type: "string",
613
- description: "The title of the recipe"
920
+ "@type": {
921
+ const: "Recipe",
922
+ description: "Document marker for Soustack recipes"
614
923
  },
615
- title: {
924
+ profile: {
616
925
  type: "string",
617
- description: "Optional display title; alias for name"
926
+ description: "Profile identifier applied to this recipe"
618
927
  },
619
- version: {
620
- type: "string",
621
- pattern: "^\\d+\\.\\d+\\.\\d+$",
622
- description: "DEPRECATED: use recipeVersion for authoring revisions"
928
+ stacks: {
929
+ type: "object",
930
+ description: "Stack declarations as a map: Record<stackName, versionNumber>",
931
+ additionalProperties: {
932
+ type: "integer",
933
+ minimum: 1
934
+ }
623
935
  },
624
- recipeVersion: {
936
+ name: {
625
937
  type: "string",
626
- pattern: "^\\d+\\.\\d+\\.\\d+$",
627
- description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
628
- },
629
- description: {
630
- type: "string"
938
+ description: "Human-readable recipe name"
631
939
  },
632
- category: {
633
- type: "string",
634
- examples: ["Main Course", "Dessert"]
940
+ ingredients: {
941
+ type: "array",
942
+ description: "Ingredients payload; content is validated by profiles/stacks"
635
943
  },
636
- tags: {
944
+ instructions: {
637
945
  type: "array",
638
- items: { type: "string" }
946
+ description: "Instruction payload; content is validated by profiles/stacks"
947
+ }
948
+ },
949
+ required: ["@type"]
950
+ };
951
+
952
+ // src/schemas/recipe/profiles/minimal.schema.json
953
+ var minimal_schema_default = {
954
+ $schema: "http://json-schema.org/draft-07/schema#",
955
+ $id: "http://soustack.org/schema/recipe/profiles/minimal.schema.json",
956
+ title: "Soustack Recipe Minimal Profile",
957
+ description: "Minimal profile that ensures the basic Recipe structure is present while allowing stacks to extend it.",
958
+ allOf: [
959
+ {
960
+ $ref: "http://soustack.org/schema/recipe/base.schema.json"
639
961
  },
640
- image: {
641
- description: "Recipe-level hero image(s)",
642
- anyOf: [
643
- {
962
+ {
963
+ type: "object",
964
+ properties: {
965
+ profile: {
966
+ const: "minimal"
967
+ },
968
+ stacks: {
969
+ type: "object",
970
+ additionalProperties: {
971
+ type: "integer",
972
+ minimum: 1
973
+ },
974
+ properties: {
975
+ attribution: { type: "integer", minimum: 1 },
976
+ taxonomy: { type: "integer", minimum: 1 },
977
+ media: { type: "integer", minimum: 1 },
978
+ nutrition: { type: "integer", minimum: 1 },
979
+ times: { type: "integer", minimum: 1 }
980
+ }
981
+ },
982
+ name: {
644
983
  type: "string",
645
- format: "uri"
984
+ minLength: 1
646
985
  },
647
- {
986
+ ingredients: {
648
987
  type: "array",
649
- minItems: 1,
650
- items: {
651
- type: "string",
652
- format: "uri"
653
- }
988
+ minItems: 1
989
+ },
990
+ instructions: {
991
+ type: "array",
992
+ minItems: 1
654
993
  }
655
- ]
656
- },
657
- dateAdded: {
658
- type: "string",
659
- format: "date-time"
660
- },
661
- metadata: {
662
- type: "object",
663
- additionalProperties: true,
664
- description: "Free-form vendor metadata"
665
- },
666
- source: {
994
+ },
995
+ required: [
996
+ "profile",
997
+ "name",
998
+ "ingredients",
999
+ "instructions"
1000
+ ],
1001
+ additionalProperties: true
1002
+ }
1003
+ ]
1004
+ };
1005
+
1006
+ // src/schemas/recipe/profiles/core.schema.json
1007
+ var core_schema_default = {
1008
+ $schema: "http://json-schema.org/draft-07/schema#",
1009
+ $id: "http://soustack.org/schema/recipe/profiles/core.schema.json",
1010
+ title: "Soustack Recipe Core Profile",
1011
+ description: "Core profile that builds on the minimal profile and is intended to be combined with recipe stacks.",
1012
+ allOf: [
1013
+ { $ref: "http://soustack.org/schema/recipe/base.schema.json" },
1014
+ {
667
1015
  type: "object",
668
1016
  properties: {
669
- author: { type: "string" },
670
- url: { type: "string", format: "uri" },
671
- name: { type: "string" },
672
- adapted: { type: "boolean" }
673
- }
674
- },
675
- yield: {
676
- $ref: "#/definitions/yield"
677
- },
678
- time: {
679
- $ref: "#/definitions/time"
680
- },
681
- equipment: {
682
- type: "array",
683
- items: { $ref: "#/definitions/equipment" }
684
- },
685
- ingredients: {
686
- type: "array",
687
- items: {
688
- anyOf: [
689
- { type: "string" },
690
- { $ref: "#/definitions/ingredient" },
691
- { $ref: "#/definitions/ingredientSubsection" }
692
- ]
693
- }
694
- },
695
- instructions: {
696
- type: "array",
697
- items: {
698
- anyOf: [
699
- { type: "string" },
700
- { $ref: "#/definitions/instruction" },
701
- { $ref: "#/definitions/instructionSubsection" }
702
- ]
703
- }
704
- },
705
- storage: {
706
- $ref: "#/definitions/storage"
707
- },
708
- substitutions: {
709
- type: "array",
710
- items: { $ref: "#/definitions/substitution" }
711
- }
712
- },
713
- definitions: {
714
- yield: {
715
- type: "object",
716
- required: ["amount", "unit"],
717
- properties: {
718
- amount: { type: "number" },
719
- unit: { type: "string" },
720
- servings: { type: "number" },
721
- description: { type: "string" }
722
- }
723
- },
724
- time: {
725
- type: "object",
726
- properties: {
727
- prep: { type: "number" },
728
- active: { type: "number" },
729
- passive: { type: "number" },
730
- total: { type: "number" },
731
- prepTime: { type: "string", format: "duration" },
732
- cookTime: { type: "string", format: "duration" }
733
- },
734
- minProperties: 1
735
- },
736
- quantity: {
737
- type: "object",
738
- required: ["amount"],
739
- properties: {
740
- amount: { type: "number" },
741
- unit: { type: ["string", "null"] }
742
- }
743
- },
744
- scaling: {
745
- type: "object",
746
- required: ["type"],
747
- properties: {
748
- type: {
749
- type: "string",
750
- enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
1017
+ profile: { const: "core" },
1018
+ stacks: {
1019
+ type: "object",
1020
+ additionalProperties: {
1021
+ type: "integer",
1022
+ minimum: 1
1023
+ }
751
1024
  },
752
- factor: { type: "number" },
753
- referenceId: { type: "string" },
754
- roundTo: { type: "number" },
755
- min: { type: "number" },
756
- max: { type: "number" }
757
- },
758
- if: {
759
- properties: { type: { const: "bakers_percentage" } }
1025
+ name: { type: "string", minLength: 1 },
1026
+ ingredients: { type: "array", minItems: 1 },
1027
+ instructions: { type: "array", minItems: 1 }
760
1028
  },
761
- then: {
762
- required: ["referenceId"]
763
- }
764
- },
765
- ingredient: {
766
- type: "object",
767
- required: ["item"],
768
- properties: {
769
- id: { type: "string" },
770
- item: { type: "string" },
771
- quantity: { $ref: "#/definitions/quantity" },
772
- name: { type: "string" },
773
- aisle: { type: "string" },
774
- prep: { type: "string" },
775
- prepAction: { type: "string" },
776
- prepTime: { type: "number" },
777
- destination: { type: "string" },
778
- scaling: { $ref: "#/definitions/scaling" },
779
- critical: { type: "boolean" },
780
- optional: { type: "boolean" },
781
- notes: { type: "string" }
782
- }
783
- },
784
- ingredientSubsection: {
785
- type: "object",
786
- required: ["subsection", "items"],
1029
+ required: ["profile", "name", "ingredients", "instructions"],
1030
+ additionalProperties: true
1031
+ }
1032
+ ]
1033
+ };
1034
+
1035
+ // spec/profiles/base.schema.json
1036
+ var base_schema_default2 = {
1037
+ $schema: "http://json-schema.org/draft-07/schema#",
1038
+ $id: "http://soustack.org/schema/v0.3.0/profiles/base",
1039
+ title: "Soustack Base Profile Schema",
1040
+ description: "Wrapper schema that exposes the unmodified Soustack base schema.",
1041
+ allOf: [
1042
+ { $ref: "http://soustack.org/schema/v0.3.0" }
1043
+ ]
1044
+ };
1045
+
1046
+ // spec/profiles/cookable.schema.json
1047
+ var cookable_schema_default = {
1048
+ $schema: "http://json-schema.org/draft-07/schema#",
1049
+ $id: "http://soustack.org/schema/v0.3.0/profiles/cookable",
1050
+ title: "Soustack Cookable Profile Schema",
1051
+ description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
1052
+ allOf: [
1053
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1054
+ {
1055
+ required: ["yield", "time", "ingredients", "instructions"],
787
1056
  properties: {
788
- subsection: { type: "string" },
789
- items: {
790
- type: "array",
791
- items: { $ref: "#/definitions/ingredient" }
792
- }
1057
+ yield: { $ref: "http://soustack.org/schema/v0.3.0#/definitions/yield" },
1058
+ time: { $ref: "http://soustack.org/schema/v0.3.0#/definitions/time" },
1059
+ ingredients: { type: "array", minItems: 1 },
1060
+ instructions: { type: "array", minItems: 1 }
793
1061
  }
794
- },
795
- equipment: {
796
- type: "object",
797
- required: ["name"],
798
- properties: {
799
- id: { type: "string" },
800
- name: { type: "string" },
801
- required: { type: "boolean" },
802
- label: { type: "string" },
803
- capacity: { $ref: "#/definitions/quantity" },
804
- scalingLimit: { type: "number" },
805
- alternatives: {
806
- type: "array",
807
- items: { type: "string" }
1062
+ }
1063
+ ]
1064
+ };
1065
+
1066
+ // spec/profiles/illustrated.schema.json
1067
+ var illustrated_schema_default = {
1068
+ $schema: "http://json-schema.org/draft-07/schema#",
1069
+ $id: "http://soustack.org/schema/v0.3.0/profiles/illustrated",
1070
+ title: "Soustack Illustrated Profile Schema",
1071
+ description: "Extends the base schema to guarantee at least one illustrative image.",
1072
+ allOf: [
1073
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1074
+ {
1075
+ anyOf: [
1076
+ { required: ["image"] },
1077
+ {
1078
+ properties: {
1079
+ instructions: {
1080
+ type: "array",
1081
+ contains: {
1082
+ anyOf: [
1083
+ { $ref: "#/definitions/imageInstruction" },
1084
+ { $ref: "#/definitions/instructionSubsectionWithImage" }
1085
+ ]
1086
+ }
1087
+ }
1088
+ }
808
1089
  }
809
- }
1090
+ ]
1091
+ }
1092
+ ],
1093
+ definitions: {
1094
+ imageInstruction: {
1095
+ allOf: [
1096
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instruction" },
1097
+ { required: ["image"] }
1098
+ ]
810
1099
  },
811
- instruction: {
812
- type: "object",
813
- required: ["text"],
814
- properties: {
815
- id: { type: "string" },
816
- text: { type: "string" },
817
- image: {
818
- type: "string",
819
- format: "uri",
820
- description: "Optional image that illustrates this instruction"
821
- },
822
- destination: { type: "string" },
823
- dependsOn: {
824
- type: "array",
825
- items: { type: "string" }
826
- },
827
- inputs: {
828
- type: "array",
829
- items: { type: "string" }
830
- },
831
- timing: {
832
- type: "object",
833
- required: ["duration", "type"],
1100
+ instructionSubsectionWithImage: {
1101
+ allOf: [
1102
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instructionSubsection" },
1103
+ {
834
1104
  properties: {
835
- duration: {
836
- anyOf: [
837
- { type: "number" },
838
- { type: "string", pattern: "^P" }
839
- ],
840
- description: "Minutes as a number or ISO8601 duration string"
841
- },
842
- type: { type: "string", enum: ["active", "passive"] },
843
- scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
1105
+ items: {
1106
+ type: "array",
1107
+ contains: { $ref: "#/definitions/imageInstruction" }
1108
+ }
844
1109
  }
845
1110
  }
846
- }
847
- },
848
- instructionSubsection: {
849
- type: "object",
850
- required: ["subsection", "items"],
1111
+ ]
1112
+ }
1113
+ }
1114
+ };
1115
+
1116
+ // spec/profiles/quantified.schema.json
1117
+ var quantified_schema_default = {
1118
+ $schema: "http://json-schema.org/draft-07/schema#",
1119
+ $id: "http://soustack.org/schema/v0.3.0/profiles/quantified",
1120
+ title: "Soustack Quantified Profile Schema",
1121
+ description: "Extends the base schema to require quantified ingredient entries.",
1122
+ allOf: [
1123
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1124
+ {
851
1125
  properties: {
852
- subsection: { type: "string" },
853
- items: {
1126
+ ingredients: {
854
1127
  type: "array",
855
1128
  items: {
856
1129
  anyOf: [
857
- { type: "string" },
858
- { $ref: "#/definitions/instruction" }
1130
+ { $ref: "#/definitions/quantifiedIngredient" },
1131
+ { $ref: "#/definitions/quantifiedIngredientSubsection" }
859
1132
  ]
860
1133
  }
861
1134
  }
862
1135
  }
1136
+ }
1137
+ ],
1138
+ definitions: {
1139
+ quantifiedIngredient: {
1140
+ allOf: [
1141
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredient" },
1142
+ { required: ["item", "quantity"] }
1143
+ ]
863
1144
  },
864
- storage: {
865
- type: "object",
1145
+ quantifiedIngredientSubsection: {
1146
+ allOf: [
1147
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredientSubsection" },
1148
+ {
1149
+ properties: {
1150
+ items: {
1151
+ type: "array",
1152
+ items: { $ref: "#/definitions/quantifiedIngredient" }
1153
+ }
1154
+ }
1155
+ }
1156
+ ]
1157
+ }
1158
+ }
1159
+ };
1160
+
1161
+ // spec/profiles/scalable.schema.json
1162
+ var scalable_schema_default = {
1163
+ $schema: "http://json-schema.org/draft-07/schema#",
1164
+ $id: "http://soustack.org/schema/v0.3.0/profiles/scalable",
1165
+ title: "Soustack Scalable Profile Schema",
1166
+ description: "Extends the base schema to guarantee quantified ingredients plus a structured yield for deterministic scaling.",
1167
+ allOf: [
1168
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1169
+ {
1170
+ required: ["yield", "ingredients"],
866
1171
  properties: {
867
- roomTemp: { $ref: "#/definitions/storageMethod" },
868
- refrigerated: { $ref: "#/definitions/storageMethod" },
869
- frozen: {
1172
+ yield: {
870
1173
  allOf: [
871
- { $ref: "#/definitions/storageMethod" },
872
- {
873
- type: "object",
874
- properties: { thawing: { type: "string" } }
875
- }
1174
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/yield" },
1175
+ { properties: { amount: { type: "number", exclusiveMinimum: 0 } } }
876
1176
  ]
877
1177
  },
878
- reheating: { type: "string" },
879
- makeAhead: {
1178
+ ingredients: {
880
1179
  type: "array",
1180
+ minItems: 1,
881
1181
  items: {
882
- allOf: [
883
- { $ref: "#/definitions/storageMethod" },
884
- {
1182
+ anyOf: [
1183
+ { $ref: "#/definitions/scalableIngredient" },
1184
+ { $ref: "#/definitions/scalableIngredientSubsection" }
1185
+ ]
1186
+ }
1187
+ }
1188
+ }
1189
+ }
1190
+ ],
1191
+ definitions: {
1192
+ scalableIngredient: {
1193
+ allOf: [
1194
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredient" },
1195
+ { required: ["item", "quantity"] },
1196
+ {
1197
+ properties: {
1198
+ quantity: {
1199
+ allOf: [
1200
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/quantity" },
1201
+ { properties: { amount: { type: "number", exclusiveMinimum: 0 } } }
1202
+ ]
1203
+ }
1204
+ }
1205
+ },
1206
+ {
1207
+ if: {
1208
+ properties: {
1209
+ scaling: {
885
1210
  type: "object",
886
- required: ["component", "storage"],
887
- properties: {
888
- component: { type: "string" },
889
- storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
890
- }
1211
+ properties: { type: { const: "bakers_percentage" } },
1212
+ required: ["type"]
891
1213
  }
1214
+ },
1215
+ required: ["scaling"]
1216
+ },
1217
+ then: { required: ["id"] }
1218
+ }
1219
+ ]
1220
+ },
1221
+ scalableIngredientSubsection: {
1222
+ allOf: [
1223
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredientSubsection" },
1224
+ {
1225
+ properties: {
1226
+ items: {
1227
+ type: "array",
1228
+ minItems: 1,
1229
+ items: { $ref: "#/definitions/scalableIngredient" }
1230
+ }
1231
+ }
1232
+ }
1233
+ ]
1234
+ }
1235
+ }
1236
+ };
1237
+
1238
+ // spec/profiles/schedulable.schema.json
1239
+ var schedulable_schema_default = {
1240
+ $schema: "http://json-schema.org/draft-07/schema#",
1241
+ $id: "http://soustack.org/schema/v0.3.0/profiles/schedulable",
1242
+ title: "Soustack Schedulable Profile Schema",
1243
+ description: "Extends the base schema to ensure every instruction is fully scheduled.",
1244
+ allOf: [
1245
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1246
+ {
1247
+ properties: {
1248
+ instructions: {
1249
+ type: "array",
1250
+ items: {
1251
+ anyOf: [
1252
+ { $ref: "#/definitions/schedulableInstruction" },
1253
+ { $ref: "#/definitions/schedulableInstructionSubsection" }
892
1254
  ]
893
1255
  }
894
1256
  }
895
1257
  }
1258
+ }
1259
+ ],
1260
+ definitions: {
1261
+ schedulableInstruction: {
1262
+ allOf: [
1263
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instruction" },
1264
+ { required: ["id", "timing"] }
1265
+ ]
896
1266
  },
897
- storageMethod: {
1267
+ schedulableInstructionSubsection: {
1268
+ allOf: [
1269
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instructionSubsection" },
1270
+ {
1271
+ properties: {
1272
+ items: {
1273
+ type: "array",
1274
+ items: { $ref: "#/definitions/schedulableInstruction" }
1275
+ }
1276
+ }
1277
+ }
1278
+ ]
1279
+ }
1280
+ }
1281
+ };
1282
+
1283
+ // src/schemas/recipe/stacks/attribution/1.schema.json
1284
+ var schema_default = {
1285
+ $schema: "http://json-schema.org/draft-07/schema#",
1286
+ $id: "https://soustack.org/schemas/recipe/stacks/attribution/1.schema.json",
1287
+ title: "Soustack Recipe Stack: attribution v1",
1288
+ description: "Schema for the attribution stack. Ensures namespace data is present when the stack is enabled and vice versa.",
1289
+ type: "object",
1290
+ properties: {
1291
+ stacks: {
898
1292
  type: "object",
899
- required: ["duration"],
900
- properties: {
901
- duration: { type: "string", pattern: "^P" },
902
- method: { type: "string" },
903
- notes: { type: "string" }
1293
+ additionalProperties: {
1294
+ type: "integer",
1295
+ minimum: 1
904
1296
  }
905
1297
  },
906
- substitution: {
1298
+ attribution: {
907
1299
  type: "object",
908
- required: ["ingredient"],
909
1300
  properties: {
910
- ingredient: { type: "string" },
911
- critical: { type: "boolean" },
912
- notes: { type: "string" },
913
- alternatives: {
914
- type: "array",
915
- items: {
1301
+ url: { type: "string" },
1302
+ author: { type: "string" },
1303
+ datePublished: { type: "string" }
1304
+ },
1305
+ additionalProperties: false
1306
+ }
1307
+ },
1308
+ allOf: [
1309
+ {
1310
+ if: {
1311
+ properties: {
1312
+ stacks: {
916
1313
  type: "object",
917
- required: ["name", "ratio"],
918
1314
  properties: {
919
- name: { type: "string" },
920
- ratio: { type: "string" },
921
- notes: { type: "string" },
922
- impact: { type: "string" },
923
- dietary: {
924
- type: "array",
925
- items: { type: "string" }
926
- }
927
- }
1315
+ attribution: { const: 1 }
1316
+ },
1317
+ required: ["attribution"]
1318
+ }
1319
+ }
1320
+ },
1321
+ then: {
1322
+ required: ["attribution"]
1323
+ }
1324
+ },
1325
+ {
1326
+ if: {
1327
+ required: ["attribution"]
1328
+ },
1329
+ then: {
1330
+ required: ["stacks"],
1331
+ properties: {
1332
+ stacks: {
1333
+ type: "object",
1334
+ properties: {
1335
+ attribution: { const: 1 }
1336
+ },
1337
+ required: ["attribution"]
928
1338
  }
929
1339
  }
930
1340
  }
931
1341
  }
932
- }
1342
+ ],
1343
+ additionalProperties: true
933
1344
  };
934
1345
 
935
- // src/profiles/base.schema.json
936
- var base_schema_default = {
1346
+ // src/schemas/recipe/stacks/media/1.schema.json
1347
+ var schema_default2 = {
937
1348
  $schema: "http://json-schema.org/draft-07/schema#",
938
- $id: "http://soustack.org/schema/v0.2.1/profiles/base",
939
- title: "Soustack Base Profile Schema",
940
- description: "Wrapper schema that exposes the unmodified Soustack base schema.",
1349
+ $id: "https://soustack.org/schemas/recipe/stacks/media/1.schema.json",
1350
+ title: "Soustack Recipe Stack: media v1",
1351
+ description: "Schema for the media stack. Guards media blocks based on stack activation and ensures declarations accompany payloads.",
1352
+ type: "object",
1353
+ properties: {
1354
+ stacks: {
1355
+ type: "object",
1356
+ additionalProperties: {
1357
+ type: "integer",
1358
+ minimum: 1
1359
+ }
1360
+ },
1361
+ media: {
1362
+ type: "object",
1363
+ properties: {
1364
+ images: { type: "array", items: { type: "string" } },
1365
+ videos: { type: "array", items: { type: "string" } }
1366
+ },
1367
+ additionalProperties: false
1368
+ }
1369
+ },
941
1370
  allOf: [
942
- { $ref: "http://soustack.org/schema/v0.2.1" }
943
- ]
1371
+ {
1372
+ if: {
1373
+ properties: {
1374
+ stacks: {
1375
+ type: "object",
1376
+ properties: {
1377
+ media: { const: 1 }
1378
+ },
1379
+ required: ["media"]
1380
+ }
1381
+ }
1382
+ },
1383
+ then: {
1384
+ required: ["media"]
1385
+ }
1386
+ },
1387
+ {
1388
+ if: {
1389
+ required: ["media"]
1390
+ },
1391
+ then: {
1392
+ required: ["stacks"],
1393
+ properties: {
1394
+ stacks: {
1395
+ type: "object",
1396
+ properties: {
1397
+ media: { const: 1 }
1398
+ },
1399
+ required: ["media"]
1400
+ }
1401
+ }
1402
+ }
1403
+ }
1404
+ ],
1405
+ additionalProperties: true
944
1406
  };
945
1407
 
946
- // src/profiles/cookable.schema.json
947
- var cookable_schema_default = {
1408
+ // src/schemas/recipe/stacks/nutrition/1.schema.json
1409
+ var schema_default3 = {
948
1410
  $schema: "http://json-schema.org/draft-07/schema#",
949
- $id: "http://soustack.org/schema/v0.2.1/profiles/cookable",
950
- title: "Soustack Cookable Profile Schema",
951
- description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
1411
+ $id: "https://soustack.org/schemas/recipe/stacks/nutrition/1.schema.json",
1412
+ title: "Soustack Recipe Stack: nutrition v1",
1413
+ description: "Schema for the nutrition stack. Keeps nutrition data aligned with stack declarations and vice versa.",
1414
+ type: "object",
1415
+ properties: {
1416
+ stacks: {
1417
+ type: "object",
1418
+ additionalProperties: {
1419
+ type: "integer",
1420
+ minimum: 1
1421
+ }
1422
+ },
1423
+ nutrition: {
1424
+ type: "object",
1425
+ properties: {
1426
+ calories: { type: "number" },
1427
+ protein_g: { type: "number" }
1428
+ },
1429
+ additionalProperties: false
1430
+ }
1431
+ },
952
1432
  allOf: [
953
- { $ref: "http://soustack.org/schema/v0.2.1" },
954
1433
  {
955
- required: ["yield", "time", "ingredients", "instructions"],
956
- properties: {
957
- yield: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/yield" },
958
- time: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/time" },
959
- ingredients: { type: "array", minItems: 1 },
960
- instructions: { type: "array", minItems: 1 }
1434
+ if: {
1435
+ properties: {
1436
+ stacks: {
1437
+ type: "object",
1438
+ properties: {
1439
+ nutrition: { const: 1 }
1440
+ },
1441
+ required: ["nutrition"]
1442
+ }
1443
+ }
1444
+ },
1445
+ then: {
1446
+ required: ["nutrition"]
1447
+ }
1448
+ },
1449
+ {
1450
+ if: {
1451
+ required: ["nutrition"]
1452
+ },
1453
+ then: {
1454
+ required: ["stacks"],
1455
+ properties: {
1456
+ stacks: {
1457
+ type: "object",
1458
+ properties: {
1459
+ nutrition: { const: 1 }
1460
+ },
1461
+ required: ["nutrition"]
1462
+ }
1463
+ }
961
1464
  }
962
1465
  }
963
- ]
1466
+ ],
1467
+ additionalProperties: true
964
1468
  };
965
1469
 
966
- // src/profiles/quantified.schema.json
967
- var quantified_schema_default = {
1470
+ // src/schemas/recipe/stacks/schedule/1.schema.json
1471
+ var schema_default4 = {
968
1472
  $schema: "http://json-schema.org/draft-07/schema#",
969
- $id: "http://soustack.org/schema/v0.2.1/profiles/quantified",
970
- title: "Soustack Quantified Profile Schema",
971
- description: "Extends the base schema to require quantified ingredient entries.",
1473
+ $id: "https://soustack.org/schemas/recipe/stacks/schedule/1.schema.json",
1474
+ title: "Soustack Recipe Stack: schedule v1",
1475
+ description: "Schema for the schedule stack. Enforces bidirectional stack gating and restricts usage to the core profile.",
1476
+ type: "object",
1477
+ properties: {
1478
+ profile: { type: "string" },
1479
+ stacks: {
1480
+ type: "object",
1481
+ additionalProperties: {
1482
+ type: "integer",
1483
+ minimum: 1
1484
+ }
1485
+ },
1486
+ schedule: {
1487
+ type: "object",
1488
+ properties: {
1489
+ tasks: { type: "array" }
1490
+ },
1491
+ additionalProperties: false
1492
+ }
1493
+ },
972
1494
  allOf: [
973
- { $ref: "http://soustack.org/schema/v0.2.1" },
974
1495
  {
975
- properties: {
976
- ingredients: {
977
- type: "array",
978
- items: {
979
- anyOf: [
980
- { $ref: "#/definitions/quantifiedIngredient" },
981
- { $ref: "#/definitions/quantifiedIngredientSubsection" }
982
- ]
1496
+ if: {
1497
+ properties: {
1498
+ stacks: {
1499
+ type: "object",
1500
+ properties: {
1501
+ schedule: { const: 1 }
1502
+ },
1503
+ required: ["schedule"]
983
1504
  }
984
1505
  }
1506
+ },
1507
+ then: {
1508
+ required: ["schedule", "profile"],
1509
+ properties: {
1510
+ profile: { const: "core" }
1511
+ }
985
1512
  }
986
- }
987
- ],
988
- definitions: {
989
- quantifiedIngredient: {
990
- allOf: [
991
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredient" },
992
- { required: ["item", "quantity"] }
993
- ]
994
1513
  },
995
- quantifiedIngredientSubsection: {
996
- allOf: [
997
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredientSubsection" },
998
- {
999
- properties: {
1000
- items: {
1001
- type: "array",
1002
- items: { $ref: "#/definitions/quantifiedIngredient" }
1003
- }
1004
- }
1514
+ {
1515
+ if: {
1516
+ required: ["schedule"]
1517
+ },
1518
+ then: {
1519
+ required: ["stacks", "profile"],
1520
+ properties: {
1521
+ stacks: {
1522
+ type: "object",
1523
+ properties: {
1524
+ schedule: { const: 1 }
1525
+ },
1526
+ required: ["schedule"]
1527
+ },
1528
+ profile: { const: "core" }
1005
1529
  }
1006
- ]
1530
+ }
1007
1531
  }
1008
- }
1532
+ ],
1533
+ additionalProperties: true
1009
1534
  };
1010
1535
 
1011
- // src/profiles/illustrated.schema.json
1012
- var illustrated_schema_default = {
1536
+ // src/schemas/recipe/stacks/taxonomy/1.schema.json
1537
+ var schema_default5 = {
1013
1538
  $schema: "http://json-schema.org/draft-07/schema#",
1014
- $id: "http://soustack.org/schema/v0.2.1/profiles/illustrated",
1015
- title: "Soustack Illustrated Profile Schema",
1016
- description: "Extends the base schema to guarantee at least one illustrative image.",
1539
+ $id: "https://soustack.org/schemas/recipe/stacks/taxonomy/1.schema.json",
1540
+ title: "Soustack Recipe Stack: taxonomy v1",
1541
+ description: "Schema for the taxonomy stack. Enforces keyword and categorization data when enabled and ensures stack declaration accompanies the namespace block.",
1542
+ type: "object",
1543
+ properties: {
1544
+ stacks: {
1545
+ type: "object",
1546
+ additionalProperties: {
1547
+ type: "integer",
1548
+ minimum: 1
1549
+ }
1550
+ },
1551
+ taxonomy: {
1552
+ type: "object",
1553
+ properties: {
1554
+ keywords: { type: "array", items: { type: "string" } },
1555
+ category: { type: "string" },
1556
+ cuisine: { type: "string" }
1557
+ },
1558
+ additionalProperties: false
1559
+ }
1560
+ },
1017
1561
  allOf: [
1018
- { $ref: "http://soustack.org/schema/v0.2.1" },
1019
1562
  {
1020
- anyOf: [
1021
- { required: ["image"] },
1022
- {
1023
- properties: {
1024
- instructions: {
1025
- type: "array",
1026
- contains: {
1027
- anyOf: [
1028
- { $ref: "#/definitions/imageInstruction" },
1029
- { $ref: "#/definitions/instructionSubsectionWithImage" }
1030
- ]
1031
- }
1032
- }
1563
+ if: {
1564
+ properties: {
1565
+ stacks: {
1566
+ type: "object",
1567
+ properties: {
1568
+ taxonomy: { const: 1 }
1569
+ },
1570
+ required: ["taxonomy"]
1033
1571
  }
1034
1572
  }
1035
- ]
1036
- }
1037
- ],
1038
- definitions: {
1039
- imageInstruction: {
1040
- allOf: [
1041
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
1042
- { required: ["image"] }
1043
- ]
1573
+ },
1574
+ then: {
1575
+ required: ["taxonomy"]
1576
+ }
1044
1577
  },
1045
- instructionSubsectionWithImage: {
1046
- allOf: [
1047
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
1048
- {
1049
- properties: {
1050
- items: {
1051
- type: "array",
1052
- contains: { $ref: "#/definitions/imageInstruction" }
1053
- }
1578
+ {
1579
+ if: {
1580
+ required: ["taxonomy"]
1581
+ },
1582
+ then: {
1583
+ required: ["stacks"],
1584
+ properties: {
1585
+ stacks: {
1586
+ type: "object",
1587
+ properties: {
1588
+ taxonomy: { const: 1 }
1589
+ },
1590
+ required: ["taxonomy"]
1054
1591
  }
1055
1592
  }
1056
- ]
1593
+ }
1057
1594
  }
1058
- }
1595
+ ],
1596
+ additionalProperties: true
1059
1597
  };
1060
1598
 
1061
- // src/profiles/schedulable.schema.json
1062
- var schedulable_schema_default = {
1599
+ // src/schemas/recipe/stacks/times/1.schema.json
1600
+ var schema_default6 = {
1063
1601
  $schema: "http://json-schema.org/draft-07/schema#",
1064
- $id: "http://soustack.org/schema/v0.2.1/profiles/schedulable",
1065
- title: "Soustack Schedulable Profile Schema",
1066
- description: "Extends the base schema to ensure every instruction is fully scheduled.",
1602
+ $id: "https://soustack.org/schemas/recipe/stacks/times/1.schema.json",
1603
+ title: "Soustack Recipe Stack: times v1",
1604
+ description: "Schema for the times stack. Maintains alignment between stack declarations and timing payloads.",
1605
+ type: "object",
1606
+ properties: {
1607
+ stacks: {
1608
+ type: "object",
1609
+ additionalProperties: {
1610
+ type: "integer",
1611
+ minimum: 1
1612
+ }
1613
+ },
1614
+ times: {
1615
+ type: "object",
1616
+ properties: {
1617
+ prepMinutes: { type: "number" },
1618
+ cookMinutes: { type: "number" },
1619
+ totalMinutes: { type: "number" }
1620
+ },
1621
+ additionalProperties: false
1622
+ }
1623
+ },
1067
1624
  allOf: [
1068
- { $ref: "http://soustack.org/schema/v0.2.1" },
1069
1625
  {
1070
- properties: {
1071
- instructions: {
1072
- type: "array",
1073
- items: {
1074
- anyOf: [
1075
- { $ref: "#/definitions/schedulableInstruction" },
1076
- { $ref: "#/definitions/schedulableInstructionSubsection" }
1077
- ]
1626
+ if: {
1627
+ properties: {
1628
+ stacks: {
1629
+ type: "object",
1630
+ properties: {
1631
+ times: { const: 1 }
1632
+ },
1633
+ required: ["times"]
1078
1634
  }
1079
1635
  }
1636
+ },
1637
+ then: {
1638
+ required: ["times"]
1080
1639
  }
1081
- }
1082
- ],
1083
- definitions: {
1084
- schedulableInstruction: {
1085
- allOf: [
1086
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
1087
- { required: ["id", "timing"] }
1088
- ]
1089
1640
  },
1090
- schedulableInstructionSubsection: {
1091
- allOf: [
1092
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
1093
- {
1094
- properties: {
1095
- items: {
1096
- type: "array",
1097
- items: { $ref: "#/definitions/schedulableInstruction" }
1098
- }
1641
+ {
1642
+ if: {
1643
+ required: ["times"]
1644
+ },
1645
+ then: {
1646
+ required: ["stacks"],
1647
+ properties: {
1648
+ stacks: {
1649
+ type: "object",
1650
+ properties: {
1651
+ times: { const: 1 }
1652
+ },
1653
+ required: ["times"]
1099
1654
  }
1100
1655
  }
1101
- ]
1656
+ }
1102
1657
  }
1103
- }
1658
+ ],
1659
+ additionalProperties: true
1104
1660
  };
1105
1661
 
1106
1662
  // src/validator.ts
1107
- var profileSchemas = {
1108
- base: base_schema_default,
1109
- cookable: cookable_schema_default,
1110
- scalable: base_schema_default,
1111
- quantified: quantified_schema_default,
1112
- illustrated: illustrated_schema_default,
1113
- schedulable: schedulable_schema_default
1114
- };
1663
+ var LEGACY_ROOT_SCHEMA_ID = "http://soustack.org/schema/v0.3.0";
1664
+ var DEFAULT_ROOT_SCHEMA_ID = "https://soustack.spec/soustack.schema.json";
1665
+ var BASE_SCHEMA_ID = "http://soustack.org/schema/recipe/base.schema.json";
1666
+ var PROFILE_SCHEMA_PREFIX = "http://soustack.org/schema/recipe/profiles/";
1115
1667
  var validationContexts = /* @__PURE__ */ new Map();
1668
+ function loadAllSchemas(ajv) {
1669
+ const schemas = [
1670
+ soustack_schema_default,
1671
+ base_schema_default,
1672
+ minimal_schema_default,
1673
+ core_schema_default,
1674
+ base_schema_default2,
1675
+ cookable_schema_default,
1676
+ illustrated_schema_default,
1677
+ quantified_schema_default,
1678
+ scalable_schema_default,
1679
+ schedulable_schema_default,
1680
+ schema_default,
1681
+ schema_default2,
1682
+ schema_default3,
1683
+ schema_default4,
1684
+ schema_default5,
1685
+ schema_default6
1686
+ ];
1687
+ for (const schema of schemas) {
1688
+ if (schema && typeof schema === "object" && "$id" in schema) {
1689
+ const schemaWithId = schema;
1690
+ if (schemaWithId.$id) {
1691
+ ajv.addSchema(schemaWithId, schemaWithId.$id);
1692
+ }
1693
+ }
1694
+ }
1695
+ ajv.addSchema(
1696
+ {
1697
+ $id: DEFAULT_ROOT_SCHEMA_ID,
1698
+ allOf: [
1699
+ { $ref: LEGACY_ROOT_SCHEMA_ID },
1700
+ {
1701
+ type: "object",
1702
+ properties: {
1703
+ $schema: { const: DEFAULT_ROOT_SCHEMA_ID }
1704
+ }
1705
+ }
1706
+ ]
1707
+ },
1708
+ DEFAULT_ROOT_SCHEMA_ID
1709
+ );
1710
+ }
1116
1711
  function createContext(collectAllErrors) {
1117
- const ajv = new Ajv({ strict: false, allErrors: collectAllErrors });
1712
+ const ajv = new Ajv2020({
1713
+ strict: false,
1714
+ allErrors: collectAllErrors,
1715
+ validateSchema: false
1716
+ // Don't validate schemas themselves
1717
+ });
1118
1718
  addFormats(ajv);
1119
- const loadedIds = /* @__PURE__ */ new Set();
1120
- const addSchemaIfNew = (schema) => {
1121
- if (!schema) return;
1122
- const schemaId = schema == null ? void 0 : schema.$id;
1123
- if (schemaId && loadedIds.has(schemaId)) return;
1124
- ajv.addSchema(schema);
1125
- if (schemaId) loadedIds.add(schemaId);
1719
+ loadAllSchemas(ajv);
1720
+ const rootValidator = ajv.getSchema(DEFAULT_ROOT_SCHEMA_ID) || ajv.getSchema(LEGACY_ROOT_SCHEMA_ID);
1721
+ const baseValidator = ajv.getSchema(BASE_SCHEMA_ID);
1722
+ return {
1723
+ ajv,
1724
+ rootValidator: rootValidator || void 0,
1725
+ baseValidator: baseValidator || void 0,
1726
+ validators: /* @__PURE__ */ new Map()
1126
1727
  };
1127
- addSchemaIfNew(schema_default);
1128
- addSchemaIfNew(soustack_schema_default);
1129
- Object.values(profileSchemas).forEach(addSchemaIfNew);
1130
- return { ajv, validators: {} };
1131
1728
  }
1132
1729
  function getContext(collectAllErrors) {
1133
1730
  if (!validationContexts.has(collectAllErrors)) {
@@ -1141,200 +1738,227 @@ function cloneRecipe(recipe) {
1141
1738
  }
1142
1739
  return JSON.parse(JSON.stringify(recipe));
1143
1740
  }
1144
- function detectProfileFromSchema(schemaRef) {
1145
- if (!schemaRef) return void 0;
1146
- const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
1147
- if (match) {
1148
- const profile = match[1].toLowerCase();
1149
- if (profile in profileSchemas) return profile;
1150
- }
1151
- return void 0;
1152
- }
1153
- function getValidator(profile, context) {
1154
- if (!profileSchemas[profile]) {
1155
- throw new Error(`Unknown Soustack profile: ${profile}`);
1156
- }
1157
- if (!context.validators[profile]) {
1158
- context.validators[profile] = context.ajv.compile(profileSchemas[profile]);
1159
- }
1160
- return context.validators[profile];
1161
- }
1162
- function normalizeRecipe(recipe) {
1163
- const normalized = cloneRecipe(recipe);
1164
- const warnings = [];
1165
- normalizeTime(normalized);
1166
- if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
1167
- normalized.recipeVersion = normalized.version;
1168
- warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
1169
- }
1170
- return { normalized, warnings };
1171
- }
1172
- function normalizeTime(recipe) {
1173
- const time = recipe == null ? void 0 : recipe.time;
1174
- if (!time || typeof time !== "object" || Array.isArray(time)) return;
1175
- const structuredKeys = [
1176
- "prep",
1177
- "active",
1178
- "passive",
1179
- "total"
1180
- ];
1181
- structuredKeys.forEach((key) => {
1182
- const value = time[key];
1183
- if (typeof value === "number") return;
1184
- const parsed = parseDuration(value);
1185
- if (parsed !== null) {
1186
- time[key] = parsed;
1187
- }
1188
- });
1189
- }
1190
- var _a, _b;
1191
- var allowedTopLevelProps = /* @__PURE__ */ new Set([
1192
- ...Object.keys((_b = (_a = soustack_schema_default) == null ? void 0 : _a.properties) != null ? _b : {}),
1193
- "metadata",
1194
- "$schema"
1195
- ]);
1196
- function detectUnknownTopLevelKeys(recipe) {
1197
- if (!recipe || typeof recipe !== "object") return [];
1198
- const disallowedKeys = Object.keys(recipe).filter(
1199
- (key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
1200
- );
1201
- return disallowedKeys.map((key) => ({
1202
- path: `/${key}`,
1203
- keyword: "additionalProperties",
1204
- message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
1205
- }));
1206
- }
1207
1741
  function formatAjvError(error) {
1208
- var _a2;
1742
+ var _a;
1209
1743
  let path = error.instancePath || "/";
1210
- if (error.keyword === "additionalProperties" && ((_a2 = error.params) == null ? void 0 : _a2.additionalProperty)) {
1744
+ if (error.keyword === "additionalProperties" && ((_a = error.params) == null ? void 0 : _a.additionalProperty)) {
1211
1745
  const extra = error.params.additionalProperty;
1212
1746
  path = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
1213
1747
  }
1214
1748
  return {
1215
1749
  path,
1216
1750
  keyword: error.keyword,
1217
- message: error.message || "Validation error"
1218
- };
1219
- }
1220
- function runAjvValidation(data, profile, context, schemaRef) {
1221
- const validator = schemaRef ? context.ajv.getSchema(schemaRef) : void 0;
1222
- const validateFn = validator != null ? validator : getValidator(profile, context);
1223
- const isValid = validateFn(data);
1224
- return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
1225
- }
1226
- function isInstruction(item) {
1227
- return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
1228
- }
1229
- function isInstructionSubsection(item) {
1230
- return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
1231
- }
1232
- function checkInstructionGraph(recipe) {
1233
- const instructions = recipe == null ? void 0 : recipe.instructions;
1234
- if (!Array.isArray(instructions)) return [];
1235
- const instructionIds = /* @__PURE__ */ new Set();
1236
- const dependencyRefs = [];
1237
- const collect = (items, basePath) => {
1238
- items.forEach((item, index) => {
1239
- const currentPath = `${basePath}/${index}`;
1240
- if (isInstructionSubsection(item) && Array.isArray(item.items)) {
1241
- collect(item.items, `${currentPath}/items`);
1242
- return;
1243
- }
1244
- if (isInstruction(item)) {
1245
- const id = typeof item.id === "string" ? item.id : void 0;
1246
- if (id) instructionIds.add(id);
1247
- if (Array.isArray(item.dependsOn)) {
1248
- item.dependsOn.forEach((depId, depIndex) => {
1249
- if (typeof depId === "string") {
1250
- dependencyRefs.push({
1251
- fromId: id,
1252
- toId: depId,
1253
- path: `${currentPath}/dependsOn/${depIndex}`
1254
- });
1255
- }
1256
- });
1257
- }
1751
+ message: error.message || "Validation error"
1752
+ };
1753
+ }
1754
+ function isSoustackSchemaId(schemaId) {
1755
+ return schemaId.startsWith("http://soustack.org/schema") || schemaId.startsWith("https://soustack.org/schema") || schemaId.startsWith("https://soustack.spec/") || schemaId.startsWith("https://soustack.org/schemas/");
1756
+ }
1757
+ function inferStacksFromPayload(recipe) {
1758
+ const inferred = {};
1759
+ const payloadToStack = {
1760
+ attribution: "attribution",
1761
+ taxonomy: "taxonomy",
1762
+ media: "media",
1763
+ times: "times",
1764
+ nutrition: "nutrition",
1765
+ schedule: "schedule"
1766
+ };
1767
+ for (const [field, stackName] of Object.entries(payloadToStack)) {
1768
+ if (recipe && typeof recipe === "object" && field in recipe && recipe[field] !== void 0) {
1769
+ inferred[stackName] = 1;
1770
+ }
1771
+ }
1772
+ return inferred;
1773
+ }
1774
+ function getComposedValidator(profile, stacks, context) {
1775
+ const stackIdentifiers = Object.entries(stacks).map(([name, version]) => `${name}@${version}`).sort();
1776
+ const cacheKey = `${profile}::${stackIdentifiers.join(",")}`;
1777
+ const cached = context.validators.get(cacheKey);
1778
+ if (cached) return cached;
1779
+ const allOf = [{ $ref: BASE_SCHEMA_ID }];
1780
+ if (!context.ajv.getSchema(BASE_SCHEMA_ID)) {
1781
+ throw new Error(`Base schema not loaded: ${BASE_SCHEMA_ID}. Ensure schemas are loaded before creating validators.`);
1782
+ }
1783
+ const profileSchemaId = `${PROFILE_SCHEMA_PREFIX}${profile}.schema.json`;
1784
+ if (!context.ajv.getSchema(profileSchemaId)) {
1785
+ throw new Error(`Profile schema not loaded: ${profileSchemaId}`);
1786
+ }
1787
+ allOf.push({ $ref: profileSchemaId });
1788
+ for (const [name, version] of Object.entries(stacks)) {
1789
+ if (typeof version === "number" && version >= 1) {
1790
+ const stackSchemaId = `https://soustack.org/schemas/recipe/stacks/${name}/${version}.schema.json`;
1791
+ if (!context.ajv.getSchema(stackSchemaId)) {
1792
+ throw new Error(`Stack schema not loaded: ${stackSchemaId}`);
1258
1793
  }
1259
- });
1794
+ allOf.push({ $ref: stackSchemaId });
1795
+ }
1796
+ }
1797
+ const composedSchema = {
1798
+ $id: `urn:soustack:composed:${cacheKey}`,
1799
+ allOf
1260
1800
  };
1261
- collect(instructions, "/instructions");
1262
- const errors = [];
1263
- dependencyRefs.forEach((ref) => {
1264
- if (!instructionIds.has(ref.toId)) {
1265
- errors.push({
1266
- path: ref.path,
1267
- message: `Instruction dependency references missing id '${ref.toId}'.`
1268
- });
1801
+ const validateFn = context.ajv.compile(composedSchema);
1802
+ context.validators.set(cacheKey, validateFn);
1803
+ return validateFn;
1804
+ }
1805
+ function validateRecipeSchemaNormalized(normalizedInput, inputHasStacks, collectAllErrors, schemaOverride) {
1806
+ const normalized = cloneRecipe(normalizedInput);
1807
+ const context = getContext(collectAllErrors);
1808
+ const schemaId = typeof schemaOverride === "string" ? schemaOverride : typeof normalized.$schema === "string" ? normalized.$schema : void 0;
1809
+ const hasSchemaOverride = typeof schemaOverride === "string";
1810
+ const isSoustackSchema = schemaId ? isSoustackSchemaId(schemaId) : false;
1811
+ if (schemaId && isSoustackSchema) {
1812
+ const schemaValidator = context.ajv.getSchema(schemaId);
1813
+ if (!schemaValidator) {
1814
+ return {
1815
+ ok: false,
1816
+ errors: [
1817
+ {
1818
+ path: "/$schema",
1819
+ message: `Unknown schema: ${schemaId}`
1820
+ }
1821
+ ]
1822
+ };
1269
1823
  }
1270
- });
1271
- const adjacency = /* @__PURE__ */ new Map();
1272
- dependencyRefs.forEach((ref) => {
1273
- var _a2;
1274
- if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
1275
- const list = (_a2 = adjacency.get(ref.fromId)) != null ? _a2 : [];
1276
- list.push({ toId: ref.toId, path: ref.path });
1277
- adjacency.set(ref.fromId, list);
1824
+ const schemaInput = cloneRecipe(normalized);
1825
+ if (hasSchemaOverride && "$schema" in schemaInput && schemaInput.$schema !== schemaId) {
1826
+ delete schemaInput.$schema;
1278
1827
  }
1279
- });
1280
- const visiting = /* @__PURE__ */ new Set();
1281
- const visited = /* @__PURE__ */ new Set();
1282
- const detectCycles = (nodeId) => {
1283
- var _a2;
1284
- if (visiting.has(nodeId)) return;
1285
- if (visited.has(nodeId)) return;
1286
- visiting.add(nodeId);
1287
- const neighbors = (_a2 = adjacency.get(nodeId)) != null ? _a2 : [];
1288
- neighbors.forEach((edge) => {
1289
- if (visiting.has(edge.toId)) {
1290
- errors.push({
1291
- path: edge.path,
1292
- message: `Circular dependency detected involving instruction id '${edge.toId}'.`
1293
- });
1294
- return;
1828
+ const isLegacySchema = schemaId.startsWith(LEGACY_ROOT_SCHEMA_ID);
1829
+ const shouldRemoveStacks = (isLegacySchema || schemaId === DEFAULT_ROOT_SCHEMA_ID) && !inputHasStacks;
1830
+ if (isLegacySchema && "@type" in schemaInput) {
1831
+ delete schemaInput["@type"];
1832
+ }
1833
+ if (shouldRemoveStacks && "stacks" in schemaInput) {
1834
+ delete schemaInput.stacks;
1835
+ }
1836
+ const schemaValid = schemaValidator(schemaInput);
1837
+ const schemaErrors = schemaValidator.errors || [];
1838
+ return {
1839
+ ok: !!schemaValid,
1840
+ errors: schemaErrors.map(formatAjvError)
1841
+ };
1842
+ }
1843
+ const hasProfile = normalized.profile && typeof normalized.profile === "string";
1844
+ let declaredStacks = {};
1845
+ if (normalized.stacks && typeof normalized.stacks === "object" && !Array.isArray(normalized.stacks)) {
1846
+ for (const [name, version] of Object.entries(normalized.stacks)) {
1847
+ if (typeof version === "number" && version >= 1) {
1848
+ declaredStacks[name] = version;
1295
1849
  }
1296
- detectCycles(edge.toId);
1297
- });
1298
- visiting.delete(nodeId);
1299
- visited.add(nodeId);
1850
+ }
1851
+ }
1852
+ const inferredStacks = inferStacksFromPayload(normalized);
1853
+ const allStacks = { ...declaredStacks };
1854
+ for (const [name, version] of Object.entries(inferredStacks)) {
1855
+ if (!allStacks[name] || allStacks[name] < version) {
1856
+ allStacks[name] = version;
1857
+ }
1858
+ }
1859
+ let isValid;
1860
+ let errors = [];
1861
+ const profile = hasProfile ? normalized.profile.toLowerCase() : "core";
1862
+ const profileSchemaId = `${PROFILE_SCHEMA_PREFIX}${profile}.schema.json`;
1863
+ if (!context.ajv.getSchema(profileSchemaId)) {
1864
+ return {
1865
+ ok: false,
1866
+ errors: [
1867
+ {
1868
+ path: "/profile",
1869
+ message: `Profile schema not loaded: ${profileSchemaId}`
1870
+ }
1871
+ ]
1872
+ };
1873
+ }
1874
+ {
1875
+ const validationCopy = cloneRecipe(normalized);
1876
+ if (!validationCopy.stacks || typeof validationCopy.stacks !== "object" || Array.isArray(validationCopy.stacks)) {
1877
+ validationCopy.stacks = declaredStacks;
1878
+ }
1879
+ if (!validationCopy.profile) {
1880
+ validationCopy.profile = profile;
1881
+ }
1882
+ const validator = getComposedValidator(profile, allStacks, context);
1883
+ isValid = validator(validationCopy);
1884
+ errors = validator.errors || [];
1885
+ if (isValid && context.rootValidator) {
1886
+ const rootCheckCopy = cloneRecipe(normalized);
1887
+ if ("@type" in rootCheckCopy) {
1888
+ delete rootCheckCopy["@type"];
1889
+ }
1890
+ if ("stacks" in rootCheckCopy) {
1891
+ delete rootCheckCopy.stacks;
1892
+ }
1893
+ if ("profile" in rootCheckCopy) {
1894
+ delete rootCheckCopy.profile;
1895
+ }
1896
+ const stackPayloadFields = ["attribution", "taxonomy", "media", "times", "nutrition", "schedule"];
1897
+ for (const field of stackPayloadFields) {
1898
+ if (field in rootCheckCopy) {
1899
+ delete rootCheckCopy[field];
1900
+ }
1901
+ }
1902
+ const rootValid = context.rootValidator(rootCheckCopy);
1903
+ if (!rootValid && context.rootValidator.errors) {
1904
+ const unknownKeyErrors = context.rootValidator.errors.filter(
1905
+ (e) => e.keyword === "additionalProperties" && (e.instancePath === "" || e.instancePath === "/")
1906
+ );
1907
+ const schemaConstErrors = context.rootValidator.errors.filter(
1908
+ (e) => e.keyword === "const" && e.instancePath === "/$schema"
1909
+ );
1910
+ const relevantErrors = [...unknownKeyErrors, ...schemaConstErrors];
1911
+ if (relevantErrors.length > 0) {
1912
+ errors.push(...relevantErrors);
1913
+ isValid = false;
1914
+ }
1915
+ }
1916
+ }
1917
+ }
1918
+ return {
1919
+ ok: isValid,
1920
+ errors: errors.map(formatAjvError)
1300
1921
  };
1301
- instructionIds.forEach((id) => detectCycles(id));
1302
- return errors;
1303
1922
  }
1304
1923
  function validateRecipe(input, options = {}) {
1305
- var _a2, _b2, _c, _d;
1306
- const collectAllErrors = (_a2 = options.collectAllErrors) != null ? _a2 : true;
1307
- const context = getContext(collectAllErrors);
1308
- const schemaRef = (_b2 = options.schema) != null ? _b2 : typeof (input == null ? void 0 : input.$schema) === "string" ? input.$schema : void 0;
1309
- const profile = (_d = (_c = options.profile) != null ? _c : detectProfileFromSchema(schemaRef)) != null ? _d : "base";
1310
- const { normalized, warnings } = normalizeRecipe(input);
1311
- const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
1312
- const validationErrors = runAjvValidation(normalized, profile, context, schemaRef);
1313
- const graphErrors = profile === "schedulable" && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
1314
- const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
1924
+ var _a, _b;
1925
+ const { recipe: normalized, warnings } = normalizeRecipe(input);
1926
+ if (options.profile) {
1927
+ normalized.profile = options.profile;
1928
+ }
1929
+ const inputHasStacks = !!input && typeof input === "object" && !Array.isArray(input) && "stacks" in input;
1930
+ const { ok: schemaOk, errors: schemaErrors } = validateRecipeSchemaNormalized(
1931
+ normalized,
1932
+ inputHasStacks,
1933
+ (_a = options.collectAllErrors) != null ? _a : true,
1934
+ options.schema
1935
+ );
1936
+ const mode = (_b = options.mode) != null ? _b : "full";
1937
+ let conformanceIssues = [];
1938
+ let conformanceOk = true;
1939
+ if (mode === "full") {
1940
+ if (schemaOk) {
1941
+ const conformanceResult = validateConformance(normalized);
1942
+ conformanceIssues = conformanceResult.issues;
1943
+ conformanceOk = conformanceResult.ok;
1944
+ } else {
1945
+ conformanceOk = false;
1946
+ }
1947
+ }
1948
+ const ok = schemaOk && (mode === "schema" ? true : conformanceOk);
1949
+ const normalizedRecipe = ok || options.includeNormalized ? normalized : void 0;
1315
1950
  return {
1316
- valid: errors.length === 0,
1317
- errors,
1951
+ ok,
1952
+ schemaErrors,
1953
+ conformanceIssues,
1318
1954
  warnings,
1319
- normalized: errors.length === 0 ? normalized : void 0
1955
+ normalizedRecipe
1320
1956
  };
1321
1957
  }
1322
1958
  function detectProfiles(recipe) {
1323
- var _a2;
1324
- const result = validateRecipe(recipe, { profile: "base", collectAllErrors: false });
1325
- if (!result.valid) return [];
1326
- const normalizedRecipe = (_a2 = result.normalized) != null ? _a2 : recipe;
1327
- const profiles = ["base"];
1328
- const context = getContext(false);
1329
- Object.keys(profileSchemas).forEach((profile) => {
1330
- if (profile === "base") return;
1331
- if (!profileSchemas[profile]) return;
1332
- const errors = runAjvValidation(normalizedRecipe, profile, context);
1333
- if (errors.length === 0) {
1334
- profiles.push(profile);
1335
- }
1336
- });
1337
- return profiles;
1959
+ const result = validateRecipe(recipe, { collectAllErrors: false });
1960
+ if (!result.ok) return [];
1961
+ return ["core"];
1338
1962
  }
1339
1963
 
1340
1964
  // src/converters/yield.ts
@@ -1377,12 +2001,12 @@ function parseYield(value) {
1377
2001
  return void 0;
1378
2002
  }
1379
2003
  function formatYield(yieldValue) {
1380
- var _a2;
2004
+ var _a;
1381
2005
  if (!yieldValue) return void 0;
1382
2006
  if (!yieldValue.amount && !yieldValue.unit) {
1383
2007
  return void 0;
1384
2008
  }
1385
- const amount = (_a2 = yieldValue.amount) != null ? _a2 : "";
2009
+ const amount = (_a = yieldValue.amount) != null ? _a : "";
1386
2010
  const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
1387
2011
  return `${amount}${unit}`.trim() || yieldValue.description;
1388
2012
  }
@@ -1423,7 +2047,7 @@ function extractUrl(value) {
1423
2047
 
1424
2048
  // src/fromSchemaOrg.ts
1425
2049
  function fromSchemaOrg(input) {
1426
- var _a2;
2050
+ var _a;
1427
2051
  const recipeNode = extractRecipeNode(input);
1428
2052
  if (!recipeNode) {
1429
2053
  return null;
@@ -1435,22 +2059,42 @@ function fromSchemaOrg(input) {
1435
2059
  const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
1436
2060
  const category = extractFirst(recipeNode.recipeCategory);
1437
2061
  const source = convertSource(recipeNode);
1438
- const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
1439
- return {
2062
+ const dateModified = recipeNode.dateModified || void 0;
2063
+ const nutrition = convertNutrition(recipeNode.nutrition);
2064
+ const attribution = convertAttribution(recipeNode);
2065
+ const taxonomy = convertTaxonomy(tags, category, extractFirst(recipeNode.recipeCuisine));
2066
+ const media = convertMedia(recipeNode.image, recipeNode.video);
2067
+ const times = convertTimes(time);
2068
+ const stacks = {};
2069
+ if (attribution) stacks.attribution = 1;
2070
+ if (taxonomy) stacks.taxonomy = 1;
2071
+ if (media) stacks.media = 1;
2072
+ if (nutrition) stacks.nutrition = 1;
2073
+ if (times) stacks.times = 1;
2074
+ const rawRecipe = {
2075
+ "@type": "Recipe",
2076
+ profile: "minimal",
2077
+ stacks,
1440
2078
  name: recipeNode.name.trim(),
1441
- description: ((_a2 = recipeNode.description) == null ? void 0 : _a2.trim()) || void 0,
2079
+ description: ((_a = recipeNode.description) == null ? void 0 : _a.trim()) || void 0,
1442
2080
  image: normalizeImage(recipeNode.image),
1443
2081
  category,
1444
2082
  tags: tags.length ? tags : void 0,
1445
2083
  source,
1446
2084
  dateAdded: recipeNode.datePublished || void 0,
1447
- dateModified: recipeNode.dateModified || void 0,
1448
2085
  yield: recipeYield,
1449
2086
  time,
1450
2087
  ingredients,
1451
2088
  instructions,
1452
- nutrition
2089
+ ...dateModified ? { dateModified } : {},
2090
+ ...nutrition ? { nutrition } : {},
2091
+ ...attribution ? { attribution } : {},
2092
+ ...taxonomy ? { taxonomy } : {},
2093
+ ...media ? { media } : {},
2094
+ ...times ? { times } : {}
1453
2095
  };
2096
+ const { recipe } = normalizeRecipe(rawRecipe);
2097
+ return recipe;
1454
2098
  }
1455
2099
  function extractRecipeNode(input) {
1456
2100
  if (!input) return null;
@@ -1497,7 +2141,7 @@ function convertIngredients(value) {
1497
2141
  return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean);
1498
2142
  }
1499
2143
  function convertInstructions(value) {
1500
- var _a2;
2144
+ var _a;
1501
2145
  if (!value) return [];
1502
2146
  const normalized = Array.isArray(value) ? value : [value];
1503
2147
  const result = [];
@@ -1514,7 +2158,7 @@ function convertInstructions(value) {
1514
2158
  const subsectionItems = extractSectionItems(entry.itemListElement);
1515
2159
  if (subsectionItems.length) {
1516
2160
  result.push({
1517
- subsection: ((_a2 = entry.name) == null ? void 0 : _a2.trim()) || "Section",
2161
+ subsection: ((_a = entry.name) == null ? void 0 : _a.trim()) || "Section",
1518
2162
  items: subsectionItems
1519
2163
  });
1520
2164
  }
@@ -1598,9 +2242,9 @@ function isHowToSection(value) {
1598
2242
  return Boolean(value) && typeof value === "object" && value["@type"] === "HowToSection" && Array.isArray(value.itemListElement);
1599
2243
  }
1600
2244
  function convertTime(recipe) {
1601
- var _a2, _b2, _c;
1602
- const prep = smartParseDuration((_a2 = recipe.prepTime) != null ? _a2 : "");
1603
- const cook = smartParseDuration((_b2 = recipe.cookTime) != null ? _b2 : "");
2245
+ var _a, _b, _c;
2246
+ const prep = smartParseDuration((_a = recipe.prepTime) != null ? _a : "");
2247
+ const cook = smartParseDuration((_b = recipe.cookTime) != null ? _b : "");
1604
2248
  const total = smartParseDuration((_c = recipe.totalTime) != null ? _c : "");
1605
2249
  const structured = {};
1606
2250
  if (prep !== null && prep !== void 0) structured.prep = prep;
@@ -1634,10 +2278,10 @@ function extractFirst(value) {
1634
2278
  return arr.length ? arr[0] : void 0;
1635
2279
  }
1636
2280
  function convertSource(recipe) {
1637
- var _a2;
2281
+ var _a;
1638
2282
  const author = extractEntityName(recipe.author);
1639
2283
  const publisher = extractEntityName(recipe.publisher);
1640
- const url = (_a2 = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a2.trim();
2284
+ const url = (_a = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a.trim();
1641
2285
  const source = {};
1642
2286
  if (author) source.author = author;
1643
2287
  if (publisher) source.name = publisher;
@@ -1665,17 +2309,174 @@ function extractEntityName(value) {
1665
2309
  }
1666
2310
  return void 0;
1667
2311
  }
2312
+ function convertAttribution(recipe) {
2313
+ var _a, _b;
2314
+ const attribution = {};
2315
+ const url = (_a = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a.trim();
2316
+ const author = extractEntityName(recipe.author);
2317
+ const datePublished = (_b = recipe.datePublished) == null ? void 0 : _b.trim();
2318
+ if (url) attribution.url = url;
2319
+ if (author) attribution.author = author;
2320
+ if (datePublished) attribution.datePublished = datePublished;
2321
+ return Object.keys(attribution).length ? attribution : void 0;
2322
+ }
2323
+ function convertTaxonomy(keywords, category, cuisine) {
2324
+ const taxonomy = {};
2325
+ if (keywords.length) taxonomy.keywords = keywords;
2326
+ if (category) taxonomy.category = category;
2327
+ if (cuisine) taxonomy.cuisine = cuisine;
2328
+ return Object.keys(taxonomy).length ? taxonomy : void 0;
2329
+ }
2330
+ function normalizeMediaList(value) {
2331
+ if (!value) return [];
2332
+ if (typeof value === "string") return [value.trim()].filter(Boolean);
2333
+ if (Array.isArray(value)) {
2334
+ return value.map((item) => typeof item === "string" ? item.trim() : extractMediaUrl(item)).filter((entry) => Boolean(entry == null ? void 0 : entry.length));
2335
+ }
2336
+ const url = extractMediaUrl(value);
2337
+ return url ? [url] : [];
2338
+ }
2339
+ function extractMediaUrl(value) {
2340
+ if (value && typeof value === "object" && "url" in value && typeof value.url === "string") {
2341
+ const trimmed = value.url.trim();
2342
+ return trimmed || void 0;
2343
+ }
2344
+ return void 0;
2345
+ }
2346
+ function convertMedia(image, video) {
2347
+ const normalizedImage = normalizeImage(image);
2348
+ const images = normalizedImage ? Array.isArray(normalizedImage) ? normalizedImage : [normalizedImage] : [];
2349
+ const videos = normalizeMediaList(video);
2350
+ const media = {};
2351
+ if (images.length) media.images = images;
2352
+ if (videos.length) media.videos = videos;
2353
+ return Object.keys(media).length ? media : void 0;
2354
+ }
2355
+ function convertTimes(time) {
2356
+ if (!time) return void 0;
2357
+ const times = {};
2358
+ if (typeof time.prep === "number") times.prepMinutes = time.prep;
2359
+ if (typeof time.active === "number") times.cookMinutes = time.active;
2360
+ if (typeof time.total === "number") times.totalMinutes = time.total;
2361
+ return Object.keys(times).length ? times : void 0;
2362
+ }
2363
+ function convertNutrition(nutrition) {
2364
+ if (!nutrition || typeof nutrition !== "object") {
2365
+ return void 0;
2366
+ }
2367
+ const result = {};
2368
+ let hasData = false;
2369
+ if ("calories" in nutrition) {
2370
+ const calories = nutrition.calories;
2371
+ if (typeof calories === "number") {
2372
+ result.calories = calories;
2373
+ hasData = true;
2374
+ } else if (typeof calories === "string") {
2375
+ const parsed = parseFloat(calories.replace(/[^\d.-]/g, ""));
2376
+ if (!isNaN(parsed)) {
2377
+ result.calories = parsed;
2378
+ hasData = true;
2379
+ }
2380
+ }
2381
+ }
2382
+ if ("proteinContent" in nutrition || "protein_g" in nutrition) {
2383
+ const protein = nutrition.proteinContent || nutrition.protein_g;
2384
+ if (typeof protein === "number") {
2385
+ result.protein_g = protein;
2386
+ hasData = true;
2387
+ } else if (typeof protein === "string") {
2388
+ const parsed = parseFloat(protein.replace(/[^\d.-]/g, ""));
2389
+ if (!isNaN(parsed)) {
2390
+ result.protein_g = parsed;
2391
+ hasData = true;
2392
+ }
2393
+ }
2394
+ }
2395
+ return hasData ? result : void 0;
2396
+ }
2397
+
2398
+ // src/schemas/registry/stacks.json
2399
+ var stacks_default = {
2400
+ stacks: [
2401
+ {
2402
+ id: "attribution",
2403
+ versions: [1],
2404
+ latest: 1,
2405
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/attribution",
2406
+ schema: "http://soustack.org/schema/v0.3.0/stacks/attribution",
2407
+ schemaOrgMappable: true,
2408
+ schemaOrgConfidence: "medium",
2409
+ minProfile: "minimal",
2410
+ allowedOnMinimal: true
2411
+ },
2412
+ {
2413
+ id: "taxonomy",
2414
+ versions: [1],
2415
+ latest: 1,
2416
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/taxonomy",
2417
+ schema: "http://soustack.org/schema/v0.3.0/stacks/taxonomy",
2418
+ schemaOrgMappable: true,
2419
+ schemaOrgConfidence: "high",
2420
+ minProfile: "minimal",
2421
+ allowedOnMinimal: true
2422
+ },
2423
+ {
2424
+ id: "media",
2425
+ versions: [1],
2426
+ latest: 1,
2427
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/media",
2428
+ schema: "http://soustack.org/schema/v0.3.0/stacks/media",
2429
+ schemaOrgMappable: true,
2430
+ schemaOrgConfidence: "medium",
2431
+ minProfile: "minimal",
2432
+ allowedOnMinimal: true
2433
+ },
2434
+ {
2435
+ id: "nutrition",
2436
+ versions: [1],
2437
+ latest: 1,
2438
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/nutrition",
2439
+ schema: "http://soustack.org/schema/v0.3.0/stacks/nutrition",
2440
+ schemaOrgMappable: false,
2441
+ schemaOrgConfidence: "low",
2442
+ minProfile: "minimal",
2443
+ allowedOnMinimal: true
2444
+ },
2445
+ {
2446
+ id: "times",
2447
+ versions: [1],
2448
+ latest: 1,
2449
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/times",
2450
+ schema: "http://soustack.org/schema/v0.3.0/stacks/times",
2451
+ schemaOrgMappable: true,
2452
+ schemaOrgConfidence: "medium",
2453
+ minProfile: "minimal",
2454
+ allowedOnMinimal: true
2455
+ },
2456
+ {
2457
+ id: "schedule",
2458
+ versions: [1],
2459
+ latest: 1,
2460
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/schedule",
2461
+ schema: "http://soustack.org/schema/v0.3.0/stacks/schedule",
2462
+ schemaOrgMappable: false,
2463
+ schemaOrgConfidence: "low",
2464
+ minProfile: "core",
2465
+ allowedOnMinimal: false
2466
+ }
2467
+ ]
2468
+ };
1668
2469
 
1669
2470
  // src/converters/toSchemaOrg.ts
1670
2471
  function convertBasicMetadata(recipe) {
1671
- var _a2;
2472
+ var _a;
1672
2473
  return cleanOutput({
1673
2474
  "@context": "https://schema.org",
1674
2475
  "@type": "Recipe",
1675
2476
  name: recipe.name,
1676
2477
  description: recipe.description,
1677
2478
  image: recipe.image,
1678
- url: (_a2 = recipe.source) == null ? void 0 : _a2.url,
2479
+ url: (_a = recipe.source) == null ? void 0 : _a.url,
1679
2480
  datePublished: recipe.dateAdded,
1680
2481
  dateModified: recipe.dateModified
1681
2482
  });
@@ -1683,7 +2484,7 @@ function convertBasicMetadata(recipe) {
1683
2484
  function convertIngredients2(ingredients = []) {
1684
2485
  const result = [];
1685
2486
  ingredients.forEach((ingredient) => {
1686
- var _a2;
2487
+ var _a;
1687
2488
  if (!ingredient) {
1688
2489
  return;
1689
2490
  }
@@ -1713,7 +2514,7 @@ function convertIngredients2(ingredients = []) {
1713
2514
  });
1714
2515
  return;
1715
2516
  }
1716
- const value = (_a2 = ingredient.item) == null ? void 0 : _a2.trim();
2517
+ const value = (_a = ingredient.item) == null ? void 0 : _a.trim();
1717
2518
  if (value) {
1718
2519
  result.push(value);
1719
2520
  }
@@ -1748,13 +2549,13 @@ function convertInstruction(entry) {
1748
2549
  return createHowToStep(String(entry));
1749
2550
  }
1750
2551
  function createHowToStep(entry) {
1751
- var _a2;
2552
+ var _a;
1752
2553
  if (!entry) return null;
1753
2554
  if (typeof entry === "string") {
1754
2555
  const trimmed2 = entry.trim();
1755
2556
  return trimmed2 || null;
1756
2557
  }
1757
- const trimmed = (_a2 = entry.text) == null ? void 0 : _a2.trim();
2558
+ const trimmed = (_a = entry.text) == null ? void 0 : _a.trim();
1758
2559
  if (!trimmed) {
1759
2560
  return null;
1760
2561
  }
@@ -1806,6 +2607,22 @@ function convertTime2(time) {
1806
2607
  }
1807
2608
  return result;
1808
2609
  }
2610
+ function convertTimesModule(times) {
2611
+ if (!times) {
2612
+ return {};
2613
+ }
2614
+ const result = {};
2615
+ if (times.prepMinutes !== void 0) {
2616
+ result.prepTime = formatDuration(times.prepMinutes);
2617
+ }
2618
+ if (times.cookMinutes !== void 0) {
2619
+ result.cookTime = formatDuration(times.cookMinutes);
2620
+ }
2621
+ if (times.totalMinutes !== void 0) {
2622
+ result.totalTime = formatDuration(times.totalMinutes);
2623
+ }
2624
+ return result;
2625
+ }
1809
2626
  function convertYield(yld) {
1810
2627
  if (!yld) {
1811
2628
  return void 0;
@@ -1844,33 +2661,65 @@ function convertCategoryTags(category, tags) {
1844
2661
  }
1845
2662
  return result;
1846
2663
  }
1847
- function convertNutrition(nutrition) {
2664
+ function convertNutrition2(nutrition) {
1848
2665
  if (!nutrition) {
1849
2666
  return void 0;
1850
2667
  }
1851
- return {
1852
- ...nutrition,
2668
+ const result = {
1853
2669
  "@type": "NutritionInformation"
1854
2670
  };
2671
+ if (nutrition.calories !== void 0) {
2672
+ if (typeof nutrition.calories === "number") {
2673
+ result.calories = `${nutrition.calories} calories`;
2674
+ } else {
2675
+ result.calories = nutrition.calories;
2676
+ }
2677
+ }
2678
+ Object.keys(nutrition).forEach((key) => {
2679
+ if (key !== "calories" && key !== "@type") {
2680
+ result[key] = nutrition[key];
2681
+ }
2682
+ });
2683
+ return result;
1855
2684
  }
1856
2685
  function cleanOutput(obj) {
1857
2686
  return Object.fromEntries(
1858
2687
  Object.entries(obj).filter(([, value]) => value !== void 0)
1859
2688
  );
1860
2689
  }
2690
+ function getSchemaOrgMappableStacks(stacks = {}) {
2691
+ const mappableStackIds = /* @__PURE__ */ new Set();
2692
+ const mappableFromRegistry = stacks_default.stacks.filter((stack) => stack.schemaOrgMappable).map((stack) => `${stack.id}@${stack.latest}`);
2693
+ for (const [name, version] of Object.entries(stacks)) {
2694
+ const stackId = `${name}@${version}`;
2695
+ if (mappableFromRegistry.includes(stackId)) {
2696
+ mappableStackIds.add(stackId);
2697
+ }
2698
+ }
2699
+ return mappableStackIds;
2700
+ }
1861
2701
  function toSchemaOrg(recipe) {
1862
2702
  const base = convertBasicMetadata(recipe);
1863
2703
  const ingredients = convertIngredients2(recipe.ingredients);
1864
2704
  const instructions = convertInstructions2(recipe.instructions);
1865
- const nutrition = convertNutrition(recipe.nutrition);
2705
+ const recipeStacks = recipe.stacks && typeof recipe.stacks === "object" && !Array.isArray(recipe.stacks) ? recipe.stacks : {};
2706
+ const mappableStacks = getSchemaOrgMappableStacks(recipeStacks);
2707
+ const hasMappableNutrition = mappableStacks.has("nutrition@1");
2708
+ const nutrition = hasMappableNutrition ? convertNutrition2(recipe.nutrition) : void 0;
2709
+ const hasMappableTimes = mappableStacks.has("times@1");
2710
+ const timeData = hasMappableTimes ? recipe.times ? convertTimesModule(recipe.times) : convertTime2(recipe.time) : {};
2711
+ const hasMappableAttribution = mappableStacks.has("attribution@1");
2712
+ const attributionData = hasMappableAttribution ? convertAuthor(recipe.source) : {};
2713
+ const hasMappableTaxonomy = mappableStacks.has("taxonomy@1");
2714
+ const taxonomyData = hasMappableTaxonomy ? convertCategoryTags(recipe.category, recipe.tags) : {};
1866
2715
  return cleanOutput({
1867
2716
  ...base,
1868
2717
  recipeIngredient: ingredients.length ? ingredients : void 0,
1869
2718
  recipeInstructions: instructions.length ? instructions : void 0,
1870
2719
  recipeYield: convertYield(recipe.yield),
1871
- ...convertTime2(recipe.time),
1872
- ...convertAuthor(recipe.source),
1873
- ...convertCategoryTags(recipe.category, recipe.tags),
2720
+ ...timeData,
2721
+ ...attributionData,
2722
+ ...taxonomyData,
1874
2723
  nutrition
1875
2724
  });
1876
2725
  }
@@ -1889,8 +2738,6 @@ function isRecipeNode(value) {
1889
2738
  return false;
1890
2739
  }
1891
2740
  const type = value["@type"];
1892
- fetch("http://127.0.0.1:7243/ingest/7225c3b5-9ac2-4c94-b561-807ca9003b66", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/utils.ts:14", message: "isRecipeNode check", data: { type, typeLower: typeof type === "string" ? type.toLowerCase() : Array.isArray(type) ? type.map((t) => typeof t === "string" ? t.toLowerCase() : t) : void 0, isMatch: typeof type === "string" ? RECIPE_TYPES.has(type.toLowerCase()) : Array.isArray(type) ? type.some((e) => typeof e === "string" && RECIPE_TYPES.has(e.toLowerCase())) : false }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
1893
- });
1894
2741
  if (typeof type === "string") {
1895
2742
  return RECIPE_TYPES.has(type.toLowerCase());
1896
2743
  }
@@ -1928,7 +2775,7 @@ function extractRecipeBrowser(html) {
1928
2775
  return { recipe: null, source: null };
1929
2776
  }
1930
2777
  function extractJsonLdBrowser(html) {
1931
- var _a2;
2778
+ var _a;
1932
2779
  if (typeof globalThis.DOMParser === "undefined") {
1933
2780
  return null;
1934
2781
  }
@@ -1943,7 +2790,7 @@ function extractJsonLdBrowser(html) {
1943
2790
  if (!parsed) return;
1944
2791
  collectCandidates(parsed, candidates);
1945
2792
  });
1946
- return (_a2 = candidates[0]) != null ? _a2 : null;
2793
+ return (_a = candidates[0]) != null ? _a : null;
1947
2794
  }
1948
2795
  function extractMicrodataBrowser(html) {
1949
2796
  if (typeof globalThis.DOMParser === "undefined") {
@@ -1976,8 +2823,8 @@ function extractMicrodataBrowser(html) {
1976
2823
  }
1977
2824
  const instructions = [];
1978
2825
  recipeEl.querySelectorAll('[itemprop="recipeInstructions"]').forEach((el) => {
1979
- var _a2;
1980
- const text = normalizeText(el.getAttribute("content")) || normalizeText(((_a2 = el.querySelector('[itemprop="text"]')) == null ? void 0 : _a2.textContent) || void 0) || normalizeText(el.textContent || void 0);
2826
+ var _a;
2827
+ const text = normalizeText(el.getAttribute("content")) || normalizeText(((_a = el.querySelector('[itemprop="text"]')) == null ? void 0 : _a.textContent) || void 0) || normalizeText(el.textContent || void 0);
1981
2828
  if (text) instructions.push(text);
1982
2829
  });
1983
2830
  if (instructions.length) {
@@ -2019,8 +2866,521 @@ function extractSchemaOrgRecipeFromHTML(html) {
2019
2866
  }
2020
2867
 
2021
2868
  // src/specVersion.ts
2022
- var SOUSTACK_SPEC_VERSION = "0.2.1";
2869
+ var SOUSTACK_SPEC_VERSION = "0.3.0";
2870
+
2871
+ // src/conversion/units.ts
2872
+ var MASS_UNITS = {
2873
+ g: {
2874
+ dimension: "mass",
2875
+ toMetricBase: 1,
2876
+ metricBaseUnit: "g",
2877
+ isMetric: true
2878
+ },
2879
+ kg: {
2880
+ dimension: "mass",
2881
+ toMetricBase: 1e3,
2882
+ metricBaseUnit: "g",
2883
+ isMetric: true
2884
+ },
2885
+ oz: {
2886
+ dimension: "mass",
2887
+ toMetricBase: 28.349523125,
2888
+ metricBaseUnit: "g",
2889
+ isMetric: false
2890
+ },
2891
+ lb: {
2892
+ dimension: "mass",
2893
+ toMetricBase: 453.59237,
2894
+ metricBaseUnit: "g",
2895
+ isMetric: false
2896
+ }
2897
+ };
2898
+ var VOLUME_UNITS = {
2899
+ ml: {
2900
+ dimension: "volume",
2901
+ toMetricBase: 1,
2902
+ metricBaseUnit: "ml",
2903
+ isMetric: true
2904
+ },
2905
+ l: {
2906
+ dimension: "volume",
2907
+ toMetricBase: 1e3,
2908
+ metricBaseUnit: "ml",
2909
+ isMetric: true
2910
+ },
2911
+ tsp: {
2912
+ dimension: "volume",
2913
+ toMetricBase: 4.92892159375,
2914
+ metricBaseUnit: "ml",
2915
+ isMetric: false
2916
+ },
2917
+ tbsp: {
2918
+ dimension: "volume",
2919
+ toMetricBase: 14.78676478125,
2920
+ metricBaseUnit: "ml",
2921
+ isMetric: false
2922
+ },
2923
+ fl_oz: {
2924
+ dimension: "volume",
2925
+ toMetricBase: 29.5735295625,
2926
+ metricBaseUnit: "ml",
2927
+ isMetric: false
2928
+ },
2929
+ cup: {
2930
+ dimension: "volume",
2931
+ toMetricBase: 236.5882365,
2932
+ metricBaseUnit: "ml",
2933
+ isMetric: false
2934
+ },
2935
+ pint: {
2936
+ dimension: "volume",
2937
+ toMetricBase: 473.176473,
2938
+ metricBaseUnit: "ml",
2939
+ isMetric: false
2940
+ },
2941
+ quart: {
2942
+ dimension: "volume",
2943
+ toMetricBase: 946.352946,
2944
+ metricBaseUnit: "ml",
2945
+ isMetric: false
2946
+ },
2947
+ gallon: {
2948
+ dimension: "volume",
2949
+ toMetricBase: 3785.411784,
2950
+ metricBaseUnit: "ml",
2951
+ isMetric: false
2952
+ }
2953
+ };
2954
+ var COUNT_UNITS = {
2955
+ clove: {
2956
+ dimension: "count",
2957
+ toMetricBase: 1,
2958
+ metricBaseUnit: "count",
2959
+ isMetric: true
2960
+ },
2961
+ sprig: {
2962
+ dimension: "count",
2963
+ toMetricBase: 1,
2964
+ metricBaseUnit: "count",
2965
+ isMetric: true
2966
+ },
2967
+ leaf: {
2968
+ dimension: "count",
2969
+ toMetricBase: 1,
2970
+ metricBaseUnit: "count",
2971
+ isMetric: true
2972
+ },
2973
+ pinch: {
2974
+ dimension: "count",
2975
+ toMetricBase: 1,
2976
+ metricBaseUnit: "count",
2977
+ isMetric: true
2978
+ },
2979
+ bottle: {
2980
+ dimension: "count",
2981
+ toMetricBase: 1,
2982
+ metricBaseUnit: "count",
2983
+ isMetric: true
2984
+ },
2985
+ count: {
2986
+ dimension: "count",
2987
+ toMetricBase: 1,
2988
+ metricBaseUnit: "count",
2989
+ isMetric: true
2990
+ }
2991
+ };
2992
+ var UNIT_DEFINITIONS = {
2993
+ ...MASS_UNITS,
2994
+ ...VOLUME_UNITS,
2995
+ ...COUNT_UNITS
2996
+ };
2997
+ function normalizeUnitToken(unit) {
2998
+ var _a;
2999
+ if (!unit) {
3000
+ return null;
3001
+ }
3002
+ const token = unit.trim().toLowerCase().replace(/[\s-]+/g, "_");
3003
+ const canonical = (_a = UNIT_SYNONYMS[token]) != null ? _a : token;
3004
+ return canonical in UNIT_DEFINITIONS ? canonical : null;
3005
+ }
3006
+ var UNIT_SYNONYMS = {
3007
+ teaspoons: "tsp",
3008
+ teaspoon: "tsp",
3009
+ tsps: "tsp",
3010
+ tbsp: "tbsp",
3011
+ tbsps: "tbsp",
3012
+ tablespoon: "tbsp",
3013
+ tablespoons: "tbsp",
3014
+ cup: "cup",
3015
+ cups: "cup",
3016
+ pint: "pint",
3017
+ pints: "pint",
3018
+ quart: "quart",
3019
+ quarts: "quart",
3020
+ gallon: "gallon",
3021
+ gallons: "gallon",
3022
+ ml: "ml",
3023
+ milliliter: "ml",
3024
+ milliliters: "ml",
3025
+ millilitre: "ml",
3026
+ millilitres: "ml",
3027
+ l: "l",
3028
+ liter: "l",
3029
+ liters: "l",
3030
+ litre: "l",
3031
+ litres: "l",
3032
+ fl_oz: "fl_oz",
3033
+ "fl.oz": "fl_oz",
3034
+ "fl.oz.": "fl_oz",
3035
+ "fl_oz.": "fl_oz",
3036
+ "fl oz": "fl_oz",
3037
+ "fl oz.": "fl_oz",
3038
+ fluid_ounce: "fl_oz",
3039
+ fluid_ounces: "fl_oz",
3040
+ oz: "oz",
3041
+ ounce: "oz",
3042
+ ounces: "oz",
3043
+ lb: "lb",
3044
+ lbs: "lb",
3045
+ pound: "lb",
3046
+ pounds: "lb",
3047
+ g: "g",
3048
+ gram: "g",
3049
+ grams: "g",
3050
+ kg: "kg",
3051
+ kilogram: "kg",
3052
+ kilograms: "kg",
3053
+ clove: "clove",
3054
+ cloves: "clove",
3055
+ sprig: "sprig",
3056
+ sprigs: "sprig",
3057
+ leaf: "leaf",
3058
+ leaves: "leaf",
3059
+ pinch: "pinch",
3060
+ pinches: "pinch",
3061
+ bottle: "bottle",
3062
+ bottles: "bottle",
3063
+ count: "count",
3064
+ counts: "count"
3065
+ };
3066
+ function convertToMetricBase(quantity, unit) {
3067
+ const definition = UNIT_DEFINITIONS[unit];
3068
+ const quantityInMetricBase = quantity * definition.toMetricBase;
3069
+ return {
3070
+ quantity: quantityInMetricBase,
3071
+ baseUnit: definition.metricBaseUnit,
3072
+ definition
3073
+ };
3074
+ }
3075
+
3076
+ // src/conversion/convertLineItem.ts
3077
+ var UnknownUnitError = class extends Error {
3078
+ constructor(unit) {
3079
+ super(`Unknown unit "${unit}".`);
3080
+ this.unit = unit;
3081
+ this.name = "UnknownUnitError";
3082
+ }
3083
+ };
3084
+ var UnsupportedConversionError = class extends Error {
3085
+ constructor(unit, mode) {
3086
+ super(`Cannot convert unit "${unit}" in ${mode} mode.`);
3087
+ this.unit = unit;
3088
+ this.mode = mode;
3089
+ this.name = "UnsupportedConversionError";
3090
+ }
3091
+ };
3092
+ var MissingEquivalencyError = class extends Error {
3093
+ constructor(ingredient, unit) {
3094
+ super(
3095
+ `No volume to mass equivalency for "${ingredient}" (${unit}).`
3096
+ );
3097
+ this.ingredient = ingredient;
3098
+ this.unit = unit;
3099
+ this.name = "MissingEquivalencyError";
3100
+ }
3101
+ };
3102
+ var VOLUME_TO_MASS_EQUIV_G_PER_UNIT = {
3103
+ flour: {
3104
+ cup: 120
3105
+ }
3106
+ };
3107
+ var DEFAULT_ROUND_MODE = "sane";
3108
+ function convertLineItemToMetric(item, mode, opts) {
3109
+ var _a, _b, _c, _d;
3110
+ const roundMode = (_a = opts == null ? void 0 : opts.round) != null ? _a : DEFAULT_ROUND_MODE;
3111
+ const normalizedUnit = normalizeUnitToken(item.unit);
3112
+ if (!normalizedUnit) {
3113
+ if (!item.unit || item.unit.trim() === "") {
3114
+ return item;
3115
+ }
3116
+ throw new UnknownUnitError(item.unit);
3117
+ }
3118
+ const definition = UNIT_DEFINITIONS[normalizedUnit];
3119
+ if (definition.dimension === "count") {
3120
+ return item;
3121
+ }
3122
+ if (mode === "volume") {
3123
+ if (definition.dimension !== "volume") {
3124
+ throw new UnsupportedConversionError((_b = item.unit) != null ? _b : "", mode);
3125
+ }
3126
+ const { quantity, unit } = finalizeMetricVolume(
3127
+ convertToMetricBase(item.quantity, normalizedUnit).quantity,
3128
+ roundMode
3129
+ );
3130
+ return {
3131
+ ...item,
3132
+ quantity,
3133
+ unit
3134
+ };
3135
+ }
3136
+ if (definition.dimension === "mass") {
3137
+ const { quantity, unit } = finalizeMetricMass(
3138
+ convertToMetricBase(item.quantity, normalizedUnit).quantity,
3139
+ roundMode
3140
+ );
3141
+ return {
3142
+ ...item,
3143
+ quantity,
3144
+ unit
3145
+ };
3146
+ }
3147
+ if (definition.dimension !== "volume") {
3148
+ throw new UnsupportedConversionError((_c = item.unit) != null ? _c : "", mode);
3149
+ }
3150
+ const gramsPerUnit = lookupEquivalency(
3151
+ item.ingredient,
3152
+ normalizedUnit
3153
+ );
3154
+ if (!gramsPerUnit) {
3155
+ throw new MissingEquivalencyError(item.ingredient, (_d = item.unit) != null ? _d : "");
3156
+ }
3157
+ const grams = item.quantity * gramsPerUnit;
3158
+ const massResult = finalizeMetricMass(grams, roundMode);
3159
+ return {
3160
+ ...item,
3161
+ quantity: massResult.quantity,
3162
+ unit: massResult.unit,
3163
+ notes: `Converted using ${gramsPerUnit}g per ${normalizedUnit} for ${item.ingredient}.`
3164
+ };
3165
+ }
3166
+ function finalizeMetricVolume(milliliters, roundMode) {
3167
+ if (roundMode === "none") {
3168
+ return milliliters >= 1e3 ? { quantity: milliliters / 1e3, unit: "l" } : { quantity: milliliters, unit: "ml" };
3169
+ }
3170
+ const roundedMl = roundMilliliters(milliliters);
3171
+ if (roundedMl >= 1e3) {
3172
+ const liters = roundedMl / 1e3;
3173
+ return {
3174
+ quantity: roundLargeMetric(liters),
3175
+ unit: "l"
3176
+ };
3177
+ }
3178
+ return { quantity: roundedMl, unit: "ml" };
3179
+ }
3180
+ function finalizeMetricMass(grams, roundMode) {
3181
+ if (roundMode === "none") {
3182
+ return grams >= 1e3 ? { quantity: grams / 1e3, unit: "kg" } : { quantity: grams, unit: "g" };
3183
+ }
3184
+ const roundedGrams = roundGrams(grams);
3185
+ if (roundedGrams >= 1e3) {
3186
+ const kilograms = roundedGrams / 1e3;
3187
+ return {
3188
+ quantity: roundLargeMetric(kilograms),
3189
+ unit: "kg"
3190
+ };
3191
+ }
3192
+ return { quantity: roundedGrams, unit: "g" };
3193
+ }
3194
+ function roundGrams(value) {
3195
+ if (value < 1e3) {
3196
+ return Math.round(value);
3197
+ }
3198
+ return Math.round(value / 5) * 5;
3199
+ }
3200
+ function roundMilliliters(value) {
3201
+ if (value < 1e3) {
3202
+ return Math.round(value);
3203
+ }
3204
+ return Math.round(value / 10) * 10;
3205
+ }
3206
+ function roundLargeMetric(value) {
3207
+ return Math.round(value * 100) / 100;
3208
+ }
3209
+ function lookupEquivalency(ingredient, unit) {
3210
+ var _a;
3211
+ const key = ingredient.trim().toLowerCase();
3212
+ return (_a = VOLUME_TO_MASS_EQUIV_G_PER_UNIT[key]) == null ? void 0 : _a[unit];
3213
+ }
3214
+
3215
+ // src/mise-en-place/index.ts
3216
+ function miseEnPlace(ingredients) {
3217
+ const list = Array.isArray(ingredients) ? ingredients : [];
3218
+ const prepGroups = /* @__PURE__ */ new Map();
3219
+ const stateGroups = /* @__PURE__ */ new Map();
3220
+ let measureTask;
3221
+ let otherTask;
3222
+ const ungrouped = [];
3223
+ for (const ingredient of list) {
3224
+ if (!ingredient || typeof ingredient !== "object") continue;
3225
+ const label = deriveIngredientLabel(ingredient);
3226
+ const quantity = normalizeQuantity(ingredient.quantity);
3227
+ const baseNotes = toDisplayString(ingredient.notes);
3228
+ const prepNotes = toDisplayString(ingredient.prep);
3229
+ const isOptional = typeof ingredient.optional === "boolean" ? ingredient.optional : void 0;
3230
+ const buildItem = (extraNotes) => {
3231
+ const item = {
3232
+ ingredient: label
3233
+ };
3234
+ if (quantity) {
3235
+ item.quantity = { ...quantity };
3236
+ }
3237
+ if (typeof isOptional === "boolean") {
3238
+ item.optional = isOptional;
3239
+ }
3240
+ const notes = combineNotes(extraNotes, baseNotes);
3241
+ if (notes) {
3242
+ item.notes = notes;
3243
+ }
3244
+ return item;
3245
+ };
3246
+ let addedToTask = false;
3247
+ let hasPrepGrouping = false;
3248
+ const prepActionKeys = extractNormalizedList(ingredient.prepActions);
3249
+ if (prepActionKeys.length > 0) {
3250
+ hasPrepGrouping = true;
3251
+ for (const actionKey of prepActionKeys) {
3252
+ const task = ensureGroup(prepGroups, actionKey, () => ({
3253
+ category: "prep",
3254
+ action: actionKey,
3255
+ items: []
3256
+ }));
3257
+ task.items.push(buildItem());
3258
+ addedToTask = true;
3259
+ }
3260
+ } else {
3261
+ const singleActionKey = normalizeKey(ingredient.prepAction);
3262
+ if (singleActionKey) {
3263
+ hasPrepGrouping = true;
3264
+ const task = ensureGroup(prepGroups, singleActionKey, () => ({
3265
+ category: "prep",
3266
+ action: singleActionKey,
3267
+ items: []
3268
+ }));
3269
+ task.items.push(buildItem());
3270
+ addedToTask = true;
3271
+ } else if (prepNotes) {
3272
+ otherTask = otherTask != null ? otherTask : { category: "other", items: [] };
3273
+ otherTask.items.push(buildItem(prepNotes));
3274
+ addedToTask = true;
3275
+ }
3276
+ }
3277
+ const formKey = normalizeKey(ingredient.form);
3278
+ const hasStateGrouping = Boolean(formKey);
3279
+ if (formKey) {
3280
+ const task = ensureGroup(stateGroups, formKey, () => ({
3281
+ category: "state",
3282
+ form: formKey,
3283
+ items: []
3284
+ }));
3285
+ task.items.push(buildItem());
3286
+ addedToTask = true;
3287
+ }
3288
+ const shouldMeasure = Boolean(quantity) && !hasPrepGrouping && !hasStateGrouping;
3289
+ if (shouldMeasure) {
3290
+ measureTask = measureTask != null ? measureTask : { category: "measure", items: [] };
3291
+ measureTask.items.push(buildItem());
3292
+ addedToTask = true;
3293
+ }
3294
+ if (!addedToTask) {
3295
+ ungrouped.push(ingredient);
3296
+ }
3297
+ }
3298
+ const tasks = [
3299
+ ...Array.from(prepGroups.values()).sort((a, b) => localeCompare(a.action, b.action)),
3300
+ ...Array.from(stateGroups.values()).sort((a, b) => localeCompare(a.form, b.form))
3301
+ ];
3302
+ if (measureTask) {
3303
+ tasks.push(measureTask);
3304
+ }
3305
+ if (otherTask) {
3306
+ tasks.push(otherTask);
3307
+ }
3308
+ return { tasks, ungrouped };
3309
+ }
3310
+ function deriveIngredientLabel(ingredient) {
3311
+ var _a, _b, _c;
3312
+ return (_c = (_b = (_a = toDisplayString(ingredient.name)) != null ? _a : toDisplayString(ingredient.item)) != null ? _b : toDisplayString(ingredient.id)) != null ? _c : "ingredient";
3313
+ }
3314
+ function extractNormalizedList(values) {
3315
+ if (!Array.isArray(values)) {
3316
+ return [];
3317
+ }
3318
+ const seen = /* @__PURE__ */ new Set();
3319
+ const result = [];
3320
+ for (const value of values) {
3321
+ const key = normalizeKey(value);
3322
+ if (key && !seen.has(key)) {
3323
+ seen.add(key);
3324
+ result.push(key);
3325
+ }
3326
+ }
3327
+ return result;
3328
+ }
3329
+ function normalizeKey(value) {
3330
+ if (typeof value !== "string") {
3331
+ return null;
3332
+ }
3333
+ const trimmed = value.trim().toLowerCase();
3334
+ return trimmed || null;
3335
+ }
3336
+ function toDisplayString(value) {
3337
+ if (typeof value !== "string") {
3338
+ return void 0;
3339
+ }
3340
+ const trimmed = value.trim();
3341
+ return trimmed || void 0;
3342
+ }
3343
+ function combineNotes(...notes) {
3344
+ const cleaned = notes.map((note) => toDisplayString(note != null ? note : void 0)).filter(Boolean);
3345
+ if (cleaned.length === 0) {
3346
+ return void 0;
3347
+ }
3348
+ return cleaned.join(" | ");
3349
+ }
3350
+ function normalizeQuantity(quantity) {
3351
+ if (!quantity || typeof quantity !== "object") {
3352
+ return void 0;
3353
+ }
3354
+ const amount = quantity.amount;
3355
+ if (typeof amount !== "number" || Number.isNaN(amount)) {
3356
+ return void 0;
3357
+ }
3358
+ const normalized = { amount };
3359
+ if ("unit" in quantity) {
3360
+ const unit = quantity.unit;
3361
+ if (typeof unit === "string") {
3362
+ const trimmed = unit.trim();
3363
+ if (trimmed) {
3364
+ normalized.unit = trimmed;
3365
+ }
3366
+ } else if (unit === null) {
3367
+ normalized.unit = null;
3368
+ }
3369
+ }
3370
+ return normalized;
3371
+ }
3372
+ function ensureGroup(map, key, factory) {
3373
+ let task = map.get(key);
3374
+ if (!task) {
3375
+ task = factory();
3376
+ map.set(key, task);
3377
+ }
3378
+ return task;
3379
+ }
3380
+ function localeCompare(left, right) {
3381
+ return (left != null ? left : "").localeCompare(right != null ? right : "");
3382
+ }
2023
3383
 
2024
- export { SOUSTACK_SPEC_VERSION, detectProfiles, extractSchemaOrgRecipeFromHTML, fromSchemaOrg, scaleRecipe, toSchemaOrg, validateRecipe };
3384
+ export { MissingEquivalencyError, SOUSTACK_SPEC_VERSION, UnknownUnitError, UnsupportedConversionError, convertLineItemToMetric, detectProfiles, extractSchemaOrgRecipeFromHTML, fromSchemaOrg, miseEnPlace, normalizeRecipe, scaleRecipe, toSchemaOrg, validateRecipe };
2025
3385
  //# sourceMappingURL=index.mjs.map
2026
3386
  //# sourceMappingURL=index.mjs.map