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