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