soustack 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -364,8 +364,22 @@ function fromSchemaOrg(input) {
364
364
  const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
365
365
  const category = extractFirst(recipeNode.recipeCategory);
366
366
  const source = convertSource(recipeNode);
367
- const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
367
+ const dateModified = recipeNode.dateModified || void 0;
368
+ const nutrition = convertNutrition(recipeNode.nutrition);
369
+ const attribution = convertAttribution(recipeNode);
370
+ const taxonomy = convertTaxonomy(tags, category, extractFirst(recipeNode.recipeCuisine));
371
+ const media = convertMedia(recipeNode.image, recipeNode.video);
372
+ const times = convertTimes(time);
373
+ const modules = [];
374
+ if (attribution) modules.push("attribution@1");
375
+ if (taxonomy) modules.push("taxonomy@1");
376
+ if (media) modules.push("media@1");
377
+ if (nutrition) modules.push("nutrition@1");
378
+ if (times) modules.push("times@1");
368
379
  return {
380
+ "@type": "Recipe",
381
+ profile: "minimal",
382
+ modules: modules.sort(),
369
383
  name: recipeNode.name.trim(),
370
384
  description: recipeNode.description?.trim() || void 0,
371
385
  image: normalizeImage(recipeNode.image),
@@ -373,12 +387,16 @@ function fromSchemaOrg(input) {
373
387
  tags: tags.length ? tags : void 0,
374
388
  source,
375
389
  dateAdded: recipeNode.datePublished || void 0,
376
- dateModified: recipeNode.dateModified || void 0,
377
390
  yield: recipeYield,
378
391
  time,
379
392
  ingredients,
380
393
  instructions,
381
- nutrition
394
+ ...dateModified ? { dateModified } : {},
395
+ ...nutrition ? { nutrition } : {},
396
+ ...attribution ? { attribution } : {},
397
+ ...taxonomy ? { taxonomy } : {},
398
+ ...media ? { media } : {},
399
+ ...times ? { times } : {}
382
400
  };
383
401
  }
384
402
  function extractRecipeNode(input) {
@@ -591,6 +609,174 @@ function extractEntityName(value) {
591
609
  }
592
610
  return void 0;
593
611
  }
612
+ function convertAttribution(recipe) {
613
+ const attribution = {};
614
+ const url = (recipe.url || recipe.mainEntityOfPage)?.trim();
615
+ const author = extractEntityName(recipe.author);
616
+ const datePublished = recipe.datePublished?.trim();
617
+ if (url) attribution.url = url;
618
+ if (author) attribution.author = author;
619
+ if (datePublished) attribution.datePublished = datePublished;
620
+ return Object.keys(attribution).length ? attribution : void 0;
621
+ }
622
+ function convertTaxonomy(keywords, category, cuisine) {
623
+ const taxonomy = {};
624
+ if (keywords.length) taxonomy.keywords = keywords;
625
+ if (category) taxonomy.category = category;
626
+ if (cuisine) taxonomy.cuisine = cuisine;
627
+ return Object.keys(taxonomy).length ? taxonomy : void 0;
628
+ }
629
+ function normalizeMediaList(value) {
630
+ if (!value) return [];
631
+ if (typeof value === "string") return [value.trim()].filter(Boolean);
632
+ if (Array.isArray(value)) {
633
+ return value.map((item) => typeof item === "string" ? item.trim() : extractMediaUrl(item)).filter((entry) => Boolean(entry?.length));
634
+ }
635
+ const url = extractMediaUrl(value);
636
+ return url ? [url] : [];
637
+ }
638
+ function extractMediaUrl(value) {
639
+ if (value && typeof value === "object" && "url" in value && typeof value.url === "string") {
640
+ const trimmed = value.url.trim();
641
+ return trimmed || void 0;
642
+ }
643
+ return void 0;
644
+ }
645
+ function convertMedia(image, video) {
646
+ const normalizedImage = normalizeImage(image);
647
+ const images = normalizedImage ? Array.isArray(normalizedImage) ? normalizedImage : [normalizedImage] : [];
648
+ const videos = normalizeMediaList(video);
649
+ const media = {};
650
+ if (images.length) media.images = images;
651
+ if (videos.length) media.videos = videos;
652
+ return Object.keys(media).length ? media : void 0;
653
+ }
654
+ function convertTimes(time) {
655
+ if (!time) return void 0;
656
+ const times = {};
657
+ if (typeof time.prep === "number") times.prepMinutes = time.prep;
658
+ if (typeof time.active === "number") times.cookMinutes = time.active;
659
+ if (typeof time.total === "number") times.totalMinutes = time.total;
660
+ return Object.keys(times).length ? times : void 0;
661
+ }
662
+ function convertNutrition(nutrition) {
663
+ if (!nutrition || typeof nutrition !== "object") {
664
+ return void 0;
665
+ }
666
+ const result = {};
667
+ let hasData = false;
668
+ if ("calories" in nutrition) {
669
+ const calories = nutrition.calories;
670
+ if (typeof calories === "number") {
671
+ result.calories = calories;
672
+ hasData = true;
673
+ } else if (typeof calories === "string") {
674
+ const parsed = parseFloat(calories.replace(/[^\d.-]/g, ""));
675
+ if (!isNaN(parsed)) {
676
+ result.calories = parsed;
677
+ hasData = true;
678
+ }
679
+ }
680
+ }
681
+ if ("proteinContent" in nutrition || "protein_g" in nutrition) {
682
+ const protein = nutrition.proteinContent || nutrition.protein_g;
683
+ if (typeof protein === "number") {
684
+ result.protein_g = protein;
685
+ hasData = true;
686
+ } else if (typeof protein === "string") {
687
+ const parsed = parseFloat(protein.replace(/[^\d.-]/g, ""));
688
+ if (!isNaN(parsed)) {
689
+ result.protein_g = parsed;
690
+ hasData = true;
691
+ }
692
+ }
693
+ }
694
+ return hasData ? result : void 0;
695
+ }
696
+
697
+ // src/schemas/registry/modules.json
698
+ var modules_default = {
699
+ modules: [
700
+ {
701
+ id: "attribution",
702
+ versions: [
703
+ 1
704
+ ],
705
+ latest: 1,
706
+ namespace: "https://soustack.org/schemas/recipe/modules/attribution",
707
+ schema: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
708
+ schemaOrgMappable: true,
709
+ schemaOrgConfidence: "medium",
710
+ minProfile: "minimal",
711
+ allowedOnMinimal: true
712
+ },
713
+ {
714
+ id: "taxonomy",
715
+ versions: [
716
+ 1
717
+ ],
718
+ latest: 1,
719
+ namespace: "https://soustack.org/schemas/recipe/modules/taxonomy",
720
+ schema: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
721
+ schemaOrgMappable: true,
722
+ schemaOrgConfidence: "high",
723
+ minProfile: "minimal",
724
+ allowedOnMinimal: true
725
+ },
726
+ {
727
+ id: "media",
728
+ versions: [
729
+ 1
730
+ ],
731
+ latest: 1,
732
+ namespace: "https://soustack.org/schemas/recipe/modules/media",
733
+ schema: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
734
+ schemaOrgMappable: true,
735
+ schemaOrgConfidence: "medium",
736
+ minProfile: "minimal",
737
+ allowedOnMinimal: true
738
+ },
739
+ {
740
+ id: "nutrition",
741
+ versions: [
742
+ 1
743
+ ],
744
+ latest: 1,
745
+ namespace: "https://soustack.org/schemas/recipe/modules/nutrition",
746
+ schema: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
747
+ schemaOrgMappable: false,
748
+ schemaOrgConfidence: "low",
749
+ minProfile: "minimal",
750
+ allowedOnMinimal: true
751
+ },
752
+ {
753
+ id: "times",
754
+ versions: [
755
+ 1
756
+ ],
757
+ latest: 1,
758
+ namespace: "https://soustack.org/schemas/recipe/modules/times",
759
+ schema: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
760
+ schemaOrgMappable: true,
761
+ schemaOrgConfidence: "medium",
762
+ minProfile: "minimal",
763
+ allowedOnMinimal: true
764
+ },
765
+ {
766
+ id: "schedule",
767
+ versions: [
768
+ 1
769
+ ],
770
+ latest: 1,
771
+ namespace: "https://soustack.org/schemas/recipe/modules/schedule",
772
+ schema: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
773
+ schemaOrgMappable: false,
774
+ schemaOrgConfidence: "low",
775
+ minProfile: "core",
776
+ allowedOnMinimal: false
777
+ }
778
+ ]
779
+ };
594
780
 
595
781
  // src/converters/toSchemaOrg.ts
596
782
  function convertBasicMetadata(recipe) {
@@ -729,6 +915,22 @@ function convertTime2(time) {
729
915
  }
730
916
  return result;
731
917
  }
918
+ function convertTimesModule(times) {
919
+ if (!times) {
920
+ return {};
921
+ }
922
+ const result = {};
923
+ if (times.prepMinutes !== void 0) {
924
+ result.prepTime = formatDuration(times.prepMinutes);
925
+ }
926
+ if (times.cookMinutes !== void 0) {
927
+ result.cookTime = formatDuration(times.cookMinutes);
928
+ }
929
+ if (times.totalMinutes !== void 0) {
930
+ result.totalTime = formatDuration(times.totalMinutes);
931
+ }
932
+ return result;
933
+ }
732
934
  function convertYield(yld) {
733
935
  if (!yld) {
734
936
  return void 0;
@@ -767,33 +969,58 @@ function convertCategoryTags(category, tags) {
767
969
  }
768
970
  return result;
769
971
  }
770
- function convertNutrition(nutrition) {
972
+ function convertNutrition2(nutrition) {
771
973
  if (!nutrition) {
772
974
  return void 0;
773
975
  }
774
- return {
775
- ...nutrition,
976
+ const result = {
776
977
  "@type": "NutritionInformation"
777
978
  };
979
+ if (nutrition.calories !== void 0) {
980
+ if (typeof nutrition.calories === "number") {
981
+ result.calories = `${nutrition.calories} calories`;
982
+ } else {
983
+ result.calories = nutrition.calories;
984
+ }
985
+ }
986
+ Object.keys(nutrition).forEach((key) => {
987
+ if (key !== "calories" && key !== "@type") {
988
+ result[key] = nutrition[key];
989
+ }
990
+ });
991
+ return result;
778
992
  }
779
993
  function cleanOutput(obj) {
780
994
  return Object.fromEntries(
781
995
  Object.entries(obj).filter(([, value]) => value !== void 0)
782
996
  );
783
997
  }
998
+ function getSchemaOrgMappableModules(modules = []) {
999
+ const mappableModules = modules_default.modules.filter((m) => m.schemaOrgMappable).map((m) => `${m.id}@${m.latest}`);
1000
+ return modules.filter((moduleId) => mappableModules.includes(moduleId));
1001
+ }
784
1002
  function toSchemaOrg(recipe) {
785
1003
  const base = convertBasicMetadata(recipe);
786
1004
  const ingredients = convertIngredients2(recipe.ingredients);
787
1005
  const instructions = convertInstructions2(recipe.instructions);
788
- const nutrition = convertNutrition(recipe.nutrition);
1006
+ const recipeModules = Array.isArray(recipe.modules) ? recipe.modules : [];
1007
+ const mappableModules = getSchemaOrgMappableModules(recipeModules);
1008
+ const hasMappableNutrition = mappableModules.includes("nutrition@1");
1009
+ const nutrition = hasMappableNutrition ? convertNutrition2(recipe.nutrition) : void 0;
1010
+ const hasMappableTimes = mappableModules.includes("times@1");
1011
+ const timeData = hasMappableTimes ? recipe.times ? convertTimesModule(recipe.times) : convertTime2(recipe.time) : {};
1012
+ const hasMappableAttribution = mappableModules.includes("attribution@1");
1013
+ const attributionData = hasMappableAttribution ? convertAuthor(recipe.source) : {};
1014
+ const hasMappableTaxonomy = mappableModules.includes("taxonomy@1");
1015
+ const taxonomyData = hasMappableTaxonomy ? convertCategoryTags(recipe.category, recipe.tags) : {};
789
1016
  return cleanOutput({
790
1017
  ...base,
791
1018
  recipeIngredient: ingredients.length ? ingredients : void 0,
792
1019
  recipeInstructions: instructions.length ? instructions : void 0,
793
1020
  recipeYield: convertYield(recipe.yield),
794
- ...convertTime2(recipe.time),
795
- ...convertAuthor(recipe.source),
796
- ...convertCategoryTags(recipe.category, recipe.tags),
1021
+ ...timeData,
1022
+ ...attributionData,
1023
+ ...taxonomyData,
797
1024
  nutrition
798
1025
  });
799
1026
  }
@@ -1226,894 +1453,523 @@ async function scrapeRecipe(url, options = {}) {
1226
1453
  return soustackRecipe;
1227
1454
  }
1228
1455
 
1229
- // src/schema.json
1230
- var schema_default = {
1456
+ // src/schemas/recipe/base.schema.json
1457
+ var base_schema_default = {
1231
1458
  $schema: "http://json-schema.org/draft-07/schema#",
1232
- $id: "http://soustack.org/schema/v0.2.1",
1233
- title: "Soustack Recipe Schema v0.2.1",
1234
- description: "A portable, scalable, interoperable recipe format.",
1459
+ $id: "http://soustack.org/schema/recipe/base.schema.json",
1460
+ title: "Soustack Recipe Base Schema",
1461
+ description: "Base document shape for Soustack recipe documents. Profiles and modules build on this baseline.",
1235
1462
  type: "object",
1236
- required: ["name", "ingredients", "instructions"],
1237
- additionalProperties: false,
1238
- patternProperties: {
1239
- "^x-": {}
1240
- },
1463
+ additionalProperties: true,
1241
1464
  properties: {
1242
- $schema: {
1243
- type: "string",
1244
- format: "uri",
1245
- description: "Optional schema hint for tooling compatibility"
1246
- },
1247
- id: {
1248
- type: "string",
1249
- description: "Unique identifier (slug or UUID)"
1250
- },
1251
- name: {
1252
- type: "string",
1253
- description: "The title of the recipe"
1254
- },
1255
- title: {
1256
- type: "string",
1257
- description: "Optional display title; alias for name"
1465
+ "@type": {
1466
+ const: "Recipe",
1467
+ description: "Document marker for Soustack recipes"
1258
1468
  },
1259
- version: {
1469
+ profile: {
1260
1470
  type: "string",
1261
- pattern: "^\\d+\\.\\d+\\.\\d+$",
1262
- description: "DEPRECATED: use recipeVersion for authoring revisions"
1471
+ description: "Profile identifier applied to this recipe"
1263
1472
  },
1264
- recipeVersion: {
1265
- type: "string",
1266
- pattern: "^\\d+\\.\\d+\\.\\d+$",
1267
- description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
1268
- },
1269
- description: {
1270
- type: "string"
1271
- },
1272
- category: {
1273
- type: "string",
1274
- examples: ["Main Course", "Dessert"]
1275
- },
1276
- tags: {
1473
+ modules: {
1277
1474
  type: "array",
1278
- items: { type: "string" }
1279
- },
1280
- image: {
1281
- description: "Recipe-level hero image(s)",
1282
- anyOf: [
1283
- {
1284
- type: "string",
1285
- format: "uri"
1286
- },
1287
- {
1288
- type: "array",
1289
- minItems: 1,
1290
- items: {
1291
- type: "string",
1292
- format: "uri"
1293
- }
1294
- }
1295
- ]
1296
- },
1297
- dateAdded: {
1298
- type: "string",
1299
- format: "date-time"
1300
- },
1301
- metadata: {
1302
- type: "object",
1303
- additionalProperties: true,
1304
- description: "Free-form vendor metadata"
1305
- },
1306
- source: {
1307
- type: "object",
1308
- properties: {
1309
- author: { type: "string" },
1310
- url: { type: "string", format: "uri" },
1311
- name: { type: "string" },
1312
- adapted: { type: "boolean" }
1475
+ description: "List of module identifiers applied to this recipe",
1476
+ items: {
1477
+ type: "string"
1313
1478
  }
1314
1479
  },
1315
- yield: {
1316
- $ref: "#/definitions/yield"
1317
- },
1318
- time: {
1319
- $ref: "#/definitions/time"
1320
- },
1321
- equipment: {
1322
- type: "array",
1323
- items: { $ref: "#/definitions/equipment" }
1480
+ name: {
1481
+ type: "string",
1482
+ description: "Human-readable recipe name"
1324
1483
  },
1325
1484
  ingredients: {
1326
1485
  type: "array",
1327
- items: {
1328
- anyOf: [
1329
- { type: "string" },
1330
- { $ref: "#/definitions/ingredient" },
1331
- { $ref: "#/definitions/ingredientSubsection" }
1332
- ]
1333
- }
1486
+ description: "Ingredients payload; content is validated by profiles/modules"
1334
1487
  },
1335
1488
  instructions: {
1336
1489
  type: "array",
1337
- items: {
1338
- anyOf: [
1339
- { type: "string" },
1340
- { $ref: "#/definitions/instruction" },
1341
- { $ref: "#/definitions/instructionSubsection" }
1342
- ]
1343
- }
1344
- },
1345
- storage: {
1346
- $ref: "#/definitions/storage"
1347
- },
1348
- substitutions: {
1349
- type: "array",
1350
- items: { $ref: "#/definitions/substitution" }
1490
+ description: "Instruction payload; content is validated by profiles/modules"
1351
1491
  }
1352
1492
  },
1353
- definitions: {
1354
- yield: {
1355
- type: "object",
1356
- required: ["amount", "unit"],
1357
- properties: {
1358
- amount: { type: "number" },
1359
- unit: { type: "string" },
1360
- servings: { type: "number" },
1361
- description: { type: "string" }
1362
- }
1363
- },
1364
- time: {
1365
- type: "object",
1366
- properties: {
1367
- prep: { type: "number" },
1368
- active: { type: "number" },
1369
- passive: { type: "number" },
1370
- total: { type: "number" },
1371
- prepTime: { type: "string", format: "duration" },
1372
- cookTime: { type: "string", format: "duration" }
1373
- },
1374
- minProperties: 1
1375
- },
1376
- quantity: {
1377
- type: "object",
1378
- required: ["amount"],
1379
- properties: {
1380
- amount: { type: "number" },
1381
- unit: { type: ["string", "null"] }
1382
- }
1383
- },
1384
- scaling: {
1493
+ required: ["@type"]
1494
+ };
1495
+
1496
+ // src/schemas/recipe/profiles/core.schema.json
1497
+ var core_schema_default = {
1498
+ $schema: "http://json-schema.org/draft-07/schema#",
1499
+ $id: "http://soustack.org/schema/recipe/profiles/core.schema.json",
1500
+ title: "Soustack Recipe Core Profile",
1501
+ description: "Core profile that builds on the minimal profile and is intended to be combined with recipe modules.",
1502
+ allOf: [
1503
+ { $ref: "http://soustack.org/schema/recipe/base.schema.json" },
1504
+ {
1385
1505
  type: "object",
1386
- required: ["type"],
1387
1506
  properties: {
1388
- type: {
1389
- type: "string",
1390
- enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
1507
+ profile: { const: "core" },
1508
+ modules: {
1509
+ type: "array",
1510
+ items: { type: "string" },
1511
+ uniqueItems: true,
1512
+ default: []
1391
1513
  },
1392
- factor: { type: "number" },
1393
- referenceId: { type: "string" },
1394
- roundTo: { type: "number" },
1395
- min: { type: "number" },
1396
- max: { type: "number" }
1397
- },
1398
- if: {
1399
- properties: { type: { const: "bakers_percentage" } }
1514
+ name: { type: "string", minLength: 1 },
1515
+ ingredients: { type: "array", minItems: 1 },
1516
+ instructions: { type: "array", minItems: 1 }
1400
1517
  },
1401
- then: {
1402
- required: ["referenceId"]
1403
- }
1404
- },
1405
- ingredient: {
1406
- type: "object",
1407
- required: ["item"],
1408
- properties: {
1409
- id: { type: "string" },
1410
- item: { type: "string" },
1411
- quantity: { $ref: "#/definitions/quantity" },
1412
- name: { type: "string" },
1413
- aisle: { type: "string" },
1414
- prep: { type: "string" },
1415
- prepAction: { type: "string" },
1416
- prepTime: { type: "number" },
1417
- destination: { type: "string" },
1418
- scaling: { $ref: "#/definitions/scaling" },
1419
- critical: { type: "boolean" },
1420
- optional: { type: "boolean" },
1421
- notes: { type: "string" }
1422
- }
1423
- },
1424
- ingredientSubsection: {
1425
- type: "object",
1426
- required: ["subsection", "items"],
1427
- properties: {
1428
- subsection: { type: "string" },
1429
- items: {
1430
- type: "array",
1431
- items: { $ref: "#/definitions/ingredient" }
1432
- }
1433
- }
1434
- },
1435
- equipment: {
1436
- type: "object",
1437
- required: ["name"],
1438
- properties: {
1439
- id: { type: "string" },
1440
- name: { type: "string" },
1441
- required: { type: "boolean" },
1442
- label: { type: "string" },
1443
- capacity: { $ref: "#/definitions/quantity" },
1444
- scalingLimit: { type: "number" },
1445
- alternatives: {
1446
- type: "array",
1447
- items: { type: "string" }
1448
- }
1449
- }
1518
+ required: ["profile", "name", "ingredients", "instructions"],
1519
+ additionalProperties: true
1520
+ }
1521
+ ]
1522
+ };
1523
+
1524
+ // src/schemas/recipe/profiles/minimal.schema.json
1525
+ var minimal_schema_default = {
1526
+ $schema: "http://json-schema.org/draft-07/schema#",
1527
+ $id: "http://soustack.org/schema/recipe/profiles/minimal.schema.json",
1528
+ title: "Soustack Recipe Minimal Profile",
1529
+ description: "Minimal profile that ensures the basic Recipe structure is present while allowing modules to extend it.",
1530
+ allOf: [
1531
+ {
1532
+ $ref: "http://soustack.org/schema/recipe/base.schema.json"
1450
1533
  },
1451
- instruction: {
1534
+ {
1452
1535
  type: "object",
1453
- required: ["text"],
1454
1536
  properties: {
1455
- id: { type: "string" },
1456
- text: { type: "string" },
1457
- image: {
1458
- type: "string",
1459
- format: "uri",
1460
- description: "Optional image that illustrates this instruction"
1537
+ profile: {
1538
+ const: "minimal"
1461
1539
  },
1462
- destination: { type: "string" },
1463
- dependsOn: {
1464
- type: "array",
1465
- items: { type: "string" }
1466
- },
1467
- inputs: {
1468
- type: "array",
1469
- items: { type: "string" }
1470
- },
1471
- timing: {
1472
- type: "object",
1473
- required: ["duration", "type"],
1474
- properties: {
1475
- duration: {
1476
- anyOf: [
1477
- { type: "number" },
1478
- { type: "string", pattern: "^P" }
1479
- ],
1480
- description: "Minutes as a number or ISO8601 duration string"
1481
- },
1482
- type: { type: "string", enum: ["active", "passive"] },
1483
- scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
1484
- }
1485
- }
1486
- }
1487
- },
1488
- instructionSubsection: {
1489
- type: "object",
1490
- required: ["subsection", "items"],
1491
- properties: {
1492
- subsection: { type: "string" },
1493
- items: {
1540
+ modules: {
1494
1541
  type: "array",
1495
1542
  items: {
1496
- anyOf: [
1497
- { type: "string" },
1498
- { $ref: "#/definitions/instruction" }
1543
+ type: "string",
1544
+ enum: [
1545
+ "attribution@1",
1546
+ "taxonomy@1",
1547
+ "media@1",
1548
+ "nutrition@1",
1549
+ "times@1"
1499
1550
  ]
1500
- }
1501
- }
1502
- }
1503
- },
1504
- storage: {
1505
- type: "object",
1506
- properties: {
1507
- roomTemp: { $ref: "#/definitions/storageMethod" },
1508
- refrigerated: { $ref: "#/definitions/storageMethod" },
1509
- frozen: {
1510
- allOf: [
1511
- { $ref: "#/definitions/storageMethod" },
1512
- {
1513
- type: "object",
1514
- properties: { thawing: { type: "string" } }
1515
- }
1516
- ]
1551
+ },
1552
+ default: []
1517
1553
  },
1518
- reheating: { type: "string" },
1519
- makeAhead: {
1554
+ name: {
1555
+ type: "string",
1556
+ minLength: 1
1557
+ },
1558
+ ingredients: {
1520
1559
  type: "array",
1521
- items: {
1522
- allOf: [
1523
- { $ref: "#/definitions/storageMethod" },
1524
- {
1525
- type: "object",
1526
- required: ["component", "storage"],
1527
- properties: {
1528
- component: { type: "string" },
1529
- storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
1530
- }
1531
- }
1532
- ]
1533
- }
1534
- }
1535
- }
1536
- },
1537
- storageMethod: {
1538
- type: "object",
1539
- required: ["duration"],
1540
- properties: {
1541
- duration: { type: "string", pattern: "^P" },
1542
- method: { type: "string" },
1543
- notes: { type: "string" }
1544
- }
1545
- },
1546
- substitution: {
1547
- type: "object",
1548
- required: ["ingredient"],
1549
- properties: {
1550
- ingredient: { type: "string" },
1551
- critical: { type: "boolean" },
1552
- notes: { type: "string" },
1553
- alternatives: {
1560
+ minItems: 1
1561
+ },
1562
+ instructions: {
1554
1563
  type: "array",
1555
- items: {
1556
- type: "object",
1557
- required: ["name", "ratio"],
1558
- properties: {
1559
- name: { type: "string" },
1560
- ratio: { type: "string" },
1561
- notes: { type: "string" },
1562
- impact: { type: "string" },
1563
- dietary: {
1564
- type: "array",
1565
- items: { type: "string" }
1566
- }
1567
- }
1568
- }
1564
+ minItems: 1
1569
1565
  }
1570
- }
1566
+ },
1567
+ required: [
1568
+ "profile",
1569
+ "name",
1570
+ "ingredients",
1571
+ "instructions"
1572
+ ],
1573
+ additionalProperties: true
1571
1574
  }
1572
- }
1575
+ ]
1573
1576
  };
1574
1577
 
1575
- // src/soustack.schema.json
1576
- var soustack_schema_default = {
1578
+ // src/schemas/recipe/modules/schedule/1.schema.json
1579
+ var schema_default = {
1577
1580
  $schema: "http://json-schema.org/draft-07/schema#",
1578
- $id: "http://soustack.org/schema/v0.2.1",
1579
- title: "Soustack Recipe Schema v0.2.1",
1580
- description: "A portable, scalable, interoperable recipe format.",
1581
+ $id: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
1582
+ title: "Soustack Recipe Module: schedule v1",
1583
+ description: "Schema for the schedule module. Enforces bidirectional module gating and restricts usage to the core profile.",
1581
1584
  type: "object",
1582
- required: ["name", "ingredients", "instructions"],
1583
- additionalProperties: false,
1584
- patternProperties: {
1585
- "^x-": {}
1586
- },
1587
1585
  properties: {
1588
- $schema: {
1589
- type: "string",
1590
- format: "uri",
1591
- description: "Optional schema hint for tooling compatibility"
1592
- },
1593
- id: {
1594
- type: "string",
1595
- description: "Unique identifier (slug or UUID)"
1596
- },
1597
- name: {
1598
- type: "string",
1599
- description: "The title of the recipe"
1600
- },
1601
- title: {
1602
- type: "string",
1603
- description: "Optional display title; alias for name"
1604
- },
1605
- version: {
1606
- type: "string",
1607
- pattern: "^\\d+\\.\\d+\\.\\d+$",
1608
- description: "DEPRECATED: use recipeVersion for authoring revisions"
1609
- },
1610
- recipeVersion: {
1611
- type: "string",
1612
- pattern: "^\\d+\\.\\d+\\.\\d+$",
1613
- description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
1614
- },
1615
- description: {
1616
- type: "string"
1617
- },
1618
- category: {
1619
- type: "string",
1620
- examples: ["Main Course", "Dessert"]
1621
- },
1622
- tags: {
1586
+ profile: { type: "string" },
1587
+ modules: {
1623
1588
  type: "array",
1624
1589
  items: { type: "string" }
1625
1590
  },
1626
- image: {
1627
- description: "Recipe-level hero image(s)",
1628
- anyOf: [
1629
- {
1630
- type: "string",
1631
- format: "uri"
1632
- },
1633
- {
1634
- type: "array",
1635
- minItems: 1,
1636
- items: {
1637
- type: "string",
1638
- format: "uri"
1639
- }
1640
- }
1641
- ]
1642
- },
1643
- dateAdded: {
1644
- type: "string",
1645
- format: "date-time"
1646
- },
1647
- metadata: {
1648
- type: "object",
1649
- additionalProperties: true,
1650
- description: "Free-form vendor metadata"
1651
- },
1652
- source: {
1591
+ schedule: {
1653
1592
  type: "object",
1654
1593
  properties: {
1655
- author: { type: "string" },
1656
- url: { type: "string", format: "uri" },
1657
- name: { type: "string" },
1658
- adapted: { type: "boolean" }
1659
- }
1660
- },
1661
- yield: {
1662
- $ref: "#/definitions/yield"
1663
- },
1664
- time: {
1665
- $ref: "#/definitions/time"
1666
- },
1667
- equipment: {
1668
- type: "array",
1669
- items: { $ref: "#/definitions/equipment" }
1670
- },
1671
- ingredients: {
1672
- type: "array",
1673
- items: {
1674
- anyOf: [
1675
- { type: "string" },
1676
- { $ref: "#/definitions/ingredient" },
1677
- { $ref: "#/definitions/ingredientSubsection" }
1678
- ]
1679
- }
1680
- },
1681
- instructions: {
1682
- type: "array",
1683
- items: {
1684
- anyOf: [
1685
- { type: "string" },
1686
- { $ref: "#/definitions/instruction" },
1687
- { $ref: "#/definitions/instructionSubsection" }
1688
- ]
1689
- }
1690
- },
1691
- storage: {
1692
- $ref: "#/definitions/storage"
1693
- },
1694
- substitutions: {
1695
- type: "array",
1696
- items: { $ref: "#/definitions/substitution" }
1594
+ tasks: { type: "array" }
1595
+ },
1596
+ additionalProperties: false
1697
1597
  }
1698
1598
  },
1699
- definitions: {
1700
- yield: {
1701
- type: "object",
1702
- required: ["amount", "unit"],
1703
- properties: {
1704
- amount: { type: "number" },
1705
- unit: { type: "string" },
1706
- servings: { type: "number" },
1707
- description: { type: "string" }
1708
- }
1709
- },
1710
- time: {
1711
- type: "object",
1712
- properties: {
1713
- prep: { type: "number" },
1714
- active: { type: "number" },
1715
- passive: { type: "number" },
1716
- total: { type: "number" },
1717
- prepTime: { type: "string", format: "duration" },
1718
- cookTime: { type: "string", format: "duration" }
1719
- },
1720
- minProperties: 1
1721
- },
1722
- quantity: {
1723
- type: "object",
1724
- required: ["amount"],
1725
- properties: {
1726
- amount: { type: "number" },
1727
- unit: { type: ["string", "null"] }
1728
- }
1729
- },
1730
- scaling: {
1731
- type: "object",
1732
- required: ["type"],
1733
- properties: {
1734
- type: {
1735
- type: "string",
1736
- enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
1737
- },
1738
- factor: { type: "number" },
1739
- referenceId: { type: "string" },
1740
- roundTo: { type: "number" },
1741
- min: { type: "number" },
1742
- max: { type: "number" }
1743
- },
1599
+ allOf: [
1600
+ {
1744
1601
  if: {
1745
- properties: { type: { const: "bakers_percentage" } }
1602
+ properties: {
1603
+ modules: {
1604
+ type: "array",
1605
+ contains: { const: "schedule@1" }
1606
+ }
1607
+ }
1746
1608
  },
1747
1609
  then: {
1748
- required: ["referenceId"]
1749
- }
1750
- },
1751
- ingredient: {
1752
- type: "object",
1753
- required: ["item"],
1754
- properties: {
1755
- id: { type: "string" },
1756
- item: { type: "string" },
1757
- quantity: { $ref: "#/definitions/quantity" },
1758
- name: { type: "string" },
1759
- aisle: { type: "string" },
1760
- prep: { type: "string" },
1761
- prepAction: { type: "string" },
1762
- prepTime: { type: "number" },
1763
- destination: { type: "string" },
1764
- scaling: { $ref: "#/definitions/scaling" },
1765
- critical: { type: "boolean" },
1766
- optional: { type: "boolean" },
1767
- notes: { type: "string" }
1768
- }
1769
- },
1770
- ingredientSubsection: {
1771
- type: "object",
1772
- required: ["subsection", "items"],
1773
- properties: {
1774
- subsection: { type: "string" },
1775
- items: {
1776
- type: "array",
1777
- items: { $ref: "#/definitions/ingredient" }
1610
+ required: ["schedule", "profile"],
1611
+ properties: {
1612
+ profile: { const: "core" }
1778
1613
  }
1779
1614
  }
1780
1615
  },
1781
- equipment: {
1782
- type: "object",
1783
- required: ["name"],
1784
- properties: {
1785
- id: { type: "string" },
1786
- name: { type: "string" },
1787
- required: { type: "boolean" },
1788
- label: { type: "string" },
1789
- capacity: { $ref: "#/definitions/quantity" },
1790
- scalingLimit: { type: "number" },
1791
- alternatives: {
1792
- type: "array",
1793
- items: { type: "string" }
1616
+ {
1617
+ if: {
1618
+ required: ["schedule"]
1619
+ },
1620
+ then: {
1621
+ required: ["modules", "profile"],
1622
+ properties: {
1623
+ modules: {
1624
+ type: "array",
1625
+ items: { type: "string" },
1626
+ contains: { const: "schedule@1" }
1627
+ },
1628
+ profile: { const: "core" }
1794
1629
  }
1795
1630
  }
1631
+ }
1632
+ ],
1633
+ additionalProperties: true
1634
+ };
1635
+
1636
+ // src/schemas/recipe/modules/nutrition/1.schema.json
1637
+ var schema_default2 = {
1638
+ $schema: "http://json-schema.org/draft-07/schema#",
1639
+ $id: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
1640
+ title: "Soustack Recipe Module: nutrition v1",
1641
+ description: "Schema for the nutrition module. Keeps nutrition data aligned with module declarations and vice versa.",
1642
+ type: "object",
1643
+ properties: {
1644
+ modules: {
1645
+ type: "array",
1646
+ items: { type: "string" }
1796
1647
  },
1797
- instruction: {
1648
+ nutrition: {
1798
1649
  type: "object",
1799
- required: ["text"],
1800
1650
  properties: {
1801
- id: { type: "string" },
1802
- text: { type: "string" },
1803
- image: {
1804
- type: "string",
1805
- format: "uri",
1806
- description: "Optional image that illustrates this instruction"
1807
- },
1808
- destination: { type: "string" },
1809
- dependsOn: {
1810
- type: "array",
1811
- items: { type: "string" }
1812
- },
1813
- inputs: {
1814
- type: "array",
1815
- items: { type: "string" }
1816
- },
1817
- timing: {
1818
- type: "object",
1819
- required: ["duration", "type"],
1820
- properties: {
1821
- duration: {
1822
- anyOf: [
1823
- { type: "number" },
1824
- { type: "string", pattern: "^P" }
1825
- ],
1826
- description: "Minutes as a number or ISO8601 duration string"
1827
- },
1828
- type: { type: "string", enum: ["active", "passive"] },
1829
- scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
1651
+ calories: { type: "number" },
1652
+ protein_g: { type: "number" }
1653
+ },
1654
+ additionalProperties: false
1655
+ }
1656
+ },
1657
+ allOf: [
1658
+ {
1659
+ if: {
1660
+ properties: {
1661
+ modules: {
1662
+ type: "array",
1663
+ contains: { const: "nutrition@1" }
1830
1664
  }
1831
1665
  }
1666
+ },
1667
+ then: {
1668
+ required: ["nutrition"]
1832
1669
  }
1833
1670
  },
1834
- instructionSubsection: {
1835
- type: "object",
1836
- required: ["subsection", "items"],
1837
- properties: {
1838
- subsection: { type: "string" },
1839
- items: {
1840
- type: "array",
1841
- items: {
1842
- anyOf: [
1843
- { type: "string" },
1844
- { $ref: "#/definitions/instruction" }
1845
- ]
1671
+ {
1672
+ if: {
1673
+ required: ["nutrition"]
1674
+ },
1675
+ then: {
1676
+ required: ["modules"],
1677
+ properties: {
1678
+ modules: {
1679
+ type: "array",
1680
+ items: { type: "string" },
1681
+ contains: { const: "nutrition@1" }
1846
1682
  }
1847
1683
  }
1848
1684
  }
1685
+ }
1686
+ ],
1687
+ additionalProperties: true
1688
+ };
1689
+
1690
+ // src/schemas/recipe/modules/attribution/1.schema.json
1691
+ var schema_default3 = {
1692
+ $schema: "http://json-schema.org/draft-07/schema#",
1693
+ $id: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
1694
+ title: "Soustack Recipe Module: attribution v1",
1695
+ description: "Schema for the attribution module. Ensures namespace data is present when the module is enabled and vice versa.",
1696
+ type: "object",
1697
+ properties: {
1698
+ modules: {
1699
+ type: "array",
1700
+ items: { type: "string" }
1849
1701
  },
1850
- storage: {
1702
+ attribution: {
1851
1703
  type: "object",
1852
1704
  properties: {
1853
- roomTemp: { $ref: "#/definitions/storageMethod" },
1854
- refrigerated: { $ref: "#/definitions/storageMethod" },
1855
- frozen: {
1856
- allOf: [
1857
- { $ref: "#/definitions/storageMethod" },
1858
- {
1859
- type: "object",
1860
- properties: { thawing: { type: "string" } }
1861
- }
1862
- ]
1863
- },
1864
- reheating: { type: "string" },
1865
- makeAhead: {
1866
- type: "array",
1867
- items: {
1868
- allOf: [
1869
- { $ref: "#/definitions/storageMethod" },
1870
- {
1871
- type: "object",
1872
- required: ["component", "storage"],
1873
- properties: {
1874
- component: { type: "string" },
1875
- storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
1876
- }
1877
- }
1878
- ]
1705
+ url: { type: "string" },
1706
+ author: { type: "string" },
1707
+ datePublished: { type: "string" }
1708
+ },
1709
+ additionalProperties: false
1710
+ }
1711
+ },
1712
+ allOf: [
1713
+ {
1714
+ if: {
1715
+ properties: {
1716
+ modules: {
1717
+ type: "array",
1718
+ contains: { const: "attribution@1" }
1879
1719
  }
1880
1720
  }
1721
+ },
1722
+ then: {
1723
+ required: ["attribution"]
1881
1724
  }
1882
1725
  },
1883
- storageMethod: {
1884
- type: "object",
1885
- required: ["duration"],
1886
- properties: {
1887
- duration: { type: "string", pattern: "^P" },
1888
- method: { type: "string" },
1889
- notes: { type: "string" }
1890
- }
1891
- },
1892
- substitution: {
1893
- type: "object",
1894
- required: ["ingredient"],
1895
- properties: {
1896
- ingredient: { type: "string" },
1897
- critical: { type: "boolean" },
1898
- notes: { type: "string" },
1899
- alternatives: {
1900
- type: "array",
1901
- items: {
1902
- type: "object",
1903
- required: ["name", "ratio"],
1904
- properties: {
1905
- name: { type: "string" },
1906
- ratio: { type: "string" },
1907
- notes: { type: "string" },
1908
- impact: { type: "string" },
1909
- dietary: {
1910
- type: "array",
1911
- items: { type: "string" }
1912
- }
1913
- }
1726
+ {
1727
+ if: {
1728
+ required: ["attribution"]
1729
+ },
1730
+ then: {
1731
+ required: ["modules"],
1732
+ properties: {
1733
+ modules: {
1734
+ type: "array",
1735
+ items: { type: "string" },
1736
+ contains: { const: "attribution@1" }
1914
1737
  }
1915
1738
  }
1916
1739
  }
1917
1740
  }
1918
- }
1919
- };
1920
-
1921
- // src/profiles/base.schema.json
1922
- var base_schema_default = {
1923
- $schema: "http://json-schema.org/draft-07/schema#",
1924
- $id: "http://soustack.org/schema/v0.2.1/profiles/base",
1925
- title: "Soustack Base Profile Schema",
1926
- description: "Wrapper schema that exposes the unmodified Soustack base schema.",
1927
- allOf: [
1928
- { $ref: "http://soustack.org/schema/v0.2.1" }
1929
- ]
1741
+ ],
1742
+ additionalProperties: true
1930
1743
  };
1931
1744
 
1932
- // src/profiles/cookable.schema.json
1933
- var cookable_schema_default = {
1745
+ // src/schemas/recipe/modules/taxonomy/1.schema.json
1746
+ var schema_default4 = {
1934
1747
  $schema: "http://json-schema.org/draft-07/schema#",
1935
- $id: "http://soustack.org/schema/v0.2.1/profiles/cookable",
1936
- title: "Soustack Cookable Profile Schema",
1937
- description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
1938
- allOf: [
1939
- { $ref: "http://soustack.org/schema/v0.2.1" },
1940
- {
1941
- required: ["yield", "time", "ingredients", "instructions"],
1748
+ $id: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
1749
+ title: "Soustack Recipe Module: taxonomy v1",
1750
+ description: "Schema for the taxonomy module. Enforces keyword and categorization data when enabled and ensures module declaration accompanies the namespace block.",
1751
+ type: "object",
1752
+ properties: {
1753
+ modules: {
1754
+ type: "array",
1755
+ items: { type: "string" }
1756
+ },
1757
+ taxonomy: {
1758
+ type: "object",
1942
1759
  properties: {
1943
- yield: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/yield" },
1944
- time: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/time" },
1945
- ingredients: { type: "array", minItems: 1 },
1946
- instructions: { type: "array", minItems: 1 }
1947
- }
1760
+ keywords: { type: "array", items: { type: "string" } },
1761
+ category: { type: "string" },
1762
+ cuisine: { type: "string" }
1763
+ },
1764
+ additionalProperties: false
1948
1765
  }
1949
- ]
1950
- };
1951
-
1952
- // src/profiles/quantified.schema.json
1953
- var quantified_schema_default = {
1954
- $schema: "http://json-schema.org/draft-07/schema#",
1955
- $id: "http://soustack.org/schema/v0.2.1/profiles/quantified",
1956
- title: "Soustack Quantified Profile Schema",
1957
- description: "Extends the base schema to require quantified ingredient entries.",
1766
+ },
1958
1767
  allOf: [
1959
- { $ref: "http://soustack.org/schema/v0.2.1" },
1960
1768
  {
1961
- properties: {
1962
- ingredients: {
1963
- type: "array",
1964
- items: {
1965
- anyOf: [
1966
- { $ref: "#/definitions/quantifiedIngredient" },
1967
- { $ref: "#/definitions/quantifiedIngredientSubsection" }
1968
- ]
1769
+ if: {
1770
+ properties: {
1771
+ modules: {
1772
+ type: "array",
1773
+ contains: { const: "taxonomy@1" }
1969
1774
  }
1970
1775
  }
1776
+ },
1777
+ then: {
1778
+ required: ["taxonomy"]
1971
1779
  }
1972
- }
1973
- ],
1974
- definitions: {
1975
- quantifiedIngredient: {
1976
- allOf: [
1977
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredient" },
1978
- { required: ["item", "quantity"] }
1979
- ]
1980
1780
  },
1981
- quantifiedIngredientSubsection: {
1982
- allOf: [
1983
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredientSubsection" },
1984
- {
1985
- properties: {
1986
- items: {
1987
- type: "array",
1988
- items: { $ref: "#/definitions/quantifiedIngredient" }
1989
- }
1781
+ {
1782
+ if: {
1783
+ required: ["taxonomy"]
1784
+ },
1785
+ then: {
1786
+ required: ["modules"],
1787
+ properties: {
1788
+ modules: {
1789
+ type: "array",
1790
+ items: { type: "string" },
1791
+ contains: { const: "taxonomy@1" }
1990
1792
  }
1991
1793
  }
1992
- ]
1794
+ }
1993
1795
  }
1994
- }
1796
+ ],
1797
+ additionalProperties: true
1995
1798
  };
1996
1799
 
1997
- // src/profiles/illustrated.schema.json
1998
- var illustrated_schema_default = {
1800
+ // src/schemas/recipe/modules/media/1.schema.json
1801
+ var schema_default5 = {
1999
1802
  $schema: "http://json-schema.org/draft-07/schema#",
2000
- $id: "http://soustack.org/schema/v0.2.1/profiles/illustrated",
2001
- title: "Soustack Illustrated Profile Schema",
2002
- description: "Extends the base schema to guarantee at least one illustrative image.",
1803
+ $id: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
1804
+ title: "Soustack Recipe Module: media v1",
1805
+ description: "Schema for the media module. Guards media blocks based on module activation and ensures declarations accompany payloads.",
1806
+ type: "object",
1807
+ properties: {
1808
+ modules: {
1809
+ type: "array",
1810
+ items: { type: "string" }
1811
+ },
1812
+ media: {
1813
+ type: "object",
1814
+ properties: {
1815
+ images: { type: "array", items: { type: "string" } },
1816
+ videos: { type: "array", items: { type: "string" } }
1817
+ },
1818
+ additionalProperties: false
1819
+ }
1820
+ },
2003
1821
  allOf: [
2004
- { $ref: "http://soustack.org/schema/v0.2.1" },
2005
1822
  {
2006
- anyOf: [
2007
- { required: ["image"] },
2008
- {
2009
- properties: {
2010
- instructions: {
2011
- type: "array",
2012
- contains: {
2013
- anyOf: [
2014
- { $ref: "#/definitions/imageInstruction" },
2015
- { $ref: "#/definitions/instructionSubsectionWithImage" }
2016
- ]
2017
- }
2018
- }
1823
+ if: {
1824
+ properties: {
1825
+ modules: {
1826
+ type: "array",
1827
+ contains: { const: "media@1" }
2019
1828
  }
2020
1829
  }
2021
- ]
2022
- }
2023
- ],
2024
- definitions: {
2025
- imageInstruction: {
2026
- allOf: [
2027
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
2028
- { required: ["image"] }
2029
- ]
1830
+ },
1831
+ then: {
1832
+ required: ["media"]
1833
+ }
2030
1834
  },
2031
- instructionSubsectionWithImage: {
2032
- allOf: [
2033
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
2034
- {
2035
- properties: {
2036
- items: {
2037
- type: "array",
2038
- contains: { $ref: "#/definitions/imageInstruction" }
2039
- }
1835
+ {
1836
+ if: {
1837
+ required: ["media"]
1838
+ },
1839
+ then: {
1840
+ required: ["modules"],
1841
+ properties: {
1842
+ modules: {
1843
+ type: "array",
1844
+ items: { type: "string" },
1845
+ contains: { const: "media@1" }
2040
1846
  }
2041
1847
  }
2042
- ]
1848
+ }
2043
1849
  }
2044
- }
1850
+ ],
1851
+ additionalProperties: true
2045
1852
  };
2046
1853
 
2047
- // src/profiles/schedulable.schema.json
2048
- var schedulable_schema_default = {
1854
+ // src/schemas/recipe/modules/times/1.schema.json
1855
+ var schema_default6 = {
2049
1856
  $schema: "http://json-schema.org/draft-07/schema#",
2050
- $id: "http://soustack.org/schema/v0.2.1/profiles/schedulable",
2051
- title: "Soustack Schedulable Profile Schema",
2052
- description: "Extends the base schema to ensure every instruction is fully scheduled.",
1857
+ $id: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
1858
+ title: "Soustack Recipe Module: times v1",
1859
+ description: "Schema for the times module. Maintains alignment between module declarations and timing payloads.",
1860
+ type: "object",
1861
+ properties: {
1862
+ modules: {
1863
+ type: "array",
1864
+ items: { type: "string" }
1865
+ },
1866
+ times: {
1867
+ type: "object",
1868
+ properties: {
1869
+ prepMinutes: { type: "number" },
1870
+ cookMinutes: { type: "number" },
1871
+ totalMinutes: { type: "number" }
1872
+ },
1873
+ additionalProperties: false
1874
+ }
1875
+ },
2053
1876
  allOf: [
2054
- { $ref: "http://soustack.org/schema/v0.2.1" },
2055
1877
  {
2056
- properties: {
2057
- instructions: {
2058
- type: "array",
2059
- items: {
2060
- anyOf: [
2061
- { $ref: "#/definitions/schedulableInstruction" },
2062
- { $ref: "#/definitions/schedulableInstructionSubsection" }
2063
- ]
1878
+ if: {
1879
+ properties: {
1880
+ modules: {
1881
+ type: "array",
1882
+ contains: { const: "times@1" }
2064
1883
  }
2065
1884
  }
1885
+ },
1886
+ then: {
1887
+ required: ["times"]
2066
1888
  }
2067
- }
2068
- ],
2069
- definitions: {
2070
- schedulableInstruction: {
2071
- allOf: [
2072
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
2073
- { required: ["id", "timing"] }
2074
- ]
2075
1889
  },
2076
- schedulableInstructionSubsection: {
2077
- allOf: [
2078
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
2079
- {
2080
- properties: {
2081
- items: {
2082
- type: "array",
2083
- items: { $ref: "#/definitions/schedulableInstruction" }
2084
- }
1890
+ {
1891
+ if: {
1892
+ required: ["times"]
1893
+ },
1894
+ then: {
1895
+ required: ["modules"],
1896
+ properties: {
1897
+ modules: {
1898
+ type: "array",
1899
+ items: { type: "string" },
1900
+ contains: { const: "times@1" }
2085
1901
  }
2086
1902
  }
2087
- ]
1903
+ }
2088
1904
  }
2089
- }
1905
+ ],
1906
+ additionalProperties: true
2090
1907
  };
2091
1908
 
2092
1909
  // src/validator.ts
1910
+ var CANONICAL_BASE_SCHEMA_ID = base_schema_default.$id || "http://soustack.org/schema/recipe/base.schema.json";
1911
+ var canonicalProfileId = (profile) => {
1912
+ if (profile === "minimal") {
1913
+ return minimal_schema_default.$id;
1914
+ }
1915
+ if (profile === "core") {
1916
+ return core_schema_default.$id;
1917
+ }
1918
+ throw new Error(`Unknown profile: ${profile}`);
1919
+ };
1920
+ var moduleIdToSchemaRef = (moduleId) => {
1921
+ const match = moduleId.match(/^([a-z0-9_-]+)@(\d+(?:\.\d+)*)$/i);
1922
+ if (!match) {
1923
+ throw new Error(`Invalid module identifier '${moduleId}'. Expected <name>@<version>.`);
1924
+ }
1925
+ const [, name, version] = match;
1926
+ const moduleSchemas2 = {
1927
+ "schedule@1": schema_default,
1928
+ "nutrition@1": schema_default2,
1929
+ "attribution@1": schema_default3,
1930
+ "taxonomy@1": schema_default4,
1931
+ "media@1": schema_default5,
1932
+ "times@1": schema_default6
1933
+ };
1934
+ const schema = moduleSchemas2[moduleId];
1935
+ if (schema && schema.$id) {
1936
+ return schema.$id;
1937
+ }
1938
+ return `https://soustack.org/schemas/recipe/modules/${name}/${version}.schema.json`;
1939
+ };
2093
1940
  var profileSchemas = {
2094
- base: base_schema_default,
2095
- cookable: cookable_schema_default,
2096
- scalable: base_schema_default,
2097
- quantified: quantified_schema_default,
2098
- illustrated: illustrated_schema_default,
2099
- schedulable: schedulable_schema_default
1941
+ minimal: minimal_schema_default,
1942
+ core: core_schema_default
1943
+ };
1944
+ var moduleSchemas = {
1945
+ "schedule@1": schema_default,
1946
+ "nutrition@1": schema_default2,
1947
+ "attribution@1": schema_default3,
1948
+ "taxonomy@1": schema_default4,
1949
+ "media@1": schema_default5,
1950
+ "times@1": schema_default6
2100
1951
  };
2101
1952
  var validationContexts = /* @__PURE__ */ new Map();
2102
1953
  function createContext(collectAllErrors) {
2103
1954
  const ajv = new Ajv__default.default({ strict: false, allErrors: collectAllErrors });
2104
1955
  addFormats__default.default(ajv);
2105
- const loadedIds = /* @__PURE__ */ new Set();
2106
- const addSchemaIfNew = (schema) => {
1956
+ const addSchemaWithAlias = (schema, alias) => {
2107
1957
  if (!schema) return;
2108
- const schemaId = schema?.$id;
2109
- if (schemaId && loadedIds.has(schemaId)) return;
2110
- ajv.addSchema(schema);
2111
- if (schemaId) loadedIds.add(schemaId);
1958
+ const schemaId = schema.$id || alias;
1959
+ if (schemaId) {
1960
+ ajv.addSchema(schema, schemaId);
1961
+ } else {
1962
+ ajv.addSchema(schema);
1963
+ }
2112
1964
  };
2113
- addSchemaIfNew(schema_default);
2114
- addSchemaIfNew(soustack_schema_default);
2115
- Object.values(profileSchemas).forEach(addSchemaIfNew);
2116
- return { ajv, validators: {} };
1965
+ addSchemaWithAlias(base_schema_default, CANONICAL_BASE_SCHEMA_ID);
1966
+ Object.entries(profileSchemas).forEach(([name, schema]) => {
1967
+ addSchemaWithAlias(schema, canonicalProfileId(name));
1968
+ });
1969
+ Object.entries(moduleSchemas).forEach(([moduleId, schema]) => {
1970
+ addSchemaWithAlias(schema, moduleIdToSchemaRef(moduleId));
1971
+ });
1972
+ return { ajv, validators: /* @__PURE__ */ new Map() };
2117
1973
  }
2118
1974
  function getContext(collectAllErrors) {
2119
1975
  if (!validationContexts.has(collectAllErrors)) {
@@ -2136,14 +1992,58 @@ function detectProfileFromSchema(schemaRef) {
2136
1992
  }
2137
1993
  return void 0;
2138
1994
  }
2139
- function getValidator(profile, context) {
1995
+ function resolveSchemaRef(inputSchema, requestedSchema) {
1996
+ if (typeof requestedSchema === "string") return requestedSchema;
1997
+ if (typeof inputSchema !== "string") return void 0;
1998
+ return detectProfileFromSchema(inputSchema) ? inputSchema : void 0;
1999
+ }
2000
+ function inferModulesFromPayload(recipe) {
2001
+ const inferred = [];
2002
+ const payloadToModule = {
2003
+ attribution: "attribution@1",
2004
+ taxonomy: "taxonomy@1",
2005
+ media: "media@1",
2006
+ times: "times@1",
2007
+ nutrition: "nutrition@1",
2008
+ schedule: "schedule@1"
2009
+ };
2010
+ for (const [field, moduleId] of Object.entries(payloadToModule)) {
2011
+ if (recipe && typeof recipe === "object" && field in recipe && recipe[field] != null) {
2012
+ const payload = recipe[field];
2013
+ if (typeof payload === "object" && !Array.isArray(payload)) {
2014
+ if (Object.keys(payload).length > 0) {
2015
+ inferred.push(moduleId);
2016
+ }
2017
+ } else if (Array.isArray(payload) && payload.length > 0) {
2018
+ inferred.push(moduleId);
2019
+ } else if (payload !== null && payload !== void 0) {
2020
+ inferred.push(moduleId);
2021
+ }
2022
+ }
2023
+ }
2024
+ return inferred;
2025
+ }
2026
+ function getCombinedValidator(profile, modules, recipe, context) {
2027
+ const inferredModules = inferModulesFromPayload(recipe);
2028
+ const allModules = /* @__PURE__ */ new Set([...modules, ...inferredModules]);
2029
+ const sortedModules = Array.from(allModules).sort();
2030
+ const cacheKey = `${profile}::${sortedModules.join(",")}`;
2031
+ const cached = context.validators.get(cacheKey);
2032
+ if (cached) return cached;
2140
2033
  if (!profileSchemas[profile]) {
2141
2034
  throw new Error(`Unknown Soustack profile: ${profile}`);
2142
2035
  }
2143
- if (!context.validators[profile]) {
2144
- context.validators[profile] = context.ajv.compile(profileSchemas[profile]);
2145
- }
2146
- return context.validators[profile];
2036
+ const schema = {
2037
+ $id: `urn:soustack:recipe:${cacheKey}`,
2038
+ allOf: [
2039
+ { $ref: CANONICAL_BASE_SCHEMA_ID },
2040
+ { $ref: canonicalProfileId(profile) },
2041
+ ...sortedModules.map((moduleId) => ({ $ref: moduleIdToSchemaRef(moduleId) }))
2042
+ ]
2043
+ };
2044
+ const validateFn = context.ajv.compile(schema);
2045
+ context.validators.set(cacheKey, validateFn);
2046
+ return validateFn;
2147
2047
  }
2148
2048
  function normalizeRecipe(recipe) {
2149
2049
  const normalized = cloneRecipe(recipe);
@@ -2174,9 +2074,33 @@ function normalizeTime(recipe) {
2174
2074
  });
2175
2075
  }
2176
2076
  var allowedTopLevelProps = /* @__PURE__ */ new Set([
2177
- ...Object.keys(soustack_schema_default?.properties ?? {}),
2178
- "metadata",
2179
- "$schema"
2077
+ ...Object.keys(base_schema_default?.properties ?? {}),
2078
+ "$schema",
2079
+ // Module fields (validated by module schemas)
2080
+ "attribution",
2081
+ "taxonomy",
2082
+ "media",
2083
+ "times",
2084
+ "nutrition",
2085
+ "schedule",
2086
+ // Common recipe fields (allowed by base schema's additionalProperties: true)
2087
+ "description",
2088
+ "image",
2089
+ "category",
2090
+ "tags",
2091
+ "source",
2092
+ "dateAdded",
2093
+ "dateModified",
2094
+ "yield",
2095
+ "time",
2096
+ "id",
2097
+ "title",
2098
+ "recipeVersion",
2099
+ "version",
2100
+ // deprecated but allowed
2101
+ "equipment",
2102
+ "storage",
2103
+ "substitutions"
2180
2104
  ]);
2181
2105
  function detectUnknownTopLevelKeys(recipe) {
2182
2106
  if (!recipe || typeof recipe !== "object") return [];
@@ -2201,11 +2125,19 @@ function formatAjvError(error) {
2201
2125
  message: error.message || "Validation error"
2202
2126
  };
2203
2127
  }
2204
- function runAjvValidation(data, profile, context, schemaRef) {
2205
- const validator = schemaRef ? context.ajv.getSchema(schemaRef) : void 0;
2206
- const validateFn = validator ?? getValidator(profile, context);
2207
- const isValid = validateFn(data);
2208
- return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
2128
+ function runAjvValidation(data, profile, modules, context) {
2129
+ try {
2130
+ const validateFn = getCombinedValidator(profile, modules, data, context);
2131
+ const isValid = validateFn(data);
2132
+ return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
2133
+ } catch (error) {
2134
+ return [
2135
+ {
2136
+ path: "/",
2137
+ message: error instanceof Error ? error.message : "Validation failed to initialize"
2138
+ }
2139
+ ];
2140
+ }
2209
2141
  }
2210
2142
  function isInstruction(item) {
2211
2143
  return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
@@ -2286,12 +2218,25 @@ function checkInstructionGraph(recipe) {
2286
2218
  function validateRecipe(input, options = {}) {
2287
2219
  const collectAllErrors = options.collectAllErrors ?? true;
2288
2220
  const context = getContext(collectAllErrors);
2289
- const schemaRef = options.schema ?? (typeof input?.$schema === "string" ? input.$schema : void 0);
2290
- const profile = options.profile ?? detectProfileFromSchema(schemaRef) ?? "base";
2221
+ const schemaRef = resolveSchemaRef(input?.$schema, options.schema);
2222
+ const profileFromDocument = typeof input?.profile === "string" ? input.profile : void 0;
2223
+ const profile = options.profile ?? profileFromDocument ?? detectProfileFromSchema(schemaRef) ?? "core";
2224
+ const modulesFromDocument = Array.isArray(input?.modules) ? input.modules.filter((value) => typeof value === "string") : [];
2225
+ const modules = modulesFromDocument.length > 0 ? [...modulesFromDocument].sort() : [];
2291
2226
  const { normalized, warnings } = normalizeRecipe(input);
2227
+ if (!profileFromDocument) {
2228
+ normalized.profile = profile;
2229
+ } else {
2230
+ normalized.profile = profileFromDocument;
2231
+ }
2232
+ if (!("modules" in normalized) || normalized.modules === void 0 || normalized.modules === null) {
2233
+ normalized.modules = [];
2234
+ } else if (modulesFromDocument.length > 0) {
2235
+ normalized.modules = modules;
2236
+ }
2292
2237
  const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
2293
- const validationErrors = runAjvValidation(normalized, profile, context, schemaRef);
2294
- const graphErrors = profile === "schedulable" && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
2238
+ const validationErrors = runAjvValidation(normalized, profile, modules, context);
2239
+ const graphErrors = modules.includes("schedule@1") && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
2295
2240
  const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
2296
2241
  return {
2297
2242
  valid: errors.length === 0,