soustack 0.3.0 → 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.js CHANGED
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
- var Ajv = require('ajv');
3
+ var Ajv2020 = require('ajv/dist/2020');
4
4
  var addFormats = require('ajv-formats');
5
5
 
6
6
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
7
7
 
8
- var Ajv__default = /*#__PURE__*/_interopDefault(Ajv);
8
+ var Ajv2020__default = /*#__PURE__*/_interopDefault(Ajv2020);
9
9
  var addFormats__default = /*#__PURE__*/_interopDefault(addFormats);
10
10
 
11
11
  // src/parsers/duration.ts
@@ -102,17 +102,17 @@ function scaleRecipe(recipe, options = {}) {
102
102
  const orderedIngredients = [];
103
103
  collectIngredients(scaled.ingredients || [], orderedIngredients);
104
104
  orderedIngredients.filter((ing) => {
105
- var _a2;
106
- return (((_a2 = ing.scaling) == null ? void 0 : _a2.type) || "linear") !== "bakers_percentage";
105
+ var _a;
106
+ return (((_a = ing.scaling) == null ? void 0 : _a.type) || "linear") !== "bakers_percentage";
107
107
  }).forEach((ing) => {
108
108
  const key = getIngredientKey(ing);
109
109
  scaledAmounts.set(key, calculateIndependentIngredient(ing, multiplier));
110
110
  });
111
111
  orderedIngredients.filter((ing) => {
112
- var _a2;
113
- return ((_a2 = ing.scaling) == null ? void 0 : _a2.type) === "bakers_percentage";
112
+ var _a;
113
+ return ((_a = ing.scaling) == null ? void 0 : _a.type) === "bakers_percentage";
114
114
  }).forEach((ing) => {
115
- var _a2, _b2;
115
+ var _a, _b;
116
116
  const key = getIngredientKey(ing);
117
117
  const scaling = ing.scaling;
118
118
  if (!(scaling == null ? void 0 : scaling.referenceId)) {
@@ -122,9 +122,9 @@ function scaleRecipe(recipe, options = {}) {
122
122
  if (referenceAmount === void 0) {
123
123
  throw new Error(`Reference ingredient "${scaling.referenceId}" not found for baker's percentage item "${key}"`);
124
124
  }
125
- const baseAmount = ((_a2 = ing.quantity) == null ? void 0 : _a2.amount) || 0;
125
+ const baseAmount = ((_a = ing.quantity) == null ? void 0 : _a.amount) || 0;
126
126
  const referenceBase = baseAmounts.get(scaling.referenceId);
127
- const factor = (_b2 = scaling.factor) != null ? _b2 : referenceBase ? baseAmount / referenceBase : void 0;
127
+ const factor = (_b = scaling.factor) != null ? _b : referenceBase ? baseAmount / referenceBase : void 0;
128
128
  if (factor === void 0) {
129
129
  throw new Error(`Unable to determine factor for baker's percentage ingredient "${key}"`);
130
130
  }
@@ -144,19 +144,19 @@ function scaleRecipe(recipe, options = {}) {
144
144
  return scaled;
145
145
  }
146
146
  function resolveMultiplier(recipe, options) {
147
- var _a2, _b2;
147
+ var _a, _b;
148
148
  if (options.multiplier && options.multiplier > 0) {
149
149
  return options.multiplier;
150
150
  }
151
- if ((_a2 = options.targetYield) == null ? void 0 : _a2.amount) {
152
- const base = ((_b2 = recipe.yield) == null ? void 0 : _b2.amount) || 1;
151
+ if ((_a = options.targetYield) == null ? void 0 : _a.amount) {
152
+ const base = ((_b = recipe.yield) == null ? void 0 : _b.amount) || 1;
153
153
  return options.targetYield.amount / base;
154
154
  }
155
155
  return 1;
156
156
  }
157
157
  function applyYieldScaling(recipe, options, multiplier) {
158
- var _a2, _b2, _c, _d, _e, _f, _g;
159
- const baseAmount = (_b2 = (_a2 = recipe.yield) == null ? void 0 : _a2.amount) != null ? _b2 : 1;
158
+ var _a, _b, _c, _d, _e, _f, _g;
159
+ const baseAmount = (_b = (_a = recipe.yield) == null ? void 0 : _a.amount) != null ? _b : 1;
160
160
  const targetAmount = (_d = (_c = options.targetYield) == null ? void 0 : _c.amount) != null ? _d : baseAmount * multiplier;
161
161
  const unit = (_g = (_e = options.targetYield) == null ? void 0 : _e.unit) != null ? _g : (_f = recipe.yield) == null ? void 0 : _f.unit;
162
162
  if (!recipe.yield && !options.targetYield) return;
@@ -169,9 +169,9 @@ function getIngredientKey(ing) {
169
169
  return ing.id || ing.item;
170
170
  }
171
171
  function calculateIndependentIngredient(ing, multiplier) {
172
- var _a2, _b2, _c, _d, _e, _f;
173
- const baseAmount = ((_a2 = ing.quantity) == null ? void 0 : _a2.amount) || 0;
174
- const type = ((_b2 = ing.scaling) == null ? void 0 : _b2.type) || "linear";
172
+ var _a, _b, _c, _d, _e, _f;
173
+ const baseAmount = ((_a = ing.quantity) == null ? void 0 : _a.amount) || 0;
174
+ const type = ((_b = ing.scaling) == null ? void 0 : _b.type) || "linear";
175
175
  switch (type) {
176
176
  case "fixed":
177
177
  return baseAmount;
@@ -201,12 +201,12 @@ function collectIngredients(items, bucket) {
201
201
  }
202
202
  function collectBaseIngredientAmounts(items, map = /* @__PURE__ */ new Map()) {
203
203
  items.forEach((item) => {
204
- var _a2, _b2;
204
+ var _a, _b;
205
205
  if (typeof item === "string") return;
206
206
  if ("subsection" in item) {
207
207
  collectBaseIngredientAmounts(item.items, map);
208
208
  } else {
209
- map.set(getIngredientKey(item), (_b2 = (_a2 = item.quantity) == null ? void 0 : _a2.amount) != null ? _b2 : 0);
209
+ map.set(getIngredientKey(item), (_b = (_a = item.quantity) == null ? void 0 : _a.amount) != null ? _b : 0);
210
210
  }
211
211
  });
212
212
  return map;
@@ -247,12 +247,680 @@ function toDurationMinutes(duration) {
247
247
  return 0;
248
248
  }
249
249
 
250
+ // src/normalize.ts
251
+ function normalizeRecipe(input) {
252
+ if (!input || typeof input !== "object") {
253
+ throw new Error("Recipe input must be an object");
254
+ }
255
+ const recipe = JSON.parse(JSON.stringify(input));
256
+ const warnings = [];
257
+ const legacyField = ["mod", "ules"].join("");
258
+ if (legacyField in recipe) {
259
+ throw new Error("The legacy field is no longer supported. Use `stacks` instead.");
260
+ }
261
+ normalizeStacks(recipe, warnings);
262
+ if (!recipe.stacks) {
263
+ recipe.stacks = {};
264
+ }
265
+ if (recipe && typeof recipe === "object" && "version" in recipe && !recipe.recipeVersion && typeof recipe.version === "string") {
266
+ recipe.recipeVersion = recipe.version;
267
+ warnings.push("'version' is deprecated; mapped to 'recipeVersion'.");
268
+ }
269
+ normalizeTime(recipe);
270
+ return {
271
+ recipe,
272
+ warnings
273
+ };
274
+ }
275
+ function normalizeStacks(recipe, warnings) {
276
+ let stacks = {};
277
+ if (recipe.stacks && typeof recipe.stacks === "object" && !Array.isArray(recipe.stacks)) {
278
+ for (const [key, value] of Object.entries(recipe.stacks)) {
279
+ if (typeof value === "number" && Number.isInteger(value) && value >= 1) {
280
+ stacks[key] = value;
281
+ } else {
282
+ warnings.push(`Invalid stack version for '${key}': expected positive integer, got ${value}`);
283
+ }
284
+ }
285
+ }
286
+ if (Array.isArray(recipe.stacks)) {
287
+ const stackIdentifiers = recipe.stacks.filter((s) => typeof s === "string");
288
+ for (const identifier of stackIdentifiers) {
289
+ const parsed = parseStackIdentifier(identifier);
290
+ if (parsed) {
291
+ const { name, version } = parsed;
292
+ if (!stacks[name] || stacks[name] < version) {
293
+ stacks[name] = version;
294
+ }
295
+ } else {
296
+ warnings.push(`Invalid stack identifier '${identifier}': expected format 'name@version' (e.g., 'scaling@1')`);
297
+ }
298
+ }
299
+ }
300
+ recipe.stacks = stacks;
301
+ }
302
+ function parseStackIdentifier(identifier) {
303
+ if (typeof identifier !== "string" || !identifier.trim()) {
304
+ return null;
305
+ }
306
+ const match = identifier.trim().match(/^([a-z0-9_-]+)@(\d+)$/i);
307
+ if (!match) {
308
+ return null;
309
+ }
310
+ const [, name, versionStr] = match;
311
+ const version = parseInt(versionStr, 10);
312
+ if (isNaN(version) || version < 1) {
313
+ return null;
314
+ }
315
+ return { name, version };
316
+ }
317
+ function normalizeTime(recipe) {
318
+ const time = recipe == null ? void 0 : recipe.time;
319
+ if (!time || typeof time !== "object" || Array.isArray(time)) return;
320
+ const structuredKeys = [
321
+ "prep",
322
+ "active",
323
+ "passive",
324
+ "total"
325
+ ];
326
+ structuredKeys.forEach((key) => {
327
+ const value = time[key];
328
+ if (typeof value === "number") return;
329
+ const parsed = parseDuration(value);
330
+ if (parsed !== null) {
331
+ time[key] = parsed;
332
+ }
333
+ });
334
+ }
335
+
336
+ // src/conformance/index.ts
337
+ function validateConformance(recipe) {
338
+ const issues = [];
339
+ issues.push(...checkDAGValidity(recipe));
340
+ if (hasSchedulableProfile(recipe)) {
341
+ issues.push(...checkTimingSchedulability(recipe));
342
+ }
343
+ issues.push(...checkScalingSanity(recipe));
344
+ const ok = issues.filter((i) => i.severity === "error").length === 0;
345
+ return { ok, issues };
346
+ }
347
+ function hasSchedulableProfile(recipe) {
348
+ const schema = recipe.$schema;
349
+ if (typeof schema === "string") {
350
+ return schema.includes("schedulable") || schema === "http://soustack.org/schema/v0.3.0/profiles/schedulable";
351
+ }
352
+ return false;
353
+ }
354
+ function checkDAGValidity(recipe) {
355
+ const issues = [];
356
+ const instructions = recipe.instructions;
357
+ if (!Array.isArray(instructions)) {
358
+ return issues;
359
+ }
360
+ const instructionIds = /* @__PURE__ */ new Set();
361
+ const dependencyRefs = [];
362
+ const collect = (items, basePath) => {
363
+ items.forEach((item, index) => {
364
+ const currentPath = `${basePath}/${index}`;
365
+ if (isInstructionSubsection(item)) {
366
+ if (Array.isArray(item.items)) {
367
+ collect(item.items, `${currentPath}/items`);
368
+ }
369
+ return;
370
+ }
371
+ if (isInstruction(item)) {
372
+ const id = typeof item.id === "string" ? item.id : void 0;
373
+ if (id) {
374
+ instructionIds.add(id);
375
+ }
376
+ if (Array.isArray(item.dependsOn)) {
377
+ item.dependsOn.forEach((depId, depIndex) => {
378
+ if (typeof depId === "string") {
379
+ dependencyRefs.push({
380
+ fromId: id,
381
+ toId: depId,
382
+ path: `${currentPath}/dependsOn/${depIndex}`
383
+ });
384
+ }
385
+ });
386
+ }
387
+ }
388
+ });
389
+ };
390
+ collect(instructions, "/instructions");
391
+ dependencyRefs.forEach((ref) => {
392
+ if (!instructionIds.has(ref.toId)) {
393
+ issues.push({
394
+ code: "DAG_MISSING_NODE",
395
+ path: ref.path,
396
+ message: `Instruction dependency references missing step id '${ref.toId}'.`,
397
+ severity: "error"
398
+ });
399
+ }
400
+ });
401
+ const adjacency = /* @__PURE__ */ new Map();
402
+ dependencyRefs.forEach((ref) => {
403
+ var _a;
404
+ if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
405
+ const list = (_a = adjacency.get(ref.fromId)) != null ? _a : [];
406
+ list.push({ toId: ref.toId, path: ref.path });
407
+ adjacency.set(ref.fromId, list);
408
+ }
409
+ });
410
+ const visiting = /* @__PURE__ */ new Set();
411
+ const visited = /* @__PURE__ */ new Set();
412
+ const detectCycles = (nodeId) => {
413
+ var _a;
414
+ if (visiting.has(nodeId)) {
415
+ return;
416
+ }
417
+ if (visited.has(nodeId)) {
418
+ return;
419
+ }
420
+ visiting.add(nodeId);
421
+ const neighbors = (_a = adjacency.get(nodeId)) != null ? _a : [];
422
+ neighbors.forEach((edge) => {
423
+ if (visiting.has(edge.toId)) {
424
+ issues.push({
425
+ code: "DAG_CYCLE",
426
+ path: edge.path,
427
+ message: `Circular dependency detected involving step id '${edge.toId}'.`,
428
+ severity: "error"
429
+ });
430
+ return;
431
+ }
432
+ detectCycles(edge.toId);
433
+ });
434
+ visiting.delete(nodeId);
435
+ visited.add(nodeId);
436
+ };
437
+ instructionIds.forEach((id) => detectCycles(id));
438
+ return issues;
439
+ }
440
+ function checkTimingSchedulability(recipe) {
441
+ const issues = [];
442
+ const instructions = recipe.instructions;
443
+ if (!Array.isArray(instructions)) {
444
+ return issues;
445
+ }
446
+ const checkInstruction = (item, path) => {
447
+ if (isInstructionSubsection(item)) {
448
+ if (Array.isArray(item.items)) {
449
+ item.items.forEach((subItem, index) => {
450
+ checkInstruction(subItem, `${path}/items/${index}`);
451
+ });
452
+ }
453
+ return;
454
+ }
455
+ if (isInstruction(item)) {
456
+ if (!item.id) {
457
+ issues.push({
458
+ code: "SCHEDULABLE_MISSING_ID",
459
+ path,
460
+ message: "Schedulable profile requires all instructions to have an id.",
461
+ severity: "error"
462
+ });
463
+ }
464
+ if (!item.timing) {
465
+ issues.push({
466
+ code: "SCHEDULABLE_MISSING_TIMING",
467
+ path,
468
+ message: "Schedulable profile requires all instructions to have timing information.",
469
+ severity: "error"
470
+ });
471
+ } else if (!item.timing.duration) {
472
+ issues.push({
473
+ code: "SCHEDULABLE_MISSING_DURATION",
474
+ path: `${path}/timing`,
475
+ message: "Schedulable profile requires timing.duration for all instructions.",
476
+ severity: "error"
477
+ });
478
+ }
479
+ }
480
+ };
481
+ instructions.forEach((item, index) => {
482
+ checkInstruction(item, `/instructions/${index}`);
483
+ });
484
+ return issues;
485
+ }
486
+ function checkScalingSanity(recipe) {
487
+ const issues = [];
488
+ const ingredients = recipe.ingredients;
489
+ if (!Array.isArray(ingredients)) {
490
+ return issues;
491
+ }
492
+ const ingredientIds = /* @__PURE__ */ new Set();
493
+ const collectIngredientIds = (items, basePath) => {
494
+ items.forEach((item, index) => {
495
+ if (isIngredientSubsection(item)) {
496
+ if (Array.isArray(item.items)) {
497
+ collectIngredientIds(item.items);
498
+ }
499
+ return;
500
+ }
501
+ if (isIngredient(item)) {
502
+ if (typeof item.id === "string") {
503
+ ingredientIds.add(item.id);
504
+ }
505
+ }
506
+ });
507
+ };
508
+ collectIngredientIds(ingredients);
509
+ const checkIngredient = (item, path) => {
510
+ if (isIngredientSubsection(item)) {
511
+ if (Array.isArray(item.items)) {
512
+ item.items.forEach((subItem, index) => {
513
+ checkIngredient(subItem, `${path}/items/${index}`);
514
+ });
515
+ }
516
+ return;
517
+ }
518
+ if (isIngredient(item)) {
519
+ const scaling = item.scaling;
520
+ if (scaling && typeof scaling === "object" && "type" in scaling && scaling.type === "bakers_percentage") {
521
+ const bakersScaling = scaling;
522
+ if (bakersScaling.referenceId) {
523
+ if (!ingredientIds.has(bakersScaling.referenceId)) {
524
+ issues.push({
525
+ code: "SCALING_INVALID_REFERENCE",
526
+ path: `${path}/scaling/referenceId`,
527
+ message: `Baker's percentage references missing ingredient id '${bakersScaling.referenceId}'.`,
528
+ severity: "error"
529
+ });
530
+ }
531
+ } else {
532
+ issues.push({
533
+ code: "SCALING_MISSING_REFERENCE",
534
+ path: `${path}/scaling`,
535
+ message: "Baker's percentage scaling requires a referenceId.",
536
+ severity: "error"
537
+ });
538
+ }
539
+ }
540
+ }
541
+ };
542
+ ingredients.forEach((item, index) => {
543
+ checkIngredient(item, `/ingredients/${index}`);
544
+ });
545
+ return issues;
546
+ }
547
+ function isInstruction(item) {
548
+ return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
549
+ }
550
+ function isInstructionSubsection(item) {
551
+ return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
552
+ }
553
+ function isIngredient(item) {
554
+ return item && typeof item === "object" && !Array.isArray(item) && "item" in item;
555
+ }
556
+ function isIngredientSubsection(item) {
557
+ return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
558
+ }
559
+
560
+ // src/soustack.schema.json
561
+ var soustack_schema_default = {
562
+ $schema: "http://json-schema.org/draft-07/schema#",
563
+ $id: "http://soustack.org/schema/v0.3.0",
564
+ title: "Soustack Recipe Schema v0.3.0",
565
+ description: "A portable, scalable, interoperable recipe format.",
566
+ type: "object",
567
+ required: ["name", "ingredients", "instructions"],
568
+ additionalProperties: false,
569
+ patternProperties: {
570
+ "^x-": {}
571
+ },
572
+ properties: {
573
+ $schema: {
574
+ type: "string",
575
+ format: "uri",
576
+ description: "Optional schema hint for tooling compatibility"
577
+ },
578
+ id: {
579
+ type: "string",
580
+ description: "Unique identifier (slug or UUID)"
581
+ },
582
+ name: {
583
+ type: "string",
584
+ description: "The title of the recipe"
585
+ },
586
+ title: {
587
+ type: "string",
588
+ description: "Optional display title; alias for name"
589
+ },
590
+ version: {
591
+ type: "string",
592
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
593
+ description: "DEPRECATED: use recipeVersion for authoring revisions"
594
+ },
595
+ recipeVersion: {
596
+ type: "string",
597
+ pattern: "^\\d+\\.\\d+\\.\\d+$",
598
+ description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
599
+ },
600
+ description: {
601
+ type: "string"
602
+ },
603
+ category: {
604
+ type: "string",
605
+ examples: ["Main Course", "Dessert"]
606
+ },
607
+ tags: {
608
+ type: "array",
609
+ items: { type: "string" }
610
+ },
611
+ image: {
612
+ description: "Recipe-level hero image(s)",
613
+ anyOf: [
614
+ {
615
+ type: "string",
616
+ format: "uri"
617
+ },
618
+ {
619
+ type: "array",
620
+ minItems: 1,
621
+ items: {
622
+ type: "string",
623
+ format: "uri"
624
+ }
625
+ }
626
+ ]
627
+ },
628
+ dateAdded: {
629
+ type: "string",
630
+ format: "date-time"
631
+ },
632
+ metadata: {
633
+ type: "object",
634
+ additionalProperties: true,
635
+ description: "Free-form vendor metadata"
636
+ },
637
+ source: {
638
+ type: "object",
639
+ properties: {
640
+ author: { type: "string" },
641
+ url: { type: "string", format: "uri" },
642
+ name: { type: "string" },
643
+ adapted: { type: "boolean" }
644
+ }
645
+ },
646
+ yield: {
647
+ $ref: "#/definitions/yield"
648
+ },
649
+ time: {
650
+ $ref: "#/definitions/time"
651
+ },
652
+ equipment: {
653
+ type: "array",
654
+ items: { $ref: "#/definitions/equipment" }
655
+ },
656
+ ingredients: {
657
+ type: "array",
658
+ items: {
659
+ anyOf: [
660
+ { type: "string" },
661
+ { $ref: "#/definitions/ingredient" },
662
+ { $ref: "#/definitions/ingredientSubsection" }
663
+ ]
664
+ }
665
+ },
666
+ instructions: {
667
+ type: "array",
668
+ items: {
669
+ anyOf: [
670
+ { type: "string" },
671
+ { $ref: "#/definitions/instruction" },
672
+ { $ref: "#/definitions/instructionSubsection" }
673
+ ]
674
+ }
675
+ },
676
+ storage: {
677
+ $ref: "#/definitions/storage"
678
+ },
679
+ substitutions: {
680
+ type: "array",
681
+ items: { $ref: "#/definitions/substitution" }
682
+ }
683
+ },
684
+ definitions: {
685
+ yield: {
686
+ type: "object",
687
+ required: ["amount", "unit"],
688
+ properties: {
689
+ amount: { type: "number" },
690
+ unit: { type: "string" },
691
+ servings: { type: "number" },
692
+ description: { type: "string" }
693
+ }
694
+ },
695
+ time: {
696
+ type: "object",
697
+ properties: {
698
+ prep: { type: "number" },
699
+ active: { type: "number" },
700
+ passive: { type: "number" },
701
+ total: { type: "number" },
702
+ prepTime: { type: "string", format: "duration" },
703
+ cookTime: { type: "string", format: "duration" }
704
+ },
705
+ minProperties: 1
706
+ },
707
+ quantity: {
708
+ type: "object",
709
+ required: ["amount"],
710
+ properties: {
711
+ amount: { type: "number" },
712
+ unit: {
713
+ type: ["string", "null"],
714
+ description: "Display-friendly unit text; implementations may normalize or canonicalize units separately."
715
+ }
716
+ }
717
+ },
718
+ scaling: {
719
+ type: "object",
720
+ required: ["type"],
721
+ properties: {
722
+ type: {
723
+ type: "string",
724
+ enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
725
+ },
726
+ factor: { type: "number" },
727
+ referenceId: { type: "string" },
728
+ roundTo: { type: "number" },
729
+ min: { type: "number" },
730
+ max: { type: "number" }
731
+ },
732
+ if: {
733
+ properties: { type: { const: "bakers_percentage" } }
734
+ },
735
+ then: {
736
+ required: ["referenceId"]
737
+ }
738
+ },
739
+ ingredient: {
740
+ type: "object",
741
+ required: ["item"],
742
+ properties: {
743
+ id: { type: "string" },
744
+ item: { type: "string" },
745
+ quantity: { $ref: "#/definitions/quantity" },
746
+ name: { type: "string" },
747
+ aisle: { type: "string" },
748
+ prep: { type: "string" },
749
+ prepAction: { type: "string" },
750
+ prepActions: {
751
+ type: "array",
752
+ items: { type: "string" },
753
+ description: "Structured prep verbs (e.g., peel, dice) for mise en place workflows."
754
+ },
755
+ prepTime: { type: "number" },
756
+ form: {
757
+ type: "string",
758
+ description: "State of the ingredient as used (packed, sifted, melted, room_temperature, etc.)."
759
+ },
760
+ destination: { type: "string" },
761
+ scaling: { $ref: "#/definitions/scaling" },
762
+ critical: { type: "boolean" },
763
+ optional: { type: "boolean" },
764
+ notes: { type: "string" }
765
+ }
766
+ },
767
+ ingredientSubsection: {
768
+ type: "object",
769
+ required: ["subsection", "items"],
770
+ properties: {
771
+ subsection: { type: "string" },
772
+ items: {
773
+ type: "array",
774
+ items: { $ref: "#/definitions/ingredient" }
775
+ }
776
+ }
777
+ },
778
+ equipment: {
779
+ type: "object",
780
+ required: ["name"],
781
+ properties: {
782
+ id: { type: "string" },
783
+ name: { type: "string" },
784
+ required: { type: "boolean" },
785
+ label: { type: "string" },
786
+ capacity: { $ref: "#/definitions/quantity" },
787
+ scalingLimit: { type: "number" },
788
+ alternatives: {
789
+ type: "array",
790
+ items: { type: "string" }
791
+ }
792
+ }
793
+ },
794
+ instruction: {
795
+ type: "object",
796
+ required: ["text"],
797
+ properties: {
798
+ id: { type: "string" },
799
+ text: { type: "string" },
800
+ image: {
801
+ type: "string",
802
+ format: "uri",
803
+ description: "Optional image that illustrates this instruction"
804
+ },
805
+ destination: { type: "string" },
806
+ dependsOn: {
807
+ type: "array",
808
+ items: { type: "string" }
809
+ },
810
+ inputs: {
811
+ type: "array",
812
+ items: { type: "string" }
813
+ },
814
+ timing: {
815
+ type: "object",
816
+ required: ["duration", "type"],
817
+ properties: {
818
+ duration: {
819
+ anyOf: [
820
+ { type: "number" },
821
+ { type: "string", pattern: "^P" }
822
+ ],
823
+ description: "Minutes as a number or ISO8601 duration string"
824
+ },
825
+ type: { type: "string", enum: ["active", "passive"] },
826
+ scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
827
+ }
828
+ }
829
+ }
830
+ },
831
+ instructionSubsection: {
832
+ type: "object",
833
+ required: ["subsection", "items"],
834
+ properties: {
835
+ subsection: { type: "string" },
836
+ items: {
837
+ type: "array",
838
+ items: {
839
+ anyOf: [
840
+ { type: "string" },
841
+ { $ref: "#/definitions/instruction" }
842
+ ]
843
+ }
844
+ }
845
+ }
846
+ },
847
+ storage: {
848
+ type: "object",
849
+ properties: {
850
+ roomTemp: { $ref: "#/definitions/storageMethod" },
851
+ refrigerated: { $ref: "#/definitions/storageMethod" },
852
+ frozen: {
853
+ allOf: [
854
+ { $ref: "#/definitions/storageMethod" },
855
+ {
856
+ type: "object",
857
+ properties: { thawing: { type: "string" } }
858
+ }
859
+ ]
860
+ },
861
+ reheating: { type: "string" },
862
+ makeAhead: {
863
+ type: "array",
864
+ items: {
865
+ allOf: [
866
+ { $ref: "#/definitions/storageMethod" },
867
+ {
868
+ type: "object",
869
+ required: ["component", "storage"],
870
+ properties: {
871
+ component: { type: "string" },
872
+ storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
873
+ }
874
+ }
875
+ ]
876
+ }
877
+ }
878
+ }
879
+ },
880
+ storageMethod: {
881
+ type: "object",
882
+ required: ["duration"],
883
+ properties: {
884
+ duration: { type: "string", pattern: "^P" },
885
+ method: { type: "string" },
886
+ notes: { type: "string" }
887
+ }
888
+ },
889
+ substitution: {
890
+ type: "object",
891
+ required: ["ingredient"],
892
+ properties: {
893
+ ingredient: { type: "string" },
894
+ critical: { type: "boolean" },
895
+ notes: { type: "string" },
896
+ alternatives: {
897
+ type: "array",
898
+ items: {
899
+ type: "object",
900
+ required: ["name", "ratio"],
901
+ properties: {
902
+ name: { type: "string" },
903
+ ratio: { type: "string" },
904
+ notes: { type: "string" },
905
+ impact: { type: "string" },
906
+ dietary: {
907
+ type: "array",
908
+ items: { type: "string" }
909
+ }
910
+ }
911
+ }
912
+ }
913
+ }
914
+ }
915
+ }
916
+ };
917
+
250
918
  // src/schemas/recipe/base.schema.json
251
919
  var base_schema_default = {
252
920
  $schema: "http://json-schema.org/draft-07/schema#",
253
921
  $id: "http://soustack.org/schema/recipe/base.schema.json",
254
922
  title: "Soustack Recipe Base Schema",
255
- description: "Base document shape for Soustack recipe documents. Profiles and modules build on this baseline.",
923
+ description: "Base document shape for Soustack recipe documents. Profiles and stacks build on this baseline.",
256
924
  type: "object",
257
925
  additionalProperties: true,
258
926
  properties: {
@@ -264,11 +932,12 @@ var base_schema_default = {
264
932
  type: "string",
265
933
  description: "Profile identifier applied to this recipe"
266
934
  },
267
- modules: {
268
- type: "array",
269
- description: "List of module identifiers applied to this recipe",
270
- items: {
271
- type: "string"
935
+ stacks: {
936
+ type: "object",
937
+ description: "Stack declarations as a map: Record<stackName, versionNumber>",
938
+ additionalProperties: {
939
+ type: "integer",
940
+ minimum: 1
272
941
  }
273
942
  },
274
943
  name: {
@@ -277,50 +946,22 @@ var base_schema_default = {
277
946
  },
278
947
  ingredients: {
279
948
  type: "array",
280
- description: "Ingredients payload; content is validated by profiles/modules"
949
+ description: "Ingredients payload; content is validated by profiles/stacks"
281
950
  },
282
951
  instructions: {
283
952
  type: "array",
284
- description: "Instruction payload; content is validated by profiles/modules"
953
+ description: "Instruction payload; content is validated by profiles/stacks"
285
954
  }
286
955
  },
287
956
  required: ["@type"]
288
957
  };
289
958
 
290
- // src/schemas/recipe/profiles/core.schema.json
291
- var core_schema_default = {
292
- $schema: "http://json-schema.org/draft-07/schema#",
293
- $id: "http://soustack.org/schema/recipe/profiles/core.schema.json",
294
- title: "Soustack Recipe Core Profile",
295
- description: "Core profile that builds on the minimal profile and is intended to be combined with recipe modules.",
296
- allOf: [
297
- { $ref: "http://soustack.org/schema/recipe/base.schema.json" },
298
- {
299
- type: "object",
300
- properties: {
301
- profile: { const: "core" },
302
- modules: {
303
- type: "array",
304
- items: { type: "string" },
305
- uniqueItems: true,
306
- default: []
307
- },
308
- name: { type: "string", minLength: 1 },
309
- ingredients: { type: "array", minItems: 1 },
310
- instructions: { type: "array", minItems: 1 }
311
- },
312
- required: ["profile", "name", "ingredients", "instructions"],
313
- additionalProperties: true
314
- }
315
- ]
316
- };
317
-
318
959
  // src/schemas/recipe/profiles/minimal.schema.json
319
960
  var minimal_schema_default = {
320
961
  $schema: "http://json-schema.org/draft-07/schema#",
321
962
  $id: "http://soustack.org/schema/recipe/profiles/minimal.schema.json",
322
963
  title: "Soustack Recipe Minimal Profile",
323
- description: "Minimal profile that ensures the basic Recipe structure is present while allowing modules to extend it.",
964
+ description: "Minimal profile that ensures the basic Recipe structure is present while allowing stacks to extend it.",
324
965
  allOf: [
325
966
  {
326
967
  $ref: "http://soustack.org/schema/recipe/base.schema.json"
@@ -331,19 +972,19 @@ var minimal_schema_default = {
331
972
  profile: {
332
973
  const: "minimal"
333
974
  },
334
- modules: {
335
- type: "array",
336
- items: {
337
- type: "string",
338
- enum: [
339
- "attribution@1",
340
- "taxonomy@1",
341
- "media@1",
342
- "nutrition@1",
343
- "times@1"
344
- ]
975
+ stacks: {
976
+ type: "object",
977
+ additionalProperties: {
978
+ type: "integer",
979
+ minimum: 1
345
980
  },
346
- default: []
981
+ properties: {
982
+ attribution: { type: "integer", minimum: 1 },
983
+ taxonomy: { type: "integer", minimum: 1 },
984
+ media: { type: "integer", minimum: 1 },
985
+ nutrition: { type: "integer", minimum: 1 },
986
+ times: { type: "integer", minimum: 1 }
987
+ }
347
988
  },
348
989
  name: {
349
990
  type: "string",
@@ -369,23 +1010,304 @@ var minimal_schema_default = {
369
1010
  ]
370
1011
  };
371
1012
 
372
- // src/schemas/recipe/modules/schedule/1.schema.json
1013
+ // src/schemas/recipe/profiles/core.schema.json
1014
+ var core_schema_default = {
1015
+ $schema: "http://json-schema.org/draft-07/schema#",
1016
+ $id: "http://soustack.org/schema/recipe/profiles/core.schema.json",
1017
+ title: "Soustack Recipe Core Profile",
1018
+ description: "Core profile that builds on the minimal profile and is intended to be combined with recipe stacks.",
1019
+ allOf: [
1020
+ { $ref: "http://soustack.org/schema/recipe/base.schema.json" },
1021
+ {
1022
+ type: "object",
1023
+ properties: {
1024
+ profile: { const: "core" },
1025
+ stacks: {
1026
+ type: "object",
1027
+ additionalProperties: {
1028
+ type: "integer",
1029
+ minimum: 1
1030
+ }
1031
+ },
1032
+ name: { type: "string", minLength: 1 },
1033
+ ingredients: { type: "array", minItems: 1 },
1034
+ instructions: { type: "array", minItems: 1 }
1035
+ },
1036
+ required: ["profile", "name", "ingredients", "instructions"],
1037
+ additionalProperties: true
1038
+ }
1039
+ ]
1040
+ };
1041
+
1042
+ // spec/profiles/base.schema.json
1043
+ var base_schema_default2 = {
1044
+ $schema: "http://json-schema.org/draft-07/schema#",
1045
+ $id: "http://soustack.org/schema/v0.3.0/profiles/base",
1046
+ title: "Soustack Base Profile Schema",
1047
+ description: "Wrapper schema that exposes the unmodified Soustack base schema.",
1048
+ allOf: [
1049
+ { $ref: "http://soustack.org/schema/v0.3.0" }
1050
+ ]
1051
+ };
1052
+
1053
+ // spec/profiles/cookable.schema.json
1054
+ var cookable_schema_default = {
1055
+ $schema: "http://json-schema.org/draft-07/schema#",
1056
+ $id: "http://soustack.org/schema/v0.3.0/profiles/cookable",
1057
+ title: "Soustack Cookable Profile Schema",
1058
+ description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
1059
+ allOf: [
1060
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1061
+ {
1062
+ required: ["yield", "time", "ingredients", "instructions"],
1063
+ properties: {
1064
+ yield: { $ref: "http://soustack.org/schema/v0.3.0#/definitions/yield" },
1065
+ time: { $ref: "http://soustack.org/schema/v0.3.0#/definitions/time" },
1066
+ ingredients: { type: "array", minItems: 1 },
1067
+ instructions: { type: "array", minItems: 1 }
1068
+ }
1069
+ }
1070
+ ]
1071
+ };
1072
+
1073
+ // spec/profiles/illustrated.schema.json
1074
+ var illustrated_schema_default = {
1075
+ $schema: "http://json-schema.org/draft-07/schema#",
1076
+ $id: "http://soustack.org/schema/v0.3.0/profiles/illustrated",
1077
+ title: "Soustack Illustrated Profile Schema",
1078
+ description: "Extends the base schema to guarantee at least one illustrative image.",
1079
+ allOf: [
1080
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1081
+ {
1082
+ anyOf: [
1083
+ { required: ["image"] },
1084
+ {
1085
+ properties: {
1086
+ instructions: {
1087
+ type: "array",
1088
+ contains: {
1089
+ anyOf: [
1090
+ { $ref: "#/definitions/imageInstruction" },
1091
+ { $ref: "#/definitions/instructionSubsectionWithImage" }
1092
+ ]
1093
+ }
1094
+ }
1095
+ }
1096
+ }
1097
+ ]
1098
+ }
1099
+ ],
1100
+ definitions: {
1101
+ imageInstruction: {
1102
+ allOf: [
1103
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instruction" },
1104
+ { required: ["image"] }
1105
+ ]
1106
+ },
1107
+ instructionSubsectionWithImage: {
1108
+ allOf: [
1109
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instructionSubsection" },
1110
+ {
1111
+ properties: {
1112
+ items: {
1113
+ type: "array",
1114
+ contains: { $ref: "#/definitions/imageInstruction" }
1115
+ }
1116
+ }
1117
+ }
1118
+ ]
1119
+ }
1120
+ }
1121
+ };
1122
+
1123
+ // spec/profiles/quantified.schema.json
1124
+ var quantified_schema_default = {
1125
+ $schema: "http://json-schema.org/draft-07/schema#",
1126
+ $id: "http://soustack.org/schema/v0.3.0/profiles/quantified",
1127
+ title: "Soustack Quantified Profile Schema",
1128
+ description: "Extends the base schema to require quantified ingredient entries.",
1129
+ allOf: [
1130
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1131
+ {
1132
+ properties: {
1133
+ ingredients: {
1134
+ type: "array",
1135
+ items: {
1136
+ anyOf: [
1137
+ { $ref: "#/definitions/quantifiedIngredient" },
1138
+ { $ref: "#/definitions/quantifiedIngredientSubsection" }
1139
+ ]
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+ ],
1145
+ definitions: {
1146
+ quantifiedIngredient: {
1147
+ allOf: [
1148
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredient" },
1149
+ { required: ["item", "quantity"] }
1150
+ ]
1151
+ },
1152
+ quantifiedIngredientSubsection: {
1153
+ allOf: [
1154
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredientSubsection" },
1155
+ {
1156
+ properties: {
1157
+ items: {
1158
+ type: "array",
1159
+ items: { $ref: "#/definitions/quantifiedIngredient" }
1160
+ }
1161
+ }
1162
+ }
1163
+ ]
1164
+ }
1165
+ }
1166
+ };
1167
+
1168
+ // spec/profiles/scalable.schema.json
1169
+ var scalable_schema_default = {
1170
+ $schema: "http://json-schema.org/draft-07/schema#",
1171
+ $id: "http://soustack.org/schema/v0.3.0/profiles/scalable",
1172
+ title: "Soustack Scalable Profile Schema",
1173
+ description: "Extends the base schema to guarantee quantified ingredients plus a structured yield for deterministic scaling.",
1174
+ allOf: [
1175
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1176
+ {
1177
+ required: ["yield", "ingredients"],
1178
+ properties: {
1179
+ yield: {
1180
+ allOf: [
1181
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/yield" },
1182
+ { properties: { amount: { type: "number", exclusiveMinimum: 0 } } }
1183
+ ]
1184
+ },
1185
+ ingredients: {
1186
+ type: "array",
1187
+ minItems: 1,
1188
+ items: {
1189
+ anyOf: [
1190
+ { $ref: "#/definitions/scalableIngredient" },
1191
+ { $ref: "#/definitions/scalableIngredientSubsection" }
1192
+ ]
1193
+ }
1194
+ }
1195
+ }
1196
+ }
1197
+ ],
1198
+ definitions: {
1199
+ scalableIngredient: {
1200
+ allOf: [
1201
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredient" },
1202
+ { required: ["item", "quantity"] },
1203
+ {
1204
+ properties: {
1205
+ quantity: {
1206
+ allOf: [
1207
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/quantity" },
1208
+ { properties: { amount: { type: "number", exclusiveMinimum: 0 } } }
1209
+ ]
1210
+ }
1211
+ }
1212
+ },
1213
+ {
1214
+ if: {
1215
+ properties: {
1216
+ scaling: {
1217
+ type: "object",
1218
+ properties: { type: { const: "bakers_percentage" } },
1219
+ required: ["type"]
1220
+ }
1221
+ },
1222
+ required: ["scaling"]
1223
+ },
1224
+ then: { required: ["id"] }
1225
+ }
1226
+ ]
1227
+ },
1228
+ scalableIngredientSubsection: {
1229
+ allOf: [
1230
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredientSubsection" },
1231
+ {
1232
+ properties: {
1233
+ items: {
1234
+ type: "array",
1235
+ minItems: 1,
1236
+ items: { $ref: "#/definitions/scalableIngredient" }
1237
+ }
1238
+ }
1239
+ }
1240
+ ]
1241
+ }
1242
+ }
1243
+ };
1244
+
1245
+ // spec/profiles/schedulable.schema.json
1246
+ var schedulable_schema_default = {
1247
+ $schema: "http://json-schema.org/draft-07/schema#",
1248
+ $id: "http://soustack.org/schema/v0.3.0/profiles/schedulable",
1249
+ title: "Soustack Schedulable Profile Schema",
1250
+ description: "Extends the base schema to ensure every instruction is fully scheduled.",
1251
+ allOf: [
1252
+ { $ref: "http://soustack.org/schema/v0.3.0" },
1253
+ {
1254
+ properties: {
1255
+ instructions: {
1256
+ type: "array",
1257
+ items: {
1258
+ anyOf: [
1259
+ { $ref: "#/definitions/schedulableInstruction" },
1260
+ { $ref: "#/definitions/schedulableInstructionSubsection" }
1261
+ ]
1262
+ }
1263
+ }
1264
+ }
1265
+ }
1266
+ ],
1267
+ definitions: {
1268
+ schedulableInstruction: {
1269
+ allOf: [
1270
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instruction" },
1271
+ { required: ["id", "timing"] }
1272
+ ]
1273
+ },
1274
+ schedulableInstructionSubsection: {
1275
+ allOf: [
1276
+ { $ref: "http://soustack.org/schema/v0.3.0#/definitions/instructionSubsection" },
1277
+ {
1278
+ properties: {
1279
+ items: {
1280
+ type: "array",
1281
+ items: { $ref: "#/definitions/schedulableInstruction" }
1282
+ }
1283
+ }
1284
+ }
1285
+ ]
1286
+ }
1287
+ }
1288
+ };
1289
+
1290
+ // src/schemas/recipe/stacks/attribution/1.schema.json
373
1291
  var schema_default = {
374
1292
  $schema: "http://json-schema.org/draft-07/schema#",
375
- $id: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
376
- title: "Soustack Recipe Module: schedule v1",
377
- description: "Schema for the schedule module. Enforces bidirectional module gating and restricts usage to the core profile.",
1293
+ $id: "https://soustack.org/schemas/recipe/stacks/attribution/1.schema.json",
1294
+ title: "Soustack Recipe Stack: attribution v1",
1295
+ description: "Schema for the attribution stack. Ensures namespace data is present when the stack is enabled and vice versa.",
378
1296
  type: "object",
379
1297
  properties: {
380
- profile: { type: "string" },
381
- modules: {
382
- type: "array",
383
- items: { type: "string" }
1298
+ stacks: {
1299
+ type: "object",
1300
+ additionalProperties: {
1301
+ type: "integer",
1302
+ minimum: 1
1303
+ }
384
1304
  },
385
- schedule: {
1305
+ attribution: {
386
1306
  type: "object",
387
1307
  properties: {
388
- tasks: { type: "array" }
1308
+ url: { type: "string" },
1309
+ author: { type: "string" },
1310
+ datePublished: { type: "string" }
389
1311
  },
390
1312
  additionalProperties: false
391
1313
  }
@@ -394,32 +1316,33 @@ var schema_default = {
394
1316
  {
395
1317
  if: {
396
1318
  properties: {
397
- modules: {
398
- type: "array",
399
- contains: { const: "schedule@1" }
1319
+ stacks: {
1320
+ type: "object",
1321
+ properties: {
1322
+ attribution: { const: 1 }
1323
+ },
1324
+ required: ["attribution"]
400
1325
  }
401
1326
  }
402
1327
  },
403
1328
  then: {
404
- required: ["schedule", "profile"],
405
- properties: {
406
- profile: { const: "core" }
407
- }
1329
+ required: ["attribution"]
408
1330
  }
409
1331
  },
410
1332
  {
411
1333
  if: {
412
- required: ["schedule"]
1334
+ required: ["attribution"]
413
1335
  },
414
1336
  then: {
415
- required: ["modules", "profile"],
1337
+ required: ["stacks"],
416
1338
  properties: {
417
- modules: {
418
- type: "array",
419
- items: { type: "string" },
420
- contains: { const: "schedule@1" }
421
- },
422
- profile: { const: "core" }
1339
+ stacks: {
1340
+ type: "object",
1341
+ properties: {
1342
+ attribution: { const: 1 }
1343
+ },
1344
+ required: ["attribution"]
1345
+ }
423
1346
  }
424
1347
  }
425
1348
  }
@@ -427,23 +1350,26 @@ var schema_default = {
427
1350
  additionalProperties: true
428
1351
  };
429
1352
 
430
- // src/schemas/recipe/modules/nutrition/1.schema.json
1353
+ // src/schemas/recipe/stacks/media/1.schema.json
431
1354
  var schema_default2 = {
432
1355
  $schema: "http://json-schema.org/draft-07/schema#",
433
- $id: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
434
- title: "Soustack Recipe Module: nutrition v1",
435
- description: "Schema for the nutrition module. Keeps nutrition data aligned with module declarations and vice versa.",
1356
+ $id: "https://soustack.org/schemas/recipe/stacks/media/1.schema.json",
1357
+ title: "Soustack Recipe Stack: media v1",
1358
+ description: "Schema for the media stack. Guards media blocks based on stack activation and ensures declarations accompany payloads.",
436
1359
  type: "object",
437
1360
  properties: {
438
- modules: {
439
- type: "array",
440
- items: { type: "string" }
1361
+ stacks: {
1362
+ type: "object",
1363
+ additionalProperties: {
1364
+ type: "integer",
1365
+ minimum: 1
1366
+ }
441
1367
  },
442
- nutrition: {
1368
+ media: {
443
1369
  type: "object",
444
1370
  properties: {
445
- calories: { type: "number" },
446
- protein_g: { type: "number" }
1371
+ images: { type: "array", items: { type: "string" } },
1372
+ videos: { type: "array", items: { type: "string" } }
447
1373
  },
448
1374
  additionalProperties: false
449
1375
  }
@@ -452,27 +1378,32 @@ var schema_default2 = {
452
1378
  {
453
1379
  if: {
454
1380
  properties: {
455
- modules: {
456
- type: "array",
457
- contains: { const: "nutrition@1" }
1381
+ stacks: {
1382
+ type: "object",
1383
+ properties: {
1384
+ media: { const: 1 }
1385
+ },
1386
+ required: ["media"]
458
1387
  }
459
1388
  }
460
1389
  },
461
1390
  then: {
462
- required: ["nutrition"]
1391
+ required: ["media"]
463
1392
  }
464
1393
  },
465
1394
  {
466
1395
  if: {
467
- required: ["nutrition"]
1396
+ required: ["media"]
468
1397
  },
469
1398
  then: {
470
- required: ["modules"],
1399
+ required: ["stacks"],
471
1400
  properties: {
472
- modules: {
473
- type: "array",
474
- items: { type: "string" },
475
- contains: { const: "nutrition@1" }
1401
+ stacks: {
1402
+ type: "object",
1403
+ properties: {
1404
+ media: { const: 1 }
1405
+ },
1406
+ required: ["media"]
476
1407
  }
477
1408
  }
478
1409
  }
@@ -481,24 +1412,26 @@ var schema_default2 = {
481
1412
  additionalProperties: true
482
1413
  };
483
1414
 
484
- // src/schemas/recipe/modules/attribution/1.schema.json
1415
+ // src/schemas/recipe/stacks/nutrition/1.schema.json
485
1416
  var schema_default3 = {
486
1417
  $schema: "http://json-schema.org/draft-07/schema#",
487
- $id: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
488
- title: "Soustack Recipe Module: attribution v1",
489
- description: "Schema for the attribution module. Ensures namespace data is present when the module is enabled and vice versa.",
1418
+ $id: "https://soustack.org/schemas/recipe/stacks/nutrition/1.schema.json",
1419
+ title: "Soustack Recipe Stack: nutrition v1",
1420
+ description: "Schema for the nutrition stack. Keeps nutrition data aligned with stack declarations and vice versa.",
490
1421
  type: "object",
491
1422
  properties: {
492
- modules: {
493
- type: "array",
494
- items: { type: "string" }
1423
+ stacks: {
1424
+ type: "object",
1425
+ additionalProperties: {
1426
+ type: "integer",
1427
+ minimum: 1
1428
+ }
495
1429
  },
496
- attribution: {
1430
+ nutrition: {
497
1431
  type: "object",
498
1432
  properties: {
499
- url: { type: "string" },
500
- author: { type: "string" },
501
- datePublished: { type: "string" }
1433
+ calories: { type: "number" },
1434
+ protein_g: { type: "number" }
502
1435
  },
503
1436
  additionalProperties: false
504
1437
  }
@@ -507,27 +1440,32 @@ var schema_default3 = {
507
1440
  {
508
1441
  if: {
509
1442
  properties: {
510
- modules: {
511
- type: "array",
512
- contains: { const: "attribution@1" }
1443
+ stacks: {
1444
+ type: "object",
1445
+ properties: {
1446
+ nutrition: { const: 1 }
1447
+ },
1448
+ required: ["nutrition"]
513
1449
  }
514
1450
  }
515
1451
  },
516
1452
  then: {
517
- required: ["attribution"]
1453
+ required: ["nutrition"]
518
1454
  }
519
1455
  },
520
1456
  {
521
1457
  if: {
522
- required: ["attribution"]
1458
+ required: ["nutrition"]
523
1459
  },
524
1460
  then: {
525
- required: ["modules"],
1461
+ required: ["stacks"],
526
1462
  properties: {
527
- modules: {
528
- type: "array",
529
- items: { type: "string" },
530
- contains: { const: "attribution@1" }
1463
+ stacks: {
1464
+ type: "object",
1465
+ properties: {
1466
+ nutrition: { const: 1 }
1467
+ },
1468
+ required: ["nutrition"]
531
1469
  }
532
1470
  }
533
1471
  }
@@ -536,24 +1474,26 @@ var schema_default3 = {
536
1474
  additionalProperties: true
537
1475
  };
538
1476
 
539
- // src/schemas/recipe/modules/taxonomy/1.schema.json
1477
+ // src/schemas/recipe/stacks/schedule/1.schema.json
540
1478
  var schema_default4 = {
541
1479
  $schema: "http://json-schema.org/draft-07/schema#",
542
- $id: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
543
- title: "Soustack Recipe Module: taxonomy v1",
544
- description: "Schema for the taxonomy module. Enforces keyword and categorization data when enabled and ensures module declaration accompanies the namespace block.",
1480
+ $id: "https://soustack.org/schemas/recipe/stacks/schedule/1.schema.json",
1481
+ title: "Soustack Recipe Stack: schedule v1",
1482
+ description: "Schema for the schedule stack. Enforces bidirectional stack gating and restricts usage to the core profile.",
545
1483
  type: "object",
546
1484
  properties: {
547
- modules: {
548
- type: "array",
549
- items: { type: "string" }
1485
+ profile: { type: "string" },
1486
+ stacks: {
1487
+ type: "object",
1488
+ additionalProperties: {
1489
+ type: "integer",
1490
+ minimum: 1
1491
+ }
550
1492
  },
551
- taxonomy: {
1493
+ schedule: {
552
1494
  type: "object",
553
1495
  properties: {
554
- keywords: { type: "array", items: { type: "string" } },
555
- category: { type: "string" },
556
- cuisine: { type: "string" }
1496
+ tasks: { type: "array" }
557
1497
  },
558
1498
  additionalProperties: false
559
1499
  }
@@ -562,28 +1502,37 @@ var schema_default4 = {
562
1502
  {
563
1503
  if: {
564
1504
  properties: {
565
- modules: {
566
- type: "array",
567
- contains: { const: "taxonomy@1" }
1505
+ stacks: {
1506
+ type: "object",
1507
+ properties: {
1508
+ schedule: { const: 1 }
1509
+ },
1510
+ required: ["schedule"]
568
1511
  }
569
1512
  }
570
1513
  },
571
1514
  then: {
572
- required: ["taxonomy"]
1515
+ required: ["schedule", "profile"],
1516
+ properties: {
1517
+ profile: { const: "core" }
1518
+ }
573
1519
  }
574
1520
  },
575
1521
  {
576
1522
  if: {
577
- required: ["taxonomy"]
1523
+ required: ["schedule"]
578
1524
  },
579
1525
  then: {
580
- required: ["modules"],
1526
+ required: ["stacks", "profile"],
581
1527
  properties: {
582
- modules: {
583
- type: "array",
584
- items: { type: "string" },
585
- contains: { const: "taxonomy@1" }
586
- }
1528
+ stacks: {
1529
+ type: "object",
1530
+ properties: {
1531
+ schedule: { const: 1 }
1532
+ },
1533
+ required: ["schedule"]
1534
+ },
1535
+ profile: { const: "core" }
587
1536
  }
588
1537
  }
589
1538
  }
@@ -591,23 +1540,27 @@ var schema_default4 = {
591
1540
  additionalProperties: true
592
1541
  };
593
1542
 
594
- // src/schemas/recipe/modules/media/1.schema.json
1543
+ // src/schemas/recipe/stacks/taxonomy/1.schema.json
595
1544
  var schema_default5 = {
596
1545
  $schema: "http://json-schema.org/draft-07/schema#",
597
- $id: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
598
- title: "Soustack Recipe Module: media v1",
599
- description: "Schema for the media module. Guards media blocks based on module activation and ensures declarations accompany payloads.",
1546
+ $id: "https://soustack.org/schemas/recipe/stacks/taxonomy/1.schema.json",
1547
+ title: "Soustack Recipe Stack: taxonomy v1",
1548
+ description: "Schema for the taxonomy stack. Enforces keyword and categorization data when enabled and ensures stack declaration accompanies the namespace block.",
600
1549
  type: "object",
601
1550
  properties: {
602
- modules: {
603
- type: "array",
604
- items: { type: "string" }
1551
+ stacks: {
1552
+ type: "object",
1553
+ additionalProperties: {
1554
+ type: "integer",
1555
+ minimum: 1
1556
+ }
605
1557
  },
606
- media: {
1558
+ taxonomy: {
607
1559
  type: "object",
608
1560
  properties: {
609
- images: { type: "array", items: { type: "string" } },
610
- videos: { type: "array", items: { type: "string" } }
1561
+ keywords: { type: "array", items: { type: "string" } },
1562
+ category: { type: "string" },
1563
+ cuisine: { type: "string" }
611
1564
  },
612
1565
  additionalProperties: false
613
1566
  }
@@ -616,27 +1569,32 @@ var schema_default5 = {
616
1569
  {
617
1570
  if: {
618
1571
  properties: {
619
- modules: {
620
- type: "array",
621
- contains: { const: "media@1" }
1572
+ stacks: {
1573
+ type: "object",
1574
+ properties: {
1575
+ taxonomy: { const: 1 }
1576
+ },
1577
+ required: ["taxonomy"]
622
1578
  }
623
1579
  }
624
1580
  },
625
1581
  then: {
626
- required: ["media"]
1582
+ required: ["taxonomy"]
627
1583
  }
628
1584
  },
629
1585
  {
630
1586
  if: {
631
- required: ["media"]
1587
+ required: ["taxonomy"]
632
1588
  },
633
1589
  then: {
634
- required: ["modules"],
1590
+ required: ["stacks"],
635
1591
  properties: {
636
- modules: {
637
- type: "array",
638
- items: { type: "string" },
639
- contains: { const: "media@1" }
1592
+ stacks: {
1593
+ type: "object",
1594
+ properties: {
1595
+ taxonomy: { const: 1 }
1596
+ },
1597
+ required: ["taxonomy"]
640
1598
  }
641
1599
  }
642
1600
  }
@@ -645,17 +1603,20 @@ var schema_default5 = {
645
1603
  additionalProperties: true
646
1604
  };
647
1605
 
648
- // src/schemas/recipe/modules/times/1.schema.json
1606
+ // src/schemas/recipe/stacks/times/1.schema.json
649
1607
  var schema_default6 = {
650
1608
  $schema: "http://json-schema.org/draft-07/schema#",
651
- $id: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
652
- title: "Soustack Recipe Module: times v1",
653
- description: "Schema for the times module. Maintains alignment between module declarations and timing payloads.",
1609
+ $id: "https://soustack.org/schemas/recipe/stacks/times/1.schema.json",
1610
+ title: "Soustack Recipe Stack: times v1",
1611
+ description: "Schema for the times stack. Maintains alignment between stack declarations and timing payloads.",
654
1612
  type: "object",
655
1613
  properties: {
656
- modules: {
657
- type: "array",
658
- items: { type: "string" }
1614
+ stacks: {
1615
+ type: "object",
1616
+ additionalProperties: {
1617
+ type: "integer",
1618
+ minimum: 1
1619
+ }
659
1620
  },
660
1621
  times: {
661
1622
  type: "object",
@@ -671,9 +1632,12 @@ var schema_default6 = {
671
1632
  {
672
1633
  if: {
673
1634
  properties: {
674
- modules: {
675
- type: "array",
676
- contains: { const: "times@1" }
1635
+ stacks: {
1636
+ type: "object",
1637
+ properties: {
1638
+ times: { const: 1 }
1639
+ },
1640
+ required: ["times"]
677
1641
  }
678
1642
  }
679
1643
  },
@@ -686,12 +1650,14 @@ var schema_default6 = {
686
1650
  required: ["times"]
687
1651
  },
688
1652
  then: {
689
- required: ["modules"],
1653
+ required: ["stacks"],
690
1654
  properties: {
691
- modules: {
692
- type: "array",
693
- items: { type: "string" },
694
- contains: { const: "times@1" }
1655
+ stacks: {
1656
+ type: "object",
1657
+ properties: {
1658
+ times: { const: 1 }
1659
+ },
1660
+ required: ["times"]
695
1661
  }
696
1662
  }
697
1663
  }
@@ -701,69 +1667,71 @@ var schema_default6 = {
701
1667
  };
702
1668
 
703
1669
  // src/validator.ts
704
- var CANONICAL_BASE_SCHEMA_ID = base_schema_default.$id || "http://soustack.org/schema/recipe/base.schema.json";
705
- var canonicalProfileId = (profile) => {
706
- if (profile === "minimal") {
707
- return minimal_schema_default.$id;
708
- }
709
- if (profile === "core") {
710
- return core_schema_default.$id;
711
- }
712
- throw new Error(`Unknown profile: ${profile}`);
713
- };
714
- var moduleIdToSchemaRef = (moduleId) => {
715
- const match = moduleId.match(/^([a-z0-9_-]+)@(\d+(?:\.\d+)*)$/i);
716
- if (!match) {
717
- throw new Error(`Invalid module identifier '${moduleId}'. Expected <name>@<version>.`);
718
- }
719
- const [, name, version] = match;
720
- const moduleSchemas2 = {
721
- "schedule@1": schema_default,
722
- "nutrition@1": schema_default2,
723
- "attribution@1": schema_default3,
724
- "taxonomy@1": schema_default4,
725
- "media@1": schema_default5,
726
- "times@1": schema_default6
727
- };
728
- const schema = moduleSchemas2[moduleId];
729
- if (schema && schema.$id) {
730
- return schema.$id;
731
- }
732
- return `https://soustack.org/schemas/recipe/modules/${name}/${version}.schema.json`;
733
- };
734
- var profileSchemas = {
735
- minimal: minimal_schema_default,
736
- core: core_schema_default
737
- };
738
- var moduleSchemas = {
739
- "schedule@1": schema_default,
740
- "nutrition@1": schema_default2,
741
- "attribution@1": schema_default3,
742
- "taxonomy@1": schema_default4,
743
- "media@1": schema_default5,
744
- "times@1": schema_default6
745
- };
1670
+ var LEGACY_ROOT_SCHEMA_ID = "http://soustack.org/schema/v0.3.0";
1671
+ var DEFAULT_ROOT_SCHEMA_ID = "https://soustack.spec/soustack.schema.json";
1672
+ var BASE_SCHEMA_ID = "http://soustack.org/schema/recipe/base.schema.json";
1673
+ var PROFILE_SCHEMA_PREFIX = "http://soustack.org/schema/recipe/profiles/";
746
1674
  var validationContexts = /* @__PURE__ */ new Map();
1675
+ function loadAllSchemas(ajv) {
1676
+ const schemas = [
1677
+ soustack_schema_default,
1678
+ base_schema_default,
1679
+ minimal_schema_default,
1680
+ core_schema_default,
1681
+ base_schema_default2,
1682
+ cookable_schema_default,
1683
+ illustrated_schema_default,
1684
+ quantified_schema_default,
1685
+ scalable_schema_default,
1686
+ schedulable_schema_default,
1687
+ schema_default,
1688
+ schema_default2,
1689
+ schema_default3,
1690
+ schema_default4,
1691
+ schema_default5,
1692
+ schema_default6
1693
+ ];
1694
+ for (const schema of schemas) {
1695
+ if (schema && typeof schema === "object" && "$id" in schema) {
1696
+ const schemaWithId = schema;
1697
+ if (schemaWithId.$id) {
1698
+ ajv.addSchema(schemaWithId, schemaWithId.$id);
1699
+ }
1700
+ }
1701
+ }
1702
+ ajv.addSchema(
1703
+ {
1704
+ $id: DEFAULT_ROOT_SCHEMA_ID,
1705
+ allOf: [
1706
+ { $ref: LEGACY_ROOT_SCHEMA_ID },
1707
+ {
1708
+ type: "object",
1709
+ properties: {
1710
+ $schema: { const: DEFAULT_ROOT_SCHEMA_ID }
1711
+ }
1712
+ }
1713
+ ]
1714
+ },
1715
+ DEFAULT_ROOT_SCHEMA_ID
1716
+ );
1717
+ }
747
1718
  function createContext(collectAllErrors) {
748
- const ajv = new Ajv__default.default({ strict: false, allErrors: collectAllErrors });
1719
+ const ajv = new Ajv2020__default.default({
1720
+ strict: false,
1721
+ allErrors: collectAllErrors,
1722
+ validateSchema: false
1723
+ // Don't validate schemas themselves
1724
+ });
749
1725
  addFormats__default.default(ajv);
750
- const addSchemaWithAlias = (schema, alias) => {
751
- if (!schema) return;
752
- const schemaId = schema.$id || alias;
753
- if (schemaId) {
754
- ajv.addSchema(schema, schemaId);
755
- } else {
756
- ajv.addSchema(schema);
757
- }
1726
+ loadAllSchemas(ajv);
1727
+ const rootValidator = ajv.getSchema(DEFAULT_ROOT_SCHEMA_ID) || ajv.getSchema(LEGACY_ROOT_SCHEMA_ID);
1728
+ const baseValidator = ajv.getSchema(BASE_SCHEMA_ID);
1729
+ return {
1730
+ ajv,
1731
+ rootValidator: rootValidator || void 0,
1732
+ baseValidator: baseValidator || void 0,
1733
+ validators: /* @__PURE__ */ new Map()
758
1734
  };
759
- addSchemaWithAlias(base_schema_default, CANONICAL_BASE_SCHEMA_ID);
760
- Object.entries(profileSchemas).forEach(([name, schema]) => {
761
- addSchemaWithAlias(schema, canonicalProfileId(name));
762
- });
763
- Object.entries(moduleSchemas).forEach(([moduleId, schema]) => {
764
- addSchemaWithAlias(schema, moduleIdToSchemaRef(moduleId));
765
- });
766
- return { ajv, validators: /* @__PURE__ */ new Map() };
767
1735
  }
768
1736
  function getContext(collectAllErrors) {
769
1737
  if (!validationContexts.has(collectAllErrors)) {
@@ -777,288 +1745,227 @@ function cloneRecipe(recipe) {
777
1745
  }
778
1746
  return JSON.parse(JSON.stringify(recipe));
779
1747
  }
780
- function detectProfileFromSchema(schemaRef) {
781
- if (!schemaRef) return void 0;
782
- const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
783
- if (match) {
784
- const profile = match[1].toLowerCase();
785
- if (profile in profileSchemas) return profile;
1748
+ function formatAjvError(error) {
1749
+ var _a;
1750
+ let path = error.instancePath || "/";
1751
+ if (error.keyword === "additionalProperties" && ((_a = error.params) == null ? void 0 : _a.additionalProperty)) {
1752
+ const extra = error.params.additionalProperty;
1753
+ path = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
786
1754
  }
787
- return void 0;
1755
+ return {
1756
+ path,
1757
+ keyword: error.keyword,
1758
+ message: error.message || "Validation error"
1759
+ };
788
1760
  }
789
- function resolveSchemaRef(inputSchema, requestedSchema) {
790
- if (typeof requestedSchema === "string") return requestedSchema;
791
- if (typeof inputSchema !== "string") return void 0;
792
- return detectProfileFromSchema(inputSchema) ? inputSchema : void 0;
793
- }
794
- function inferModulesFromPayload(recipe) {
795
- const inferred = [];
796
- const payloadToModule = {
797
- attribution: "attribution@1",
798
- taxonomy: "taxonomy@1",
799
- media: "media@1",
800
- times: "times@1",
801
- nutrition: "nutrition@1",
802
- schedule: "schedule@1"
1761
+ function isSoustackSchemaId(schemaId) {
1762
+ return schemaId.startsWith("http://soustack.org/schema") || schemaId.startsWith("https://soustack.org/schema") || schemaId.startsWith("https://soustack.spec/") || schemaId.startsWith("https://soustack.org/schemas/");
1763
+ }
1764
+ function inferStacksFromPayload(recipe) {
1765
+ const inferred = {};
1766
+ const payloadToStack = {
1767
+ attribution: "attribution",
1768
+ taxonomy: "taxonomy",
1769
+ media: "media",
1770
+ times: "times",
1771
+ nutrition: "nutrition",
1772
+ schedule: "schedule"
803
1773
  };
804
- for (const [field, moduleId] of Object.entries(payloadToModule)) {
805
- if (recipe && typeof recipe === "object" && field in recipe && recipe[field] != null) {
806
- const payload = recipe[field];
807
- if (typeof payload === "object" && !Array.isArray(payload)) {
808
- if (Object.keys(payload).length > 0) {
809
- inferred.push(moduleId);
810
- }
811
- } else if (Array.isArray(payload) && payload.length > 0) {
812
- inferred.push(moduleId);
813
- } else if (payload !== null && payload !== void 0) {
814
- inferred.push(moduleId);
815
- }
1774
+ for (const [field, stackName] of Object.entries(payloadToStack)) {
1775
+ if (recipe && typeof recipe === "object" && field in recipe && recipe[field] !== void 0) {
1776
+ inferred[stackName] = 1;
816
1777
  }
817
1778
  }
818
1779
  return inferred;
819
1780
  }
820
- function getCombinedValidator(profile, modules, recipe, context) {
821
- const inferredModules = inferModulesFromPayload(recipe);
822
- const allModules = /* @__PURE__ */ new Set([...modules, ...inferredModules]);
823
- const sortedModules = Array.from(allModules).sort();
824
- const cacheKey = `${profile}::${sortedModules.join(",")}`;
1781
+ function getComposedValidator(profile, stacks, context) {
1782
+ const stackIdentifiers = Object.entries(stacks).map(([name, version]) => `${name}@${version}`).sort();
1783
+ const cacheKey = `${profile}::${stackIdentifiers.join(",")}`;
825
1784
  const cached = context.validators.get(cacheKey);
826
1785
  if (cached) return cached;
827
- if (!profileSchemas[profile]) {
828
- throw new Error(`Unknown Soustack profile: ${profile}`);
829
- }
830
- const schema = {
831
- $id: `urn:soustack:recipe:${cacheKey}`,
832
- allOf: [
833
- { $ref: CANONICAL_BASE_SCHEMA_ID },
834
- { $ref: canonicalProfileId(profile) },
835
- ...sortedModules.map((moduleId) => ({ $ref: moduleIdToSchemaRef(moduleId) }))
836
- ]
1786
+ const allOf = [{ $ref: BASE_SCHEMA_ID }];
1787
+ if (!context.ajv.getSchema(BASE_SCHEMA_ID)) {
1788
+ throw new Error(`Base schema not loaded: ${BASE_SCHEMA_ID}. Ensure schemas are loaded before creating validators.`);
1789
+ }
1790
+ const profileSchemaId = `${PROFILE_SCHEMA_PREFIX}${profile}.schema.json`;
1791
+ if (!context.ajv.getSchema(profileSchemaId)) {
1792
+ throw new Error(`Profile schema not loaded: ${profileSchemaId}`);
1793
+ }
1794
+ allOf.push({ $ref: profileSchemaId });
1795
+ for (const [name, version] of Object.entries(stacks)) {
1796
+ if (typeof version === "number" && version >= 1) {
1797
+ const stackSchemaId = `https://soustack.org/schemas/recipe/stacks/${name}/${version}.schema.json`;
1798
+ if (!context.ajv.getSchema(stackSchemaId)) {
1799
+ throw new Error(`Stack schema not loaded: ${stackSchemaId}`);
1800
+ }
1801
+ allOf.push({ $ref: stackSchemaId });
1802
+ }
1803
+ }
1804
+ const composedSchema = {
1805
+ $id: `urn:soustack:composed:${cacheKey}`,
1806
+ allOf
837
1807
  };
838
- const validateFn = context.ajv.compile(schema);
1808
+ const validateFn = context.ajv.compile(composedSchema);
839
1809
  context.validators.set(cacheKey, validateFn);
840
1810
  return validateFn;
841
1811
  }
842
- function normalizeRecipe(recipe) {
843
- const normalized = cloneRecipe(recipe);
844
- const warnings = [];
845
- normalizeTime(normalized);
846
- if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
847
- normalized.recipeVersion = normalized.version;
848
- warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
849
- }
850
- return { normalized, warnings };
851
- }
852
- function normalizeTime(recipe) {
853
- const time = recipe == null ? void 0 : recipe.time;
854
- if (!time || typeof time !== "object" || Array.isArray(time)) return;
855
- const structuredKeys = [
856
- "prep",
857
- "active",
858
- "passive",
859
- "total"
860
- ];
861
- structuredKeys.forEach((key) => {
862
- const value = time[key];
863
- if (typeof value === "number") return;
864
- const parsed = parseDuration(value);
865
- if (parsed !== null) {
866
- time[key] = parsed;
1812
+ function validateRecipeSchemaNormalized(normalizedInput, inputHasStacks, collectAllErrors, schemaOverride) {
1813
+ const normalized = cloneRecipe(normalizedInput);
1814
+ const context = getContext(collectAllErrors);
1815
+ const schemaId = typeof schemaOverride === "string" ? schemaOverride : typeof normalized.$schema === "string" ? normalized.$schema : void 0;
1816
+ const hasSchemaOverride = typeof schemaOverride === "string";
1817
+ const isSoustackSchema = schemaId ? isSoustackSchemaId(schemaId) : false;
1818
+ if (schemaId && isSoustackSchema) {
1819
+ const schemaValidator = context.ajv.getSchema(schemaId);
1820
+ if (!schemaValidator) {
1821
+ return {
1822
+ ok: false,
1823
+ errors: [
1824
+ {
1825
+ path: "/$schema",
1826
+ message: `Unknown schema: ${schemaId}`
1827
+ }
1828
+ ]
1829
+ };
867
1830
  }
868
- });
869
- }
870
- var _a, _b;
871
- var allowedTopLevelProps = /* @__PURE__ */ new Set([
872
- ...Object.keys((_b = (_a = base_schema_default) == null ? void 0 : _a.properties) != null ? _b : {}),
873
- "$schema",
874
- // Module fields (validated by module schemas)
875
- "attribution",
876
- "taxonomy",
877
- "media",
878
- "times",
879
- "nutrition",
880
- "schedule",
881
- // Common recipe fields (allowed by base schema's additionalProperties: true)
882
- "description",
883
- "image",
884
- "category",
885
- "tags",
886
- "source",
887
- "dateAdded",
888
- "dateModified",
889
- "yield",
890
- "time",
891
- "id",
892
- "title",
893
- "recipeVersion",
894
- "version",
895
- // deprecated but allowed
896
- "equipment",
897
- "storage",
898
- "substitutions"
899
- ]);
900
- function detectUnknownTopLevelKeys(recipe) {
901
- if (!recipe || typeof recipe !== "object") return [];
902
- const disallowedKeys = Object.keys(recipe).filter(
903
- (key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
904
- );
905
- return disallowedKeys.map((key) => ({
906
- path: `/${key}`,
907
- keyword: "additionalProperties",
908
- message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
909
- }));
910
- }
911
- function formatAjvError(error) {
912
- var _a2;
913
- let path = error.instancePath || "/";
914
- if (error.keyword === "additionalProperties" && ((_a2 = error.params) == null ? void 0 : _a2.additionalProperty)) {
915
- const extra = error.params.additionalProperty;
916
- path = `${error.instancePath || ""}/${extra}`.replace(/\/+/g, "/") || "/";
1831
+ const schemaInput = cloneRecipe(normalized);
1832
+ if (hasSchemaOverride && "$schema" in schemaInput && schemaInput.$schema !== schemaId) {
1833
+ delete schemaInput.$schema;
1834
+ }
1835
+ const isLegacySchema = schemaId.startsWith(LEGACY_ROOT_SCHEMA_ID);
1836
+ const shouldRemoveStacks = (isLegacySchema || schemaId === DEFAULT_ROOT_SCHEMA_ID) && !inputHasStacks;
1837
+ if (isLegacySchema && "@type" in schemaInput) {
1838
+ delete schemaInput["@type"];
1839
+ }
1840
+ if (shouldRemoveStacks && "stacks" in schemaInput) {
1841
+ delete schemaInput.stacks;
1842
+ }
1843
+ const schemaValid = schemaValidator(schemaInput);
1844
+ const schemaErrors = schemaValidator.errors || [];
1845
+ return {
1846
+ ok: !!schemaValid,
1847
+ errors: schemaErrors.map(formatAjvError)
1848
+ };
917
1849
  }
918
- return {
919
- path,
920
- keyword: error.keyword,
921
- message: error.message || "Validation error"
922
- };
923
- }
924
- function runAjvValidation(data, profile, modules, context) {
925
- try {
926
- const validateFn = getCombinedValidator(profile, modules, data, context);
927
- const isValid = validateFn(data);
928
- return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
929
- } catch (error) {
930
- return [
931
- {
932
- path: "/",
933
- message: error instanceof Error ? error.message : "Validation failed to initialize"
1850
+ const hasProfile = normalized.profile && typeof normalized.profile === "string";
1851
+ let declaredStacks = {};
1852
+ if (normalized.stacks && typeof normalized.stacks === "object" && !Array.isArray(normalized.stacks)) {
1853
+ for (const [name, version] of Object.entries(normalized.stacks)) {
1854
+ if (typeof version === "number" && version >= 1) {
1855
+ declaredStacks[name] = version;
934
1856
  }
935
- ];
1857
+ }
936
1858
  }
937
- }
938
- function isInstruction(item) {
939
- return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
940
- }
941
- function isInstructionSubsection(item) {
942
- return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
943
- }
944
- function checkInstructionGraph(recipe) {
945
- const instructions = recipe == null ? void 0 : recipe.instructions;
946
- if (!Array.isArray(instructions)) return [];
947
- const instructionIds = /* @__PURE__ */ new Set();
948
- const dependencyRefs = [];
949
- const collect = (items, basePath) => {
950
- items.forEach((item, index) => {
951
- const currentPath = `${basePath}/${index}`;
952
- if (isInstructionSubsection(item) && Array.isArray(item.items)) {
953
- collect(item.items, `${currentPath}/items`);
954
- return;
1859
+ const inferredStacks = inferStacksFromPayload(normalized);
1860
+ const allStacks = { ...declaredStacks };
1861
+ for (const [name, version] of Object.entries(inferredStacks)) {
1862
+ if (!allStacks[name] || allStacks[name] < version) {
1863
+ allStacks[name] = version;
1864
+ }
1865
+ }
1866
+ let isValid;
1867
+ let errors = [];
1868
+ const profile = hasProfile ? normalized.profile.toLowerCase() : "core";
1869
+ const profileSchemaId = `${PROFILE_SCHEMA_PREFIX}${profile}.schema.json`;
1870
+ if (!context.ajv.getSchema(profileSchemaId)) {
1871
+ return {
1872
+ ok: false,
1873
+ errors: [
1874
+ {
1875
+ path: "/profile",
1876
+ message: `Profile schema not loaded: ${profileSchemaId}`
1877
+ }
1878
+ ]
1879
+ };
1880
+ }
1881
+ {
1882
+ const validationCopy = cloneRecipe(normalized);
1883
+ if (!validationCopy.stacks || typeof validationCopy.stacks !== "object" || Array.isArray(validationCopy.stacks)) {
1884
+ validationCopy.stacks = declaredStacks;
1885
+ }
1886
+ if (!validationCopy.profile) {
1887
+ validationCopy.profile = profile;
1888
+ }
1889
+ const validator = getComposedValidator(profile, allStacks, context);
1890
+ isValid = validator(validationCopy);
1891
+ errors = validator.errors || [];
1892
+ if (isValid && context.rootValidator) {
1893
+ const rootCheckCopy = cloneRecipe(normalized);
1894
+ if ("@type" in rootCheckCopy) {
1895
+ delete rootCheckCopy["@type"];
955
1896
  }
956
- if (isInstruction(item)) {
957
- const id = typeof item.id === "string" ? item.id : void 0;
958
- if (id) instructionIds.add(id);
959
- if (Array.isArray(item.dependsOn)) {
960
- item.dependsOn.forEach((depId, depIndex) => {
961
- if (typeof depId === "string") {
962
- dependencyRefs.push({
963
- fromId: id,
964
- toId: depId,
965
- path: `${currentPath}/dependsOn/${depIndex}`
966
- });
967
- }
968
- });
1897
+ if ("stacks" in rootCheckCopy) {
1898
+ delete rootCheckCopy.stacks;
1899
+ }
1900
+ if ("profile" in rootCheckCopy) {
1901
+ delete rootCheckCopy.profile;
1902
+ }
1903
+ const stackPayloadFields = ["attribution", "taxonomy", "media", "times", "nutrition", "schedule"];
1904
+ for (const field of stackPayloadFields) {
1905
+ if (field in rootCheckCopy) {
1906
+ delete rootCheckCopy[field];
969
1907
  }
970
1908
  }
971
- });
972
- };
973
- collect(instructions, "/instructions");
974
- const errors = [];
975
- dependencyRefs.forEach((ref) => {
976
- if (!instructionIds.has(ref.toId)) {
977
- errors.push({
978
- path: ref.path,
979
- message: `Instruction dependency references missing id '${ref.toId}'.`
980
- });
981
- }
982
- });
983
- const adjacency = /* @__PURE__ */ new Map();
984
- dependencyRefs.forEach((ref) => {
985
- var _a2;
986
- if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
987
- const list = (_a2 = adjacency.get(ref.fromId)) != null ? _a2 : [];
988
- list.push({ toId: ref.toId, path: ref.path });
989
- adjacency.set(ref.fromId, list);
990
- }
991
- });
992
- const visiting = /* @__PURE__ */ new Set();
993
- const visited = /* @__PURE__ */ new Set();
994
- const detectCycles = (nodeId) => {
995
- var _a2;
996
- if (visiting.has(nodeId)) return;
997
- if (visited.has(nodeId)) return;
998
- visiting.add(nodeId);
999
- const neighbors = (_a2 = adjacency.get(nodeId)) != null ? _a2 : [];
1000
- neighbors.forEach((edge) => {
1001
- if (visiting.has(edge.toId)) {
1002
- errors.push({
1003
- path: edge.path,
1004
- message: `Circular dependency detected involving instruction id '${edge.toId}'.`
1005
- });
1006
- return;
1909
+ const rootValid = context.rootValidator(rootCheckCopy);
1910
+ if (!rootValid && context.rootValidator.errors) {
1911
+ const unknownKeyErrors = context.rootValidator.errors.filter(
1912
+ (e) => e.keyword === "additionalProperties" && (e.instancePath === "" || e.instancePath === "/")
1913
+ );
1914
+ const schemaConstErrors = context.rootValidator.errors.filter(
1915
+ (e) => e.keyword === "const" && e.instancePath === "/$schema"
1916
+ );
1917
+ const relevantErrors = [...unknownKeyErrors, ...schemaConstErrors];
1918
+ if (relevantErrors.length > 0) {
1919
+ errors.push(...relevantErrors);
1920
+ isValid = false;
1921
+ }
1007
1922
  }
1008
- detectCycles(edge.toId);
1009
- });
1010
- visiting.delete(nodeId);
1011
- visited.add(nodeId);
1923
+ }
1924
+ }
1925
+ return {
1926
+ ok: isValid,
1927
+ errors: errors.map(formatAjvError)
1012
1928
  };
1013
- instructionIds.forEach((id) => detectCycles(id));
1014
- return errors;
1015
1929
  }
1016
1930
  function validateRecipe(input, options = {}) {
1017
- var _a2, _b2, _c, _d;
1018
- const collectAllErrors = (_a2 = options.collectAllErrors) != null ? _a2 : true;
1019
- const context = getContext(collectAllErrors);
1020
- const schemaRef = resolveSchemaRef(input == null ? void 0 : input.$schema, options.schema);
1021
- const profileFromDocument = typeof (input == null ? void 0 : input.profile) === "string" ? input.profile : void 0;
1022
- const profile = (_d = (_c = (_b2 = options.profile) != null ? _b2 : profileFromDocument) != null ? _c : detectProfileFromSchema(schemaRef)) != null ? _d : "core";
1023
- const modulesFromDocument = Array.isArray(input == null ? void 0 : input.modules) ? input.modules.filter((value) => typeof value === "string") : [];
1024
- const modules = modulesFromDocument.length > 0 ? [...modulesFromDocument].sort() : [];
1025
- const { normalized, warnings } = normalizeRecipe(input);
1026
- if (!profileFromDocument) {
1027
- normalized.profile = profile;
1028
- } else {
1029
- normalized.profile = profileFromDocument;
1030
- }
1031
- if (!("modules" in normalized) || normalized.modules === void 0 || normalized.modules === null) {
1032
- normalized.modules = [];
1033
- } else if (modulesFromDocument.length > 0) {
1034
- normalized.modules = modules;
1931
+ var _a, _b;
1932
+ const { recipe: normalized, warnings } = normalizeRecipe(input);
1933
+ if (options.profile) {
1934
+ normalized.profile = options.profile;
1935
+ }
1936
+ const inputHasStacks = !!input && typeof input === "object" && !Array.isArray(input) && "stacks" in input;
1937
+ const { ok: schemaOk, errors: schemaErrors } = validateRecipeSchemaNormalized(
1938
+ normalized,
1939
+ inputHasStacks,
1940
+ (_a = options.collectAllErrors) != null ? _a : true,
1941
+ options.schema
1942
+ );
1943
+ const mode = (_b = options.mode) != null ? _b : "full";
1944
+ let conformanceIssues = [];
1945
+ let conformanceOk = true;
1946
+ if (mode === "full") {
1947
+ if (schemaOk) {
1948
+ const conformanceResult = validateConformance(normalized);
1949
+ conformanceIssues = conformanceResult.issues;
1950
+ conformanceOk = conformanceResult.ok;
1951
+ } else {
1952
+ conformanceOk = false;
1953
+ }
1035
1954
  }
1036
- const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
1037
- const validationErrors = runAjvValidation(normalized, profile, modules, context);
1038
- const graphErrors = modules.includes("schedule@1") && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
1039
- const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
1955
+ const ok = schemaOk && (mode === "schema" ? true : conformanceOk);
1956
+ const normalizedRecipe = ok || options.includeNormalized ? normalized : void 0;
1040
1957
  return {
1041
- valid: errors.length === 0,
1042
- errors,
1958
+ ok,
1959
+ schemaErrors,
1960
+ conformanceIssues,
1043
1961
  warnings,
1044
- normalized: errors.length === 0 ? normalized : void 0
1962
+ normalizedRecipe
1045
1963
  };
1046
1964
  }
1047
1965
  function detectProfiles(recipe) {
1048
- var _a2;
1049
- const result = validateRecipe(recipe, { profile: "core", collectAllErrors: false });
1050
- if (!result.valid) return [];
1051
- const normalizedRecipe = (_a2 = result.normalized) != null ? _a2 : recipe;
1052
- const profiles = [];
1053
- const context = getContext(false);
1054
- Object.keys(profileSchemas).forEach((profile) => {
1055
- if (!profileSchemas[profile]) return;
1056
- const errors = runAjvValidation(normalizedRecipe, profile, [], context);
1057
- if (errors.length === 0) {
1058
- profiles.push(profile);
1059
- }
1060
- });
1061
- return profiles;
1966
+ const result = validateRecipe(recipe, { collectAllErrors: false });
1967
+ if (!result.ok) return [];
1968
+ return ["core"];
1062
1969
  }
1063
1970
 
1064
1971
  // src/converters/yield.ts
@@ -1101,12 +2008,12 @@ function parseYield(value) {
1101
2008
  return void 0;
1102
2009
  }
1103
2010
  function formatYield(yieldValue) {
1104
- var _a2;
2011
+ var _a;
1105
2012
  if (!yieldValue) return void 0;
1106
2013
  if (!yieldValue.amount && !yieldValue.unit) {
1107
2014
  return void 0;
1108
2015
  }
1109
- const amount = (_a2 = yieldValue.amount) != null ? _a2 : "";
2016
+ const amount = (_a = yieldValue.amount) != null ? _a : "";
1110
2017
  const unit = yieldValue.unit ? ` ${yieldValue.unit}` : "";
1111
2018
  return `${amount}${unit}`.trim() || yieldValue.description;
1112
2019
  }
@@ -1147,7 +2054,7 @@ function extractUrl(value) {
1147
2054
 
1148
2055
  // src/fromSchemaOrg.ts
1149
2056
  function fromSchemaOrg(input) {
1150
- var _a2;
2057
+ var _a;
1151
2058
  const recipeNode = extractRecipeNode(input);
1152
2059
  if (!recipeNode) {
1153
2060
  return null;
@@ -1165,18 +2072,18 @@ function fromSchemaOrg(input) {
1165
2072
  const taxonomy = convertTaxonomy(tags, category, extractFirst(recipeNode.recipeCuisine));
1166
2073
  const media = convertMedia(recipeNode.image, recipeNode.video);
1167
2074
  const times = convertTimes(time);
1168
- const modules = [];
1169
- if (attribution) modules.push("attribution@1");
1170
- if (taxonomy) modules.push("taxonomy@1");
1171
- if (media) modules.push("media@1");
1172
- if (nutrition) modules.push("nutrition@1");
1173
- if (times) modules.push("times@1");
1174
- return {
2075
+ const stacks = {};
2076
+ if (attribution) stacks.attribution = 1;
2077
+ if (taxonomy) stacks.taxonomy = 1;
2078
+ if (media) stacks.media = 1;
2079
+ if (nutrition) stacks.nutrition = 1;
2080
+ if (times) stacks.times = 1;
2081
+ const rawRecipe = {
1175
2082
  "@type": "Recipe",
1176
2083
  profile: "minimal",
1177
- modules: modules.sort(),
2084
+ stacks,
1178
2085
  name: recipeNode.name.trim(),
1179
- description: ((_a2 = recipeNode.description) == null ? void 0 : _a2.trim()) || void 0,
2086
+ description: ((_a = recipeNode.description) == null ? void 0 : _a.trim()) || void 0,
1180
2087
  image: normalizeImage(recipeNode.image),
1181
2088
  category,
1182
2089
  tags: tags.length ? tags : void 0,
@@ -1193,6 +2100,8 @@ function fromSchemaOrg(input) {
1193
2100
  ...media ? { media } : {},
1194
2101
  ...times ? { times } : {}
1195
2102
  };
2103
+ const { recipe } = normalizeRecipe(rawRecipe);
2104
+ return recipe;
1196
2105
  }
1197
2106
  function extractRecipeNode(input) {
1198
2107
  if (!input) return null;
@@ -1239,7 +2148,7 @@ function convertIngredients(value) {
1239
2148
  return normalized.map((item) => typeof item === "string" ? item.trim() : "").filter(Boolean);
1240
2149
  }
1241
2150
  function convertInstructions(value) {
1242
- var _a2;
2151
+ var _a;
1243
2152
  if (!value) return [];
1244
2153
  const normalized = Array.isArray(value) ? value : [value];
1245
2154
  const result = [];
@@ -1256,7 +2165,7 @@ function convertInstructions(value) {
1256
2165
  const subsectionItems = extractSectionItems(entry.itemListElement);
1257
2166
  if (subsectionItems.length) {
1258
2167
  result.push({
1259
- subsection: ((_a2 = entry.name) == null ? void 0 : _a2.trim()) || "Section",
2168
+ subsection: ((_a = entry.name) == null ? void 0 : _a.trim()) || "Section",
1260
2169
  items: subsectionItems
1261
2170
  });
1262
2171
  }
@@ -1340,9 +2249,9 @@ function isHowToSection(value) {
1340
2249
  return Boolean(value) && typeof value === "object" && value["@type"] === "HowToSection" && Array.isArray(value.itemListElement);
1341
2250
  }
1342
2251
  function convertTime(recipe) {
1343
- var _a2, _b2, _c;
1344
- const prep = smartParseDuration((_a2 = recipe.prepTime) != null ? _a2 : "");
1345
- const cook = smartParseDuration((_b2 = recipe.cookTime) != null ? _b2 : "");
2252
+ var _a, _b, _c;
2253
+ const prep = smartParseDuration((_a = recipe.prepTime) != null ? _a : "");
2254
+ const cook = smartParseDuration((_b = recipe.cookTime) != null ? _b : "");
1346
2255
  const total = smartParseDuration((_c = recipe.totalTime) != null ? _c : "");
1347
2256
  const structured = {};
1348
2257
  if (prep !== null && prep !== void 0) structured.prep = prep;
@@ -1376,10 +2285,10 @@ function extractFirst(value) {
1376
2285
  return arr.length ? arr[0] : void 0;
1377
2286
  }
1378
2287
  function convertSource(recipe) {
1379
- var _a2;
2288
+ var _a;
1380
2289
  const author = extractEntityName(recipe.author);
1381
2290
  const publisher = extractEntityName(recipe.publisher);
1382
- const url = (_a2 = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a2.trim();
2291
+ const url = (_a = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a.trim();
1383
2292
  const source = {};
1384
2293
  if (author) source.author = author;
1385
2294
  if (publisher) source.name = publisher;
@@ -1408,11 +2317,11 @@ function extractEntityName(value) {
1408
2317
  return void 0;
1409
2318
  }
1410
2319
  function convertAttribution(recipe) {
1411
- var _a2, _b2;
2320
+ var _a, _b;
1412
2321
  const attribution = {};
1413
- const url = (_a2 = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a2.trim();
2322
+ const url = (_a = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a.trim();
1414
2323
  const author = extractEntityName(recipe.author);
1415
- const datePublished = (_b2 = recipe.datePublished) == null ? void 0 : _b2.trim();
2324
+ const datePublished = (_b = recipe.datePublished) == null ? void 0 : _b.trim();
1416
2325
  if (url) attribution.url = url;
1417
2326
  if (author) attribution.author = author;
1418
2327
  if (datePublished) attribution.datePublished = datePublished;
@@ -1493,17 +2402,15 @@ function convertNutrition(nutrition) {
1493
2402
  return hasData ? result : void 0;
1494
2403
  }
1495
2404
 
1496
- // src/schemas/registry/modules.json
1497
- var modules_default = {
1498
- modules: [
2405
+ // src/schemas/registry/stacks.json
2406
+ var stacks_default = {
2407
+ stacks: [
1499
2408
  {
1500
2409
  id: "attribution",
1501
- versions: [
1502
- 1
1503
- ],
2410
+ versions: [1],
1504
2411
  latest: 1,
1505
- namespace: "https://soustack.org/schemas/recipe/modules/attribution",
1506
- schema: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
2412
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/attribution",
2413
+ schema: "http://soustack.org/schema/v0.3.0/stacks/attribution",
1507
2414
  schemaOrgMappable: true,
1508
2415
  schemaOrgConfidence: "medium",
1509
2416
  minProfile: "minimal",
@@ -1511,12 +2418,10 @@ var modules_default = {
1511
2418
  },
1512
2419
  {
1513
2420
  id: "taxonomy",
1514
- versions: [
1515
- 1
1516
- ],
2421
+ versions: [1],
1517
2422
  latest: 1,
1518
- namespace: "https://soustack.org/schemas/recipe/modules/taxonomy",
1519
- schema: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
2423
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/taxonomy",
2424
+ schema: "http://soustack.org/schema/v0.3.0/stacks/taxonomy",
1520
2425
  schemaOrgMappable: true,
1521
2426
  schemaOrgConfidence: "high",
1522
2427
  minProfile: "minimal",
@@ -1524,12 +2429,10 @@ var modules_default = {
1524
2429
  },
1525
2430
  {
1526
2431
  id: "media",
1527
- versions: [
1528
- 1
1529
- ],
2432
+ versions: [1],
1530
2433
  latest: 1,
1531
- namespace: "https://soustack.org/schemas/recipe/modules/media",
1532
- schema: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
2434
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/media",
2435
+ schema: "http://soustack.org/schema/v0.3.0/stacks/media",
1533
2436
  schemaOrgMappable: true,
1534
2437
  schemaOrgConfidence: "medium",
1535
2438
  minProfile: "minimal",
@@ -1537,12 +2440,10 @@ var modules_default = {
1537
2440
  },
1538
2441
  {
1539
2442
  id: "nutrition",
1540
- versions: [
1541
- 1
1542
- ],
2443
+ versions: [1],
1543
2444
  latest: 1,
1544
- namespace: "https://soustack.org/schemas/recipe/modules/nutrition",
1545
- schema: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
2445
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/nutrition",
2446
+ schema: "http://soustack.org/schema/v0.3.0/stacks/nutrition",
1546
2447
  schemaOrgMappable: false,
1547
2448
  schemaOrgConfidence: "low",
1548
2449
  minProfile: "minimal",
@@ -1550,12 +2451,10 @@ var modules_default = {
1550
2451
  },
1551
2452
  {
1552
2453
  id: "times",
1553
- versions: [
1554
- 1
1555
- ],
2454
+ versions: [1],
1556
2455
  latest: 1,
1557
- namespace: "https://soustack.org/schemas/recipe/modules/times",
1558
- schema: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
2456
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/times",
2457
+ schema: "http://soustack.org/schema/v0.3.0/stacks/times",
1559
2458
  schemaOrgMappable: true,
1560
2459
  schemaOrgConfidence: "medium",
1561
2460
  minProfile: "minimal",
@@ -1563,12 +2462,10 @@ var modules_default = {
1563
2462
  },
1564
2463
  {
1565
2464
  id: "schedule",
1566
- versions: [
1567
- 1
1568
- ],
2465
+ versions: [1],
1569
2466
  latest: 1,
1570
- namespace: "https://soustack.org/schemas/recipe/modules/schedule",
1571
- schema: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
2467
+ namespace: "http://soustack.org/schema/v0.3.0/stacks/schedule",
2468
+ schema: "http://soustack.org/schema/v0.3.0/stacks/schedule",
1572
2469
  schemaOrgMappable: false,
1573
2470
  schemaOrgConfidence: "low",
1574
2471
  minProfile: "core",
@@ -1579,14 +2476,14 @@ var modules_default = {
1579
2476
 
1580
2477
  // src/converters/toSchemaOrg.ts
1581
2478
  function convertBasicMetadata(recipe) {
1582
- var _a2;
2479
+ var _a;
1583
2480
  return cleanOutput({
1584
2481
  "@context": "https://schema.org",
1585
2482
  "@type": "Recipe",
1586
2483
  name: recipe.name,
1587
2484
  description: recipe.description,
1588
2485
  image: recipe.image,
1589
- url: (_a2 = recipe.source) == null ? void 0 : _a2.url,
2486
+ url: (_a = recipe.source) == null ? void 0 : _a.url,
1590
2487
  datePublished: recipe.dateAdded,
1591
2488
  dateModified: recipe.dateModified
1592
2489
  });
@@ -1594,7 +2491,7 @@ function convertBasicMetadata(recipe) {
1594
2491
  function convertIngredients2(ingredients = []) {
1595
2492
  const result = [];
1596
2493
  ingredients.forEach((ingredient) => {
1597
- var _a2;
2494
+ var _a;
1598
2495
  if (!ingredient) {
1599
2496
  return;
1600
2497
  }
@@ -1624,7 +2521,7 @@ function convertIngredients2(ingredients = []) {
1624
2521
  });
1625
2522
  return;
1626
2523
  }
1627
- const value = (_a2 = ingredient.item) == null ? void 0 : _a2.trim();
2524
+ const value = (_a = ingredient.item) == null ? void 0 : _a.trim();
1628
2525
  if (value) {
1629
2526
  result.push(value);
1630
2527
  }
@@ -1659,13 +2556,13 @@ function convertInstruction(entry) {
1659
2556
  return createHowToStep(String(entry));
1660
2557
  }
1661
2558
  function createHowToStep(entry) {
1662
- var _a2;
2559
+ var _a;
1663
2560
  if (!entry) return null;
1664
2561
  if (typeof entry === "string") {
1665
2562
  const trimmed2 = entry.trim();
1666
2563
  return trimmed2 || null;
1667
2564
  }
1668
- const trimmed = (_a2 = entry.text) == null ? void 0 : _a2.trim();
2565
+ const trimmed = (_a = entry.text) == null ? void 0 : _a.trim();
1669
2566
  if (!trimmed) {
1670
2567
  return null;
1671
2568
  }
@@ -1797,23 +2694,30 @@ function cleanOutput(obj) {
1797
2694
  Object.entries(obj).filter(([, value]) => value !== void 0)
1798
2695
  );
1799
2696
  }
1800
- function getSchemaOrgMappableModules(modules = []) {
1801
- const mappableModules = modules_default.modules.filter((m) => m.schemaOrgMappable).map((m) => `${m.id}@${m.latest}`);
1802
- return modules.filter((moduleId) => mappableModules.includes(moduleId));
2697
+ function getSchemaOrgMappableStacks(stacks = {}) {
2698
+ const mappableStackIds = /* @__PURE__ */ new Set();
2699
+ const mappableFromRegistry = stacks_default.stacks.filter((stack) => stack.schemaOrgMappable).map((stack) => `${stack.id}@${stack.latest}`);
2700
+ for (const [name, version] of Object.entries(stacks)) {
2701
+ const stackId = `${name}@${version}`;
2702
+ if (mappableFromRegistry.includes(stackId)) {
2703
+ mappableStackIds.add(stackId);
2704
+ }
2705
+ }
2706
+ return mappableStackIds;
1803
2707
  }
1804
2708
  function toSchemaOrg(recipe) {
1805
2709
  const base = convertBasicMetadata(recipe);
1806
2710
  const ingredients = convertIngredients2(recipe.ingredients);
1807
2711
  const instructions = convertInstructions2(recipe.instructions);
1808
- const recipeModules = Array.isArray(recipe.modules) ? recipe.modules : [];
1809
- const mappableModules = getSchemaOrgMappableModules(recipeModules);
1810
- const hasMappableNutrition = mappableModules.includes("nutrition@1");
2712
+ const recipeStacks = recipe.stacks && typeof recipe.stacks === "object" && !Array.isArray(recipe.stacks) ? recipe.stacks : {};
2713
+ const mappableStacks = getSchemaOrgMappableStacks(recipeStacks);
2714
+ const hasMappableNutrition = mappableStacks.has("nutrition@1");
1811
2715
  const nutrition = hasMappableNutrition ? convertNutrition2(recipe.nutrition) : void 0;
1812
- const hasMappableTimes = mappableModules.includes("times@1");
2716
+ const hasMappableTimes = mappableStacks.has("times@1");
1813
2717
  const timeData = hasMappableTimes ? recipe.times ? convertTimesModule(recipe.times) : convertTime2(recipe.time) : {};
1814
- const hasMappableAttribution = mappableModules.includes("attribution@1");
2718
+ const hasMappableAttribution = mappableStacks.has("attribution@1");
1815
2719
  const attributionData = hasMappableAttribution ? convertAuthor(recipe.source) : {};
1816
- const hasMappableTaxonomy = mappableModules.includes("taxonomy@1");
2720
+ const hasMappableTaxonomy = mappableStacks.has("taxonomy@1");
1817
2721
  const taxonomyData = hasMappableTaxonomy ? convertCategoryTags(recipe.category, recipe.tags) : {};
1818
2722
  return cleanOutput({
1819
2723
  ...base,
@@ -1841,8 +2745,6 @@ function isRecipeNode(value) {
1841
2745
  return false;
1842
2746
  }
1843
2747
  const type = value["@type"];
1844
- 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(() => {
1845
- });
1846
2748
  if (typeof type === "string") {
1847
2749
  return RECIPE_TYPES.has(type.toLowerCase());
1848
2750
  }
@@ -1880,7 +2782,7 @@ function extractRecipeBrowser(html) {
1880
2782
  return { recipe: null, source: null };
1881
2783
  }
1882
2784
  function extractJsonLdBrowser(html) {
1883
- var _a2;
2785
+ var _a;
1884
2786
  if (typeof globalThis.DOMParser === "undefined") {
1885
2787
  return null;
1886
2788
  }
@@ -1895,7 +2797,7 @@ function extractJsonLdBrowser(html) {
1895
2797
  if (!parsed) return;
1896
2798
  collectCandidates(parsed, candidates);
1897
2799
  });
1898
- return (_a2 = candidates[0]) != null ? _a2 : null;
2800
+ return (_a = candidates[0]) != null ? _a : null;
1899
2801
  }
1900
2802
  function extractMicrodataBrowser(html) {
1901
2803
  if (typeof globalThis.DOMParser === "undefined") {
@@ -1928,8 +2830,8 @@ function extractMicrodataBrowser(html) {
1928
2830
  }
1929
2831
  const instructions = [];
1930
2832
  recipeEl.querySelectorAll('[itemprop="recipeInstructions"]').forEach((el) => {
1931
- var _a2;
1932
- const text = normalizeText(el.getAttribute("content")) || normalizeText(((_a2 = el.querySelector('[itemprop="text"]')) == null ? void 0 : _a2.textContent) || void 0) || normalizeText(el.textContent || void 0);
2833
+ var _a;
2834
+ const text = normalizeText(el.getAttribute("content")) || normalizeText(((_a = el.querySelector('[itemprop="text"]')) == null ? void 0 : _a.textContent) || void 0) || normalizeText(el.textContent || void 0);
1933
2835
  if (text) instructions.push(text);
1934
2836
  });
1935
2837
  if (instructions.length) {
@@ -2100,12 +3002,12 @@ var UNIT_DEFINITIONS = {
2100
3002
  ...COUNT_UNITS
2101
3003
  };
2102
3004
  function normalizeUnitToken(unit) {
2103
- var _a2;
3005
+ var _a;
2104
3006
  if (!unit) {
2105
3007
  return null;
2106
3008
  }
2107
3009
  const token = unit.trim().toLowerCase().replace(/[\s-]+/g, "_");
2108
- const canonical = (_a2 = UNIT_SYNONYMS[token]) != null ? _a2 : token;
3010
+ const canonical = (_a = UNIT_SYNONYMS[token]) != null ? _a : token;
2109
3011
  return canonical in UNIT_DEFINITIONS ? canonical : null;
2110
3012
  }
2111
3013
  var UNIT_SYNONYMS = {
@@ -2211,8 +3113,8 @@ var VOLUME_TO_MASS_EQUIV_G_PER_UNIT = {
2211
3113
  };
2212
3114
  var DEFAULT_ROUND_MODE = "sane";
2213
3115
  function convertLineItemToMetric(item, mode, opts) {
2214
- var _a2, _b2, _c, _d;
2215
- const roundMode = (_a2 = opts == null ? void 0 : opts.round) != null ? _a2 : DEFAULT_ROUND_MODE;
3116
+ var _a, _b, _c, _d;
3117
+ const roundMode = (_a = opts == null ? void 0 : opts.round) != null ? _a : DEFAULT_ROUND_MODE;
2216
3118
  const normalizedUnit = normalizeUnitToken(item.unit);
2217
3119
  if (!normalizedUnit) {
2218
3120
  if (!item.unit || item.unit.trim() === "") {
@@ -2226,7 +3128,7 @@ function convertLineItemToMetric(item, mode, opts) {
2226
3128
  }
2227
3129
  if (mode === "volume") {
2228
3130
  if (definition.dimension !== "volume") {
2229
- throw new UnsupportedConversionError((_b2 = item.unit) != null ? _b2 : "", mode);
3131
+ throw new UnsupportedConversionError((_b = item.unit) != null ? _b : "", mode);
2230
3132
  }
2231
3133
  const { quantity, unit } = finalizeMetricVolume(
2232
3134
  convertToMetricBase(item.quantity, normalizedUnit).quantity,
@@ -2312,9 +3214,9 @@ function roundLargeMetric(value) {
2312
3214
  return Math.round(value * 100) / 100;
2313
3215
  }
2314
3216
  function lookupEquivalency(ingredient, unit) {
2315
- var _a2;
3217
+ var _a;
2316
3218
  const key = ingredient.trim().toLowerCase();
2317
- return (_a2 = VOLUME_TO_MASS_EQUIV_G_PER_UNIT[key]) == null ? void 0 : _a2[unit];
3219
+ return (_a = VOLUME_TO_MASS_EQUIV_G_PER_UNIT[key]) == null ? void 0 : _a[unit];
2318
3220
  }
2319
3221
 
2320
3222
  // src/mise-en-place/index.ts
@@ -2413,8 +3315,8 @@ function miseEnPlace(ingredients) {
2413
3315
  return { tasks, ungrouped };
2414
3316
  }
2415
3317
  function deriveIngredientLabel(ingredient) {
2416
- var _a2, _b2, _c;
2417
- return (_c = (_b2 = (_a2 = toDisplayString(ingredient.name)) != null ? _a2 : toDisplayString(ingredient.item)) != null ? _b2 : toDisplayString(ingredient.id)) != null ? _c : "ingredient";
3318
+ var _a, _b, _c;
3319
+ return (_c = (_b = (_a = toDisplayString(ingredient.name)) != null ? _a : toDisplayString(ingredient.item)) != null ? _b : toDisplayString(ingredient.id)) != null ? _c : "ingredient";
2418
3320
  }
2419
3321
  function extractNormalizedList(values) {
2420
3322
  if (!Array.isArray(values)) {
@@ -2495,6 +3397,7 @@ exports.detectProfiles = detectProfiles;
2495
3397
  exports.extractSchemaOrgRecipeFromHTML = extractSchemaOrgRecipeFromHTML;
2496
3398
  exports.fromSchemaOrg = fromSchemaOrg;
2497
3399
  exports.miseEnPlace = miseEnPlace;
3400
+ exports.normalizeRecipe = normalizeRecipe;
2498
3401
  exports.scaleRecipe = scaleRecipe;
2499
3402
  exports.toSchemaOrg = toSchemaOrg;
2500
3403
  exports.validateRecipe = validateRecipe;