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/README.md +128 -18
- package/dist/cli/index.js +1706 -665
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.mts +172 -28
- package/dist/index.d.ts +172 -28
- package/dist/index.js +2028 -662
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2022 -662
- package/dist/index.mjs.map +1 -1
- package/dist/{scrape.d.mts → scrape/index.d.mts} +38 -10
- package/dist/{scrape.d.ts → scrape/index.d.ts} +38 -10
- package/dist/{scrape.js → scrape/index.js} +268 -62
- package/dist/scrape/index.js.map +1 -0
- package/dist/{scrape.mjs → scrape/index.mjs} +268 -62
- package/dist/scrape/index.mjs.map +1 -0
- package/package.json +15 -9
- package/src/profiles/base.schema.json +2 -2
- package/src/profiles/cookable.schema.json +4 -4
- package/src/profiles/illustrated.schema.json +4 -4
- package/src/profiles/quantified.schema.json +4 -4
- package/src/profiles/scalable.schema.json +6 -6
- package/src/profiles/schedulable.schema.json +4 -4
- package/src/schema.json +15 -3
- package/src/soustack.schema.json +15 -3
- package/dist/scrape.js.map +0 -1
- package/dist/scrape.mjs.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -5,7 +5,7 @@ var fs = require('fs');
|
|
|
5
5
|
var path = require('path');
|
|
6
6
|
var glob = require('glob');
|
|
7
7
|
var cheerio = require('cheerio');
|
|
8
|
-
var
|
|
8
|
+
var Ajv2020 = require('ajv/dist/2020');
|
|
9
9
|
var addFormats = require('ajv-formats');
|
|
10
10
|
|
|
11
11
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
@@ -30,7 +30,7 @@ function _interopNamespace(e) {
|
|
|
30
30
|
|
|
31
31
|
var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
|
|
32
32
|
var path__namespace = /*#__PURE__*/_interopNamespace(path);
|
|
33
|
-
var
|
|
33
|
+
var Ajv2020__default = /*#__PURE__*/_interopDefault(Ajv2020);
|
|
34
34
|
var addFormats__default = /*#__PURE__*/_interopDefault(addFormats);
|
|
35
35
|
|
|
36
36
|
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
@@ -351,6 +351,92 @@ function extractUrl(value) {
|
|
|
351
351
|
return trimmed || void 0;
|
|
352
352
|
}
|
|
353
353
|
|
|
354
|
+
// src/normalize.ts
|
|
355
|
+
function normalizeRecipe(input) {
|
|
356
|
+
if (!input || typeof input !== "object") {
|
|
357
|
+
throw new Error("Recipe input must be an object");
|
|
358
|
+
}
|
|
359
|
+
const recipe = JSON.parse(JSON.stringify(input));
|
|
360
|
+
const warnings = [];
|
|
361
|
+
const legacyField = ["mod", "ules"].join("");
|
|
362
|
+
if (legacyField in recipe) {
|
|
363
|
+
throw new Error("The legacy field is no longer supported. Use `stacks` instead.");
|
|
364
|
+
}
|
|
365
|
+
normalizeStacks(recipe, warnings);
|
|
366
|
+
if (!recipe.stacks) {
|
|
367
|
+
recipe.stacks = {};
|
|
368
|
+
}
|
|
369
|
+
if (recipe && typeof recipe === "object" && "version" in recipe && !recipe.recipeVersion && typeof recipe.version === "string") {
|
|
370
|
+
recipe.recipeVersion = recipe.version;
|
|
371
|
+
warnings.push("'version' is deprecated; mapped to 'recipeVersion'.");
|
|
372
|
+
}
|
|
373
|
+
normalizeTime(recipe);
|
|
374
|
+
return {
|
|
375
|
+
recipe,
|
|
376
|
+
warnings
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function normalizeStacks(recipe, warnings) {
|
|
380
|
+
let stacks = {};
|
|
381
|
+
if (recipe.stacks && typeof recipe.stacks === "object" && !Array.isArray(recipe.stacks)) {
|
|
382
|
+
for (const [key, value] of Object.entries(recipe.stacks)) {
|
|
383
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 1) {
|
|
384
|
+
stacks[key] = value;
|
|
385
|
+
} else {
|
|
386
|
+
warnings.push(`Invalid stack version for '${key}': expected positive integer, got ${value}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (Array.isArray(recipe.stacks)) {
|
|
391
|
+
const stackIdentifiers = recipe.stacks.filter((s) => typeof s === "string");
|
|
392
|
+
for (const identifier of stackIdentifiers) {
|
|
393
|
+
const parsed = parseStackIdentifier(identifier);
|
|
394
|
+
if (parsed) {
|
|
395
|
+
const { name, version } = parsed;
|
|
396
|
+
if (!stacks[name] || stacks[name] < version) {
|
|
397
|
+
stacks[name] = version;
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
warnings.push(`Invalid stack identifier '${identifier}': expected format 'name@version' (e.g., 'scaling@1')`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
recipe.stacks = stacks;
|
|
405
|
+
}
|
|
406
|
+
function parseStackIdentifier(identifier) {
|
|
407
|
+
if (typeof identifier !== "string" || !identifier.trim()) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
const match = identifier.trim().match(/^([a-z0-9_-]+)@(\d+)$/i);
|
|
411
|
+
if (!match) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
const [, name, versionStr] = match;
|
|
415
|
+
const version = parseInt(versionStr, 10);
|
|
416
|
+
if (isNaN(version) || version < 1) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
return { name, version };
|
|
420
|
+
}
|
|
421
|
+
function normalizeTime(recipe) {
|
|
422
|
+
const time = recipe?.time;
|
|
423
|
+
if (!time || typeof time !== "object" || Array.isArray(time)) return;
|
|
424
|
+
const structuredKeys = [
|
|
425
|
+
"prep",
|
|
426
|
+
"active",
|
|
427
|
+
"passive",
|
|
428
|
+
"total"
|
|
429
|
+
];
|
|
430
|
+
structuredKeys.forEach((key) => {
|
|
431
|
+
const value = time[key];
|
|
432
|
+
if (typeof value === "number") return;
|
|
433
|
+
const parsed = parseDuration(value);
|
|
434
|
+
if (parsed !== null) {
|
|
435
|
+
time[key] = parsed;
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
354
440
|
// src/fromSchemaOrg.ts
|
|
355
441
|
function fromSchemaOrg(input) {
|
|
356
442
|
const recipeNode = extractRecipeNode(input);
|
|
@@ -364,8 +450,22 @@ function fromSchemaOrg(input) {
|
|
|
364
450
|
const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
|
|
365
451
|
const category = extractFirst(recipeNode.recipeCategory);
|
|
366
452
|
const source = convertSource(recipeNode);
|
|
367
|
-
const
|
|
368
|
-
|
|
453
|
+
const dateModified = recipeNode.dateModified || void 0;
|
|
454
|
+
const nutrition = convertNutrition(recipeNode.nutrition);
|
|
455
|
+
const attribution = convertAttribution(recipeNode);
|
|
456
|
+
const taxonomy = convertTaxonomy(tags, category, extractFirst(recipeNode.recipeCuisine));
|
|
457
|
+
const media = convertMedia(recipeNode.image, recipeNode.video);
|
|
458
|
+
const times = convertTimes(time);
|
|
459
|
+
const stacks = {};
|
|
460
|
+
if (attribution) stacks.attribution = 1;
|
|
461
|
+
if (taxonomy) stacks.taxonomy = 1;
|
|
462
|
+
if (media) stacks.media = 1;
|
|
463
|
+
if (nutrition) stacks.nutrition = 1;
|
|
464
|
+
if (times) stacks.times = 1;
|
|
465
|
+
const rawRecipe = {
|
|
466
|
+
"@type": "Recipe",
|
|
467
|
+
profile: "minimal",
|
|
468
|
+
stacks,
|
|
369
469
|
name: recipeNode.name.trim(),
|
|
370
470
|
description: recipeNode.description?.trim() || void 0,
|
|
371
471
|
image: normalizeImage(recipeNode.image),
|
|
@@ -373,13 +473,19 @@ function fromSchemaOrg(input) {
|
|
|
373
473
|
tags: tags.length ? tags : void 0,
|
|
374
474
|
source,
|
|
375
475
|
dateAdded: recipeNode.datePublished || void 0,
|
|
376
|
-
dateModified: recipeNode.dateModified || void 0,
|
|
377
476
|
yield: recipeYield,
|
|
378
477
|
time,
|
|
379
478
|
ingredients,
|
|
380
479
|
instructions,
|
|
381
|
-
|
|
480
|
+
...dateModified ? { dateModified } : {},
|
|
481
|
+
...nutrition ? { nutrition } : {},
|
|
482
|
+
...attribution ? { attribution } : {},
|
|
483
|
+
...taxonomy ? { taxonomy } : {},
|
|
484
|
+
...media ? { media } : {},
|
|
485
|
+
...times ? { times } : {}
|
|
382
486
|
};
|
|
487
|
+
const { recipe } = normalizeRecipe(rawRecipe);
|
|
488
|
+
return recipe;
|
|
383
489
|
}
|
|
384
490
|
function extractRecipeNode(input) {
|
|
385
491
|
if (!input) return null;
|
|
@@ -591,6 +697,162 @@ function extractEntityName(value) {
|
|
|
591
697
|
}
|
|
592
698
|
return void 0;
|
|
593
699
|
}
|
|
700
|
+
function convertAttribution(recipe) {
|
|
701
|
+
const attribution = {};
|
|
702
|
+
const url = (recipe.url || recipe.mainEntityOfPage)?.trim();
|
|
703
|
+
const author = extractEntityName(recipe.author);
|
|
704
|
+
const datePublished = recipe.datePublished?.trim();
|
|
705
|
+
if (url) attribution.url = url;
|
|
706
|
+
if (author) attribution.author = author;
|
|
707
|
+
if (datePublished) attribution.datePublished = datePublished;
|
|
708
|
+
return Object.keys(attribution).length ? attribution : void 0;
|
|
709
|
+
}
|
|
710
|
+
function convertTaxonomy(keywords, category, cuisine) {
|
|
711
|
+
const taxonomy = {};
|
|
712
|
+
if (keywords.length) taxonomy.keywords = keywords;
|
|
713
|
+
if (category) taxonomy.category = category;
|
|
714
|
+
if (cuisine) taxonomy.cuisine = cuisine;
|
|
715
|
+
return Object.keys(taxonomy).length ? taxonomy : void 0;
|
|
716
|
+
}
|
|
717
|
+
function normalizeMediaList(value) {
|
|
718
|
+
if (!value) return [];
|
|
719
|
+
if (typeof value === "string") return [value.trim()].filter(Boolean);
|
|
720
|
+
if (Array.isArray(value)) {
|
|
721
|
+
return value.map((item) => typeof item === "string" ? item.trim() : extractMediaUrl(item)).filter((entry) => Boolean(entry?.length));
|
|
722
|
+
}
|
|
723
|
+
const url = extractMediaUrl(value);
|
|
724
|
+
return url ? [url] : [];
|
|
725
|
+
}
|
|
726
|
+
function extractMediaUrl(value) {
|
|
727
|
+
if (value && typeof value === "object" && "url" in value && typeof value.url === "string") {
|
|
728
|
+
const trimmed = value.url.trim();
|
|
729
|
+
return trimmed || void 0;
|
|
730
|
+
}
|
|
731
|
+
return void 0;
|
|
732
|
+
}
|
|
733
|
+
function convertMedia(image, video) {
|
|
734
|
+
const normalizedImage = normalizeImage(image);
|
|
735
|
+
const images = normalizedImage ? Array.isArray(normalizedImage) ? normalizedImage : [normalizedImage] : [];
|
|
736
|
+
const videos = normalizeMediaList(video);
|
|
737
|
+
const media = {};
|
|
738
|
+
if (images.length) media.images = images;
|
|
739
|
+
if (videos.length) media.videos = videos;
|
|
740
|
+
return Object.keys(media).length ? media : void 0;
|
|
741
|
+
}
|
|
742
|
+
function convertTimes(time) {
|
|
743
|
+
if (!time) return void 0;
|
|
744
|
+
const times = {};
|
|
745
|
+
if (typeof time.prep === "number") times.prepMinutes = time.prep;
|
|
746
|
+
if (typeof time.active === "number") times.cookMinutes = time.active;
|
|
747
|
+
if (typeof time.total === "number") times.totalMinutes = time.total;
|
|
748
|
+
return Object.keys(times).length ? times : void 0;
|
|
749
|
+
}
|
|
750
|
+
function convertNutrition(nutrition) {
|
|
751
|
+
if (!nutrition || typeof nutrition !== "object") {
|
|
752
|
+
return void 0;
|
|
753
|
+
}
|
|
754
|
+
const result = {};
|
|
755
|
+
let hasData = false;
|
|
756
|
+
if ("calories" in nutrition) {
|
|
757
|
+
const calories = nutrition.calories;
|
|
758
|
+
if (typeof calories === "number") {
|
|
759
|
+
result.calories = calories;
|
|
760
|
+
hasData = true;
|
|
761
|
+
} else if (typeof calories === "string") {
|
|
762
|
+
const parsed = parseFloat(calories.replace(/[^\d.-]/g, ""));
|
|
763
|
+
if (!isNaN(parsed)) {
|
|
764
|
+
result.calories = parsed;
|
|
765
|
+
hasData = true;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if ("proteinContent" in nutrition || "protein_g" in nutrition) {
|
|
770
|
+
const protein = nutrition.proteinContent || nutrition.protein_g;
|
|
771
|
+
if (typeof protein === "number") {
|
|
772
|
+
result.protein_g = protein;
|
|
773
|
+
hasData = true;
|
|
774
|
+
} else if (typeof protein === "string") {
|
|
775
|
+
const parsed = parseFloat(protein.replace(/[^\d.-]/g, ""));
|
|
776
|
+
if (!isNaN(parsed)) {
|
|
777
|
+
result.protein_g = parsed;
|
|
778
|
+
hasData = true;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return hasData ? result : void 0;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// src/schemas/registry/stacks.json
|
|
786
|
+
var stacks_default = {
|
|
787
|
+
stacks: [
|
|
788
|
+
{
|
|
789
|
+
id: "attribution",
|
|
790
|
+
versions: [1],
|
|
791
|
+
latest: 1,
|
|
792
|
+
namespace: "http://soustack.org/schema/v0.3.0/stacks/attribution",
|
|
793
|
+
schema: "http://soustack.org/schema/v0.3.0/stacks/attribution",
|
|
794
|
+
schemaOrgMappable: true,
|
|
795
|
+
schemaOrgConfidence: "medium",
|
|
796
|
+
minProfile: "minimal",
|
|
797
|
+
allowedOnMinimal: true
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
id: "taxonomy",
|
|
801
|
+
versions: [1],
|
|
802
|
+
latest: 1,
|
|
803
|
+
namespace: "http://soustack.org/schema/v0.3.0/stacks/taxonomy",
|
|
804
|
+
schema: "http://soustack.org/schema/v0.3.0/stacks/taxonomy",
|
|
805
|
+
schemaOrgMappable: true,
|
|
806
|
+
schemaOrgConfidence: "high",
|
|
807
|
+
minProfile: "minimal",
|
|
808
|
+
allowedOnMinimal: true
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
id: "media",
|
|
812
|
+
versions: [1],
|
|
813
|
+
latest: 1,
|
|
814
|
+
namespace: "http://soustack.org/schema/v0.3.0/stacks/media",
|
|
815
|
+
schema: "http://soustack.org/schema/v0.3.0/stacks/media",
|
|
816
|
+
schemaOrgMappable: true,
|
|
817
|
+
schemaOrgConfidence: "medium",
|
|
818
|
+
minProfile: "minimal",
|
|
819
|
+
allowedOnMinimal: true
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
id: "nutrition",
|
|
823
|
+
versions: [1],
|
|
824
|
+
latest: 1,
|
|
825
|
+
namespace: "http://soustack.org/schema/v0.3.0/stacks/nutrition",
|
|
826
|
+
schema: "http://soustack.org/schema/v0.3.0/stacks/nutrition",
|
|
827
|
+
schemaOrgMappable: false,
|
|
828
|
+
schemaOrgConfidence: "low",
|
|
829
|
+
minProfile: "minimal",
|
|
830
|
+
allowedOnMinimal: true
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
id: "times",
|
|
834
|
+
versions: [1],
|
|
835
|
+
latest: 1,
|
|
836
|
+
namespace: "http://soustack.org/schema/v0.3.0/stacks/times",
|
|
837
|
+
schema: "http://soustack.org/schema/v0.3.0/stacks/times",
|
|
838
|
+
schemaOrgMappable: true,
|
|
839
|
+
schemaOrgConfidence: "medium",
|
|
840
|
+
minProfile: "minimal",
|
|
841
|
+
allowedOnMinimal: true
|
|
842
|
+
},
|
|
843
|
+
{
|
|
844
|
+
id: "schedule",
|
|
845
|
+
versions: [1],
|
|
846
|
+
latest: 1,
|
|
847
|
+
namespace: "http://soustack.org/schema/v0.3.0/stacks/schedule",
|
|
848
|
+
schema: "http://soustack.org/schema/v0.3.0/stacks/schedule",
|
|
849
|
+
schemaOrgMappable: false,
|
|
850
|
+
schemaOrgConfidence: "low",
|
|
851
|
+
minProfile: "core",
|
|
852
|
+
allowedOnMinimal: false
|
|
853
|
+
}
|
|
854
|
+
]
|
|
855
|
+
};
|
|
594
856
|
|
|
595
857
|
// src/converters/toSchemaOrg.ts
|
|
596
858
|
function convertBasicMetadata(recipe) {
|
|
@@ -729,6 +991,22 @@ function convertTime2(time) {
|
|
|
729
991
|
}
|
|
730
992
|
return result;
|
|
731
993
|
}
|
|
994
|
+
function convertTimesModule(times) {
|
|
995
|
+
if (!times) {
|
|
996
|
+
return {};
|
|
997
|
+
}
|
|
998
|
+
const result = {};
|
|
999
|
+
if (times.prepMinutes !== void 0) {
|
|
1000
|
+
result.prepTime = formatDuration(times.prepMinutes);
|
|
1001
|
+
}
|
|
1002
|
+
if (times.cookMinutes !== void 0) {
|
|
1003
|
+
result.cookTime = formatDuration(times.cookMinutes);
|
|
1004
|
+
}
|
|
1005
|
+
if (times.totalMinutes !== void 0) {
|
|
1006
|
+
result.totalTime = formatDuration(times.totalMinutes);
|
|
1007
|
+
}
|
|
1008
|
+
return result;
|
|
1009
|
+
}
|
|
732
1010
|
function convertYield(yld) {
|
|
733
1011
|
if (!yld) {
|
|
734
1012
|
return void 0;
|
|
@@ -767,33 +1045,65 @@ function convertCategoryTags(category, tags) {
|
|
|
767
1045
|
}
|
|
768
1046
|
return result;
|
|
769
1047
|
}
|
|
770
|
-
function
|
|
1048
|
+
function convertNutrition2(nutrition) {
|
|
771
1049
|
if (!nutrition) {
|
|
772
1050
|
return void 0;
|
|
773
1051
|
}
|
|
774
|
-
|
|
775
|
-
...nutrition,
|
|
1052
|
+
const result = {
|
|
776
1053
|
"@type": "NutritionInformation"
|
|
777
1054
|
};
|
|
1055
|
+
if (nutrition.calories !== void 0) {
|
|
1056
|
+
if (typeof nutrition.calories === "number") {
|
|
1057
|
+
result.calories = `${nutrition.calories} calories`;
|
|
1058
|
+
} else {
|
|
1059
|
+
result.calories = nutrition.calories;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
Object.keys(nutrition).forEach((key) => {
|
|
1063
|
+
if (key !== "calories" && key !== "@type") {
|
|
1064
|
+
result[key] = nutrition[key];
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
return result;
|
|
778
1068
|
}
|
|
779
1069
|
function cleanOutput(obj) {
|
|
780
1070
|
return Object.fromEntries(
|
|
781
1071
|
Object.entries(obj).filter(([, value]) => value !== void 0)
|
|
782
1072
|
);
|
|
783
1073
|
}
|
|
1074
|
+
function getSchemaOrgMappableStacks(stacks = {}) {
|
|
1075
|
+
const mappableStackIds = /* @__PURE__ */ new Set();
|
|
1076
|
+
const mappableFromRegistry = stacks_default.stacks.filter((stack) => stack.schemaOrgMappable).map((stack) => `${stack.id}@${stack.latest}`);
|
|
1077
|
+
for (const [name, version] of Object.entries(stacks)) {
|
|
1078
|
+
const stackId = `${name}@${version}`;
|
|
1079
|
+
if (mappableFromRegistry.includes(stackId)) {
|
|
1080
|
+
mappableStackIds.add(stackId);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return mappableStackIds;
|
|
1084
|
+
}
|
|
784
1085
|
function toSchemaOrg(recipe) {
|
|
785
1086
|
const base = convertBasicMetadata(recipe);
|
|
786
1087
|
const ingredients = convertIngredients2(recipe.ingredients);
|
|
787
1088
|
const instructions = convertInstructions2(recipe.instructions);
|
|
788
|
-
const
|
|
1089
|
+
const recipeStacks = recipe.stacks && typeof recipe.stacks === "object" && !Array.isArray(recipe.stacks) ? recipe.stacks : {};
|
|
1090
|
+
const mappableStacks = getSchemaOrgMappableStacks(recipeStacks);
|
|
1091
|
+
const hasMappableNutrition = mappableStacks.has("nutrition@1");
|
|
1092
|
+
const nutrition = hasMappableNutrition ? convertNutrition2(recipe.nutrition) : void 0;
|
|
1093
|
+
const hasMappableTimes = mappableStacks.has("times@1");
|
|
1094
|
+
const timeData = hasMappableTimes ? recipe.times ? convertTimesModule(recipe.times) : convertTime2(recipe.time) : {};
|
|
1095
|
+
const hasMappableAttribution = mappableStacks.has("attribution@1");
|
|
1096
|
+
const attributionData = hasMappableAttribution ? convertAuthor(recipe.source) : {};
|
|
1097
|
+
const hasMappableTaxonomy = mappableStacks.has("taxonomy@1");
|
|
1098
|
+
const taxonomyData = hasMappableTaxonomy ? convertCategoryTags(recipe.category, recipe.tags) : {};
|
|
789
1099
|
return cleanOutput({
|
|
790
1100
|
...base,
|
|
791
1101
|
recipeIngredient: ingredients.length ? ingredients : void 0,
|
|
792
1102
|
recipeInstructions: instructions.length ? instructions : void 0,
|
|
793
1103
|
recipeYield: convertYield(recipe.yield),
|
|
794
|
-
...
|
|
795
|
-
...
|
|
796
|
-
...
|
|
1104
|
+
...timeData,
|
|
1105
|
+
...attributionData,
|
|
1106
|
+
...taxonomyData,
|
|
797
1107
|
nutrition
|
|
798
1108
|
});
|
|
799
1109
|
}
|
|
@@ -865,13 +1175,16 @@ async function fetchPage(url, options = {}) {
|
|
|
865
1175
|
const response = await resolvedFetch(url, requestInit);
|
|
866
1176
|
clearTimeout(timeoutId);
|
|
867
1177
|
if (response && typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
globalFetch
|
|
872
|
-
|
|
1178
|
+
const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
|
|
1179
|
+
if (ingestUrl) {
|
|
1180
|
+
try {
|
|
1181
|
+
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1182
|
+
if (globalFetch) {
|
|
1183
|
+
globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:63", message: "fetch response", data: { url, status: response.status, statusText: response.statusText, ok: response.ok, isNYTimes: url.includes("nytimes.com") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
} catch {
|
|
873
1187
|
}
|
|
874
|
-
} catch {
|
|
875
1188
|
}
|
|
876
1189
|
}
|
|
877
1190
|
if (!response.ok) {
|
|
@@ -883,13 +1196,16 @@ async function fetchPage(url, options = {}) {
|
|
|
883
1196
|
}
|
|
884
1197
|
const html = await response.text();
|
|
885
1198
|
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
globalFetch
|
|
890
|
-
|
|
1199
|
+
const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
|
|
1200
|
+
if (ingestUrl) {
|
|
1201
|
+
try {
|
|
1202
|
+
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1203
|
+
if (globalFetch) {
|
|
1204
|
+
globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/fetch.ts:75", message: "HTML received", data: { htmlLength: html.length, hasLoginPage: html.toLowerCase().includes("login") || html.toLowerCase().includes("sign in"), hasRecipeData: html.includes("application/ld+json") || html.includes("schema.org/Recipe") }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B,D" }) }).catch(() => {
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
} catch {
|
|
891
1208
|
}
|
|
892
|
-
} catch {
|
|
893
1209
|
}
|
|
894
1210
|
}
|
|
895
1211
|
return html;
|
|
@@ -919,8 +1235,6 @@ function isRecipeNode(value) {
|
|
|
919
1235
|
return false;
|
|
920
1236
|
}
|
|
921
1237
|
const type = value["@type"];
|
|
922
|
-
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(() => {
|
|
923
|
-
});
|
|
924
1238
|
if (typeof type === "string") {
|
|
925
1239
|
return RECIPE_TYPES.has(type.toLowerCase());
|
|
926
1240
|
}
|
|
@@ -948,20 +1262,14 @@ function normalizeText(value) {
|
|
|
948
1262
|
function extractJsonLd(html) {
|
|
949
1263
|
const $ = cheerio.load(html);
|
|
950
1264
|
const scripts = $('script[type="application/ld+json"]');
|
|
951
|
-
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/jsonld.ts:8", message: "JSON-LD scripts found", data: { scriptCount: scripts.length }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "C,D" }) }).catch(() => {
|
|
952
|
-
});
|
|
953
1265
|
const candidates = [];
|
|
954
1266
|
scripts.each((_, element) => {
|
|
955
1267
|
const content = $(element).html();
|
|
956
1268
|
if (!content) return;
|
|
957
1269
|
const parsed = safeJsonParse(content);
|
|
958
1270
|
if (!parsed) return;
|
|
959
|
-
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/jsonld.ts:18", message: "JSON-LD parsed", data: { hasGraph: !!(parsed && typeof parsed === "object" && "@graph" in parsed), type: parsed && typeof parsed === "object" && "@type" in parsed ? parsed["@type"] : void 0 }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C" }) }).catch(() => {
|
|
960
|
-
});
|
|
961
1271
|
collectCandidates(parsed, candidates);
|
|
962
1272
|
});
|
|
963
|
-
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/jsonld.ts:22", message: "JSON-LD candidates", data: { candidateCount: candidates.length, candidateTypes: candidates.map((c) => c["@type"]) }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C" }) }).catch(() => {
|
|
964
|
-
});
|
|
965
1273
|
return candidates[0] ?? null;
|
|
966
1274
|
}
|
|
967
1275
|
function collectCandidates(payload, bucket) {
|
|
@@ -1143,13 +1451,16 @@ function extractRecipe(html) {
|
|
|
1143
1451
|
}
|
|
1144
1452
|
const jsonLdRecipe = extractJsonLd(html);
|
|
1145
1453
|
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
globalFetch
|
|
1150
|
-
|
|
1454
|
+
const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
|
|
1455
|
+
if (ingestUrl) {
|
|
1456
|
+
try {
|
|
1457
|
+
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1458
|
+
if (globalFetch) {
|
|
1459
|
+
globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/index.ts:6", message: "JSON-LD extraction result", data: { hasJsonLd: !!jsonLdRecipe }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "C,D" }) }).catch(() => {
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
} catch {
|
|
1151
1463
|
}
|
|
1152
|
-
} catch {
|
|
1153
1464
|
}
|
|
1154
1465
|
}
|
|
1155
1466
|
if (jsonLdRecipe) {
|
|
@@ -1157,13 +1468,16 @@ function extractRecipe(html) {
|
|
|
1157
1468
|
}
|
|
1158
1469
|
const microdataRecipe = extractMicrodata(html);
|
|
1159
1470
|
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
globalFetch
|
|
1164
|
-
|
|
1471
|
+
const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
|
|
1472
|
+
if (ingestUrl) {
|
|
1473
|
+
try {
|
|
1474
|
+
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1475
|
+
if (globalFetch) {
|
|
1476
|
+
globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/extractors/index.ts:12", message: "Microdata extraction result", data: { hasMicrodata: !!microdataRecipe }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "D" }) }).catch(() => {
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
} catch {
|
|
1165
1480
|
}
|
|
1166
|
-
} catch {
|
|
1167
1481
|
}
|
|
1168
1482
|
}
|
|
1169
1483
|
if (microdataRecipe) {
|
|
@@ -1175,35 +1489,44 @@ function extractRecipe(html) {
|
|
|
1175
1489
|
// src/scraper/index.ts
|
|
1176
1490
|
async function scrapeRecipe(url, options = {}) {
|
|
1177
1491
|
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
globalFetch
|
|
1182
|
-
|
|
1492
|
+
const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
|
|
1493
|
+
if (ingestUrl) {
|
|
1494
|
+
try {
|
|
1495
|
+
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1496
|
+
if (globalFetch) {
|
|
1497
|
+
globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:7", message: "scrapeRecipe entry", data: { url, hasOptions: !!options }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,B,C,D,E" }) }).catch(() => {
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
} catch {
|
|
1183
1501
|
}
|
|
1184
|
-
} catch {
|
|
1185
1502
|
}
|
|
1186
1503
|
}
|
|
1187
1504
|
const html = await fetchPage(url, options);
|
|
1188
1505
|
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
globalFetch
|
|
1193
|
-
|
|
1506
|
+
const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
|
|
1507
|
+
if (ingestUrl) {
|
|
1508
|
+
try {
|
|
1509
|
+
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1510
|
+
if (globalFetch) {
|
|
1511
|
+
globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:9", message: "HTML fetched", data: { htmlLength: html?.length, htmlPreview: html?.substring(0, 200) }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "B" }) }).catch(() => {
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
} catch {
|
|
1194
1515
|
}
|
|
1195
|
-
} catch {
|
|
1196
1516
|
}
|
|
1197
1517
|
}
|
|
1198
1518
|
const { recipe } = extractRecipe(html);
|
|
1199
1519
|
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
globalFetch
|
|
1204
|
-
|
|
1520
|
+
const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
|
|
1521
|
+
if (ingestUrl) {
|
|
1522
|
+
try {
|
|
1523
|
+
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1524
|
+
if (globalFetch) {
|
|
1525
|
+
globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:11", message: "extractRecipe result", data: { hasRecipe: !!recipe, recipeType: recipe?.["@type"], recipeName: recipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A,C,D" }) }).catch(() => {
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
} catch {
|
|
1205
1529
|
}
|
|
1206
|
-
} catch {
|
|
1207
1530
|
}
|
|
1208
1531
|
}
|
|
1209
1532
|
if (!recipe) {
|
|
@@ -1211,13 +1534,16 @@ async function scrapeRecipe(url, options = {}) {
|
|
|
1211
1534
|
}
|
|
1212
1535
|
const soustackRecipe = fromSchemaOrg(recipe);
|
|
1213
1536
|
if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
globalFetch
|
|
1218
|
-
|
|
1537
|
+
const ingestUrl = process.env.SOUSTACK_DEBUG_INGEST_URL;
|
|
1538
|
+
if (ingestUrl) {
|
|
1539
|
+
try {
|
|
1540
|
+
const globalFetch = typeof globalThis !== "undefined" && typeof globalThis.fetch !== "undefined" ? globalThis.fetch : null;
|
|
1541
|
+
if (globalFetch) {
|
|
1542
|
+
globalFetch(ingestUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ location: "scraper/index.ts:17", message: "fromSchemaOrg result", data: { hasSoustackRecipe: !!soustackRecipe, soustackRecipeName: soustackRecipe?.name }, timestamp: Date.now(), sessionId: "debug-session", runId: "run1", hypothesisId: "A" }) }).catch(() => {
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
} catch {
|
|
1219
1546
|
}
|
|
1220
|
-
} catch {
|
|
1221
1547
|
}
|
|
1222
1548
|
}
|
|
1223
1549
|
if (!soustackRecipe) {
|
|
@@ -1226,11 +1552,233 @@ async function scrapeRecipe(url, options = {}) {
|
|
|
1226
1552
|
return soustackRecipe;
|
|
1227
1553
|
}
|
|
1228
1554
|
|
|
1229
|
-
// src/
|
|
1230
|
-
|
|
1555
|
+
// src/conformance/index.ts
|
|
1556
|
+
function validateConformance(recipe) {
|
|
1557
|
+
const issues = [];
|
|
1558
|
+
issues.push(...checkDAGValidity(recipe));
|
|
1559
|
+
if (hasSchedulableProfile(recipe)) {
|
|
1560
|
+
issues.push(...checkTimingSchedulability(recipe));
|
|
1561
|
+
}
|
|
1562
|
+
issues.push(...checkScalingSanity(recipe));
|
|
1563
|
+
const ok = issues.filter((i) => i.severity === "error").length === 0;
|
|
1564
|
+
return { ok, issues };
|
|
1565
|
+
}
|
|
1566
|
+
function hasSchedulableProfile(recipe) {
|
|
1567
|
+
const schema = recipe.$schema;
|
|
1568
|
+
if (typeof schema === "string") {
|
|
1569
|
+
return schema.includes("schedulable") || schema === "http://soustack.org/schema/v0.3.0/profiles/schedulable";
|
|
1570
|
+
}
|
|
1571
|
+
return false;
|
|
1572
|
+
}
|
|
1573
|
+
function checkDAGValidity(recipe) {
|
|
1574
|
+
const issues = [];
|
|
1575
|
+
const instructions = recipe.instructions;
|
|
1576
|
+
if (!Array.isArray(instructions)) {
|
|
1577
|
+
return issues;
|
|
1578
|
+
}
|
|
1579
|
+
const instructionIds = /* @__PURE__ */ new Set();
|
|
1580
|
+
const dependencyRefs = [];
|
|
1581
|
+
const collect = (items, basePath) => {
|
|
1582
|
+
items.forEach((item, index) => {
|
|
1583
|
+
const currentPath = `${basePath}/${index}`;
|
|
1584
|
+
if (isInstructionSubsection(item)) {
|
|
1585
|
+
if (Array.isArray(item.items)) {
|
|
1586
|
+
collect(item.items, `${currentPath}/items`);
|
|
1587
|
+
}
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
if (isInstruction(item)) {
|
|
1591
|
+
const id = typeof item.id === "string" ? item.id : void 0;
|
|
1592
|
+
if (id) {
|
|
1593
|
+
instructionIds.add(id);
|
|
1594
|
+
}
|
|
1595
|
+
if (Array.isArray(item.dependsOn)) {
|
|
1596
|
+
item.dependsOn.forEach((depId, depIndex) => {
|
|
1597
|
+
if (typeof depId === "string") {
|
|
1598
|
+
dependencyRefs.push({
|
|
1599
|
+
fromId: id,
|
|
1600
|
+
toId: depId,
|
|
1601
|
+
path: `${currentPath}/dependsOn/${depIndex}`
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
};
|
|
1609
|
+
collect(instructions, "/instructions");
|
|
1610
|
+
dependencyRefs.forEach((ref) => {
|
|
1611
|
+
if (!instructionIds.has(ref.toId)) {
|
|
1612
|
+
issues.push({
|
|
1613
|
+
code: "DAG_MISSING_NODE",
|
|
1614
|
+
path: ref.path,
|
|
1615
|
+
message: `Instruction dependency references missing step id '${ref.toId}'.`,
|
|
1616
|
+
severity: "error"
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
1621
|
+
dependencyRefs.forEach((ref) => {
|
|
1622
|
+
if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
|
|
1623
|
+
const list = adjacency.get(ref.fromId) ?? [];
|
|
1624
|
+
list.push({ toId: ref.toId, path: ref.path });
|
|
1625
|
+
adjacency.set(ref.fromId, list);
|
|
1626
|
+
}
|
|
1627
|
+
});
|
|
1628
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
1629
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1630
|
+
const detectCycles = (nodeId) => {
|
|
1631
|
+
if (visiting.has(nodeId)) {
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (visited.has(nodeId)) {
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
visiting.add(nodeId);
|
|
1638
|
+
const neighbors = adjacency.get(nodeId) ?? [];
|
|
1639
|
+
neighbors.forEach((edge) => {
|
|
1640
|
+
if (visiting.has(edge.toId)) {
|
|
1641
|
+
issues.push({
|
|
1642
|
+
code: "DAG_CYCLE",
|
|
1643
|
+
path: edge.path,
|
|
1644
|
+
message: `Circular dependency detected involving step id '${edge.toId}'.`,
|
|
1645
|
+
severity: "error"
|
|
1646
|
+
});
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
detectCycles(edge.toId);
|
|
1650
|
+
});
|
|
1651
|
+
visiting.delete(nodeId);
|
|
1652
|
+
visited.add(nodeId);
|
|
1653
|
+
};
|
|
1654
|
+
instructionIds.forEach((id) => detectCycles(id));
|
|
1655
|
+
return issues;
|
|
1656
|
+
}
|
|
1657
|
+
function checkTimingSchedulability(recipe) {
|
|
1658
|
+
const issues = [];
|
|
1659
|
+
const instructions = recipe.instructions;
|
|
1660
|
+
if (!Array.isArray(instructions)) {
|
|
1661
|
+
return issues;
|
|
1662
|
+
}
|
|
1663
|
+
const checkInstruction = (item, path2) => {
|
|
1664
|
+
if (isInstructionSubsection(item)) {
|
|
1665
|
+
if (Array.isArray(item.items)) {
|
|
1666
|
+
item.items.forEach((subItem, index) => {
|
|
1667
|
+
checkInstruction(subItem, `${path2}/items/${index}`);
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
if (isInstruction(item)) {
|
|
1673
|
+
if (!item.id) {
|
|
1674
|
+
issues.push({
|
|
1675
|
+
code: "SCHEDULABLE_MISSING_ID",
|
|
1676
|
+
path: path2,
|
|
1677
|
+
message: "Schedulable profile requires all instructions to have an id.",
|
|
1678
|
+
severity: "error"
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
if (!item.timing) {
|
|
1682
|
+
issues.push({
|
|
1683
|
+
code: "SCHEDULABLE_MISSING_TIMING",
|
|
1684
|
+
path: path2,
|
|
1685
|
+
message: "Schedulable profile requires all instructions to have timing information.",
|
|
1686
|
+
severity: "error"
|
|
1687
|
+
});
|
|
1688
|
+
} else if (!item.timing.duration) {
|
|
1689
|
+
issues.push({
|
|
1690
|
+
code: "SCHEDULABLE_MISSING_DURATION",
|
|
1691
|
+
path: `${path2}/timing`,
|
|
1692
|
+
message: "Schedulable profile requires timing.duration for all instructions.",
|
|
1693
|
+
severity: "error"
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
};
|
|
1698
|
+
instructions.forEach((item, index) => {
|
|
1699
|
+
checkInstruction(item, `/instructions/${index}`);
|
|
1700
|
+
});
|
|
1701
|
+
return issues;
|
|
1702
|
+
}
|
|
1703
|
+
function checkScalingSanity(recipe) {
|
|
1704
|
+
const issues = [];
|
|
1705
|
+
const ingredients = recipe.ingredients;
|
|
1706
|
+
if (!Array.isArray(ingredients)) {
|
|
1707
|
+
return issues;
|
|
1708
|
+
}
|
|
1709
|
+
const ingredientIds = /* @__PURE__ */ new Set();
|
|
1710
|
+
const collectIngredientIds = (items, basePath) => {
|
|
1711
|
+
items.forEach((item, index) => {
|
|
1712
|
+
if (isIngredientSubsection(item)) {
|
|
1713
|
+
if (Array.isArray(item.items)) {
|
|
1714
|
+
collectIngredientIds(item.items);
|
|
1715
|
+
}
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
if (isIngredient(item)) {
|
|
1719
|
+
if (typeof item.id === "string") {
|
|
1720
|
+
ingredientIds.add(item.id);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
});
|
|
1724
|
+
};
|
|
1725
|
+
collectIngredientIds(ingredients);
|
|
1726
|
+
const checkIngredient = (item, path2) => {
|
|
1727
|
+
if (isIngredientSubsection(item)) {
|
|
1728
|
+
if (Array.isArray(item.items)) {
|
|
1729
|
+
item.items.forEach((subItem, index) => {
|
|
1730
|
+
checkIngredient(subItem, `${path2}/items/${index}`);
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
if (isIngredient(item)) {
|
|
1736
|
+
const scaling = item.scaling;
|
|
1737
|
+
if (scaling && typeof scaling === "object" && "type" in scaling && scaling.type === "bakers_percentage") {
|
|
1738
|
+
const bakersScaling = scaling;
|
|
1739
|
+
if (bakersScaling.referenceId) {
|
|
1740
|
+
if (!ingredientIds.has(bakersScaling.referenceId)) {
|
|
1741
|
+
issues.push({
|
|
1742
|
+
code: "SCALING_INVALID_REFERENCE",
|
|
1743
|
+
path: `${path2}/scaling/referenceId`,
|
|
1744
|
+
message: `Baker's percentage references missing ingredient id '${bakersScaling.referenceId}'.`,
|
|
1745
|
+
severity: "error"
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
} else {
|
|
1749
|
+
issues.push({
|
|
1750
|
+
code: "SCALING_MISSING_REFERENCE",
|
|
1751
|
+
path: `${path2}/scaling`,
|
|
1752
|
+
message: "Baker's percentage scaling requires a referenceId.",
|
|
1753
|
+
severity: "error"
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
ingredients.forEach((item, index) => {
|
|
1760
|
+
checkIngredient(item, `/ingredients/${index}`);
|
|
1761
|
+
});
|
|
1762
|
+
return issues;
|
|
1763
|
+
}
|
|
1764
|
+
function isInstruction(item) {
|
|
1765
|
+
return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
|
|
1766
|
+
}
|
|
1767
|
+
function isInstructionSubsection(item) {
|
|
1768
|
+
return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
|
|
1769
|
+
}
|
|
1770
|
+
function isIngredient(item) {
|
|
1771
|
+
return item && typeof item === "object" && !Array.isArray(item) && "item" in item;
|
|
1772
|
+
}
|
|
1773
|
+
function isIngredientSubsection(item) {
|
|
1774
|
+
return item && typeof item === "object" && !Array.isArray(item) && "items" in item && "subsection" in item;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// src/soustack.schema.json
|
|
1778
|
+
var soustack_schema_default = {
|
|
1231
1779
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1232
|
-
$id: "http://soustack.org/schema/v0.
|
|
1233
|
-
title: "Soustack Recipe Schema v0.
|
|
1780
|
+
$id: "http://soustack.org/schema/v0.3.0",
|
|
1781
|
+
title: "Soustack Recipe Schema v0.3.0",
|
|
1234
1782
|
description: "A portable, scalable, interoperable recipe format.",
|
|
1235
1783
|
type: "object",
|
|
1236
1784
|
required: ["name", "ingredients", "instructions"],
|
|
@@ -1378,7 +1926,10 @@ var schema_default = {
|
|
|
1378
1926
|
required: ["amount"],
|
|
1379
1927
|
properties: {
|
|
1380
1928
|
amount: { type: "number" },
|
|
1381
|
-
unit: {
|
|
1929
|
+
unit: {
|
|
1930
|
+
type: ["string", "null"],
|
|
1931
|
+
description: "Display-friendly unit text; implementations may normalize or canonicalize units separately."
|
|
1932
|
+
}
|
|
1382
1933
|
}
|
|
1383
1934
|
},
|
|
1384
1935
|
scaling: {
|
|
@@ -1413,7 +1964,16 @@ var schema_default = {
|
|
|
1413
1964
|
aisle: { type: "string" },
|
|
1414
1965
|
prep: { type: "string" },
|
|
1415
1966
|
prepAction: { type: "string" },
|
|
1967
|
+
prepActions: {
|
|
1968
|
+
type: "array",
|
|
1969
|
+
items: { type: "string" },
|
|
1970
|
+
description: "Structured prep verbs (e.g., peel, dice) for mise en place workflows."
|
|
1971
|
+
},
|
|
1416
1972
|
prepTime: { type: "number" },
|
|
1973
|
+
form: {
|
|
1974
|
+
type: "string",
|
|
1975
|
+
description: "State of the ingredient as used (packed, sifted, melted, room_temperature, etc.)."
|
|
1976
|
+
},
|
|
1417
1977
|
destination: { type: "string" },
|
|
1418
1978
|
scaling: { $ref: "#/definitions/scaling" },
|
|
1419
1979
|
critical: { type: "boolean" },
|
|
@@ -1572,399 +2132,280 @@ var schema_default = {
|
|
|
1572
2132
|
}
|
|
1573
2133
|
};
|
|
1574
2134
|
|
|
1575
|
-
// src/
|
|
1576
|
-
var
|
|
2135
|
+
// src/schemas/recipe/base.schema.json
|
|
2136
|
+
var base_schema_default = {
|
|
1577
2137
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1578
|
-
$id: "http://soustack.org/schema/
|
|
1579
|
-
title: "Soustack Recipe Schema
|
|
1580
|
-
description: "
|
|
2138
|
+
$id: "http://soustack.org/schema/recipe/base.schema.json",
|
|
2139
|
+
title: "Soustack Recipe Base Schema",
|
|
2140
|
+
description: "Base document shape for Soustack recipe documents. Profiles and stacks build on this baseline.",
|
|
1581
2141
|
type: "object",
|
|
1582
|
-
|
|
1583
|
-
additionalProperties: false,
|
|
1584
|
-
patternProperties: {
|
|
1585
|
-
"^x-": {}
|
|
1586
|
-
},
|
|
2142
|
+
additionalProperties: true,
|
|
1587
2143
|
properties: {
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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: {
|
|
1623
|
-
type: "array",
|
|
1624
|
-
items: { type: "string" }
|
|
1625
|
-
},
|
|
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
|
-
]
|
|
2144
|
+
"@type": {
|
|
2145
|
+
const: "Recipe",
|
|
2146
|
+
description: "Document marker for Soustack recipes"
|
|
1642
2147
|
},
|
|
1643
|
-
|
|
2148
|
+
profile: {
|
|
1644
2149
|
type: "string",
|
|
1645
|
-
|
|
1646
|
-
},
|
|
1647
|
-
metadata: {
|
|
1648
|
-
type: "object",
|
|
1649
|
-
additionalProperties: true,
|
|
1650
|
-
description: "Free-form vendor metadata"
|
|
2150
|
+
description: "Profile identifier applied to this recipe"
|
|
1651
2151
|
},
|
|
1652
|
-
|
|
2152
|
+
stacks: {
|
|
1653
2153
|
type: "object",
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
adapted: { type: "boolean" }
|
|
2154
|
+
description: "Stack declarations as a map: Record<stackName, versionNumber>",
|
|
2155
|
+
additionalProperties: {
|
|
2156
|
+
type: "integer",
|
|
2157
|
+
minimum: 1
|
|
1659
2158
|
}
|
|
1660
2159
|
},
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
time: {
|
|
1665
|
-
$ref: "#/definitions/time"
|
|
1666
|
-
},
|
|
1667
|
-
equipment: {
|
|
1668
|
-
type: "array",
|
|
1669
|
-
items: { $ref: "#/definitions/equipment" }
|
|
2160
|
+
name: {
|
|
2161
|
+
type: "string",
|
|
2162
|
+
description: "Human-readable recipe name"
|
|
1670
2163
|
},
|
|
1671
2164
|
ingredients: {
|
|
1672
2165
|
type: "array",
|
|
1673
|
-
|
|
1674
|
-
anyOf: [
|
|
1675
|
-
{ type: "string" },
|
|
1676
|
-
{ $ref: "#/definitions/ingredient" },
|
|
1677
|
-
{ $ref: "#/definitions/ingredientSubsection" }
|
|
1678
|
-
]
|
|
1679
|
-
}
|
|
2166
|
+
description: "Ingredients payload; content is validated by profiles/stacks"
|
|
1680
2167
|
},
|
|
1681
2168
|
instructions: {
|
|
1682
2169
|
type: "array",
|
|
1683
|
-
|
|
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" }
|
|
2170
|
+
description: "Instruction payload; content is validated by profiles/stacks"
|
|
1697
2171
|
}
|
|
1698
2172
|
},
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
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
|
-
},
|
|
1744
|
-
if: {
|
|
1745
|
-
properties: { type: { const: "bakers_percentage" } }
|
|
1746
|
-
},
|
|
1747
|
-
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" }
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
},
|
|
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" }
|
|
1794
|
-
}
|
|
1795
|
-
}
|
|
2173
|
+
required: ["@type"]
|
|
2174
|
+
};
|
|
2175
|
+
|
|
2176
|
+
// src/schemas/recipe/profiles/minimal.schema.json
|
|
2177
|
+
var minimal_schema_default = {
|
|
2178
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2179
|
+
$id: "http://soustack.org/schema/recipe/profiles/minimal.schema.json",
|
|
2180
|
+
title: "Soustack Recipe Minimal Profile",
|
|
2181
|
+
description: "Minimal profile that ensures the basic Recipe structure is present while allowing stacks to extend it.",
|
|
2182
|
+
allOf: [
|
|
2183
|
+
{
|
|
2184
|
+
$ref: "http://soustack.org/schema/recipe/base.schema.json"
|
|
1796
2185
|
},
|
|
1797
|
-
|
|
2186
|
+
{
|
|
1798
2187
|
type: "object",
|
|
1799
|
-
required: ["text"],
|
|
1800
2188
|
properties: {
|
|
1801
|
-
|
|
1802
|
-
|
|
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" }
|
|
2189
|
+
profile: {
|
|
2190
|
+
const: "minimal"
|
|
1812
2191
|
},
|
|
1813
|
-
|
|
1814
|
-
type: "array",
|
|
1815
|
-
items: { type: "string" }
|
|
1816
|
-
},
|
|
1817
|
-
timing: {
|
|
2192
|
+
stacks: {
|
|
1818
2193
|
type: "object",
|
|
1819
|
-
|
|
2194
|
+
additionalProperties: {
|
|
2195
|
+
type: "integer",
|
|
2196
|
+
minimum: 1
|
|
2197
|
+
},
|
|
1820
2198
|
properties: {
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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"] }
|
|
2199
|
+
attribution: { type: "integer", minimum: 1 },
|
|
2200
|
+
taxonomy: { type: "integer", minimum: 1 },
|
|
2201
|
+
media: { type: "integer", minimum: 1 },
|
|
2202
|
+
nutrition: { type: "integer", minimum: 1 },
|
|
2203
|
+
times: { type: "integer", minimum: 1 }
|
|
1830
2204
|
}
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
properties: {
|
|
1838
|
-
subsection: { type: "string" },
|
|
1839
|
-
items: {
|
|
2205
|
+
},
|
|
2206
|
+
name: {
|
|
2207
|
+
type: "string",
|
|
2208
|
+
minLength: 1
|
|
2209
|
+
},
|
|
2210
|
+
ingredients: {
|
|
1840
2211
|
type: "array",
|
|
1841
|
-
|
|
1842
|
-
anyOf: [
|
|
1843
|
-
{ type: "string" },
|
|
1844
|
-
{ $ref: "#/definitions/instruction" }
|
|
1845
|
-
]
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
}
|
|
1849
|
-
},
|
|
1850
|
-
storage: {
|
|
1851
|
-
type: "object",
|
|
1852
|
-
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
|
-
]
|
|
2212
|
+
minItems: 1
|
|
1863
2213
|
},
|
|
1864
|
-
|
|
1865
|
-
makeAhead: {
|
|
2214
|
+
instructions: {
|
|
1866
2215
|
type: "array",
|
|
1867
|
-
|
|
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
|
-
]
|
|
1879
|
-
}
|
|
2216
|
+
minItems: 1
|
|
1880
2217
|
}
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
2218
|
+
},
|
|
2219
|
+
required: [
|
|
2220
|
+
"profile",
|
|
2221
|
+
"name",
|
|
2222
|
+
"ingredients",
|
|
2223
|
+
"instructions"
|
|
2224
|
+
],
|
|
2225
|
+
additionalProperties: true
|
|
2226
|
+
}
|
|
2227
|
+
]
|
|
2228
|
+
};
|
|
2229
|
+
|
|
2230
|
+
// src/schemas/recipe/profiles/core.schema.json
|
|
2231
|
+
var core_schema_default = {
|
|
2232
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2233
|
+
$id: "http://soustack.org/schema/recipe/profiles/core.schema.json",
|
|
2234
|
+
title: "Soustack Recipe Core Profile",
|
|
2235
|
+
description: "Core profile that builds on the minimal profile and is intended to be combined with recipe stacks.",
|
|
2236
|
+
allOf: [
|
|
2237
|
+
{ $ref: "http://soustack.org/schema/recipe/base.schema.json" },
|
|
2238
|
+
{
|
|
1893
2239
|
type: "object",
|
|
1894
|
-
required: ["ingredient"],
|
|
1895
2240
|
properties: {
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
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
|
-
}
|
|
2241
|
+
profile: { const: "core" },
|
|
2242
|
+
stacks: {
|
|
2243
|
+
type: "object",
|
|
2244
|
+
additionalProperties: {
|
|
2245
|
+
type: "integer",
|
|
2246
|
+
minimum: 1
|
|
1914
2247
|
}
|
|
1915
|
-
}
|
|
1916
|
-
|
|
2248
|
+
},
|
|
2249
|
+
name: { type: "string", minLength: 1 },
|
|
2250
|
+
ingredients: { type: "array", minItems: 1 },
|
|
2251
|
+
instructions: { type: "array", minItems: 1 }
|
|
2252
|
+
},
|
|
2253
|
+
required: ["profile", "name", "ingredients", "instructions"],
|
|
2254
|
+
additionalProperties: true
|
|
1917
2255
|
}
|
|
1918
|
-
|
|
2256
|
+
]
|
|
1919
2257
|
};
|
|
1920
2258
|
|
|
1921
|
-
//
|
|
1922
|
-
var
|
|
2259
|
+
// spec/profiles/base.schema.json
|
|
2260
|
+
var base_schema_default2 = {
|
|
1923
2261
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1924
|
-
$id: "http://soustack.org/schema/v0.
|
|
2262
|
+
$id: "http://soustack.org/schema/v0.3.0/profiles/base",
|
|
1925
2263
|
title: "Soustack Base Profile Schema",
|
|
1926
2264
|
description: "Wrapper schema that exposes the unmodified Soustack base schema.",
|
|
1927
2265
|
allOf: [
|
|
1928
|
-
{ $ref: "http://soustack.org/schema/v0.
|
|
2266
|
+
{ $ref: "http://soustack.org/schema/v0.3.0" }
|
|
1929
2267
|
]
|
|
1930
2268
|
};
|
|
1931
2269
|
|
|
1932
|
-
//
|
|
2270
|
+
// spec/profiles/cookable.schema.json
|
|
1933
2271
|
var cookable_schema_default = {
|
|
1934
2272
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1935
|
-
$id: "http://soustack.org/schema/v0.
|
|
2273
|
+
$id: "http://soustack.org/schema/v0.3.0/profiles/cookable",
|
|
1936
2274
|
title: "Soustack Cookable Profile Schema",
|
|
1937
2275
|
description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
|
|
1938
2276
|
allOf: [
|
|
1939
|
-
{ $ref: "http://soustack.org/schema/v0.
|
|
2277
|
+
{ $ref: "http://soustack.org/schema/v0.3.0" },
|
|
1940
2278
|
{
|
|
1941
2279
|
required: ["yield", "time", "ingredients", "instructions"],
|
|
1942
2280
|
properties: {
|
|
1943
|
-
yield: { $ref: "http://soustack.org/schema/v0.
|
|
1944
|
-
time: { $ref: "http://soustack.org/schema/v0.
|
|
2281
|
+
yield: { $ref: "http://soustack.org/schema/v0.3.0#/definitions/yield" },
|
|
2282
|
+
time: { $ref: "http://soustack.org/schema/v0.3.0#/definitions/time" },
|
|
1945
2283
|
ingredients: { type: "array", minItems: 1 },
|
|
1946
2284
|
instructions: { type: "array", minItems: 1 }
|
|
1947
2285
|
}
|
|
1948
2286
|
}
|
|
1949
|
-
]
|
|
2287
|
+
]
|
|
2288
|
+
};
|
|
2289
|
+
|
|
2290
|
+
// spec/profiles/illustrated.schema.json
|
|
2291
|
+
var illustrated_schema_default = {
|
|
2292
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2293
|
+
$id: "http://soustack.org/schema/v0.3.0/profiles/illustrated",
|
|
2294
|
+
title: "Soustack Illustrated Profile Schema",
|
|
2295
|
+
description: "Extends the base schema to guarantee at least one illustrative image.",
|
|
2296
|
+
allOf: [
|
|
2297
|
+
{ $ref: "http://soustack.org/schema/v0.3.0" },
|
|
2298
|
+
{
|
|
2299
|
+
anyOf: [
|
|
2300
|
+
{ required: ["image"] },
|
|
2301
|
+
{
|
|
2302
|
+
properties: {
|
|
2303
|
+
instructions: {
|
|
2304
|
+
type: "array",
|
|
2305
|
+
contains: {
|
|
2306
|
+
anyOf: [
|
|
2307
|
+
{ $ref: "#/definitions/imageInstruction" },
|
|
2308
|
+
{ $ref: "#/definitions/instructionSubsectionWithImage" }
|
|
2309
|
+
]
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
]
|
|
2315
|
+
}
|
|
2316
|
+
],
|
|
2317
|
+
definitions: {
|
|
2318
|
+
imageInstruction: {
|
|
2319
|
+
allOf: [
|
|
2320
|
+
{ $ref: "http://soustack.org/schema/v0.3.0#/definitions/instruction" },
|
|
2321
|
+
{ required: ["image"] }
|
|
2322
|
+
]
|
|
2323
|
+
},
|
|
2324
|
+
instructionSubsectionWithImage: {
|
|
2325
|
+
allOf: [
|
|
2326
|
+
{ $ref: "http://soustack.org/schema/v0.3.0#/definitions/instructionSubsection" },
|
|
2327
|
+
{
|
|
2328
|
+
properties: {
|
|
2329
|
+
items: {
|
|
2330
|
+
type: "array",
|
|
2331
|
+
contains: { $ref: "#/definitions/imageInstruction" }
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
]
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
};
|
|
2339
|
+
|
|
2340
|
+
// spec/profiles/quantified.schema.json
|
|
2341
|
+
var quantified_schema_default = {
|
|
2342
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2343
|
+
$id: "http://soustack.org/schema/v0.3.0/profiles/quantified",
|
|
2344
|
+
title: "Soustack Quantified Profile Schema",
|
|
2345
|
+
description: "Extends the base schema to require quantified ingredient entries.",
|
|
2346
|
+
allOf: [
|
|
2347
|
+
{ $ref: "http://soustack.org/schema/v0.3.0" },
|
|
2348
|
+
{
|
|
2349
|
+
properties: {
|
|
2350
|
+
ingredients: {
|
|
2351
|
+
type: "array",
|
|
2352
|
+
items: {
|
|
2353
|
+
anyOf: [
|
|
2354
|
+
{ $ref: "#/definitions/quantifiedIngredient" },
|
|
2355
|
+
{ $ref: "#/definitions/quantifiedIngredientSubsection" }
|
|
2356
|
+
]
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
],
|
|
2362
|
+
definitions: {
|
|
2363
|
+
quantifiedIngredient: {
|
|
2364
|
+
allOf: [
|
|
2365
|
+
{ $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredient" },
|
|
2366
|
+
{ required: ["item", "quantity"] }
|
|
2367
|
+
]
|
|
2368
|
+
},
|
|
2369
|
+
quantifiedIngredientSubsection: {
|
|
2370
|
+
allOf: [
|
|
2371
|
+
{ $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredientSubsection" },
|
|
2372
|
+
{
|
|
2373
|
+
properties: {
|
|
2374
|
+
items: {
|
|
2375
|
+
type: "array",
|
|
2376
|
+
items: { $ref: "#/definitions/quantifiedIngredient" }
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
]
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
1950
2383
|
};
|
|
1951
2384
|
|
|
1952
|
-
//
|
|
1953
|
-
var
|
|
2385
|
+
// spec/profiles/scalable.schema.json
|
|
2386
|
+
var scalable_schema_default = {
|
|
1954
2387
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
1955
|
-
$id: "http://soustack.org/schema/v0.
|
|
1956
|
-
title: "Soustack
|
|
1957
|
-
description: "Extends the base schema to
|
|
2388
|
+
$id: "http://soustack.org/schema/v0.3.0/profiles/scalable",
|
|
2389
|
+
title: "Soustack Scalable Profile Schema",
|
|
2390
|
+
description: "Extends the base schema to guarantee quantified ingredients plus a structured yield for deterministic scaling.",
|
|
1958
2391
|
allOf: [
|
|
1959
|
-
{ $ref: "http://soustack.org/schema/v0.
|
|
2392
|
+
{ $ref: "http://soustack.org/schema/v0.3.0" },
|
|
1960
2393
|
{
|
|
2394
|
+
required: ["yield", "ingredients"],
|
|
1961
2395
|
properties: {
|
|
2396
|
+
yield: {
|
|
2397
|
+
allOf: [
|
|
2398
|
+
{ $ref: "http://soustack.org/schema/v0.3.0#/definitions/yield" },
|
|
2399
|
+
{ properties: { amount: { type: "number", exclusiveMinimum: 0 } } }
|
|
2400
|
+
]
|
|
2401
|
+
},
|
|
1962
2402
|
ingredients: {
|
|
1963
2403
|
type: "array",
|
|
2404
|
+
minItems: 1,
|
|
1964
2405
|
items: {
|
|
1965
2406
|
anyOf: [
|
|
1966
|
-
{ $ref: "#/definitions/
|
|
1967
|
-
{ $ref: "#/definitions/
|
|
2407
|
+
{ $ref: "#/definitions/scalableIngredient" },
|
|
2408
|
+
{ $ref: "#/definitions/scalableIngredientSubsection" }
|
|
1968
2409
|
]
|
|
1969
2410
|
}
|
|
1970
2411
|
}
|
|
@@ -1972,20 +2413,44 @@ var quantified_schema_default = {
|
|
|
1972
2413
|
}
|
|
1973
2414
|
],
|
|
1974
2415
|
definitions: {
|
|
1975
|
-
|
|
2416
|
+
scalableIngredient: {
|
|
1976
2417
|
allOf: [
|
|
1977
|
-
{ $ref: "http://soustack.org/schema/v0.
|
|
1978
|
-
{ required: ["item", "quantity"] }
|
|
2418
|
+
{ $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredient" },
|
|
2419
|
+
{ required: ["item", "quantity"] },
|
|
2420
|
+
{
|
|
2421
|
+
properties: {
|
|
2422
|
+
quantity: {
|
|
2423
|
+
allOf: [
|
|
2424
|
+
{ $ref: "http://soustack.org/schema/v0.3.0#/definitions/quantity" },
|
|
2425
|
+
{ properties: { amount: { type: "number", exclusiveMinimum: 0 } } }
|
|
2426
|
+
]
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
},
|
|
2430
|
+
{
|
|
2431
|
+
if: {
|
|
2432
|
+
properties: {
|
|
2433
|
+
scaling: {
|
|
2434
|
+
type: "object",
|
|
2435
|
+
properties: { type: { const: "bakers_percentage" } },
|
|
2436
|
+
required: ["type"]
|
|
2437
|
+
}
|
|
2438
|
+
},
|
|
2439
|
+
required: ["scaling"]
|
|
2440
|
+
},
|
|
2441
|
+
then: { required: ["id"] }
|
|
2442
|
+
}
|
|
1979
2443
|
]
|
|
1980
2444
|
},
|
|
1981
|
-
|
|
2445
|
+
scalableIngredientSubsection: {
|
|
1982
2446
|
allOf: [
|
|
1983
|
-
{ $ref: "http://soustack.org/schema/v0.
|
|
2447
|
+
{ $ref: "http://soustack.org/schema/v0.3.0#/definitions/ingredientSubsection" },
|
|
1984
2448
|
{
|
|
1985
2449
|
properties: {
|
|
1986
2450
|
items: {
|
|
1987
2451
|
type: "array",
|
|
1988
|
-
|
|
2452
|
+
minItems: 1,
|
|
2453
|
+
items: { $ref: "#/definitions/scalableIngredient" }
|
|
1989
2454
|
}
|
|
1990
2455
|
}
|
|
1991
2456
|
}
|
|
@@ -1994,48 +2459,43 @@ var quantified_schema_default = {
|
|
|
1994
2459
|
}
|
|
1995
2460
|
};
|
|
1996
2461
|
|
|
1997
|
-
//
|
|
1998
|
-
var
|
|
2462
|
+
// spec/profiles/schedulable.schema.json
|
|
2463
|
+
var schedulable_schema_default = {
|
|
1999
2464
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2000
|
-
$id: "http://soustack.org/schema/v0.
|
|
2001
|
-
title: "Soustack
|
|
2002
|
-
description: "Extends the base schema to
|
|
2465
|
+
$id: "http://soustack.org/schema/v0.3.0/profiles/schedulable",
|
|
2466
|
+
title: "Soustack Schedulable Profile Schema",
|
|
2467
|
+
description: "Extends the base schema to ensure every instruction is fully scheduled.",
|
|
2003
2468
|
allOf: [
|
|
2004
|
-
{ $ref: "http://soustack.org/schema/v0.
|
|
2469
|
+
{ $ref: "http://soustack.org/schema/v0.3.0" },
|
|
2005
2470
|
{
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
{ $ref: "#/definitions/imageInstruction" },
|
|
2015
|
-
{ $ref: "#/definitions/instructionSubsectionWithImage" }
|
|
2016
|
-
]
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2471
|
+
properties: {
|
|
2472
|
+
instructions: {
|
|
2473
|
+
type: "array",
|
|
2474
|
+
items: {
|
|
2475
|
+
anyOf: [
|
|
2476
|
+
{ $ref: "#/definitions/schedulableInstruction" },
|
|
2477
|
+
{ $ref: "#/definitions/schedulableInstructionSubsection" }
|
|
2478
|
+
]
|
|
2019
2479
|
}
|
|
2020
2480
|
}
|
|
2021
|
-
|
|
2481
|
+
}
|
|
2022
2482
|
}
|
|
2023
2483
|
],
|
|
2024
2484
|
definitions: {
|
|
2025
|
-
|
|
2485
|
+
schedulableInstruction: {
|
|
2026
2486
|
allOf: [
|
|
2027
|
-
{ $ref: "http://soustack.org/schema/v0.
|
|
2028
|
-
{ required: ["
|
|
2487
|
+
{ $ref: "http://soustack.org/schema/v0.3.0#/definitions/instruction" },
|
|
2488
|
+
{ required: ["id", "timing"] }
|
|
2029
2489
|
]
|
|
2030
2490
|
},
|
|
2031
|
-
|
|
2491
|
+
schedulableInstructionSubsection: {
|
|
2032
2492
|
allOf: [
|
|
2033
|
-
{ $ref: "http://soustack.org/schema/v0.
|
|
2493
|
+
{ $ref: "http://soustack.org/schema/v0.3.0#/definitions/instructionSubsection" },
|
|
2034
2494
|
{
|
|
2035
2495
|
properties: {
|
|
2036
2496
|
items: {
|
|
2037
2497
|
type: "array",
|
|
2038
|
-
|
|
2498
|
+
items: { $ref: "#/definitions/schedulableInstruction" }
|
|
2039
2499
|
}
|
|
2040
2500
|
}
|
|
2041
2501
|
}
|
|
@@ -2044,76 +2504,451 @@ var illustrated_schema_default = {
|
|
|
2044
2504
|
}
|
|
2045
2505
|
};
|
|
2046
2506
|
|
|
2047
|
-
// src/
|
|
2048
|
-
var
|
|
2507
|
+
// src/schemas/recipe/stacks/attribution/1.schema.json
|
|
2508
|
+
var schema_default = {
|
|
2509
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2510
|
+
$id: "https://soustack.org/schemas/recipe/stacks/attribution/1.schema.json",
|
|
2511
|
+
title: "Soustack Recipe Stack: attribution v1",
|
|
2512
|
+
description: "Schema for the attribution stack. Ensures namespace data is present when the stack is enabled and vice versa.",
|
|
2513
|
+
type: "object",
|
|
2514
|
+
properties: {
|
|
2515
|
+
stacks: {
|
|
2516
|
+
type: "object",
|
|
2517
|
+
additionalProperties: {
|
|
2518
|
+
type: "integer",
|
|
2519
|
+
minimum: 1
|
|
2520
|
+
}
|
|
2521
|
+
},
|
|
2522
|
+
attribution: {
|
|
2523
|
+
type: "object",
|
|
2524
|
+
properties: {
|
|
2525
|
+
url: { type: "string" },
|
|
2526
|
+
author: { type: "string" },
|
|
2527
|
+
datePublished: { type: "string" }
|
|
2528
|
+
},
|
|
2529
|
+
additionalProperties: false
|
|
2530
|
+
}
|
|
2531
|
+
},
|
|
2532
|
+
allOf: [
|
|
2533
|
+
{
|
|
2534
|
+
if: {
|
|
2535
|
+
properties: {
|
|
2536
|
+
stacks: {
|
|
2537
|
+
type: "object",
|
|
2538
|
+
properties: {
|
|
2539
|
+
attribution: { const: 1 }
|
|
2540
|
+
},
|
|
2541
|
+
required: ["attribution"]
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
},
|
|
2545
|
+
then: {
|
|
2546
|
+
required: ["attribution"]
|
|
2547
|
+
}
|
|
2548
|
+
},
|
|
2549
|
+
{
|
|
2550
|
+
if: {
|
|
2551
|
+
required: ["attribution"]
|
|
2552
|
+
},
|
|
2553
|
+
then: {
|
|
2554
|
+
required: ["stacks"],
|
|
2555
|
+
properties: {
|
|
2556
|
+
stacks: {
|
|
2557
|
+
type: "object",
|
|
2558
|
+
properties: {
|
|
2559
|
+
attribution: { const: 1 }
|
|
2560
|
+
},
|
|
2561
|
+
required: ["attribution"]
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
],
|
|
2567
|
+
additionalProperties: true
|
|
2568
|
+
};
|
|
2569
|
+
|
|
2570
|
+
// src/schemas/recipe/stacks/media/1.schema.json
|
|
2571
|
+
var schema_default2 = {
|
|
2572
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2573
|
+
$id: "https://soustack.org/schemas/recipe/stacks/media/1.schema.json",
|
|
2574
|
+
title: "Soustack Recipe Stack: media v1",
|
|
2575
|
+
description: "Schema for the media stack. Guards media blocks based on stack activation and ensures declarations accompany payloads.",
|
|
2576
|
+
type: "object",
|
|
2577
|
+
properties: {
|
|
2578
|
+
stacks: {
|
|
2579
|
+
type: "object",
|
|
2580
|
+
additionalProperties: {
|
|
2581
|
+
type: "integer",
|
|
2582
|
+
minimum: 1
|
|
2583
|
+
}
|
|
2584
|
+
},
|
|
2585
|
+
media: {
|
|
2586
|
+
type: "object",
|
|
2587
|
+
properties: {
|
|
2588
|
+
images: { type: "array", items: { type: "string" } },
|
|
2589
|
+
videos: { type: "array", items: { type: "string" } }
|
|
2590
|
+
},
|
|
2591
|
+
additionalProperties: false
|
|
2592
|
+
}
|
|
2593
|
+
},
|
|
2594
|
+
allOf: [
|
|
2595
|
+
{
|
|
2596
|
+
if: {
|
|
2597
|
+
properties: {
|
|
2598
|
+
stacks: {
|
|
2599
|
+
type: "object",
|
|
2600
|
+
properties: {
|
|
2601
|
+
media: { const: 1 }
|
|
2602
|
+
},
|
|
2603
|
+
required: ["media"]
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
},
|
|
2607
|
+
then: {
|
|
2608
|
+
required: ["media"]
|
|
2609
|
+
}
|
|
2610
|
+
},
|
|
2611
|
+
{
|
|
2612
|
+
if: {
|
|
2613
|
+
required: ["media"]
|
|
2614
|
+
},
|
|
2615
|
+
then: {
|
|
2616
|
+
required: ["stacks"],
|
|
2617
|
+
properties: {
|
|
2618
|
+
stacks: {
|
|
2619
|
+
type: "object",
|
|
2620
|
+
properties: {
|
|
2621
|
+
media: { const: 1 }
|
|
2622
|
+
},
|
|
2623
|
+
required: ["media"]
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
],
|
|
2629
|
+
additionalProperties: true
|
|
2630
|
+
};
|
|
2631
|
+
|
|
2632
|
+
// src/schemas/recipe/stacks/nutrition/1.schema.json
|
|
2633
|
+
var schema_default3 = {
|
|
2634
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2635
|
+
$id: "https://soustack.org/schemas/recipe/stacks/nutrition/1.schema.json",
|
|
2636
|
+
title: "Soustack Recipe Stack: nutrition v1",
|
|
2637
|
+
description: "Schema for the nutrition stack. Keeps nutrition data aligned with stack declarations and vice versa.",
|
|
2638
|
+
type: "object",
|
|
2639
|
+
properties: {
|
|
2640
|
+
stacks: {
|
|
2641
|
+
type: "object",
|
|
2642
|
+
additionalProperties: {
|
|
2643
|
+
type: "integer",
|
|
2644
|
+
minimum: 1
|
|
2645
|
+
}
|
|
2646
|
+
},
|
|
2647
|
+
nutrition: {
|
|
2648
|
+
type: "object",
|
|
2649
|
+
properties: {
|
|
2650
|
+
calories: { type: "number" },
|
|
2651
|
+
protein_g: { type: "number" }
|
|
2652
|
+
},
|
|
2653
|
+
additionalProperties: false
|
|
2654
|
+
}
|
|
2655
|
+
},
|
|
2656
|
+
allOf: [
|
|
2657
|
+
{
|
|
2658
|
+
if: {
|
|
2659
|
+
properties: {
|
|
2660
|
+
stacks: {
|
|
2661
|
+
type: "object",
|
|
2662
|
+
properties: {
|
|
2663
|
+
nutrition: { const: 1 }
|
|
2664
|
+
},
|
|
2665
|
+
required: ["nutrition"]
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
},
|
|
2669
|
+
then: {
|
|
2670
|
+
required: ["nutrition"]
|
|
2671
|
+
}
|
|
2672
|
+
},
|
|
2673
|
+
{
|
|
2674
|
+
if: {
|
|
2675
|
+
required: ["nutrition"]
|
|
2676
|
+
},
|
|
2677
|
+
then: {
|
|
2678
|
+
required: ["stacks"],
|
|
2679
|
+
properties: {
|
|
2680
|
+
stacks: {
|
|
2681
|
+
type: "object",
|
|
2682
|
+
properties: {
|
|
2683
|
+
nutrition: { const: 1 }
|
|
2684
|
+
},
|
|
2685
|
+
required: ["nutrition"]
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
],
|
|
2691
|
+
additionalProperties: true
|
|
2692
|
+
};
|
|
2693
|
+
|
|
2694
|
+
// src/schemas/recipe/stacks/schedule/1.schema.json
|
|
2695
|
+
var schema_default4 = {
|
|
2696
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2697
|
+
$id: "https://soustack.org/schemas/recipe/stacks/schedule/1.schema.json",
|
|
2698
|
+
title: "Soustack Recipe Stack: schedule v1",
|
|
2699
|
+
description: "Schema for the schedule stack. Enforces bidirectional stack gating and restricts usage to the core profile.",
|
|
2700
|
+
type: "object",
|
|
2701
|
+
properties: {
|
|
2702
|
+
profile: { type: "string" },
|
|
2703
|
+
stacks: {
|
|
2704
|
+
type: "object",
|
|
2705
|
+
additionalProperties: {
|
|
2706
|
+
type: "integer",
|
|
2707
|
+
minimum: 1
|
|
2708
|
+
}
|
|
2709
|
+
},
|
|
2710
|
+
schedule: {
|
|
2711
|
+
type: "object",
|
|
2712
|
+
properties: {
|
|
2713
|
+
tasks: { type: "array" }
|
|
2714
|
+
},
|
|
2715
|
+
additionalProperties: false
|
|
2716
|
+
}
|
|
2717
|
+
},
|
|
2718
|
+
allOf: [
|
|
2719
|
+
{
|
|
2720
|
+
if: {
|
|
2721
|
+
properties: {
|
|
2722
|
+
stacks: {
|
|
2723
|
+
type: "object",
|
|
2724
|
+
properties: {
|
|
2725
|
+
schedule: { const: 1 }
|
|
2726
|
+
},
|
|
2727
|
+
required: ["schedule"]
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
},
|
|
2731
|
+
then: {
|
|
2732
|
+
required: ["schedule", "profile"],
|
|
2733
|
+
properties: {
|
|
2734
|
+
profile: { const: "core" }
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
},
|
|
2738
|
+
{
|
|
2739
|
+
if: {
|
|
2740
|
+
required: ["schedule"]
|
|
2741
|
+
},
|
|
2742
|
+
then: {
|
|
2743
|
+
required: ["stacks", "profile"],
|
|
2744
|
+
properties: {
|
|
2745
|
+
stacks: {
|
|
2746
|
+
type: "object",
|
|
2747
|
+
properties: {
|
|
2748
|
+
schedule: { const: 1 }
|
|
2749
|
+
},
|
|
2750
|
+
required: ["schedule"]
|
|
2751
|
+
},
|
|
2752
|
+
profile: { const: "core" }
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
],
|
|
2757
|
+
additionalProperties: true
|
|
2758
|
+
};
|
|
2759
|
+
|
|
2760
|
+
// src/schemas/recipe/stacks/taxonomy/1.schema.json
|
|
2761
|
+
var schema_default5 = {
|
|
2762
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2763
|
+
$id: "https://soustack.org/schemas/recipe/stacks/taxonomy/1.schema.json",
|
|
2764
|
+
title: "Soustack Recipe Stack: taxonomy v1",
|
|
2765
|
+
description: "Schema for the taxonomy stack. Enforces keyword and categorization data when enabled and ensures stack declaration accompanies the namespace block.",
|
|
2766
|
+
type: "object",
|
|
2767
|
+
properties: {
|
|
2768
|
+
stacks: {
|
|
2769
|
+
type: "object",
|
|
2770
|
+
additionalProperties: {
|
|
2771
|
+
type: "integer",
|
|
2772
|
+
minimum: 1
|
|
2773
|
+
}
|
|
2774
|
+
},
|
|
2775
|
+
taxonomy: {
|
|
2776
|
+
type: "object",
|
|
2777
|
+
properties: {
|
|
2778
|
+
keywords: { type: "array", items: { type: "string" } },
|
|
2779
|
+
category: { type: "string" },
|
|
2780
|
+
cuisine: { type: "string" }
|
|
2781
|
+
},
|
|
2782
|
+
additionalProperties: false
|
|
2783
|
+
}
|
|
2784
|
+
},
|
|
2785
|
+
allOf: [
|
|
2786
|
+
{
|
|
2787
|
+
if: {
|
|
2788
|
+
properties: {
|
|
2789
|
+
stacks: {
|
|
2790
|
+
type: "object",
|
|
2791
|
+
properties: {
|
|
2792
|
+
taxonomy: { const: 1 }
|
|
2793
|
+
},
|
|
2794
|
+
required: ["taxonomy"]
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
},
|
|
2798
|
+
then: {
|
|
2799
|
+
required: ["taxonomy"]
|
|
2800
|
+
}
|
|
2801
|
+
},
|
|
2802
|
+
{
|
|
2803
|
+
if: {
|
|
2804
|
+
required: ["taxonomy"]
|
|
2805
|
+
},
|
|
2806
|
+
then: {
|
|
2807
|
+
required: ["stacks"],
|
|
2808
|
+
properties: {
|
|
2809
|
+
stacks: {
|
|
2810
|
+
type: "object",
|
|
2811
|
+
properties: {
|
|
2812
|
+
taxonomy: { const: 1 }
|
|
2813
|
+
},
|
|
2814
|
+
required: ["taxonomy"]
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
],
|
|
2820
|
+
additionalProperties: true
|
|
2821
|
+
};
|
|
2822
|
+
|
|
2823
|
+
// src/schemas/recipe/stacks/times/1.schema.json
|
|
2824
|
+
var schema_default6 = {
|
|
2049
2825
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
2050
|
-
$id: "
|
|
2051
|
-
title: "Soustack
|
|
2052
|
-
description: "
|
|
2826
|
+
$id: "https://soustack.org/schemas/recipe/stacks/times/1.schema.json",
|
|
2827
|
+
title: "Soustack Recipe Stack: times v1",
|
|
2828
|
+
description: "Schema for the times stack. Maintains alignment between stack declarations and timing payloads.",
|
|
2829
|
+
type: "object",
|
|
2830
|
+
properties: {
|
|
2831
|
+
stacks: {
|
|
2832
|
+
type: "object",
|
|
2833
|
+
additionalProperties: {
|
|
2834
|
+
type: "integer",
|
|
2835
|
+
minimum: 1
|
|
2836
|
+
}
|
|
2837
|
+
},
|
|
2838
|
+
times: {
|
|
2839
|
+
type: "object",
|
|
2840
|
+
properties: {
|
|
2841
|
+
prepMinutes: { type: "number" },
|
|
2842
|
+
cookMinutes: { type: "number" },
|
|
2843
|
+
totalMinutes: { type: "number" }
|
|
2844
|
+
},
|
|
2845
|
+
additionalProperties: false
|
|
2846
|
+
}
|
|
2847
|
+
},
|
|
2053
2848
|
allOf: [
|
|
2054
|
-
{ $ref: "http://soustack.org/schema/v0.2.1" },
|
|
2055
2849
|
{
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
{
|
|
2062
|
-
|
|
2063
|
-
]
|
|
2850
|
+
if: {
|
|
2851
|
+
properties: {
|
|
2852
|
+
stacks: {
|
|
2853
|
+
type: "object",
|
|
2854
|
+
properties: {
|
|
2855
|
+
times: { const: 1 }
|
|
2856
|
+
},
|
|
2857
|
+
required: ["times"]
|
|
2064
2858
|
}
|
|
2065
2859
|
}
|
|
2860
|
+
},
|
|
2861
|
+
then: {
|
|
2862
|
+
required: ["times"]
|
|
2066
2863
|
}
|
|
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
2864
|
},
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2865
|
+
{
|
|
2866
|
+
if: {
|
|
2867
|
+
required: ["times"]
|
|
2868
|
+
},
|
|
2869
|
+
then: {
|
|
2870
|
+
required: ["stacks"],
|
|
2871
|
+
properties: {
|
|
2872
|
+
stacks: {
|
|
2873
|
+
type: "object",
|
|
2874
|
+
properties: {
|
|
2875
|
+
times: { const: 1 }
|
|
2876
|
+
},
|
|
2877
|
+
required: ["times"]
|
|
2085
2878
|
}
|
|
2086
2879
|
}
|
|
2087
|
-
|
|
2880
|
+
}
|
|
2088
2881
|
}
|
|
2089
|
-
|
|
2882
|
+
],
|
|
2883
|
+
additionalProperties: true
|
|
2090
2884
|
};
|
|
2091
2885
|
|
|
2092
2886
|
// src/validator.ts
|
|
2093
|
-
var
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
quantified: quantified_schema_default,
|
|
2098
|
-
illustrated: illustrated_schema_default,
|
|
2099
|
-
schedulable: schedulable_schema_default
|
|
2100
|
-
};
|
|
2887
|
+
var LEGACY_ROOT_SCHEMA_ID = "http://soustack.org/schema/v0.3.0";
|
|
2888
|
+
var DEFAULT_ROOT_SCHEMA_ID = "https://soustack.spec/soustack.schema.json";
|
|
2889
|
+
var BASE_SCHEMA_ID = "http://soustack.org/schema/recipe/base.schema.json";
|
|
2890
|
+
var PROFILE_SCHEMA_PREFIX = "http://soustack.org/schema/recipe/profiles/";
|
|
2101
2891
|
var validationContexts = /* @__PURE__ */ new Map();
|
|
2892
|
+
function loadAllSchemas(ajv) {
|
|
2893
|
+
const schemas = [
|
|
2894
|
+
soustack_schema_default,
|
|
2895
|
+
base_schema_default,
|
|
2896
|
+
minimal_schema_default,
|
|
2897
|
+
core_schema_default,
|
|
2898
|
+
base_schema_default2,
|
|
2899
|
+
cookable_schema_default,
|
|
2900
|
+
illustrated_schema_default,
|
|
2901
|
+
quantified_schema_default,
|
|
2902
|
+
scalable_schema_default,
|
|
2903
|
+
schedulable_schema_default,
|
|
2904
|
+
schema_default,
|
|
2905
|
+
schema_default2,
|
|
2906
|
+
schema_default3,
|
|
2907
|
+
schema_default4,
|
|
2908
|
+
schema_default5,
|
|
2909
|
+
schema_default6
|
|
2910
|
+
];
|
|
2911
|
+
for (const schema of schemas) {
|
|
2912
|
+
if (schema && typeof schema === "object" && "$id" in schema) {
|
|
2913
|
+
const schemaWithId = schema;
|
|
2914
|
+
if (schemaWithId.$id) {
|
|
2915
|
+
ajv.addSchema(schemaWithId, schemaWithId.$id);
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
ajv.addSchema(
|
|
2920
|
+
{
|
|
2921
|
+
$id: DEFAULT_ROOT_SCHEMA_ID,
|
|
2922
|
+
allOf: [
|
|
2923
|
+
{ $ref: LEGACY_ROOT_SCHEMA_ID },
|
|
2924
|
+
{
|
|
2925
|
+
type: "object",
|
|
2926
|
+
properties: {
|
|
2927
|
+
$schema: { const: DEFAULT_ROOT_SCHEMA_ID }
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
]
|
|
2931
|
+
},
|
|
2932
|
+
DEFAULT_ROOT_SCHEMA_ID
|
|
2933
|
+
);
|
|
2934
|
+
}
|
|
2102
2935
|
function createContext(collectAllErrors) {
|
|
2103
|
-
const ajv = new
|
|
2936
|
+
const ajv = new Ajv2020__default.default({
|
|
2937
|
+
strict: false,
|
|
2938
|
+
allErrors: collectAllErrors,
|
|
2939
|
+
validateSchema: false
|
|
2940
|
+
// Don't validate schemas themselves
|
|
2941
|
+
});
|
|
2104
2942
|
addFormats__default.default(ajv);
|
|
2105
|
-
|
|
2106
|
-
const
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2943
|
+
loadAllSchemas(ajv);
|
|
2944
|
+
const rootValidator = ajv.getSchema(DEFAULT_ROOT_SCHEMA_ID) || ajv.getSchema(LEGACY_ROOT_SCHEMA_ID);
|
|
2945
|
+
const baseValidator = ajv.getSchema(BASE_SCHEMA_ID);
|
|
2946
|
+
return {
|
|
2947
|
+
ajv,
|
|
2948
|
+
rootValidator: rootValidator || void 0,
|
|
2949
|
+
baseValidator: baseValidator || void 0,
|
|
2950
|
+
validators: /* @__PURE__ */ new Map()
|
|
2112
2951
|
};
|
|
2113
|
-
addSchemaIfNew(schema_default);
|
|
2114
|
-
addSchemaIfNew(soustack_schema_default);
|
|
2115
|
-
Object.values(profileSchemas).forEach(addSchemaIfNew);
|
|
2116
|
-
return { ajv, validators: {} };
|
|
2117
2952
|
}
|
|
2118
2953
|
function getContext(collectAllErrors) {
|
|
2119
2954
|
if (!validationContexts.has(collectAllErrors)) {
|
|
@@ -2127,68 +2962,6 @@ function cloneRecipe(recipe) {
|
|
|
2127
2962
|
}
|
|
2128
2963
|
return JSON.parse(JSON.stringify(recipe));
|
|
2129
2964
|
}
|
|
2130
|
-
function detectProfileFromSchema(schemaRef) {
|
|
2131
|
-
if (!schemaRef) return void 0;
|
|
2132
|
-
const match = schemaRef.match(/\/profiles\/([a-z]+)\.schema\.json$/i);
|
|
2133
|
-
if (match) {
|
|
2134
|
-
const profile = match[1].toLowerCase();
|
|
2135
|
-
if (profile in profileSchemas) return profile;
|
|
2136
|
-
}
|
|
2137
|
-
return void 0;
|
|
2138
|
-
}
|
|
2139
|
-
function getValidator(profile, context) {
|
|
2140
|
-
if (!profileSchemas[profile]) {
|
|
2141
|
-
throw new Error(`Unknown Soustack profile: ${profile}`);
|
|
2142
|
-
}
|
|
2143
|
-
if (!context.validators[profile]) {
|
|
2144
|
-
context.validators[profile] = context.ajv.compile(profileSchemas[profile]);
|
|
2145
|
-
}
|
|
2146
|
-
return context.validators[profile];
|
|
2147
|
-
}
|
|
2148
|
-
function normalizeRecipe(recipe) {
|
|
2149
|
-
const normalized = cloneRecipe(recipe);
|
|
2150
|
-
const warnings = [];
|
|
2151
|
-
normalizeTime(normalized);
|
|
2152
|
-
if (normalized && typeof normalized === "object" && "version" in normalized && !normalized.recipeVersion && typeof normalized.version === "string") {
|
|
2153
|
-
normalized.recipeVersion = normalized.version;
|
|
2154
|
-
warnings.push({ path: "/version", message: "'version' is deprecated; mapped to 'recipeVersion'." });
|
|
2155
|
-
}
|
|
2156
|
-
return { normalized, warnings };
|
|
2157
|
-
}
|
|
2158
|
-
function normalizeTime(recipe) {
|
|
2159
|
-
const time = recipe?.time;
|
|
2160
|
-
if (!time || typeof time !== "object" || Array.isArray(time)) return;
|
|
2161
|
-
const structuredKeys = [
|
|
2162
|
-
"prep",
|
|
2163
|
-
"active",
|
|
2164
|
-
"passive",
|
|
2165
|
-
"total"
|
|
2166
|
-
];
|
|
2167
|
-
structuredKeys.forEach((key) => {
|
|
2168
|
-
const value = time[key];
|
|
2169
|
-
if (typeof value === "number") return;
|
|
2170
|
-
const parsed = parseDuration(value);
|
|
2171
|
-
if (parsed !== null) {
|
|
2172
|
-
time[key] = parsed;
|
|
2173
|
-
}
|
|
2174
|
-
});
|
|
2175
|
-
}
|
|
2176
|
-
var allowedTopLevelProps = /* @__PURE__ */ new Set([
|
|
2177
|
-
...Object.keys(soustack_schema_default?.properties ?? {}),
|
|
2178
|
-
"metadata",
|
|
2179
|
-
"$schema"
|
|
2180
|
-
]);
|
|
2181
|
-
function detectUnknownTopLevelKeys(recipe) {
|
|
2182
|
-
if (!recipe || typeof recipe !== "object") return [];
|
|
2183
|
-
const disallowedKeys = Object.keys(recipe).filter(
|
|
2184
|
-
(key) => !allowedTopLevelProps.has(key) && !key.startsWith("x-")
|
|
2185
|
-
);
|
|
2186
|
-
return disallowedKeys.map((key) => ({
|
|
2187
|
-
path: `/${key}`,
|
|
2188
|
-
keyword: "additionalProperties",
|
|
2189
|
-
message: `Unknown top-level property '${key}' is not allowed by the Soustack spec`
|
|
2190
|
-
}));
|
|
2191
|
-
}
|
|
2192
2965
|
function formatAjvError(error) {
|
|
2193
2966
|
let path2 = error.instancePath || "/";
|
|
2194
2967
|
if (error.keyword === "additionalProperties" && error.params?.additionalProperty) {
|
|
@@ -2201,112 +2974,227 @@ function formatAjvError(error) {
|
|
|
2201
2974
|
message: error.message || "Validation error"
|
|
2202
2975
|
};
|
|
2203
2976
|
}
|
|
2204
|
-
function
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2977
|
+
function isSoustackSchemaId(schemaId) {
|
|
2978
|
+
return schemaId.startsWith("http://soustack.org/schema") || schemaId.startsWith("https://soustack.org/schema") || schemaId.startsWith("https://soustack.spec/") || schemaId.startsWith("https://soustack.org/schemas/");
|
|
2979
|
+
}
|
|
2980
|
+
function inferStacksFromPayload(recipe) {
|
|
2981
|
+
const inferred = {};
|
|
2982
|
+
const payloadToStack = {
|
|
2983
|
+
attribution: "attribution",
|
|
2984
|
+
taxonomy: "taxonomy",
|
|
2985
|
+
media: "media",
|
|
2986
|
+
times: "times",
|
|
2987
|
+
nutrition: "nutrition",
|
|
2988
|
+
schedule: "schedule"
|
|
2989
|
+
};
|
|
2990
|
+
for (const [field, stackName] of Object.entries(payloadToStack)) {
|
|
2991
|
+
if (recipe && typeof recipe === "object" && field in recipe && recipe[field] !== void 0) {
|
|
2992
|
+
inferred[stackName] = 1;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
return inferred;
|
|
2996
|
+
}
|
|
2997
|
+
function getComposedValidator(profile, stacks, context) {
|
|
2998
|
+
const stackIdentifiers = Object.entries(stacks).map(([name, version]) => `${name}@${version}`).sort();
|
|
2999
|
+
const cacheKey = `${profile}::${stackIdentifiers.join(",")}`;
|
|
3000
|
+
const cached = context.validators.get(cacheKey);
|
|
3001
|
+
if (cached) return cached;
|
|
3002
|
+
const allOf = [{ $ref: BASE_SCHEMA_ID }];
|
|
3003
|
+
if (!context.ajv.getSchema(BASE_SCHEMA_ID)) {
|
|
3004
|
+
throw new Error(`Base schema not loaded: ${BASE_SCHEMA_ID}. Ensure schemas are loaded before creating validators.`);
|
|
3005
|
+
}
|
|
3006
|
+
const profileSchemaId = `${PROFILE_SCHEMA_PREFIX}${profile}.schema.json`;
|
|
3007
|
+
if (!context.ajv.getSchema(profileSchemaId)) {
|
|
3008
|
+
throw new Error(`Profile schema not loaded: ${profileSchemaId}`);
|
|
3009
|
+
}
|
|
3010
|
+
allOf.push({ $ref: profileSchemaId });
|
|
3011
|
+
for (const [name, version] of Object.entries(stacks)) {
|
|
3012
|
+
if (typeof version === "number" && version >= 1) {
|
|
3013
|
+
const stackSchemaId = `https://soustack.org/schemas/recipe/stacks/${name}/${version}.schema.json`;
|
|
3014
|
+
if (!context.ajv.getSchema(stackSchemaId)) {
|
|
3015
|
+
throw new Error(`Stack schema not loaded: ${stackSchemaId}`);
|
|
3016
|
+
}
|
|
3017
|
+
allOf.push({ $ref: stackSchemaId });
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
const composedSchema = {
|
|
3021
|
+
$id: `urn:soustack:composed:${cacheKey}`,
|
|
3022
|
+
allOf
|
|
3023
|
+
};
|
|
3024
|
+
const validateFn = context.ajv.compile(composedSchema);
|
|
3025
|
+
context.validators.set(cacheKey, validateFn);
|
|
3026
|
+
return validateFn;
|
|
2215
3027
|
}
|
|
2216
|
-
function
|
|
2217
|
-
const
|
|
2218
|
-
|
|
2219
|
-
const
|
|
2220
|
-
const
|
|
2221
|
-
const
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
3028
|
+
function validateRecipeSchemaNormalized(normalizedInput, inputHasStacks, collectAllErrors, schemaOverride) {
|
|
3029
|
+
const normalized = cloneRecipe(normalizedInput);
|
|
3030
|
+
const context = getContext(collectAllErrors);
|
|
3031
|
+
const schemaId = typeof schemaOverride === "string" ? schemaOverride : typeof normalized.$schema === "string" ? normalized.$schema : void 0;
|
|
3032
|
+
const hasSchemaOverride = typeof schemaOverride === "string";
|
|
3033
|
+
const isSoustackSchema = schemaId ? isSoustackSchemaId(schemaId) : false;
|
|
3034
|
+
if (schemaId && isSoustackSchema) {
|
|
3035
|
+
const schemaValidator = context.ajv.getSchema(schemaId);
|
|
3036
|
+
if (!schemaValidator) {
|
|
3037
|
+
return {
|
|
3038
|
+
ok: false,
|
|
3039
|
+
errors: [
|
|
3040
|
+
{
|
|
3041
|
+
path: "/$schema",
|
|
3042
|
+
message: `Unknown schema: ${schemaId}`
|
|
3043
|
+
}
|
|
3044
|
+
]
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
const schemaInput = cloneRecipe(normalized);
|
|
3048
|
+
if (hasSchemaOverride && "$schema" in schemaInput && schemaInput.$schema !== schemaId) {
|
|
3049
|
+
delete schemaInput.$schema;
|
|
3050
|
+
}
|
|
3051
|
+
const isLegacySchema = schemaId.startsWith(LEGACY_ROOT_SCHEMA_ID);
|
|
3052
|
+
const shouldRemoveStacks = (isLegacySchema || schemaId === DEFAULT_ROOT_SCHEMA_ID) && !inputHasStacks;
|
|
3053
|
+
if (isLegacySchema && "@type" in schemaInput) {
|
|
3054
|
+
delete schemaInput["@type"];
|
|
3055
|
+
}
|
|
3056
|
+
if (shouldRemoveStacks && "stacks" in schemaInput) {
|
|
3057
|
+
delete schemaInput.stacks;
|
|
3058
|
+
}
|
|
3059
|
+
const schemaValid = schemaValidator(schemaInput);
|
|
3060
|
+
const schemaErrors = schemaValidator.errors || [];
|
|
3061
|
+
return {
|
|
3062
|
+
ok: !!schemaValid,
|
|
3063
|
+
errors: schemaErrors.map(formatAjvError)
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
const hasProfile = normalized.profile && typeof normalized.profile === "string";
|
|
3067
|
+
let declaredStacks = {};
|
|
3068
|
+
if (normalized.stacks && typeof normalized.stacks === "object" && !Array.isArray(normalized.stacks)) {
|
|
3069
|
+
for (const [name, version] of Object.entries(normalized.stacks)) {
|
|
3070
|
+
if (typeof version === "number" && version >= 1) {
|
|
3071
|
+
declaredStacks[name] = version;
|
|
2227
3072
|
}
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
const inferredStacks = inferStacksFromPayload(normalized);
|
|
3076
|
+
const allStacks = { ...declaredStacks };
|
|
3077
|
+
for (const [name, version] of Object.entries(inferredStacks)) {
|
|
3078
|
+
if (!allStacks[name] || allStacks[name] < version) {
|
|
3079
|
+
allStacks[name] = version;
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
let isValid;
|
|
3083
|
+
let errors = [];
|
|
3084
|
+
const profile = hasProfile ? normalized.profile.toLowerCase() : "core";
|
|
3085
|
+
const profileSchemaId = `${PROFILE_SCHEMA_PREFIX}${profile}.schema.json`;
|
|
3086
|
+
if (!context.ajv.getSchema(profileSchemaId)) {
|
|
3087
|
+
return {
|
|
3088
|
+
ok: false,
|
|
3089
|
+
errors: [
|
|
3090
|
+
{
|
|
3091
|
+
path: "/profile",
|
|
3092
|
+
message: `Profile schema not loaded: ${profileSchemaId}`
|
|
2241
3093
|
}
|
|
2242
|
-
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
errors.push({
|
|
2250
|
-
path: ref.path,
|
|
2251
|
-
message: `Instruction dependency references missing id '${ref.toId}'.`
|
|
2252
|
-
});
|
|
3094
|
+
]
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
{
|
|
3098
|
+
const validationCopy = cloneRecipe(normalized);
|
|
3099
|
+
if (!validationCopy.stacks || typeof validationCopy.stacks !== "object" || Array.isArray(validationCopy.stacks)) {
|
|
3100
|
+
validationCopy.stacks = declaredStacks;
|
|
2253
3101
|
}
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
dependencyRefs.forEach((ref) => {
|
|
2257
|
-
if (ref.fromId && instructionIds.has(ref.fromId) && instructionIds.has(ref.toId)) {
|
|
2258
|
-
const list = adjacency.get(ref.fromId) ?? [];
|
|
2259
|
-
list.push({ toId: ref.toId, path: ref.path });
|
|
2260
|
-
adjacency.set(ref.fromId, list);
|
|
3102
|
+
if (!validationCopy.profile) {
|
|
3103
|
+
validationCopy.profile = profile;
|
|
2261
3104
|
}
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
const neighbors = adjacency.get(nodeId) ?? [];
|
|
2270
|
-
neighbors.forEach((edge) => {
|
|
2271
|
-
if (visiting.has(edge.toId)) {
|
|
2272
|
-
errors.push({
|
|
2273
|
-
path: edge.path,
|
|
2274
|
-
message: `Circular dependency detected involving instruction id '${edge.toId}'.`
|
|
2275
|
-
});
|
|
2276
|
-
return;
|
|
3105
|
+
const validator = getComposedValidator(profile, allStacks, context);
|
|
3106
|
+
isValid = validator(validationCopy);
|
|
3107
|
+
errors = validator.errors || [];
|
|
3108
|
+
if (isValid && context.rootValidator) {
|
|
3109
|
+
const rootCheckCopy = cloneRecipe(normalized);
|
|
3110
|
+
if ("@type" in rootCheckCopy) {
|
|
3111
|
+
delete rootCheckCopy["@type"];
|
|
2277
3112
|
}
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
3113
|
+
if ("stacks" in rootCheckCopy) {
|
|
3114
|
+
delete rootCheckCopy.stacks;
|
|
3115
|
+
}
|
|
3116
|
+
if ("profile" in rootCheckCopy) {
|
|
3117
|
+
delete rootCheckCopy.profile;
|
|
3118
|
+
}
|
|
3119
|
+
const stackPayloadFields = ["attribution", "taxonomy", "media", "times", "nutrition", "schedule"];
|
|
3120
|
+
for (const field of stackPayloadFields) {
|
|
3121
|
+
if (field in rootCheckCopy) {
|
|
3122
|
+
delete rootCheckCopy[field];
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
const rootValid = context.rootValidator(rootCheckCopy);
|
|
3126
|
+
if (!rootValid && context.rootValidator.errors) {
|
|
3127
|
+
const unknownKeyErrors = context.rootValidator.errors.filter(
|
|
3128
|
+
(e) => e.keyword === "additionalProperties" && (e.instancePath === "" || e.instancePath === "/")
|
|
3129
|
+
);
|
|
3130
|
+
const schemaConstErrors = context.rootValidator.errors.filter(
|
|
3131
|
+
(e) => e.keyword === "const" && e.instancePath === "/$schema"
|
|
3132
|
+
);
|
|
3133
|
+
const relevantErrors = [...unknownKeyErrors, ...schemaConstErrors];
|
|
3134
|
+
if (relevantErrors.length > 0) {
|
|
3135
|
+
errors.push(...relevantErrors);
|
|
3136
|
+
isValid = false;
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
return {
|
|
3142
|
+
ok: isValid,
|
|
3143
|
+
errors: errors.map(formatAjvError)
|
|
2282
3144
|
};
|
|
2283
|
-
instructionIds.forEach((id) => detectCycles(id));
|
|
2284
|
-
return errors;
|
|
2285
3145
|
}
|
|
2286
3146
|
function validateRecipe(input, options = {}) {
|
|
2287
|
-
const
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
const
|
|
2292
|
-
const
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
3147
|
+
const { recipe: normalized, warnings } = normalizeRecipe(input);
|
|
3148
|
+
if (options.profile) {
|
|
3149
|
+
normalized.profile = options.profile;
|
|
3150
|
+
}
|
|
3151
|
+
const inputHasStacks = !!input && typeof input === "object" && !Array.isArray(input) && "stacks" in input;
|
|
3152
|
+
const { ok: schemaOk, errors: schemaErrors } = validateRecipeSchemaNormalized(
|
|
3153
|
+
normalized,
|
|
3154
|
+
inputHasStacks,
|
|
3155
|
+
options.collectAllErrors ?? true,
|
|
3156
|
+
options.schema
|
|
3157
|
+
);
|
|
3158
|
+
const mode = options.mode ?? "full";
|
|
3159
|
+
let conformanceIssues = [];
|
|
3160
|
+
let conformanceOk = true;
|
|
3161
|
+
if (mode === "full") {
|
|
3162
|
+
if (schemaOk) {
|
|
3163
|
+
const conformanceResult = validateConformance(normalized);
|
|
3164
|
+
conformanceIssues = conformanceResult.issues;
|
|
3165
|
+
conformanceOk = conformanceResult.ok;
|
|
3166
|
+
} else {
|
|
3167
|
+
conformanceOk = false;
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
const ok = schemaOk && (mode === "schema" ? true : conformanceOk);
|
|
3171
|
+
const normalizedRecipe = ok || options.includeNormalized ? normalized : void 0;
|
|
2296
3172
|
return {
|
|
2297
|
-
|
|
2298
|
-
|
|
3173
|
+
ok,
|
|
3174
|
+
schemaErrors,
|
|
3175
|
+
conformanceIssues,
|
|
2299
3176
|
warnings,
|
|
2300
|
-
|
|
3177
|
+
normalizedRecipe
|
|
2301
3178
|
};
|
|
2302
3179
|
}
|
|
2303
3180
|
|
|
2304
3181
|
// bin/cli.ts
|
|
2305
|
-
var supportedProfiles = [
|
|
3182
|
+
var supportedProfiles = [
|
|
3183
|
+
"base",
|
|
3184
|
+
"equipped",
|
|
3185
|
+
"illustrated",
|
|
3186
|
+
"lite",
|
|
3187
|
+
"prepped",
|
|
3188
|
+
"scalable",
|
|
3189
|
+
"timed"
|
|
3190
|
+
];
|
|
2306
3191
|
async function runCli(argv) {
|
|
2307
3192
|
const [command, ...args] = argv;
|
|
2308
3193
|
try {
|
|
2309
3194
|
switch (command) {
|
|
3195
|
+
case "check":
|
|
3196
|
+
await handleCheck(args);
|
|
3197
|
+
return;
|
|
2310
3198
|
case "validate":
|
|
2311
3199
|
await handleValidate(args);
|
|
2312
3200
|
return;
|
|
@@ -2336,23 +3224,50 @@ async function runCli(argv) {
|
|
|
2336
3224
|
}
|
|
2337
3225
|
function printUsage() {
|
|
2338
3226
|
console.log("Usage:");
|
|
2339
|
-
console.log(" soustack
|
|
3227
|
+
console.log(" soustack check <file> --json");
|
|
3228
|
+
console.log(
|
|
3229
|
+
" soustack validate <fileOrGlob> [--profile <name>] [--force-profile] [--schema-only] [--strict] [--json]"
|
|
3230
|
+
);
|
|
2340
3231
|
console.log(" soustack convert --from <schemaorg|soustack> --to <schemaorg|soustack> <input> [-o <output>]");
|
|
2341
3232
|
console.log(" soustack import --url <url> [-o <soustack.json>]");
|
|
2342
|
-
console.log(" soustack test [--profile <name>] [--strict] [--json]");
|
|
3233
|
+
console.log(" soustack test [--profile <name>] [--force-profile] [--schema-only] [--strict] [--json]");
|
|
2343
3234
|
console.log(" soustack scale <soustack.json> <multiplier>");
|
|
2344
3235
|
console.log(" soustack scrape <url> -o <soustack.json>");
|
|
3236
|
+
console.log(`
|
|
3237
|
+
Profiles: ${supportedProfiles.join(", ")}`);
|
|
3238
|
+
}
|
|
3239
|
+
async function handleCheck(args) {
|
|
3240
|
+
const { target, json } = parseCheckArgs(args);
|
|
3241
|
+
if (!target) throw new Error("Path to Soustack recipe JSON is required");
|
|
3242
|
+
if (!json) throw new Error("Check usage: check <file> --json");
|
|
3243
|
+
try {
|
|
3244
|
+
const input = readJsonFile(target);
|
|
3245
|
+
const result = validateRecipe(input, { mode: "full", includeNormalized: true });
|
|
3246
|
+
const report = buildConformanceReport(result);
|
|
3247
|
+
console.log(JSON.stringify(report, null, 2));
|
|
3248
|
+
if (!report.ok) process.exitCode = 1;
|
|
3249
|
+
} catch (error) {
|
|
3250
|
+
const report = buildConformanceReport({
|
|
3251
|
+
ok: false,
|
|
3252
|
+
warnings: [],
|
|
3253
|
+
schemaErrors: [{ path: "/", message: error?.message || "Validation failed" }],
|
|
3254
|
+
conformanceIssues: [],
|
|
3255
|
+
normalizedRecipe: void 0
|
|
3256
|
+
});
|
|
3257
|
+
console.log(JSON.stringify(report, null, 2));
|
|
3258
|
+
process.exitCode = 1;
|
|
3259
|
+
}
|
|
2345
3260
|
}
|
|
2346
3261
|
async function handleValidate(args) {
|
|
2347
|
-
const { target, profile, strict, json } = parseValidateArgs(args);
|
|
3262
|
+
const { target, profile, forceProfile, strict, json, mode } = parseValidateArgs(args);
|
|
2348
3263
|
if (!target) throw new Error("Path or glob to Soustack recipe JSON is required");
|
|
2349
3264
|
const files = expandTargets(target);
|
|
2350
3265
|
if (files.length === 0) throw new Error(`No files matched pattern: ${target}`);
|
|
2351
|
-
const results = files.map((file) => validateFile(file, profile));
|
|
2352
|
-
reportValidation(results, { strict, json
|
|
3266
|
+
const results = files.map((file) => validateFile(file, profile, mode, forceProfile));
|
|
3267
|
+
reportValidation(results, { strict, json});
|
|
2353
3268
|
}
|
|
2354
3269
|
async function handleTest(args) {
|
|
2355
|
-
const { profile, strict, json } = parseValidationFlags(args);
|
|
3270
|
+
const { profile, forceProfile, strict, json, mode } = parseValidationFlags(args);
|
|
2356
3271
|
const cwd = process.cwd();
|
|
2357
3272
|
const files = glob.globSync("**/*.soustack.json", {
|
|
2358
3273
|
cwd,
|
|
@@ -2364,7 +3279,7 @@ async function handleTest(args) {
|
|
|
2364
3279
|
console.log("No *.soustack.json files found in the current repository.");
|
|
2365
3280
|
return;
|
|
2366
3281
|
}
|
|
2367
|
-
const results = files.map((file) => validateFile(file, profile));
|
|
3282
|
+
const results = files.map((file) => validateFile(file, profile, mode, forceProfile));
|
|
2368
3283
|
reportValidation(results, { strict, json, context: "test" });
|
|
2369
3284
|
}
|
|
2370
3285
|
async function handleConvert(args) {
|
|
@@ -2416,8 +3331,10 @@ async function handleScrape(args) {
|
|
|
2416
3331
|
}
|
|
2417
3332
|
function parseValidateArgs(args) {
|
|
2418
3333
|
let profile;
|
|
3334
|
+
let forceProfile = false;
|
|
2419
3335
|
let strict = false;
|
|
2420
3336
|
let json = false;
|
|
3337
|
+
let mode = "full";
|
|
2421
3338
|
let target;
|
|
2422
3339
|
for (let i = 0; i < args.length; i++) {
|
|
2423
3340
|
const arg = args[i];
|
|
@@ -2426,6 +3343,12 @@ function parseValidateArgs(args) {
|
|
|
2426
3343
|
profile = normalizeProfile(args[i + 1]);
|
|
2427
3344
|
i++;
|
|
2428
3345
|
break;
|
|
3346
|
+
case "--force-profile":
|
|
3347
|
+
forceProfile = true;
|
|
3348
|
+
break;
|
|
3349
|
+
case "--schema-only":
|
|
3350
|
+
mode = "schema";
|
|
3351
|
+
break;
|
|
2429
3352
|
case "--strict":
|
|
2430
3353
|
strict = true;
|
|
2431
3354
|
break;
|
|
@@ -2439,11 +3362,26 @@ function parseValidateArgs(args) {
|
|
|
2439
3362
|
break;
|
|
2440
3363
|
}
|
|
2441
3364
|
}
|
|
2442
|
-
return { profile, strict, json, target };
|
|
3365
|
+
return { profile, forceProfile, strict, json, mode, target };
|
|
3366
|
+
}
|
|
3367
|
+
function parseCheckArgs(args) {
|
|
3368
|
+
let json = false;
|
|
3369
|
+
let target;
|
|
3370
|
+
for (let i = 0; i < args.length; i++) {
|
|
3371
|
+
const arg = args[i];
|
|
3372
|
+
if (arg === "--json") {
|
|
3373
|
+
json = true;
|
|
3374
|
+
continue;
|
|
3375
|
+
}
|
|
3376
|
+
if (!arg.startsWith("--") && !target) {
|
|
3377
|
+
target = arg;
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
return { target, json };
|
|
2443
3381
|
}
|
|
2444
3382
|
function parseValidationFlags(args) {
|
|
2445
|
-
const { profile, strict, json } = parseValidateArgs(args);
|
|
2446
|
-
return { profile, strict, json };
|
|
3383
|
+
const { profile, forceProfile, strict, json, mode } = parseValidateArgs(args);
|
|
3384
|
+
return { profile, forceProfile, strict, json, mode };
|
|
2447
3385
|
}
|
|
2448
3386
|
function normalizeProfile(value) {
|
|
2449
3387
|
if (!value) return void 0;
|
|
@@ -2451,7 +3389,7 @@ function normalizeProfile(value) {
|
|
|
2451
3389
|
if (supportedProfiles.includes(normalized)) {
|
|
2452
3390
|
return normalized;
|
|
2453
3391
|
}
|
|
2454
|
-
throw new Error(`Unknown Soustack profile: ${value}`);
|
|
3392
|
+
throw new Error(`Unknown Soustack profile: ${value}. Supported profiles: ${supportedProfiles.join(", ")}`);
|
|
2455
3393
|
}
|
|
2456
3394
|
function parseConvertArgs(args) {
|
|
2457
3395
|
let from;
|
|
@@ -2517,16 +3455,64 @@ function expandTargets(target) {
|
|
|
2517
3455
|
const unique = Array.from(new Set(matches.map((match) => path__namespace.resolve(match))));
|
|
2518
3456
|
return unique;
|
|
2519
3457
|
}
|
|
2520
|
-
function validateFile(file, profile) {
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
3458
|
+
function validateFile(file, profile, mode = "full", forceProfile = false) {
|
|
3459
|
+
try {
|
|
3460
|
+
const recipe = readJsonFile(file);
|
|
3461
|
+
const { recipe: validationRecipe, mismatchError } = resolveProfileForValidation(recipe, profile, forceProfile);
|
|
3462
|
+
if (mismatchError) {
|
|
3463
|
+
return {
|
|
3464
|
+
file,
|
|
3465
|
+
profile,
|
|
3466
|
+
ok: false,
|
|
3467
|
+
warnings: [],
|
|
3468
|
+
schemaErrors: [mismatchError],
|
|
3469
|
+
conformanceIssues: []
|
|
3470
|
+
};
|
|
3471
|
+
}
|
|
3472
|
+
const result = validateRecipe(validationRecipe, profile ? { profile, mode } : { mode });
|
|
3473
|
+
return {
|
|
3474
|
+
file,
|
|
3475
|
+
profile,
|
|
3476
|
+
ok: result.ok,
|
|
3477
|
+
warnings: result.warnings,
|
|
3478
|
+
schemaErrors: result.schemaErrors,
|
|
3479
|
+
conformanceIssues: result.conformanceIssues
|
|
3480
|
+
};
|
|
3481
|
+
} catch (error) {
|
|
3482
|
+
return {
|
|
3483
|
+
file,
|
|
3484
|
+
profile,
|
|
3485
|
+
ok: false,
|
|
3486
|
+
warnings: [],
|
|
3487
|
+
schemaErrors: [{ path: "/", message: error?.message || "Validation failed", keyword: "error" }],
|
|
3488
|
+
conformanceIssues: []
|
|
3489
|
+
};
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
function resolveProfileForValidation(recipe, profile, forceProfile = false) {
|
|
3493
|
+
if (!profile) return { recipe };
|
|
3494
|
+
if (!recipe || typeof recipe !== "object" || Array.isArray(recipe)) {
|
|
3495
|
+
return { recipe };
|
|
3496
|
+
}
|
|
3497
|
+
const recipeProfileRaw = recipe.profile;
|
|
3498
|
+
const recipeProfile = typeof recipeProfileRaw === "string" ? recipeProfileRaw.toLowerCase() : void 0;
|
|
3499
|
+
if (!recipeProfile) {
|
|
3500
|
+
return { recipe: { ...recipe, profile } };
|
|
3501
|
+
}
|
|
3502
|
+
if (recipeProfile !== profile) {
|
|
3503
|
+
if (!forceProfile) {
|
|
3504
|
+
return {
|
|
3505
|
+
recipe,
|
|
3506
|
+
mismatchError: {
|
|
3507
|
+
path: "/profile",
|
|
3508
|
+
keyword: "profile",
|
|
3509
|
+
message: `Recipe profile "${recipeProfile}" does not match --profile "${profile}". Use --force-profile to override.`
|
|
3510
|
+
}
|
|
3511
|
+
};
|
|
3512
|
+
}
|
|
3513
|
+
return { recipe: { ...recipe, profile } };
|
|
3514
|
+
}
|
|
3515
|
+
return { recipe };
|
|
2530
3516
|
}
|
|
2531
3517
|
function reportValidation(results, options) {
|
|
2532
3518
|
const summary = {
|
|
@@ -2542,9 +3528,10 @@ function reportValidation(results, options) {
|
|
|
2542
3528
|
return {
|
|
2543
3529
|
file: path__namespace.relative(process.cwd(), result.file),
|
|
2544
3530
|
profile: result.profile,
|
|
2545
|
-
|
|
3531
|
+
ok: result.ok,
|
|
2546
3532
|
warnings: result.warnings,
|
|
2547
|
-
|
|
3533
|
+
schemaErrors: result.schemaErrors,
|
|
3534
|
+
conformanceIssues: result.conformanceIssues,
|
|
2548
3535
|
passed
|
|
2549
3536
|
};
|
|
2550
3537
|
});
|
|
@@ -2554,14 +3541,22 @@ function reportValidation(results, options) {
|
|
|
2554
3541
|
serializable.forEach((entry) => {
|
|
2555
3542
|
const prefix = entry.passed ? "\u2705" : "\u274C";
|
|
2556
3543
|
console.log(`${prefix} ${entry.file}`);
|
|
2557
|
-
if (!entry.passed && entry.
|
|
2558
|
-
|
|
3544
|
+
if (!entry.passed && entry.schemaErrors.length) {
|
|
3545
|
+
console.log(" Schema errors:");
|
|
3546
|
+
entry.schemaErrors.forEach((error) => {
|
|
2559
3547
|
console.log(` \u2022 [${error.path}] ${error.message}`);
|
|
2560
3548
|
});
|
|
2561
3549
|
}
|
|
3550
|
+
if (!entry.passed && entry.conformanceIssues.length) {
|
|
3551
|
+
console.log(" Conformance issues:");
|
|
3552
|
+
entry.conformanceIssues.forEach((issue) => {
|
|
3553
|
+
console.log(` \u2022 [${issue.path}] ${issue.message} (${issue.code})`);
|
|
3554
|
+
});
|
|
3555
|
+
}
|
|
2562
3556
|
if (!entry.passed && options.strict && entry.warnings.length) {
|
|
3557
|
+
console.log(" Warnings:");
|
|
2563
3558
|
entry.warnings.forEach((warning) => {
|
|
2564
|
-
console.log(` \u2022
|
|
3559
|
+
console.log(` \u2022 ${warning} (warning)`);
|
|
2565
3560
|
});
|
|
2566
3561
|
}
|
|
2567
3562
|
});
|
|
@@ -2573,7 +3568,7 @@ function reportValidation(results, options) {
|
|
|
2573
3568
|
}
|
|
2574
3569
|
}
|
|
2575
3570
|
function isEffectivelyValid(result, strict) {
|
|
2576
|
-
return result.
|
|
3571
|
+
return result.ok && (!strict || result.warnings.length === 0);
|
|
2577
3572
|
}
|
|
2578
3573
|
function readJsonFile(relativePath) {
|
|
2579
3574
|
const absolutePath = path__namespace.resolve(relativePath);
|
|
@@ -2605,6 +3600,52 @@ function writeOutput(data, outputPath) {
|
|
|
2605
3600
|
const absolutePath = path__namespace.resolve(outputPath);
|
|
2606
3601
|
fs__namespace.writeFileSync(absolutePath, serialized, "utf-8");
|
|
2607
3602
|
}
|
|
3603
|
+
function buildConformanceReport(result) {
|
|
3604
|
+
const recipe = result.normalizedRecipe;
|
|
3605
|
+
const level = typeof recipe?.level === "string" ? recipe.level : null;
|
|
3606
|
+
const stacks = normalizeStacksForReport(recipe?.stacks);
|
|
3607
|
+
const schemaErrors = sortSchemaErrors(result.schemaErrors).map((error) => ({
|
|
3608
|
+
path: error.path,
|
|
3609
|
+
keyword: error.keyword ?? null,
|
|
3610
|
+
message: error.message
|
|
3611
|
+
}));
|
|
3612
|
+
const conformanceIssues = sortConformanceIssues(result.conformanceIssues).map((issue) => ({
|
|
3613
|
+
code: issue.code,
|
|
3614
|
+
path: issue.path,
|
|
3615
|
+
severity: issue.severity === "warning" ? "warn" : "error",
|
|
3616
|
+
message: issue.message
|
|
3617
|
+
}));
|
|
3618
|
+
return {
|
|
3619
|
+
ok: result.ok,
|
|
3620
|
+
level,
|
|
3621
|
+
stacks,
|
|
3622
|
+
warnings: result.warnings,
|
|
3623
|
+
schemaErrors,
|
|
3624
|
+
conformanceIssues
|
|
3625
|
+
};
|
|
3626
|
+
}
|
|
3627
|
+
function normalizeStacksForReport(stacks) {
|
|
3628
|
+
if (!stacks || typeof stacks !== "object" || Array.isArray(stacks)) return {};
|
|
3629
|
+
const entries = Object.entries(stacks).filter(([, value]) => typeof value === "number");
|
|
3630
|
+
entries.sort(([a], [b]) => a.localeCompare(b));
|
|
3631
|
+
return Object.fromEntries(entries);
|
|
3632
|
+
}
|
|
3633
|
+
function sortSchemaErrors(errors) {
|
|
3634
|
+
return [...errors].sort((left, right) => {
|
|
3635
|
+
const pathCompare = left.path.localeCompare(right.path);
|
|
3636
|
+
if (pathCompare !== 0) return pathCompare;
|
|
3637
|
+
const leftKeyword = left.keyword ?? "";
|
|
3638
|
+
const rightKeyword = right.keyword ?? "";
|
|
3639
|
+
return leftKeyword.localeCompare(rightKeyword);
|
|
3640
|
+
});
|
|
3641
|
+
}
|
|
3642
|
+
function sortConformanceIssues(issues) {
|
|
3643
|
+
return [...issues].sort((left, right) => {
|
|
3644
|
+
const pathCompare = left.path.localeCompare(right.path);
|
|
3645
|
+
if (pathCompare !== 0) return pathCompare;
|
|
3646
|
+
return left.code.localeCompare(right.code);
|
|
3647
|
+
});
|
|
3648
|
+
}
|
|
2608
3649
|
if (__require.main === module) {
|
|
2609
3650
|
runCli(process.argv.slice(2)).catch((error) => {
|
|
2610
3651
|
console.error(`\u274C ${error?.message ?? error}`);
|