soustack 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -247,894 +247,523 @@ function toDurationMinutes(duration) {
247
247
  return 0;
248
248
  }
249
249
 
250
- // src/schema.json
251
- var schema_default = {
250
+ // src/schemas/recipe/base.schema.json
251
+ var base_schema_default = {
252
252
  $schema: "http://json-schema.org/draft-07/schema#",
253
- $id: "http://soustack.org/schema/v0.2.1",
254
- title: "Soustack Recipe Schema v0.2.1",
255
- description: "A portable, scalable, interoperable recipe format.",
253
+ $id: "http://soustack.org/schema/recipe/base.schema.json",
254
+ title: "Soustack Recipe Base Schema",
255
+ description: "Base document shape for Soustack recipe documents. Profiles and modules build on this baseline.",
256
256
  type: "object",
257
- required: ["name", "ingredients", "instructions"],
258
- additionalProperties: false,
259
- patternProperties: {
260
- "^x-": {}
261
- },
257
+ additionalProperties: true,
262
258
  properties: {
263
- $schema: {
264
- type: "string",
265
- format: "uri",
266
- description: "Optional schema hint for tooling compatibility"
267
- },
268
- id: {
269
- type: "string",
270
- description: "Unique identifier (slug or UUID)"
271
- },
272
- name: {
273
- type: "string",
274
- description: "The title of the recipe"
275
- },
276
- title: {
277
- type: "string",
278
- description: "Optional display title; alias for name"
259
+ "@type": {
260
+ const: "Recipe",
261
+ description: "Document marker for Soustack recipes"
279
262
  },
280
- version: {
263
+ profile: {
281
264
  type: "string",
282
- pattern: "^\\d+\\.\\d+\\.\\d+$",
283
- description: "DEPRECATED: use recipeVersion for authoring revisions"
265
+ description: "Profile identifier applied to this recipe"
284
266
  },
285
- recipeVersion: {
286
- type: "string",
287
- pattern: "^\\d+\\.\\d+\\.\\d+$",
288
- description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
289
- },
290
- description: {
291
- type: "string"
292
- },
293
- category: {
294
- type: "string",
295
- examples: ["Main Course", "Dessert"]
296
- },
297
- tags: {
267
+ modules: {
298
268
  type: "array",
299
- items: { type: "string" }
300
- },
301
- image: {
302
- description: "Recipe-level hero image(s)",
303
- anyOf: [
304
- {
305
- type: "string",
306
- format: "uri"
307
- },
308
- {
309
- type: "array",
310
- minItems: 1,
311
- items: {
312
- type: "string",
313
- format: "uri"
314
- }
315
- }
316
- ]
317
- },
318
- dateAdded: {
319
- type: "string",
320
- format: "date-time"
321
- },
322
- metadata: {
323
- type: "object",
324
- additionalProperties: true,
325
- description: "Free-form vendor metadata"
326
- },
327
- source: {
328
- type: "object",
329
- properties: {
330
- author: { type: "string" },
331
- url: { type: "string", format: "uri" },
332
- name: { type: "string" },
333
- adapted: { type: "boolean" }
269
+ description: "List of module identifiers applied to this recipe",
270
+ items: {
271
+ type: "string"
334
272
  }
335
273
  },
336
- yield: {
337
- $ref: "#/definitions/yield"
338
- },
339
- time: {
340
- $ref: "#/definitions/time"
341
- },
342
- equipment: {
343
- type: "array",
344
- items: { $ref: "#/definitions/equipment" }
274
+ name: {
275
+ type: "string",
276
+ description: "Human-readable recipe name"
345
277
  },
346
278
  ingredients: {
347
279
  type: "array",
348
- items: {
349
- anyOf: [
350
- { type: "string" },
351
- { $ref: "#/definitions/ingredient" },
352
- { $ref: "#/definitions/ingredientSubsection" }
353
- ]
354
- }
280
+ description: "Ingredients payload; content is validated by profiles/modules"
355
281
  },
356
282
  instructions: {
357
283
  type: "array",
358
- items: {
359
- anyOf: [
360
- { type: "string" },
361
- { $ref: "#/definitions/instruction" },
362
- { $ref: "#/definitions/instructionSubsection" }
363
- ]
364
- }
365
- },
366
- storage: {
367
- $ref: "#/definitions/storage"
368
- },
369
- substitutions: {
370
- type: "array",
371
- items: { $ref: "#/definitions/substitution" }
284
+ description: "Instruction payload; content is validated by profiles/modules"
372
285
  }
373
286
  },
374
- definitions: {
375
- yield: {
376
- type: "object",
377
- required: ["amount", "unit"],
378
- properties: {
379
- amount: { type: "number" },
380
- unit: { type: "string" },
381
- servings: { type: "number" },
382
- description: { type: "string" }
383
- }
384
- },
385
- time: {
386
- type: "object",
387
- properties: {
388
- prep: { type: "number" },
389
- active: { type: "number" },
390
- passive: { type: "number" },
391
- total: { type: "number" },
392
- prepTime: { type: "string", format: "duration" },
393
- cookTime: { type: "string", format: "duration" }
394
- },
395
- minProperties: 1
396
- },
397
- quantity: {
398
- type: "object",
399
- required: ["amount"],
400
- properties: {
401
- amount: { type: "number" },
402
- unit: { type: ["string", "null"] }
403
- }
404
- },
405
- scaling: {
287
+ required: ["@type"]
288
+ };
289
+
290
+ // src/schemas/recipe/profiles/core.schema.json
291
+ var core_schema_default = {
292
+ $schema: "http://json-schema.org/draft-07/schema#",
293
+ $id: "http://soustack.org/schema/recipe/profiles/core.schema.json",
294
+ title: "Soustack Recipe Core Profile",
295
+ description: "Core profile that builds on the minimal profile and is intended to be combined with recipe modules.",
296
+ allOf: [
297
+ { $ref: "http://soustack.org/schema/recipe/base.schema.json" },
298
+ {
406
299
  type: "object",
407
- required: ["type"],
408
300
  properties: {
409
- type: {
410
- type: "string",
411
- enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
301
+ profile: { const: "core" },
302
+ modules: {
303
+ type: "array",
304
+ items: { type: "string" },
305
+ uniqueItems: true,
306
+ default: []
412
307
  },
413
- factor: { type: "number" },
414
- referenceId: { type: "string" },
415
- roundTo: { type: "number" },
416
- min: { type: "number" },
417
- max: { type: "number" }
418
- },
419
- if: {
420
- properties: { type: { const: "bakers_percentage" } }
308
+ name: { type: "string", minLength: 1 },
309
+ ingredients: { type: "array", minItems: 1 },
310
+ instructions: { type: "array", minItems: 1 }
421
311
  },
422
- then: {
423
- required: ["referenceId"]
424
- }
425
- },
426
- ingredient: {
427
- type: "object",
428
- required: ["item"],
429
- properties: {
430
- id: { type: "string" },
431
- item: { type: "string" },
432
- quantity: { $ref: "#/definitions/quantity" },
433
- name: { type: "string" },
434
- aisle: { type: "string" },
435
- prep: { type: "string" },
436
- prepAction: { type: "string" },
437
- prepTime: { type: "number" },
438
- destination: { type: "string" },
439
- scaling: { $ref: "#/definitions/scaling" },
440
- critical: { type: "boolean" },
441
- optional: { type: "boolean" },
442
- notes: { type: "string" }
443
- }
444
- },
445
- ingredientSubsection: {
446
- type: "object",
447
- required: ["subsection", "items"],
448
- properties: {
449
- subsection: { type: "string" },
450
- items: {
451
- type: "array",
452
- items: { $ref: "#/definitions/ingredient" }
453
- }
454
- }
455
- },
456
- equipment: {
457
- type: "object",
458
- required: ["name"],
459
- properties: {
460
- id: { type: "string" },
461
- name: { type: "string" },
462
- required: { type: "boolean" },
463
- label: { type: "string" },
464
- capacity: { $ref: "#/definitions/quantity" },
465
- scalingLimit: { type: "number" },
466
- alternatives: {
467
- type: "array",
468
- items: { type: "string" }
469
- }
470
- }
312
+ required: ["profile", "name", "ingredients", "instructions"],
313
+ additionalProperties: true
314
+ }
315
+ ]
316
+ };
317
+
318
+ // src/schemas/recipe/profiles/minimal.schema.json
319
+ var minimal_schema_default = {
320
+ $schema: "http://json-schema.org/draft-07/schema#",
321
+ $id: "http://soustack.org/schema/recipe/profiles/minimal.schema.json",
322
+ title: "Soustack Recipe Minimal Profile",
323
+ description: "Minimal profile that ensures the basic Recipe structure is present while allowing modules to extend it.",
324
+ allOf: [
325
+ {
326
+ $ref: "http://soustack.org/schema/recipe/base.schema.json"
471
327
  },
472
- instruction: {
328
+ {
473
329
  type: "object",
474
- required: ["text"],
475
330
  properties: {
476
- id: { type: "string" },
477
- text: { type: "string" },
478
- image: {
479
- type: "string",
480
- format: "uri",
481
- description: "Optional image that illustrates this instruction"
482
- },
483
- destination: { type: "string" },
484
- dependsOn: {
485
- type: "array",
486
- items: { type: "string" }
331
+ profile: {
332
+ const: "minimal"
487
333
  },
488
- inputs: {
489
- type: "array",
490
- items: { type: "string" }
491
- },
492
- timing: {
493
- type: "object",
494
- required: ["duration", "type"],
495
- properties: {
496
- duration: {
497
- anyOf: [
498
- { type: "number" },
499
- { type: "string", pattern: "^P" }
500
- ],
501
- description: "Minutes as a number or ISO8601 duration string"
502
- },
503
- type: { type: "string", enum: ["active", "passive"] },
504
- scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
505
- }
506
- }
507
- }
508
- },
509
- instructionSubsection: {
510
- type: "object",
511
- required: ["subsection", "items"],
512
- properties: {
513
- subsection: { type: "string" },
514
- items: {
334
+ modules: {
515
335
  type: "array",
516
336
  items: {
517
- anyOf: [
518
- { type: "string" },
519
- { $ref: "#/definitions/instruction" }
337
+ type: "string",
338
+ enum: [
339
+ "attribution@1",
340
+ "taxonomy@1",
341
+ "media@1",
342
+ "nutrition@1",
343
+ "times@1"
520
344
  ]
521
- }
522
- }
523
- }
524
- },
525
- storage: {
526
- type: "object",
527
- properties: {
528
- roomTemp: { $ref: "#/definitions/storageMethod" },
529
- refrigerated: { $ref: "#/definitions/storageMethod" },
530
- frozen: {
531
- allOf: [
532
- { $ref: "#/definitions/storageMethod" },
533
- {
534
- type: "object",
535
- properties: { thawing: { type: "string" } }
536
- }
537
- ]
345
+ },
346
+ default: []
538
347
  },
539
- reheating: { type: "string" },
540
- makeAhead: {
348
+ name: {
349
+ type: "string",
350
+ minLength: 1
351
+ },
352
+ ingredients: {
541
353
  type: "array",
542
- items: {
543
- allOf: [
544
- { $ref: "#/definitions/storageMethod" },
545
- {
546
- type: "object",
547
- required: ["component", "storage"],
548
- properties: {
549
- component: { type: "string" },
550
- storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
551
- }
552
- }
553
- ]
554
- }
555
- }
556
- }
557
- },
558
- storageMethod: {
559
- type: "object",
560
- required: ["duration"],
561
- properties: {
562
- duration: { type: "string", pattern: "^P" },
563
- method: { type: "string" },
564
- notes: { type: "string" }
565
- }
566
- },
567
- substitution: {
568
- type: "object",
569
- required: ["ingredient"],
570
- properties: {
571
- ingredient: { type: "string" },
572
- critical: { type: "boolean" },
573
- notes: { type: "string" },
574
- alternatives: {
354
+ minItems: 1
355
+ },
356
+ instructions: {
575
357
  type: "array",
576
- items: {
577
- type: "object",
578
- required: ["name", "ratio"],
579
- properties: {
580
- name: { type: "string" },
581
- ratio: { type: "string" },
582
- notes: { type: "string" },
583
- impact: { type: "string" },
584
- dietary: {
585
- type: "array",
586
- items: { type: "string" }
587
- }
588
- }
589
- }
358
+ minItems: 1
590
359
  }
591
- }
360
+ },
361
+ required: [
362
+ "profile",
363
+ "name",
364
+ "ingredients",
365
+ "instructions"
366
+ ],
367
+ additionalProperties: true
592
368
  }
593
- }
369
+ ]
594
370
  };
595
371
 
596
- // src/soustack.schema.json
597
- var soustack_schema_default = {
372
+ // src/schemas/recipe/modules/schedule/1.schema.json
373
+ var schema_default = {
598
374
  $schema: "http://json-schema.org/draft-07/schema#",
599
- $id: "http://soustack.org/schema/v0.2.1",
600
- title: "Soustack Recipe Schema v0.2.1",
601
- description: "A portable, scalable, interoperable recipe format.",
375
+ $id: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
376
+ title: "Soustack Recipe Module: schedule v1",
377
+ description: "Schema for the schedule module. Enforces bidirectional module gating and restricts usage to the core profile.",
602
378
  type: "object",
603
- required: ["name", "ingredients", "instructions"],
604
- additionalProperties: false,
605
- patternProperties: {
606
- "^x-": {}
607
- },
608
379
  properties: {
609
- $schema: {
610
- type: "string",
611
- format: "uri",
612
- description: "Optional schema hint for tooling compatibility"
613
- },
614
- id: {
615
- type: "string",
616
- description: "Unique identifier (slug or UUID)"
617
- },
618
- name: {
619
- type: "string",
620
- description: "The title of the recipe"
621
- },
622
- title: {
623
- type: "string",
624
- description: "Optional display title; alias for name"
625
- },
626
- version: {
627
- type: "string",
628
- pattern: "^\\d+\\.\\d+\\.\\d+$",
629
- description: "DEPRECATED: use recipeVersion for authoring revisions"
630
- },
631
- recipeVersion: {
632
- type: "string",
633
- pattern: "^\\d+\\.\\d+\\.\\d+$",
634
- description: "Recipe content revision (semantic versioning, e.g., 1.0.0)"
635
- },
636
- description: {
637
- type: "string"
638
- },
639
- category: {
640
- type: "string",
641
- examples: ["Main Course", "Dessert"]
642
- },
643
- tags: {
380
+ profile: { type: "string" },
381
+ modules: {
644
382
  type: "array",
645
383
  items: { type: "string" }
646
384
  },
647
- image: {
648
- description: "Recipe-level hero image(s)",
649
- anyOf: [
650
- {
651
- type: "string",
652
- format: "uri"
653
- },
654
- {
655
- type: "array",
656
- minItems: 1,
657
- items: {
658
- type: "string",
659
- format: "uri"
660
- }
661
- }
662
- ]
663
- },
664
- dateAdded: {
665
- type: "string",
666
- format: "date-time"
667
- },
668
- metadata: {
669
- type: "object",
670
- additionalProperties: true,
671
- description: "Free-form vendor metadata"
672
- },
673
- source: {
385
+ schedule: {
674
386
  type: "object",
675
387
  properties: {
676
- author: { type: "string" },
677
- url: { type: "string", format: "uri" },
678
- name: { type: "string" },
679
- adapted: { type: "boolean" }
680
- }
681
- },
682
- yield: {
683
- $ref: "#/definitions/yield"
684
- },
685
- time: {
686
- $ref: "#/definitions/time"
687
- },
688
- equipment: {
689
- type: "array",
690
- items: { $ref: "#/definitions/equipment" }
691
- },
692
- ingredients: {
693
- type: "array",
694
- items: {
695
- anyOf: [
696
- { type: "string" },
697
- { $ref: "#/definitions/ingredient" },
698
- { $ref: "#/definitions/ingredientSubsection" }
699
- ]
700
- }
701
- },
702
- instructions: {
703
- type: "array",
704
- items: {
705
- anyOf: [
706
- { type: "string" },
707
- { $ref: "#/definitions/instruction" },
708
- { $ref: "#/definitions/instructionSubsection" }
709
- ]
710
- }
711
- },
712
- storage: {
713
- $ref: "#/definitions/storage"
714
- },
715
- substitutions: {
716
- type: "array",
717
- items: { $ref: "#/definitions/substitution" }
388
+ tasks: { type: "array" }
389
+ },
390
+ additionalProperties: false
718
391
  }
719
392
  },
720
- definitions: {
721
- yield: {
722
- type: "object",
723
- required: ["amount", "unit"],
724
- properties: {
725
- amount: { type: "number" },
726
- unit: { type: "string" },
727
- servings: { type: "number" },
728
- description: { type: "string" }
729
- }
730
- },
731
- time: {
732
- type: "object",
733
- properties: {
734
- prep: { type: "number" },
735
- active: { type: "number" },
736
- passive: { type: "number" },
737
- total: { type: "number" },
738
- prepTime: { type: "string", format: "duration" },
739
- cookTime: { type: "string", format: "duration" }
740
- },
741
- minProperties: 1
742
- },
743
- quantity: {
744
- type: "object",
745
- required: ["amount"],
746
- properties: {
747
- amount: { type: "number" },
748
- unit: { type: ["string", "null"] }
749
- }
750
- },
751
- scaling: {
752
- type: "object",
753
- required: ["type"],
754
- properties: {
755
- type: {
756
- type: "string",
757
- enum: ["linear", "discrete", "proportional", "fixed", "bakers_percentage"]
758
- },
759
- factor: { type: "number" },
760
- referenceId: { type: "string" },
761
- roundTo: { type: "number" },
762
- min: { type: "number" },
763
- max: { type: "number" }
764
- },
393
+ allOf: [
394
+ {
765
395
  if: {
766
- properties: { type: { const: "bakers_percentage" } }
396
+ properties: {
397
+ modules: {
398
+ type: "array",
399
+ contains: { const: "schedule@1" }
400
+ }
401
+ }
767
402
  },
768
403
  then: {
769
- required: ["referenceId"]
770
- }
771
- },
772
- ingredient: {
773
- type: "object",
774
- required: ["item"],
775
- properties: {
776
- id: { type: "string" },
777
- item: { type: "string" },
778
- quantity: { $ref: "#/definitions/quantity" },
779
- name: { type: "string" },
780
- aisle: { type: "string" },
781
- prep: { type: "string" },
782
- prepAction: { type: "string" },
783
- prepTime: { type: "number" },
784
- destination: { type: "string" },
785
- scaling: { $ref: "#/definitions/scaling" },
786
- critical: { type: "boolean" },
787
- optional: { type: "boolean" },
788
- notes: { type: "string" }
789
- }
790
- },
791
- ingredientSubsection: {
792
- type: "object",
793
- required: ["subsection", "items"],
794
- properties: {
795
- subsection: { type: "string" },
796
- items: {
797
- type: "array",
798
- items: { $ref: "#/definitions/ingredient" }
404
+ required: ["schedule", "profile"],
405
+ properties: {
406
+ profile: { const: "core" }
799
407
  }
800
408
  }
801
409
  },
802
- equipment: {
803
- type: "object",
804
- required: ["name"],
805
- properties: {
806
- id: { type: "string" },
807
- name: { type: "string" },
808
- required: { type: "boolean" },
809
- label: { type: "string" },
810
- capacity: { $ref: "#/definitions/quantity" },
811
- scalingLimit: { type: "number" },
812
- alternatives: {
813
- type: "array",
814
- items: { type: "string" }
410
+ {
411
+ if: {
412
+ required: ["schedule"]
413
+ },
414
+ then: {
415
+ required: ["modules", "profile"],
416
+ properties: {
417
+ modules: {
418
+ type: "array",
419
+ items: { type: "string" },
420
+ contains: { const: "schedule@1" }
421
+ },
422
+ profile: { const: "core" }
815
423
  }
816
424
  }
425
+ }
426
+ ],
427
+ additionalProperties: true
428
+ };
429
+
430
+ // src/schemas/recipe/modules/nutrition/1.schema.json
431
+ var schema_default2 = {
432
+ $schema: "http://json-schema.org/draft-07/schema#",
433
+ $id: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
434
+ title: "Soustack Recipe Module: nutrition v1",
435
+ description: "Schema for the nutrition module. Keeps nutrition data aligned with module declarations and vice versa.",
436
+ type: "object",
437
+ properties: {
438
+ modules: {
439
+ type: "array",
440
+ items: { type: "string" }
817
441
  },
818
- instruction: {
442
+ nutrition: {
819
443
  type: "object",
820
- required: ["text"],
821
444
  properties: {
822
- id: { type: "string" },
823
- text: { type: "string" },
824
- image: {
825
- type: "string",
826
- format: "uri",
827
- description: "Optional image that illustrates this instruction"
828
- },
829
- destination: { type: "string" },
830
- dependsOn: {
831
- type: "array",
832
- items: { type: "string" }
833
- },
834
- inputs: {
835
- type: "array",
836
- items: { type: "string" }
837
- },
838
- timing: {
839
- type: "object",
840
- required: ["duration", "type"],
841
- properties: {
842
- duration: {
843
- anyOf: [
844
- { type: "number" },
845
- { type: "string", pattern: "^P" }
846
- ],
847
- description: "Minutes as a number or ISO8601 duration string"
848
- },
849
- type: { type: "string", enum: ["active", "passive"] },
850
- scaling: { type: "string", enum: ["linear", "fixed", "sqrt"] }
445
+ calories: { type: "number" },
446
+ protein_g: { type: "number" }
447
+ },
448
+ additionalProperties: false
449
+ }
450
+ },
451
+ allOf: [
452
+ {
453
+ if: {
454
+ properties: {
455
+ modules: {
456
+ type: "array",
457
+ contains: { const: "nutrition@1" }
851
458
  }
852
459
  }
460
+ },
461
+ then: {
462
+ required: ["nutrition"]
853
463
  }
854
464
  },
855
- instructionSubsection: {
856
- type: "object",
857
- required: ["subsection", "items"],
858
- properties: {
859
- subsection: { type: "string" },
860
- items: {
861
- type: "array",
862
- items: {
863
- anyOf: [
864
- { type: "string" },
865
- { $ref: "#/definitions/instruction" }
866
- ]
465
+ {
466
+ if: {
467
+ required: ["nutrition"]
468
+ },
469
+ then: {
470
+ required: ["modules"],
471
+ properties: {
472
+ modules: {
473
+ type: "array",
474
+ items: { type: "string" },
475
+ contains: { const: "nutrition@1" }
867
476
  }
868
477
  }
869
478
  }
479
+ }
480
+ ],
481
+ additionalProperties: true
482
+ };
483
+
484
+ // src/schemas/recipe/modules/attribution/1.schema.json
485
+ var schema_default3 = {
486
+ $schema: "http://json-schema.org/draft-07/schema#",
487
+ $id: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
488
+ title: "Soustack Recipe Module: attribution v1",
489
+ description: "Schema for the attribution module. Ensures namespace data is present when the module is enabled and vice versa.",
490
+ type: "object",
491
+ properties: {
492
+ modules: {
493
+ type: "array",
494
+ items: { type: "string" }
870
495
  },
871
- storage: {
496
+ attribution: {
872
497
  type: "object",
873
498
  properties: {
874
- roomTemp: { $ref: "#/definitions/storageMethod" },
875
- refrigerated: { $ref: "#/definitions/storageMethod" },
876
- frozen: {
877
- allOf: [
878
- { $ref: "#/definitions/storageMethod" },
879
- {
880
- type: "object",
881
- properties: { thawing: { type: "string" } }
882
- }
883
- ]
884
- },
885
- reheating: { type: "string" },
886
- makeAhead: {
887
- type: "array",
888
- items: {
889
- allOf: [
890
- { $ref: "#/definitions/storageMethod" },
891
- {
892
- type: "object",
893
- required: ["component", "storage"],
894
- properties: {
895
- component: { type: "string" },
896
- storage: { type: "string", enum: ["roomTemp", "refrigerated", "frozen"] }
897
- }
898
- }
899
- ]
499
+ url: { type: "string" },
500
+ author: { type: "string" },
501
+ datePublished: { type: "string" }
502
+ },
503
+ additionalProperties: false
504
+ }
505
+ },
506
+ allOf: [
507
+ {
508
+ if: {
509
+ properties: {
510
+ modules: {
511
+ type: "array",
512
+ contains: { const: "attribution@1" }
900
513
  }
901
514
  }
515
+ },
516
+ then: {
517
+ required: ["attribution"]
902
518
  }
903
519
  },
904
- storageMethod: {
905
- type: "object",
906
- required: ["duration"],
907
- properties: {
908
- duration: { type: "string", pattern: "^P" },
909
- method: { type: "string" },
910
- notes: { type: "string" }
911
- }
912
- },
913
- substitution: {
914
- type: "object",
915
- required: ["ingredient"],
916
- properties: {
917
- ingredient: { type: "string" },
918
- critical: { type: "boolean" },
919
- notes: { type: "string" },
920
- alternatives: {
921
- type: "array",
922
- items: {
923
- type: "object",
924
- required: ["name", "ratio"],
925
- properties: {
926
- name: { type: "string" },
927
- ratio: { type: "string" },
928
- notes: { type: "string" },
929
- impact: { type: "string" },
930
- dietary: {
931
- type: "array",
932
- items: { type: "string" }
933
- }
934
- }
520
+ {
521
+ if: {
522
+ required: ["attribution"]
523
+ },
524
+ then: {
525
+ required: ["modules"],
526
+ properties: {
527
+ modules: {
528
+ type: "array",
529
+ items: { type: "string" },
530
+ contains: { const: "attribution@1" }
935
531
  }
936
532
  }
937
533
  }
938
534
  }
939
- }
940
- };
941
-
942
- // src/profiles/base.schema.json
943
- var base_schema_default = {
944
- $schema: "http://json-schema.org/draft-07/schema#",
945
- $id: "http://soustack.org/schema/v0.2.1/profiles/base",
946
- title: "Soustack Base Profile Schema",
947
- description: "Wrapper schema that exposes the unmodified Soustack base schema.",
948
- allOf: [
949
- { $ref: "http://soustack.org/schema/v0.2.1" }
950
- ]
535
+ ],
536
+ additionalProperties: true
951
537
  };
952
538
 
953
- // src/profiles/cookable.schema.json
954
- var cookable_schema_default = {
539
+ // src/schemas/recipe/modules/taxonomy/1.schema.json
540
+ var schema_default4 = {
955
541
  $schema: "http://json-schema.org/draft-07/schema#",
956
- $id: "http://soustack.org/schema/v0.2.1/profiles/cookable",
957
- title: "Soustack Cookable Profile Schema",
958
- description: "Extends the base schema to require structured yield + time metadata and non-empty ingredient/instruction lists.",
959
- allOf: [
960
- { $ref: "http://soustack.org/schema/v0.2.1" },
961
- {
962
- required: ["yield", "time", "ingredients", "instructions"],
542
+ $id: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
543
+ title: "Soustack Recipe Module: taxonomy v1",
544
+ description: "Schema for the taxonomy module. Enforces keyword and categorization data when enabled and ensures module declaration accompanies the namespace block.",
545
+ type: "object",
546
+ properties: {
547
+ modules: {
548
+ type: "array",
549
+ items: { type: "string" }
550
+ },
551
+ taxonomy: {
552
+ type: "object",
963
553
  properties: {
964
- yield: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/yield" },
965
- time: { $ref: "http://soustack.org/schema/v0.2.1#/definitions/time" },
966
- ingredients: { type: "array", minItems: 1 },
967
- instructions: { type: "array", minItems: 1 }
968
- }
554
+ keywords: { type: "array", items: { type: "string" } },
555
+ category: { type: "string" },
556
+ cuisine: { type: "string" }
557
+ },
558
+ additionalProperties: false
969
559
  }
970
- ]
971
- };
972
-
973
- // src/profiles/quantified.schema.json
974
- var quantified_schema_default = {
975
- $schema: "http://json-schema.org/draft-07/schema#",
976
- $id: "http://soustack.org/schema/v0.2.1/profiles/quantified",
977
- title: "Soustack Quantified Profile Schema",
978
- description: "Extends the base schema to require quantified ingredient entries.",
560
+ },
979
561
  allOf: [
980
- { $ref: "http://soustack.org/schema/v0.2.1" },
981
562
  {
982
- properties: {
983
- ingredients: {
984
- type: "array",
985
- items: {
986
- anyOf: [
987
- { $ref: "#/definitions/quantifiedIngredient" },
988
- { $ref: "#/definitions/quantifiedIngredientSubsection" }
989
- ]
563
+ if: {
564
+ properties: {
565
+ modules: {
566
+ type: "array",
567
+ contains: { const: "taxonomy@1" }
990
568
  }
991
569
  }
570
+ },
571
+ then: {
572
+ required: ["taxonomy"]
992
573
  }
993
- }
994
- ],
995
- definitions: {
996
- quantifiedIngredient: {
997
- allOf: [
998
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredient" },
999
- { required: ["item", "quantity"] }
1000
- ]
1001
574
  },
1002
- quantifiedIngredientSubsection: {
1003
- allOf: [
1004
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/ingredientSubsection" },
1005
- {
1006
- properties: {
1007
- items: {
1008
- type: "array",
1009
- items: { $ref: "#/definitions/quantifiedIngredient" }
1010
- }
575
+ {
576
+ if: {
577
+ required: ["taxonomy"]
578
+ },
579
+ then: {
580
+ required: ["modules"],
581
+ properties: {
582
+ modules: {
583
+ type: "array",
584
+ items: { type: "string" },
585
+ contains: { const: "taxonomy@1" }
1011
586
  }
1012
587
  }
1013
- ]
588
+ }
589
+ }
590
+ ],
591
+ additionalProperties: true
592
+ };
593
+
594
+ // src/schemas/recipe/modules/media/1.schema.json
595
+ var schema_default5 = {
596
+ $schema: "http://json-schema.org/draft-07/schema#",
597
+ $id: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
598
+ title: "Soustack Recipe Module: media v1",
599
+ description: "Schema for the media module. Guards media blocks based on module activation and ensures declarations accompany payloads.",
600
+ type: "object",
601
+ properties: {
602
+ modules: {
603
+ type: "array",
604
+ items: { type: "string" }
605
+ },
606
+ media: {
607
+ type: "object",
608
+ properties: {
609
+ images: { type: "array", items: { type: "string" } },
610
+ videos: { type: "array", items: { type: "string" } }
611
+ },
612
+ additionalProperties: false
1014
613
  }
1015
- }
1016
- };
1017
-
1018
- // src/profiles/illustrated.schema.json
1019
- var illustrated_schema_default = {
1020
- $schema: "http://json-schema.org/draft-07/schema#",
1021
- $id: "http://soustack.org/schema/v0.2.1/profiles/illustrated",
1022
- title: "Soustack Illustrated Profile Schema",
1023
- description: "Extends the base schema to guarantee at least one illustrative image.",
614
+ },
1024
615
  allOf: [
1025
- { $ref: "http://soustack.org/schema/v0.2.1" },
1026
616
  {
1027
- anyOf: [
1028
- { required: ["image"] },
1029
- {
1030
- properties: {
1031
- instructions: {
1032
- type: "array",
1033
- contains: {
1034
- anyOf: [
1035
- { $ref: "#/definitions/imageInstruction" },
1036
- { $ref: "#/definitions/instructionSubsectionWithImage" }
1037
- ]
1038
- }
1039
- }
617
+ if: {
618
+ properties: {
619
+ modules: {
620
+ type: "array",
621
+ contains: { const: "media@1" }
1040
622
  }
1041
623
  }
1042
- ]
1043
- }
1044
- ],
1045
- definitions: {
1046
- imageInstruction: {
1047
- allOf: [
1048
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
1049
- { required: ["image"] }
1050
- ]
624
+ },
625
+ then: {
626
+ required: ["media"]
627
+ }
1051
628
  },
1052
- instructionSubsectionWithImage: {
1053
- allOf: [
1054
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
1055
- {
1056
- properties: {
1057
- items: {
1058
- type: "array",
1059
- contains: { $ref: "#/definitions/imageInstruction" }
1060
- }
629
+ {
630
+ if: {
631
+ required: ["media"]
632
+ },
633
+ then: {
634
+ required: ["modules"],
635
+ properties: {
636
+ modules: {
637
+ type: "array",
638
+ items: { type: "string" },
639
+ contains: { const: "media@1" }
1061
640
  }
1062
641
  }
1063
- ]
642
+ }
1064
643
  }
1065
- }
644
+ ],
645
+ additionalProperties: true
1066
646
  };
1067
647
 
1068
- // src/profiles/schedulable.schema.json
1069
- var schedulable_schema_default = {
648
+ // src/schemas/recipe/modules/times/1.schema.json
649
+ var schema_default6 = {
1070
650
  $schema: "http://json-schema.org/draft-07/schema#",
1071
- $id: "http://soustack.org/schema/v0.2.1/profiles/schedulable",
1072
- title: "Soustack Schedulable Profile Schema",
1073
- description: "Extends the base schema to ensure every instruction is fully scheduled.",
651
+ $id: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
652
+ title: "Soustack Recipe Module: times v1",
653
+ description: "Schema for the times module. Maintains alignment between module declarations and timing payloads.",
654
+ type: "object",
655
+ properties: {
656
+ modules: {
657
+ type: "array",
658
+ items: { type: "string" }
659
+ },
660
+ times: {
661
+ type: "object",
662
+ properties: {
663
+ prepMinutes: { type: "number" },
664
+ cookMinutes: { type: "number" },
665
+ totalMinutes: { type: "number" }
666
+ },
667
+ additionalProperties: false
668
+ }
669
+ },
1074
670
  allOf: [
1075
- { $ref: "http://soustack.org/schema/v0.2.1" },
1076
671
  {
1077
- properties: {
1078
- instructions: {
1079
- type: "array",
1080
- items: {
1081
- anyOf: [
1082
- { $ref: "#/definitions/schedulableInstruction" },
1083
- { $ref: "#/definitions/schedulableInstructionSubsection" }
1084
- ]
672
+ if: {
673
+ properties: {
674
+ modules: {
675
+ type: "array",
676
+ contains: { const: "times@1" }
1085
677
  }
1086
678
  }
679
+ },
680
+ then: {
681
+ required: ["times"]
1087
682
  }
1088
- }
1089
- ],
1090
- definitions: {
1091
- schedulableInstruction: {
1092
- allOf: [
1093
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instruction" },
1094
- { required: ["id", "timing"] }
1095
- ]
1096
683
  },
1097
- schedulableInstructionSubsection: {
1098
- allOf: [
1099
- { $ref: "http://soustack.org/schema/v0.2.1#/definitions/instructionSubsection" },
1100
- {
1101
- properties: {
1102
- items: {
1103
- type: "array",
1104
- items: { $ref: "#/definitions/schedulableInstruction" }
1105
- }
684
+ {
685
+ if: {
686
+ required: ["times"]
687
+ },
688
+ then: {
689
+ required: ["modules"],
690
+ properties: {
691
+ modules: {
692
+ type: "array",
693
+ items: { type: "string" },
694
+ contains: { const: "times@1" }
1106
695
  }
1107
696
  }
1108
- ]
697
+ }
1109
698
  }
1110
- }
699
+ ],
700
+ additionalProperties: true
1111
701
  };
1112
702
 
1113
703
  // src/validator.ts
704
+ var CANONICAL_BASE_SCHEMA_ID = base_schema_default.$id || "http://soustack.org/schema/recipe/base.schema.json";
705
+ var canonicalProfileId = (profile) => {
706
+ if (profile === "minimal") {
707
+ return minimal_schema_default.$id;
708
+ }
709
+ if (profile === "core") {
710
+ return core_schema_default.$id;
711
+ }
712
+ throw new Error(`Unknown profile: ${profile}`);
713
+ };
714
+ var moduleIdToSchemaRef = (moduleId) => {
715
+ const match = moduleId.match(/^([a-z0-9_-]+)@(\d+(?:\.\d+)*)$/i);
716
+ if (!match) {
717
+ throw new Error(`Invalid module identifier '${moduleId}'. Expected <name>@<version>.`);
718
+ }
719
+ const [, name, version] = match;
720
+ const moduleSchemas2 = {
721
+ "schedule@1": schema_default,
722
+ "nutrition@1": schema_default2,
723
+ "attribution@1": schema_default3,
724
+ "taxonomy@1": schema_default4,
725
+ "media@1": schema_default5,
726
+ "times@1": schema_default6
727
+ };
728
+ const schema = moduleSchemas2[moduleId];
729
+ if (schema && schema.$id) {
730
+ return schema.$id;
731
+ }
732
+ return `https://soustack.org/schemas/recipe/modules/${name}/${version}.schema.json`;
733
+ };
1114
734
  var profileSchemas = {
1115
- base: base_schema_default,
1116
- cookable: cookable_schema_default,
1117
- scalable: base_schema_default,
1118
- quantified: quantified_schema_default,
1119
- illustrated: illustrated_schema_default,
1120
- schedulable: schedulable_schema_default
735
+ minimal: minimal_schema_default,
736
+ core: core_schema_default
737
+ };
738
+ var moduleSchemas = {
739
+ "schedule@1": schema_default,
740
+ "nutrition@1": schema_default2,
741
+ "attribution@1": schema_default3,
742
+ "taxonomy@1": schema_default4,
743
+ "media@1": schema_default5,
744
+ "times@1": schema_default6
1121
745
  };
1122
746
  var validationContexts = /* @__PURE__ */ new Map();
1123
747
  function createContext(collectAllErrors) {
1124
748
  const ajv = new Ajv__default.default({ strict: false, allErrors: collectAllErrors });
1125
749
  addFormats__default.default(ajv);
1126
- const loadedIds = /* @__PURE__ */ new Set();
1127
- const addSchemaIfNew = (schema) => {
750
+ const addSchemaWithAlias = (schema, alias) => {
1128
751
  if (!schema) return;
1129
- const schemaId = schema == null ? void 0 : schema.$id;
1130
- if (schemaId && loadedIds.has(schemaId)) return;
1131
- ajv.addSchema(schema);
1132
- if (schemaId) loadedIds.add(schemaId);
752
+ const schemaId = schema.$id || alias;
753
+ if (schemaId) {
754
+ ajv.addSchema(schema, schemaId);
755
+ } else {
756
+ ajv.addSchema(schema);
757
+ }
1133
758
  };
1134
- addSchemaIfNew(schema_default);
1135
- addSchemaIfNew(soustack_schema_default);
1136
- Object.values(profileSchemas).forEach(addSchemaIfNew);
1137
- return { ajv, validators: {} };
759
+ addSchemaWithAlias(base_schema_default, CANONICAL_BASE_SCHEMA_ID);
760
+ Object.entries(profileSchemas).forEach(([name, schema]) => {
761
+ addSchemaWithAlias(schema, canonicalProfileId(name));
762
+ });
763
+ Object.entries(moduleSchemas).forEach(([moduleId, schema]) => {
764
+ addSchemaWithAlias(schema, moduleIdToSchemaRef(moduleId));
765
+ });
766
+ return { ajv, validators: /* @__PURE__ */ new Map() };
1138
767
  }
1139
768
  function getContext(collectAllErrors) {
1140
769
  if (!validationContexts.has(collectAllErrors)) {
@@ -1157,14 +786,58 @@ function detectProfileFromSchema(schemaRef) {
1157
786
  }
1158
787
  return void 0;
1159
788
  }
1160
- function getValidator(profile, context) {
789
+ function resolveSchemaRef(inputSchema, requestedSchema) {
790
+ if (typeof requestedSchema === "string") return requestedSchema;
791
+ if (typeof inputSchema !== "string") return void 0;
792
+ return detectProfileFromSchema(inputSchema) ? inputSchema : void 0;
793
+ }
794
+ function inferModulesFromPayload(recipe) {
795
+ const inferred = [];
796
+ const payloadToModule = {
797
+ attribution: "attribution@1",
798
+ taxonomy: "taxonomy@1",
799
+ media: "media@1",
800
+ times: "times@1",
801
+ nutrition: "nutrition@1",
802
+ schedule: "schedule@1"
803
+ };
804
+ for (const [field, moduleId] of Object.entries(payloadToModule)) {
805
+ if (recipe && typeof recipe === "object" && field in recipe && recipe[field] != null) {
806
+ const payload = recipe[field];
807
+ if (typeof payload === "object" && !Array.isArray(payload)) {
808
+ if (Object.keys(payload).length > 0) {
809
+ inferred.push(moduleId);
810
+ }
811
+ } else if (Array.isArray(payload) && payload.length > 0) {
812
+ inferred.push(moduleId);
813
+ } else if (payload !== null && payload !== void 0) {
814
+ inferred.push(moduleId);
815
+ }
816
+ }
817
+ }
818
+ return inferred;
819
+ }
820
+ function getCombinedValidator(profile, modules, recipe, context) {
821
+ const inferredModules = inferModulesFromPayload(recipe);
822
+ const allModules = /* @__PURE__ */ new Set([...modules, ...inferredModules]);
823
+ const sortedModules = Array.from(allModules).sort();
824
+ const cacheKey = `${profile}::${sortedModules.join(",")}`;
825
+ const cached = context.validators.get(cacheKey);
826
+ if (cached) return cached;
1161
827
  if (!profileSchemas[profile]) {
1162
828
  throw new Error(`Unknown Soustack profile: ${profile}`);
1163
829
  }
1164
- if (!context.validators[profile]) {
1165
- context.validators[profile] = context.ajv.compile(profileSchemas[profile]);
1166
- }
1167
- return context.validators[profile];
830
+ const schema = {
831
+ $id: `urn:soustack:recipe:${cacheKey}`,
832
+ allOf: [
833
+ { $ref: CANONICAL_BASE_SCHEMA_ID },
834
+ { $ref: canonicalProfileId(profile) },
835
+ ...sortedModules.map((moduleId) => ({ $ref: moduleIdToSchemaRef(moduleId) }))
836
+ ]
837
+ };
838
+ const validateFn = context.ajv.compile(schema);
839
+ context.validators.set(cacheKey, validateFn);
840
+ return validateFn;
1168
841
  }
1169
842
  function normalizeRecipe(recipe) {
1170
843
  const normalized = cloneRecipe(recipe);
@@ -1196,9 +869,33 @@ function normalizeTime(recipe) {
1196
869
  }
1197
870
  var _a, _b;
1198
871
  var allowedTopLevelProps = /* @__PURE__ */ new Set([
1199
- ...Object.keys((_b = (_a = soustack_schema_default) == null ? void 0 : _a.properties) != null ? _b : {}),
1200
- "metadata",
1201
- "$schema"
872
+ ...Object.keys((_b = (_a = base_schema_default) == null ? void 0 : _a.properties) != null ? _b : {}),
873
+ "$schema",
874
+ // Module fields (validated by module schemas)
875
+ "attribution",
876
+ "taxonomy",
877
+ "media",
878
+ "times",
879
+ "nutrition",
880
+ "schedule",
881
+ // Common recipe fields (allowed by base schema's additionalProperties: true)
882
+ "description",
883
+ "image",
884
+ "category",
885
+ "tags",
886
+ "source",
887
+ "dateAdded",
888
+ "dateModified",
889
+ "yield",
890
+ "time",
891
+ "id",
892
+ "title",
893
+ "recipeVersion",
894
+ "version",
895
+ // deprecated but allowed
896
+ "equipment",
897
+ "storage",
898
+ "substitutions"
1202
899
  ]);
1203
900
  function detectUnknownTopLevelKeys(recipe) {
1204
901
  if (!recipe || typeof recipe !== "object") return [];
@@ -1224,11 +921,19 @@ function formatAjvError(error) {
1224
921
  message: error.message || "Validation error"
1225
922
  };
1226
923
  }
1227
- function runAjvValidation(data, profile, context, schemaRef) {
1228
- const validator = schemaRef ? context.ajv.getSchema(schemaRef) : void 0;
1229
- const validateFn = validator != null ? validator : getValidator(profile, context);
1230
- const isValid = validateFn(data);
1231
- return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
924
+ function runAjvValidation(data, profile, modules, context) {
925
+ try {
926
+ const validateFn = getCombinedValidator(profile, modules, data, context);
927
+ const isValid = validateFn(data);
928
+ return !isValid && validateFn.errors ? validateFn.errors.map(formatAjvError) : [];
929
+ } catch (error) {
930
+ return [
931
+ {
932
+ path: "/",
933
+ message: error instanceof Error ? error.message : "Validation failed to initialize"
934
+ }
935
+ ];
936
+ }
1232
937
  }
1233
938
  function isInstruction(item) {
1234
939
  return item && typeof item === "object" && !Array.isArray(item) && "text" in item;
@@ -1312,12 +1017,25 @@ function validateRecipe(input, options = {}) {
1312
1017
  var _a2, _b2, _c, _d;
1313
1018
  const collectAllErrors = (_a2 = options.collectAllErrors) != null ? _a2 : true;
1314
1019
  const context = getContext(collectAllErrors);
1315
- const schemaRef = (_b2 = options.schema) != null ? _b2 : typeof (input == null ? void 0 : input.$schema) === "string" ? input.$schema : void 0;
1316
- const profile = (_d = (_c = options.profile) != null ? _c : detectProfileFromSchema(schemaRef)) != null ? _d : "base";
1020
+ const schemaRef = resolveSchemaRef(input == null ? void 0 : input.$schema, options.schema);
1021
+ const profileFromDocument = typeof (input == null ? void 0 : input.profile) === "string" ? input.profile : void 0;
1022
+ const profile = (_d = (_c = (_b2 = options.profile) != null ? _b2 : profileFromDocument) != null ? _c : detectProfileFromSchema(schemaRef)) != null ? _d : "core";
1023
+ const modulesFromDocument = Array.isArray(input == null ? void 0 : input.modules) ? input.modules.filter((value) => typeof value === "string") : [];
1024
+ const modules = modulesFromDocument.length > 0 ? [...modulesFromDocument].sort() : [];
1317
1025
  const { normalized, warnings } = normalizeRecipe(input);
1026
+ if (!profileFromDocument) {
1027
+ normalized.profile = profile;
1028
+ } else {
1029
+ normalized.profile = profileFromDocument;
1030
+ }
1031
+ if (!("modules" in normalized) || normalized.modules === void 0 || normalized.modules === null) {
1032
+ normalized.modules = [];
1033
+ } else if (modulesFromDocument.length > 0) {
1034
+ normalized.modules = modules;
1035
+ }
1318
1036
  const unknownKeyErrors = detectUnknownTopLevelKeys(normalized);
1319
- const validationErrors = runAjvValidation(normalized, profile, context, schemaRef);
1320
- const graphErrors = profile === "schedulable" && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
1037
+ const validationErrors = runAjvValidation(normalized, profile, modules, context);
1038
+ const graphErrors = modules.includes("schedule@1") && validationErrors.length === 0 ? checkInstructionGraph(normalized) : [];
1321
1039
  const errors = [...unknownKeyErrors, ...validationErrors, ...graphErrors];
1322
1040
  return {
1323
1041
  valid: errors.length === 0,
@@ -1328,15 +1046,14 @@ function validateRecipe(input, options = {}) {
1328
1046
  }
1329
1047
  function detectProfiles(recipe) {
1330
1048
  var _a2;
1331
- const result = validateRecipe(recipe, { profile: "base", collectAllErrors: false });
1049
+ const result = validateRecipe(recipe, { profile: "core", collectAllErrors: false });
1332
1050
  if (!result.valid) return [];
1333
1051
  const normalizedRecipe = (_a2 = result.normalized) != null ? _a2 : recipe;
1334
- const profiles = ["base"];
1052
+ const profiles = [];
1335
1053
  const context = getContext(false);
1336
1054
  Object.keys(profileSchemas).forEach((profile) => {
1337
- if (profile === "base") return;
1338
1055
  if (!profileSchemas[profile]) return;
1339
- const errors = runAjvValidation(normalizedRecipe, profile, context);
1056
+ const errors = runAjvValidation(normalizedRecipe, profile, [], context);
1340
1057
  if (errors.length === 0) {
1341
1058
  profiles.push(profile);
1342
1059
  }
@@ -1442,8 +1159,22 @@ function fromSchemaOrg(input) {
1442
1159
  const tags = collectTags(recipeNode.recipeCuisine, recipeNode.keywords);
1443
1160
  const category = extractFirst(recipeNode.recipeCategory);
1444
1161
  const source = convertSource(recipeNode);
1445
- const nutrition = recipeNode.nutrition && typeof recipeNode.nutrition === "object" ? recipeNode.nutrition : void 0;
1162
+ const dateModified = recipeNode.dateModified || void 0;
1163
+ const nutrition = convertNutrition(recipeNode.nutrition);
1164
+ const attribution = convertAttribution(recipeNode);
1165
+ const taxonomy = convertTaxonomy(tags, category, extractFirst(recipeNode.recipeCuisine));
1166
+ const media = convertMedia(recipeNode.image, recipeNode.video);
1167
+ const times = convertTimes(time);
1168
+ const modules = [];
1169
+ if (attribution) modules.push("attribution@1");
1170
+ if (taxonomy) modules.push("taxonomy@1");
1171
+ if (media) modules.push("media@1");
1172
+ if (nutrition) modules.push("nutrition@1");
1173
+ if (times) modules.push("times@1");
1446
1174
  return {
1175
+ "@type": "Recipe",
1176
+ profile: "minimal",
1177
+ modules: modules.sort(),
1447
1178
  name: recipeNode.name.trim(),
1448
1179
  description: ((_a2 = recipeNode.description) == null ? void 0 : _a2.trim()) || void 0,
1449
1180
  image: normalizeImage(recipeNode.image),
@@ -1451,12 +1182,16 @@ function fromSchemaOrg(input) {
1451
1182
  tags: tags.length ? tags : void 0,
1452
1183
  source,
1453
1184
  dateAdded: recipeNode.datePublished || void 0,
1454
- dateModified: recipeNode.dateModified || void 0,
1455
1185
  yield: recipeYield,
1456
1186
  time,
1457
1187
  ingredients,
1458
1188
  instructions,
1459
- nutrition
1189
+ ...dateModified ? { dateModified } : {},
1190
+ ...nutrition ? { nutrition } : {},
1191
+ ...attribution ? { attribution } : {},
1192
+ ...taxonomy ? { taxonomy } : {},
1193
+ ...media ? { media } : {},
1194
+ ...times ? { times } : {}
1460
1195
  };
1461
1196
  }
1462
1197
  function extractRecipeNode(input) {
@@ -1672,6 +1407,175 @@ function extractEntityName(value) {
1672
1407
  }
1673
1408
  return void 0;
1674
1409
  }
1410
+ function convertAttribution(recipe) {
1411
+ var _a2, _b2;
1412
+ const attribution = {};
1413
+ const url = (_a2 = recipe.url || recipe.mainEntityOfPage) == null ? void 0 : _a2.trim();
1414
+ const author = extractEntityName(recipe.author);
1415
+ const datePublished = (_b2 = recipe.datePublished) == null ? void 0 : _b2.trim();
1416
+ if (url) attribution.url = url;
1417
+ if (author) attribution.author = author;
1418
+ if (datePublished) attribution.datePublished = datePublished;
1419
+ return Object.keys(attribution).length ? attribution : void 0;
1420
+ }
1421
+ function convertTaxonomy(keywords, category, cuisine) {
1422
+ const taxonomy = {};
1423
+ if (keywords.length) taxonomy.keywords = keywords;
1424
+ if (category) taxonomy.category = category;
1425
+ if (cuisine) taxonomy.cuisine = cuisine;
1426
+ return Object.keys(taxonomy).length ? taxonomy : void 0;
1427
+ }
1428
+ function normalizeMediaList(value) {
1429
+ if (!value) return [];
1430
+ if (typeof value === "string") return [value.trim()].filter(Boolean);
1431
+ if (Array.isArray(value)) {
1432
+ return value.map((item) => typeof item === "string" ? item.trim() : extractMediaUrl(item)).filter((entry) => Boolean(entry == null ? void 0 : entry.length));
1433
+ }
1434
+ const url = extractMediaUrl(value);
1435
+ return url ? [url] : [];
1436
+ }
1437
+ function extractMediaUrl(value) {
1438
+ if (value && typeof value === "object" && "url" in value && typeof value.url === "string") {
1439
+ const trimmed = value.url.trim();
1440
+ return trimmed || void 0;
1441
+ }
1442
+ return void 0;
1443
+ }
1444
+ function convertMedia(image, video) {
1445
+ const normalizedImage = normalizeImage(image);
1446
+ const images = normalizedImage ? Array.isArray(normalizedImage) ? normalizedImage : [normalizedImage] : [];
1447
+ const videos = normalizeMediaList(video);
1448
+ const media = {};
1449
+ if (images.length) media.images = images;
1450
+ if (videos.length) media.videos = videos;
1451
+ return Object.keys(media).length ? media : void 0;
1452
+ }
1453
+ function convertTimes(time) {
1454
+ if (!time) return void 0;
1455
+ const times = {};
1456
+ if (typeof time.prep === "number") times.prepMinutes = time.prep;
1457
+ if (typeof time.active === "number") times.cookMinutes = time.active;
1458
+ if (typeof time.total === "number") times.totalMinutes = time.total;
1459
+ return Object.keys(times).length ? times : void 0;
1460
+ }
1461
+ function convertNutrition(nutrition) {
1462
+ if (!nutrition || typeof nutrition !== "object") {
1463
+ return void 0;
1464
+ }
1465
+ const result = {};
1466
+ let hasData = false;
1467
+ if ("calories" in nutrition) {
1468
+ const calories = nutrition.calories;
1469
+ if (typeof calories === "number") {
1470
+ result.calories = calories;
1471
+ hasData = true;
1472
+ } else if (typeof calories === "string") {
1473
+ const parsed = parseFloat(calories.replace(/[^\d.-]/g, ""));
1474
+ if (!isNaN(parsed)) {
1475
+ result.calories = parsed;
1476
+ hasData = true;
1477
+ }
1478
+ }
1479
+ }
1480
+ if ("proteinContent" in nutrition || "protein_g" in nutrition) {
1481
+ const protein = nutrition.proteinContent || nutrition.protein_g;
1482
+ if (typeof protein === "number") {
1483
+ result.protein_g = protein;
1484
+ hasData = true;
1485
+ } else if (typeof protein === "string") {
1486
+ const parsed = parseFloat(protein.replace(/[^\d.-]/g, ""));
1487
+ if (!isNaN(parsed)) {
1488
+ result.protein_g = parsed;
1489
+ hasData = true;
1490
+ }
1491
+ }
1492
+ }
1493
+ return hasData ? result : void 0;
1494
+ }
1495
+
1496
+ // src/schemas/registry/modules.json
1497
+ var modules_default = {
1498
+ modules: [
1499
+ {
1500
+ id: "attribution",
1501
+ versions: [
1502
+ 1
1503
+ ],
1504
+ latest: 1,
1505
+ namespace: "https://soustack.org/schemas/recipe/modules/attribution",
1506
+ schema: "https://soustack.org/schemas/recipe/modules/attribution/1.schema.json",
1507
+ schemaOrgMappable: true,
1508
+ schemaOrgConfidence: "medium",
1509
+ minProfile: "minimal",
1510
+ allowedOnMinimal: true
1511
+ },
1512
+ {
1513
+ id: "taxonomy",
1514
+ versions: [
1515
+ 1
1516
+ ],
1517
+ latest: 1,
1518
+ namespace: "https://soustack.org/schemas/recipe/modules/taxonomy",
1519
+ schema: "https://soustack.org/schemas/recipe/modules/taxonomy/1.schema.json",
1520
+ schemaOrgMappable: true,
1521
+ schemaOrgConfidence: "high",
1522
+ minProfile: "minimal",
1523
+ allowedOnMinimal: true
1524
+ },
1525
+ {
1526
+ id: "media",
1527
+ versions: [
1528
+ 1
1529
+ ],
1530
+ latest: 1,
1531
+ namespace: "https://soustack.org/schemas/recipe/modules/media",
1532
+ schema: "https://soustack.org/schemas/recipe/modules/media/1.schema.json",
1533
+ schemaOrgMappable: true,
1534
+ schemaOrgConfidence: "medium",
1535
+ minProfile: "minimal",
1536
+ allowedOnMinimal: true
1537
+ },
1538
+ {
1539
+ id: "nutrition",
1540
+ versions: [
1541
+ 1
1542
+ ],
1543
+ latest: 1,
1544
+ namespace: "https://soustack.org/schemas/recipe/modules/nutrition",
1545
+ schema: "https://soustack.org/schemas/recipe/modules/nutrition/1.schema.json",
1546
+ schemaOrgMappable: false,
1547
+ schemaOrgConfidence: "low",
1548
+ minProfile: "minimal",
1549
+ allowedOnMinimal: true
1550
+ },
1551
+ {
1552
+ id: "times",
1553
+ versions: [
1554
+ 1
1555
+ ],
1556
+ latest: 1,
1557
+ namespace: "https://soustack.org/schemas/recipe/modules/times",
1558
+ schema: "https://soustack.org/schemas/recipe/modules/times/1.schema.json",
1559
+ schemaOrgMappable: true,
1560
+ schemaOrgConfidence: "medium",
1561
+ minProfile: "minimal",
1562
+ allowedOnMinimal: true
1563
+ },
1564
+ {
1565
+ id: "schedule",
1566
+ versions: [
1567
+ 1
1568
+ ],
1569
+ latest: 1,
1570
+ namespace: "https://soustack.org/schemas/recipe/modules/schedule",
1571
+ schema: "https://soustack.org/schemas/recipe/modules/schedule/1.schema.json",
1572
+ schemaOrgMappable: false,
1573
+ schemaOrgConfidence: "low",
1574
+ minProfile: "core",
1575
+ allowedOnMinimal: false
1576
+ }
1577
+ ]
1578
+ };
1675
1579
 
1676
1580
  // src/converters/toSchemaOrg.ts
1677
1581
  function convertBasicMetadata(recipe) {
@@ -1813,6 +1717,22 @@ function convertTime2(time) {
1813
1717
  }
1814
1718
  return result;
1815
1719
  }
1720
+ function convertTimesModule(times) {
1721
+ if (!times) {
1722
+ return {};
1723
+ }
1724
+ const result = {};
1725
+ if (times.prepMinutes !== void 0) {
1726
+ result.prepTime = formatDuration(times.prepMinutes);
1727
+ }
1728
+ if (times.cookMinutes !== void 0) {
1729
+ result.cookTime = formatDuration(times.cookMinutes);
1730
+ }
1731
+ if (times.totalMinutes !== void 0) {
1732
+ result.totalTime = formatDuration(times.totalMinutes);
1733
+ }
1734
+ return result;
1735
+ }
1816
1736
  function convertYield(yld) {
1817
1737
  if (!yld) {
1818
1738
  return void 0;
@@ -1851,33 +1771,58 @@ function convertCategoryTags(category, tags) {
1851
1771
  }
1852
1772
  return result;
1853
1773
  }
1854
- function convertNutrition(nutrition) {
1774
+ function convertNutrition2(nutrition) {
1855
1775
  if (!nutrition) {
1856
1776
  return void 0;
1857
1777
  }
1858
- return {
1859
- ...nutrition,
1778
+ const result = {
1860
1779
  "@type": "NutritionInformation"
1861
1780
  };
1781
+ if (nutrition.calories !== void 0) {
1782
+ if (typeof nutrition.calories === "number") {
1783
+ result.calories = `${nutrition.calories} calories`;
1784
+ } else {
1785
+ result.calories = nutrition.calories;
1786
+ }
1787
+ }
1788
+ Object.keys(nutrition).forEach((key) => {
1789
+ if (key !== "calories" && key !== "@type") {
1790
+ result[key] = nutrition[key];
1791
+ }
1792
+ });
1793
+ return result;
1862
1794
  }
1863
1795
  function cleanOutput(obj) {
1864
1796
  return Object.fromEntries(
1865
1797
  Object.entries(obj).filter(([, value]) => value !== void 0)
1866
1798
  );
1867
1799
  }
1800
+ function getSchemaOrgMappableModules(modules = []) {
1801
+ const mappableModules = modules_default.modules.filter((m) => m.schemaOrgMappable).map((m) => `${m.id}@${m.latest}`);
1802
+ return modules.filter((moduleId) => mappableModules.includes(moduleId));
1803
+ }
1868
1804
  function toSchemaOrg(recipe) {
1869
1805
  const base = convertBasicMetadata(recipe);
1870
1806
  const ingredients = convertIngredients2(recipe.ingredients);
1871
1807
  const instructions = convertInstructions2(recipe.instructions);
1872
- const nutrition = convertNutrition(recipe.nutrition);
1808
+ const recipeModules = Array.isArray(recipe.modules) ? recipe.modules : [];
1809
+ const mappableModules = getSchemaOrgMappableModules(recipeModules);
1810
+ const hasMappableNutrition = mappableModules.includes("nutrition@1");
1811
+ const nutrition = hasMappableNutrition ? convertNutrition2(recipe.nutrition) : void 0;
1812
+ const hasMappableTimes = mappableModules.includes("times@1");
1813
+ const timeData = hasMappableTimes ? recipe.times ? convertTimesModule(recipe.times) : convertTime2(recipe.time) : {};
1814
+ const hasMappableAttribution = mappableModules.includes("attribution@1");
1815
+ const attributionData = hasMappableAttribution ? convertAuthor(recipe.source) : {};
1816
+ const hasMappableTaxonomy = mappableModules.includes("taxonomy@1");
1817
+ const taxonomyData = hasMappableTaxonomy ? convertCategoryTags(recipe.category, recipe.tags) : {};
1873
1818
  return cleanOutput({
1874
1819
  ...base,
1875
1820
  recipeIngredient: ingredients.length ? ingredients : void 0,
1876
1821
  recipeInstructions: instructions.length ? instructions : void 0,
1877
1822
  recipeYield: convertYield(recipe.yield),
1878
- ...convertTime2(recipe.time),
1879
- ...convertAuthor(recipe.source),
1880
- ...convertCategoryTags(recipe.category, recipe.tags),
1823
+ ...timeData,
1824
+ ...attributionData,
1825
+ ...taxonomyData,
1881
1826
  nutrition
1882
1827
  });
1883
1828
  }
@@ -2026,12 +1971,530 @@ function extractSchemaOrgRecipeFromHTML(html) {
2026
1971
  }
2027
1972
 
2028
1973
  // src/specVersion.ts
2029
- var SOUSTACK_SPEC_VERSION = "0.2.1";
1974
+ var SOUSTACK_SPEC_VERSION = "0.3.0";
1975
+
1976
+ // src/conversion/units.ts
1977
+ var MASS_UNITS = {
1978
+ g: {
1979
+ dimension: "mass",
1980
+ toMetricBase: 1,
1981
+ metricBaseUnit: "g",
1982
+ isMetric: true
1983
+ },
1984
+ kg: {
1985
+ dimension: "mass",
1986
+ toMetricBase: 1e3,
1987
+ metricBaseUnit: "g",
1988
+ isMetric: true
1989
+ },
1990
+ oz: {
1991
+ dimension: "mass",
1992
+ toMetricBase: 28.349523125,
1993
+ metricBaseUnit: "g",
1994
+ isMetric: false
1995
+ },
1996
+ lb: {
1997
+ dimension: "mass",
1998
+ toMetricBase: 453.59237,
1999
+ metricBaseUnit: "g",
2000
+ isMetric: false
2001
+ }
2002
+ };
2003
+ var VOLUME_UNITS = {
2004
+ ml: {
2005
+ dimension: "volume",
2006
+ toMetricBase: 1,
2007
+ metricBaseUnit: "ml",
2008
+ isMetric: true
2009
+ },
2010
+ l: {
2011
+ dimension: "volume",
2012
+ toMetricBase: 1e3,
2013
+ metricBaseUnit: "ml",
2014
+ isMetric: true
2015
+ },
2016
+ tsp: {
2017
+ dimension: "volume",
2018
+ toMetricBase: 4.92892159375,
2019
+ metricBaseUnit: "ml",
2020
+ isMetric: false
2021
+ },
2022
+ tbsp: {
2023
+ dimension: "volume",
2024
+ toMetricBase: 14.78676478125,
2025
+ metricBaseUnit: "ml",
2026
+ isMetric: false
2027
+ },
2028
+ fl_oz: {
2029
+ dimension: "volume",
2030
+ toMetricBase: 29.5735295625,
2031
+ metricBaseUnit: "ml",
2032
+ isMetric: false
2033
+ },
2034
+ cup: {
2035
+ dimension: "volume",
2036
+ toMetricBase: 236.5882365,
2037
+ metricBaseUnit: "ml",
2038
+ isMetric: false
2039
+ },
2040
+ pint: {
2041
+ dimension: "volume",
2042
+ toMetricBase: 473.176473,
2043
+ metricBaseUnit: "ml",
2044
+ isMetric: false
2045
+ },
2046
+ quart: {
2047
+ dimension: "volume",
2048
+ toMetricBase: 946.352946,
2049
+ metricBaseUnit: "ml",
2050
+ isMetric: false
2051
+ },
2052
+ gallon: {
2053
+ dimension: "volume",
2054
+ toMetricBase: 3785.411784,
2055
+ metricBaseUnit: "ml",
2056
+ isMetric: false
2057
+ }
2058
+ };
2059
+ var COUNT_UNITS = {
2060
+ clove: {
2061
+ dimension: "count",
2062
+ toMetricBase: 1,
2063
+ metricBaseUnit: "count",
2064
+ isMetric: true
2065
+ },
2066
+ sprig: {
2067
+ dimension: "count",
2068
+ toMetricBase: 1,
2069
+ metricBaseUnit: "count",
2070
+ isMetric: true
2071
+ },
2072
+ leaf: {
2073
+ dimension: "count",
2074
+ toMetricBase: 1,
2075
+ metricBaseUnit: "count",
2076
+ isMetric: true
2077
+ },
2078
+ pinch: {
2079
+ dimension: "count",
2080
+ toMetricBase: 1,
2081
+ metricBaseUnit: "count",
2082
+ isMetric: true
2083
+ },
2084
+ bottle: {
2085
+ dimension: "count",
2086
+ toMetricBase: 1,
2087
+ metricBaseUnit: "count",
2088
+ isMetric: true
2089
+ },
2090
+ count: {
2091
+ dimension: "count",
2092
+ toMetricBase: 1,
2093
+ metricBaseUnit: "count",
2094
+ isMetric: true
2095
+ }
2096
+ };
2097
+ var UNIT_DEFINITIONS = {
2098
+ ...MASS_UNITS,
2099
+ ...VOLUME_UNITS,
2100
+ ...COUNT_UNITS
2101
+ };
2102
+ function normalizeUnitToken(unit) {
2103
+ var _a2;
2104
+ if (!unit) {
2105
+ return null;
2106
+ }
2107
+ const token = unit.trim().toLowerCase().replace(/[\s-]+/g, "_");
2108
+ const canonical = (_a2 = UNIT_SYNONYMS[token]) != null ? _a2 : token;
2109
+ return canonical in UNIT_DEFINITIONS ? canonical : null;
2110
+ }
2111
+ var UNIT_SYNONYMS = {
2112
+ teaspoons: "tsp",
2113
+ teaspoon: "tsp",
2114
+ tsps: "tsp",
2115
+ tbsp: "tbsp",
2116
+ tbsps: "tbsp",
2117
+ tablespoon: "tbsp",
2118
+ tablespoons: "tbsp",
2119
+ cup: "cup",
2120
+ cups: "cup",
2121
+ pint: "pint",
2122
+ pints: "pint",
2123
+ quart: "quart",
2124
+ quarts: "quart",
2125
+ gallon: "gallon",
2126
+ gallons: "gallon",
2127
+ ml: "ml",
2128
+ milliliter: "ml",
2129
+ milliliters: "ml",
2130
+ millilitre: "ml",
2131
+ millilitres: "ml",
2132
+ l: "l",
2133
+ liter: "l",
2134
+ liters: "l",
2135
+ litre: "l",
2136
+ litres: "l",
2137
+ fl_oz: "fl_oz",
2138
+ "fl.oz": "fl_oz",
2139
+ "fl.oz.": "fl_oz",
2140
+ "fl_oz.": "fl_oz",
2141
+ "fl oz": "fl_oz",
2142
+ "fl oz.": "fl_oz",
2143
+ fluid_ounce: "fl_oz",
2144
+ fluid_ounces: "fl_oz",
2145
+ oz: "oz",
2146
+ ounce: "oz",
2147
+ ounces: "oz",
2148
+ lb: "lb",
2149
+ lbs: "lb",
2150
+ pound: "lb",
2151
+ pounds: "lb",
2152
+ g: "g",
2153
+ gram: "g",
2154
+ grams: "g",
2155
+ kg: "kg",
2156
+ kilogram: "kg",
2157
+ kilograms: "kg",
2158
+ clove: "clove",
2159
+ cloves: "clove",
2160
+ sprig: "sprig",
2161
+ sprigs: "sprig",
2162
+ leaf: "leaf",
2163
+ leaves: "leaf",
2164
+ pinch: "pinch",
2165
+ pinches: "pinch",
2166
+ bottle: "bottle",
2167
+ bottles: "bottle",
2168
+ count: "count",
2169
+ counts: "count"
2170
+ };
2171
+ function convertToMetricBase(quantity, unit) {
2172
+ const definition = UNIT_DEFINITIONS[unit];
2173
+ const quantityInMetricBase = quantity * definition.toMetricBase;
2174
+ return {
2175
+ quantity: quantityInMetricBase,
2176
+ baseUnit: definition.metricBaseUnit,
2177
+ definition
2178
+ };
2179
+ }
2180
+
2181
+ // src/conversion/convertLineItem.ts
2182
+ var UnknownUnitError = class extends Error {
2183
+ constructor(unit) {
2184
+ super(`Unknown unit "${unit}".`);
2185
+ this.unit = unit;
2186
+ this.name = "UnknownUnitError";
2187
+ }
2188
+ };
2189
+ var UnsupportedConversionError = class extends Error {
2190
+ constructor(unit, mode) {
2191
+ super(`Cannot convert unit "${unit}" in ${mode} mode.`);
2192
+ this.unit = unit;
2193
+ this.mode = mode;
2194
+ this.name = "UnsupportedConversionError";
2195
+ }
2196
+ };
2197
+ var MissingEquivalencyError = class extends Error {
2198
+ constructor(ingredient, unit) {
2199
+ super(
2200
+ `No volume to mass equivalency for "${ingredient}" (${unit}).`
2201
+ );
2202
+ this.ingredient = ingredient;
2203
+ this.unit = unit;
2204
+ this.name = "MissingEquivalencyError";
2205
+ }
2206
+ };
2207
+ var VOLUME_TO_MASS_EQUIV_G_PER_UNIT = {
2208
+ flour: {
2209
+ cup: 120
2210
+ }
2211
+ };
2212
+ var DEFAULT_ROUND_MODE = "sane";
2213
+ function convertLineItemToMetric(item, mode, opts) {
2214
+ var _a2, _b2, _c, _d;
2215
+ const roundMode = (_a2 = opts == null ? void 0 : opts.round) != null ? _a2 : DEFAULT_ROUND_MODE;
2216
+ const normalizedUnit = normalizeUnitToken(item.unit);
2217
+ if (!normalizedUnit) {
2218
+ if (!item.unit || item.unit.trim() === "") {
2219
+ return item;
2220
+ }
2221
+ throw new UnknownUnitError(item.unit);
2222
+ }
2223
+ const definition = UNIT_DEFINITIONS[normalizedUnit];
2224
+ if (definition.dimension === "count") {
2225
+ return item;
2226
+ }
2227
+ if (mode === "volume") {
2228
+ if (definition.dimension !== "volume") {
2229
+ throw new UnsupportedConversionError((_b2 = item.unit) != null ? _b2 : "", mode);
2230
+ }
2231
+ const { quantity, unit } = finalizeMetricVolume(
2232
+ convertToMetricBase(item.quantity, normalizedUnit).quantity,
2233
+ roundMode
2234
+ );
2235
+ return {
2236
+ ...item,
2237
+ quantity,
2238
+ unit
2239
+ };
2240
+ }
2241
+ if (definition.dimension === "mass") {
2242
+ const { quantity, unit } = finalizeMetricMass(
2243
+ convertToMetricBase(item.quantity, normalizedUnit).quantity,
2244
+ roundMode
2245
+ );
2246
+ return {
2247
+ ...item,
2248
+ quantity,
2249
+ unit
2250
+ };
2251
+ }
2252
+ if (definition.dimension !== "volume") {
2253
+ throw new UnsupportedConversionError((_c = item.unit) != null ? _c : "", mode);
2254
+ }
2255
+ const gramsPerUnit = lookupEquivalency(
2256
+ item.ingredient,
2257
+ normalizedUnit
2258
+ );
2259
+ if (!gramsPerUnit) {
2260
+ throw new MissingEquivalencyError(item.ingredient, (_d = item.unit) != null ? _d : "");
2261
+ }
2262
+ const grams = item.quantity * gramsPerUnit;
2263
+ const massResult = finalizeMetricMass(grams, roundMode);
2264
+ return {
2265
+ ...item,
2266
+ quantity: massResult.quantity,
2267
+ unit: massResult.unit,
2268
+ notes: `Converted using ${gramsPerUnit}g per ${normalizedUnit} for ${item.ingredient}.`
2269
+ };
2270
+ }
2271
+ function finalizeMetricVolume(milliliters, roundMode) {
2272
+ if (roundMode === "none") {
2273
+ return milliliters >= 1e3 ? { quantity: milliliters / 1e3, unit: "l" } : { quantity: milliliters, unit: "ml" };
2274
+ }
2275
+ const roundedMl = roundMilliliters(milliliters);
2276
+ if (roundedMl >= 1e3) {
2277
+ const liters = roundedMl / 1e3;
2278
+ return {
2279
+ quantity: roundLargeMetric(liters),
2280
+ unit: "l"
2281
+ };
2282
+ }
2283
+ return { quantity: roundedMl, unit: "ml" };
2284
+ }
2285
+ function finalizeMetricMass(grams, roundMode) {
2286
+ if (roundMode === "none") {
2287
+ return grams >= 1e3 ? { quantity: grams / 1e3, unit: "kg" } : { quantity: grams, unit: "g" };
2288
+ }
2289
+ const roundedGrams = roundGrams(grams);
2290
+ if (roundedGrams >= 1e3) {
2291
+ const kilograms = roundedGrams / 1e3;
2292
+ return {
2293
+ quantity: roundLargeMetric(kilograms),
2294
+ unit: "kg"
2295
+ };
2296
+ }
2297
+ return { quantity: roundedGrams, unit: "g" };
2298
+ }
2299
+ function roundGrams(value) {
2300
+ if (value < 1e3) {
2301
+ return Math.round(value);
2302
+ }
2303
+ return Math.round(value / 5) * 5;
2304
+ }
2305
+ function roundMilliliters(value) {
2306
+ if (value < 1e3) {
2307
+ return Math.round(value);
2308
+ }
2309
+ return Math.round(value / 10) * 10;
2310
+ }
2311
+ function roundLargeMetric(value) {
2312
+ return Math.round(value * 100) / 100;
2313
+ }
2314
+ function lookupEquivalency(ingredient, unit) {
2315
+ var _a2;
2316
+ const key = ingredient.trim().toLowerCase();
2317
+ return (_a2 = VOLUME_TO_MASS_EQUIV_G_PER_UNIT[key]) == null ? void 0 : _a2[unit];
2318
+ }
2319
+
2320
+ // src/mise-en-place/index.ts
2321
+ function miseEnPlace(ingredients) {
2322
+ const list = Array.isArray(ingredients) ? ingredients : [];
2323
+ const prepGroups = /* @__PURE__ */ new Map();
2324
+ const stateGroups = /* @__PURE__ */ new Map();
2325
+ let measureTask;
2326
+ let otherTask;
2327
+ const ungrouped = [];
2328
+ for (const ingredient of list) {
2329
+ if (!ingredient || typeof ingredient !== "object") continue;
2330
+ const label = deriveIngredientLabel(ingredient);
2331
+ const quantity = normalizeQuantity(ingredient.quantity);
2332
+ const baseNotes = toDisplayString(ingredient.notes);
2333
+ const prepNotes = toDisplayString(ingredient.prep);
2334
+ const isOptional = typeof ingredient.optional === "boolean" ? ingredient.optional : void 0;
2335
+ const buildItem = (extraNotes) => {
2336
+ const item = {
2337
+ ingredient: label
2338
+ };
2339
+ if (quantity) {
2340
+ item.quantity = { ...quantity };
2341
+ }
2342
+ if (typeof isOptional === "boolean") {
2343
+ item.optional = isOptional;
2344
+ }
2345
+ const notes = combineNotes(extraNotes, baseNotes);
2346
+ if (notes) {
2347
+ item.notes = notes;
2348
+ }
2349
+ return item;
2350
+ };
2351
+ let addedToTask = false;
2352
+ let hasPrepGrouping = false;
2353
+ const prepActionKeys = extractNormalizedList(ingredient.prepActions);
2354
+ if (prepActionKeys.length > 0) {
2355
+ hasPrepGrouping = true;
2356
+ for (const actionKey of prepActionKeys) {
2357
+ const task = ensureGroup(prepGroups, actionKey, () => ({
2358
+ category: "prep",
2359
+ action: actionKey,
2360
+ items: []
2361
+ }));
2362
+ task.items.push(buildItem());
2363
+ addedToTask = true;
2364
+ }
2365
+ } else {
2366
+ const singleActionKey = normalizeKey(ingredient.prepAction);
2367
+ if (singleActionKey) {
2368
+ hasPrepGrouping = true;
2369
+ const task = ensureGroup(prepGroups, singleActionKey, () => ({
2370
+ category: "prep",
2371
+ action: singleActionKey,
2372
+ items: []
2373
+ }));
2374
+ task.items.push(buildItem());
2375
+ addedToTask = true;
2376
+ } else if (prepNotes) {
2377
+ otherTask = otherTask != null ? otherTask : { category: "other", items: [] };
2378
+ otherTask.items.push(buildItem(prepNotes));
2379
+ addedToTask = true;
2380
+ }
2381
+ }
2382
+ const formKey = normalizeKey(ingredient.form);
2383
+ const hasStateGrouping = Boolean(formKey);
2384
+ if (formKey) {
2385
+ const task = ensureGroup(stateGroups, formKey, () => ({
2386
+ category: "state",
2387
+ form: formKey,
2388
+ items: []
2389
+ }));
2390
+ task.items.push(buildItem());
2391
+ addedToTask = true;
2392
+ }
2393
+ const shouldMeasure = Boolean(quantity) && !hasPrepGrouping && !hasStateGrouping;
2394
+ if (shouldMeasure) {
2395
+ measureTask = measureTask != null ? measureTask : { category: "measure", items: [] };
2396
+ measureTask.items.push(buildItem());
2397
+ addedToTask = true;
2398
+ }
2399
+ if (!addedToTask) {
2400
+ ungrouped.push(ingredient);
2401
+ }
2402
+ }
2403
+ const tasks = [
2404
+ ...Array.from(prepGroups.values()).sort((a, b) => localeCompare(a.action, b.action)),
2405
+ ...Array.from(stateGroups.values()).sort((a, b) => localeCompare(a.form, b.form))
2406
+ ];
2407
+ if (measureTask) {
2408
+ tasks.push(measureTask);
2409
+ }
2410
+ if (otherTask) {
2411
+ tasks.push(otherTask);
2412
+ }
2413
+ return { tasks, ungrouped };
2414
+ }
2415
+ function deriveIngredientLabel(ingredient) {
2416
+ var _a2, _b2, _c;
2417
+ return (_c = (_b2 = (_a2 = toDisplayString(ingredient.name)) != null ? _a2 : toDisplayString(ingredient.item)) != null ? _b2 : toDisplayString(ingredient.id)) != null ? _c : "ingredient";
2418
+ }
2419
+ function extractNormalizedList(values) {
2420
+ if (!Array.isArray(values)) {
2421
+ return [];
2422
+ }
2423
+ const seen = /* @__PURE__ */ new Set();
2424
+ const result = [];
2425
+ for (const value of values) {
2426
+ const key = normalizeKey(value);
2427
+ if (key && !seen.has(key)) {
2428
+ seen.add(key);
2429
+ result.push(key);
2430
+ }
2431
+ }
2432
+ return result;
2433
+ }
2434
+ function normalizeKey(value) {
2435
+ if (typeof value !== "string") {
2436
+ return null;
2437
+ }
2438
+ const trimmed = value.trim().toLowerCase();
2439
+ return trimmed || null;
2440
+ }
2441
+ function toDisplayString(value) {
2442
+ if (typeof value !== "string") {
2443
+ return void 0;
2444
+ }
2445
+ const trimmed = value.trim();
2446
+ return trimmed || void 0;
2447
+ }
2448
+ function combineNotes(...notes) {
2449
+ const cleaned = notes.map((note) => toDisplayString(note != null ? note : void 0)).filter(Boolean);
2450
+ if (cleaned.length === 0) {
2451
+ return void 0;
2452
+ }
2453
+ return cleaned.join(" | ");
2454
+ }
2455
+ function normalizeQuantity(quantity) {
2456
+ if (!quantity || typeof quantity !== "object") {
2457
+ return void 0;
2458
+ }
2459
+ const amount = quantity.amount;
2460
+ if (typeof amount !== "number" || Number.isNaN(amount)) {
2461
+ return void 0;
2462
+ }
2463
+ const normalized = { amount };
2464
+ if ("unit" in quantity) {
2465
+ const unit = quantity.unit;
2466
+ if (typeof unit === "string") {
2467
+ const trimmed = unit.trim();
2468
+ if (trimmed) {
2469
+ normalized.unit = trimmed;
2470
+ }
2471
+ } else if (unit === null) {
2472
+ normalized.unit = null;
2473
+ }
2474
+ }
2475
+ return normalized;
2476
+ }
2477
+ function ensureGroup(map, key, factory) {
2478
+ let task = map.get(key);
2479
+ if (!task) {
2480
+ task = factory();
2481
+ map.set(key, task);
2482
+ }
2483
+ return task;
2484
+ }
2485
+ function localeCompare(left, right) {
2486
+ return (left != null ? left : "").localeCompare(right != null ? right : "");
2487
+ }
2030
2488
 
2489
+ exports.MissingEquivalencyError = MissingEquivalencyError;
2031
2490
  exports.SOUSTACK_SPEC_VERSION = SOUSTACK_SPEC_VERSION;
2491
+ exports.UnknownUnitError = UnknownUnitError;
2492
+ exports.UnsupportedConversionError = UnsupportedConversionError;
2493
+ exports.convertLineItemToMetric = convertLineItemToMetric;
2032
2494
  exports.detectProfiles = detectProfiles;
2033
2495
  exports.extractSchemaOrgRecipeFromHTML = extractSchemaOrgRecipeFromHTML;
2034
2496
  exports.fromSchemaOrg = fromSchemaOrg;
2497
+ exports.miseEnPlace = miseEnPlace;
2035
2498
  exports.scaleRecipe = scaleRecipe;
2036
2499
  exports.toSchemaOrg = toSchemaOrg;
2037
2500
  exports.validateRecipe = validateRecipe;