soustack 0.2.3 → 0.4.0

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