@tmlmt/cooklang-parser 3.0.0-alpha.10 → 3.0.0-alpha.12

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.cjs CHANGED
@@ -40,6 +40,7 @@ __export(index_exports, {
40
40
  Section: () => Section,
41
41
  ShoppingCart: () => ShoppingCart,
42
42
  ShoppingList: () => ShoppingList,
43
+ convertQuantityToSystem: () => convertQuantityToSystem,
43
44
  formatExtendedQuantity: () => formatExtendedQuantity,
44
45
  formatItemQuantity: () => formatItemQuantity,
45
46
  formatNumericValue: () => formatNumericValue,
@@ -51,7 +52,8 @@ __export(index_exports, {
51
52
  isAlternativeSelected: () => isAlternativeSelected,
52
53
  isAndGroup: () => isAndGroup,
53
54
  isGroupedItem: () => isGroupedItem,
54
- isSimpleGroup: () => isSimpleGroup
55
+ isSimpleGroup: () => isSimpleGroup,
56
+ renderFractionAsVulgar: () => renderFractionAsVulgar
55
57
  });
56
58
  module.exports = __toCommonJS(index_exports);
57
59
 
@@ -355,7 +357,8 @@ var units = [
355
357
  type: "mass",
356
358
  system: "metric",
357
359
  aliases: ["gram", "grams", "grammes"],
358
- toBase: 1
360
+ toBase: 1,
361
+ maxValue: 999
359
362
  },
360
363
  {
361
364
  name: "kg",
@@ -364,20 +367,28 @@ var units = [
364
367
  aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"],
365
368
  toBase: 1e3
366
369
  },
367
- // Mass (Imperial)
370
+ // Mass (US/UK - identical in both systems)
368
371
  {
369
372
  name: "oz",
370
373
  type: "mass",
371
- system: "imperial",
374
+ system: "ambiguous",
372
375
  aliases: ["ounce", "ounces"],
373
- toBase: 28.3495
376
+ toBase: 28.3495,
377
+ // default: US (same as UK)
378
+ toBaseBySystem: { US: 28.3495, UK: 28.3495 },
379
+ maxValue: 31,
380
+ // 16 oz = 1 lb, allow a bit more
381
+ fractions: { enabled: true, denominators: [2] }
374
382
  },
375
383
  {
376
384
  name: "lb",
377
385
  type: "mass",
378
- system: "imperial",
386
+ system: "ambiguous",
379
387
  aliases: ["pound", "pounds"],
380
- toBase: 453.592
388
+ toBase: 453.592,
389
+ // default: US (same as UK)
390
+ toBaseBySystem: { US: 453.592, UK: 453.592 },
391
+ fractions: { enabled: true, denominators: [2, 4] }
381
392
  },
382
393
  // Volume (Metric)
383
394
  {
@@ -385,21 +396,26 @@ var units = [
385
396
  type: "volume",
386
397
  system: "metric",
387
398
  aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"],
388
- toBase: 1
399
+ toBase: 1,
400
+ maxValue: 999
389
401
  },
390
402
  {
391
403
  name: "cl",
392
404
  type: "volume",
393
405
  system: "metric",
394
406
  aliases: ["centiliter", "centiliters", "centilitre", "centilitres"],
395
- toBase: 10
407
+ toBase: 10,
408
+ isBestUnit: false
409
+ // exists but not a "best" candidate
396
410
  },
397
411
  {
398
412
  name: "dl",
399
413
  type: "volume",
400
414
  system: "metric",
401
415
  aliases: ["deciliter", "deciliters", "decilitre", "decilitres"],
402
- toBase: 100
416
+ toBase: 100,
417
+ isBestUnit: false
418
+ // exists but not a "best" candidate
403
419
  },
404
420
  {
405
421
  name: "l",
@@ -408,55 +424,102 @@ var units = [
408
424
  aliases: ["liter", "liters", "litre", "litres"],
409
425
  toBase: 1e3
410
426
  },
427
+ // Volume (JP)
428
+ {
429
+ name: "go",
430
+ type: "volume",
431
+ system: "JP",
432
+ aliases: ["gou", "goo", "\u5408", "rice cup"],
433
+ toBase: 180,
434
+ maxValue: 10
435
+ },
436
+ // Volume (Ambiguous: metric/US/UK)
411
437
  {
412
438
  name: "tsp",
413
439
  type: "volume",
414
- system: "metric",
440
+ system: "ambiguous",
415
441
  aliases: ["teaspoon", "teaspoons"],
416
- toBase: 5
442
+ toBase: 5,
443
+ // default: metric
444
+ toBaseBySystem: { metric: 5, US: 4.929, UK: 5.919, JP: 5 },
445
+ maxValue: 5,
446
+ // 3 tsp = 1 tbsp (but allow a bit more)
447
+ fractions: { enabled: true, denominators: [2, 3, 4, 8] }
417
448
  },
418
449
  {
419
450
  name: "tbsp",
420
451
  type: "volume",
421
- system: "metric",
452
+ system: "ambiguous",
422
453
  aliases: ["tablespoon", "tablespoons"],
423
- toBase: 15
454
+ toBase: 15,
455
+ // default: metric
456
+ toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758, JP: 15 },
457
+ maxValue: 4,
458
+ // ~16 tbsp = 1 cup
459
+ fractions: { enabled: true }
424
460
  },
425
- // Volume (Imperial)
461
+ // Volume (Ambiguous: US/UK only)
426
462
  {
427
463
  name: "fl-oz",
428
464
  type: "volume",
429
- system: "imperial",
465
+ system: "ambiguous",
430
466
  aliases: ["fluid ounce", "fluid ounces"],
431
- toBase: 29.5735
467
+ toBase: 29.5735,
468
+ // default: US
469
+ toBaseBySystem: { US: 29.5735, UK: 28.4131 },
470
+ maxValue: 15,
471
+ // 8 fl-oz ~ 1 cup, allow more
472
+ fractions: { enabled: true, denominators: [2] }
432
473
  },
433
474
  {
434
475
  name: "cup",
435
476
  type: "volume",
436
- system: "imperial",
477
+ system: "ambiguous",
437
478
  aliases: ["cups"],
438
- toBase: 236.588
479
+ toBase: 236.588,
480
+ // default: US
481
+ toBaseBySystem: { US: 236.588, UK: 284.131 },
482
+ maxValue: 15,
483
+ // upgrade to gallons above 15 cups
484
+ fractions: { enabled: true }
439
485
  },
440
486
  {
441
487
  name: "pint",
442
488
  type: "volume",
443
- system: "imperial",
489
+ system: "ambiguous",
444
490
  aliases: ["pints"],
445
- toBase: 473.176
491
+ toBase: 473.176,
492
+ // default: US
493
+ toBaseBySystem: { US: 473.176, UK: 568.261 },
494
+ maxValue: 3,
495
+ // 2 pints = 1 quart
496
+ fractions: { enabled: true, denominators: [2] },
497
+ isBestUnit: false
498
+ // exists but not a "best" candidate
446
499
  },
447
500
  {
448
501
  name: "quart",
449
502
  type: "volume",
450
- system: "imperial",
503
+ system: "ambiguous",
451
504
  aliases: ["quarts"],
452
- toBase: 946.353
505
+ toBase: 946.353,
506
+ // default: US
507
+ toBaseBySystem: { US: 946.353, UK: 1136.52 },
508
+ maxValue: 3,
509
+ // 4 quarts = 1 gallon
510
+ fractions: { enabled: true, denominators: [2] },
511
+ isBestUnit: false
512
+ // exists but not a "best" candidate
453
513
  },
454
514
  {
455
515
  name: "gallon",
456
516
  type: "volume",
457
- system: "imperial",
517
+ system: "ambiguous",
458
518
  aliases: ["gallons"],
459
- toBase: 3785.41
519
+ toBase: 3785.41,
520
+ // default: US
521
+ toBaseBySystem: { US: 3785.41, UK: 4546.09 },
522
+ fractions: { enabled: true, denominators: [2] }
460
523
  },
461
524
  // Count units (no conversion, but recognized as a type)
462
525
  {
@@ -464,7 +527,8 @@ var units = [
464
527
  type: "count",
465
528
  system: "metric",
466
529
  aliases: ["pieces", "pc"],
467
- toBase: 1
530
+ toBase: 1,
531
+ maxValue: 999
468
532
  }
469
533
  ];
470
534
  var unitMap = /* @__PURE__ */ new Map();
@@ -488,8 +552,14 @@ function isNoUnit(unit) {
488
552
  return resolveUnit(unit.name).name === NO_UNIT;
489
553
  }
490
554
 
555
+ // src/units/conversion.ts
556
+ var import_big2 = __toESM(require("big.js"), 1);
557
+
491
558
  // src/quantities/numeric.ts
492
559
  var import_big = __toESM(require("big.js"), 1);
560
+ var DEFAULT_DENOMINATORS = [2, 3, 4];
561
+ var DEFAULT_FRACTION_ACCURACY = 0.05;
562
+ var DEFAULT_MAX_WHOLE = 4;
493
563
  function gcd(a2, b) {
494
564
  return b === 0 ? a2 : gcd(b, a2 % b);
495
565
  }
@@ -510,6 +580,41 @@ function simplifyFraction(num, den) {
510
580
  return { type: "fraction", num: simplifiedNum, den: simplifiedDen };
511
581
  }
512
582
  }
583
+ function approximateFraction(value, denominators = DEFAULT_DENOMINATORS, accuracy = DEFAULT_FRACTION_ACCURACY, maxWhole = DEFAULT_MAX_WHOLE) {
584
+ if (value <= 0 || !Number.isFinite(value)) {
585
+ return null;
586
+ }
587
+ const wholePart = Math.floor(value);
588
+ if (wholePart > maxWhole) {
589
+ return null;
590
+ }
591
+ const fractionalPart = value - wholePart;
592
+ if (fractionalPart < 1e-4) {
593
+ return null;
594
+ }
595
+ let bestFraction = null;
596
+ for (const den of denominators) {
597
+ const exactNum = value * den;
598
+ const roundedNum = Math.round(exactNum);
599
+ if (roundedNum === 0) continue;
600
+ const approximatedValue = roundedNum / den;
601
+ const relativeError = Math.abs(approximatedValue - value) / value;
602
+ if (relativeError <= accuracy) {
603
+ if (!bestFraction || relativeError < bestFraction.error) {
604
+ bestFraction = { num: roundedNum, den, error: relativeError };
605
+ }
606
+ }
607
+ }
608
+ if (!bestFraction) {
609
+ return null;
610
+ }
611
+ const commonDivisor = gcd(bestFraction.num, bestFraction.den);
612
+ return {
613
+ type: "fraction",
614
+ num: bestFraction.num / commonDivisor,
615
+ den: bestFraction.den / commonDivisor
616
+ };
617
+ }
513
618
  function getNumericValue(v) {
514
619
  if (v.type === "decimal") {
515
620
  return v.decimal;
@@ -558,9 +663,35 @@ function addNumericValues(val1, val2) {
558
663
  };
559
664
  }
560
665
  }
561
- var toRoundedDecimal = (v) => {
666
+ var toRoundedDecimal = (v, precision = 3) => {
562
667
  const value = v.type === "decimal" ? v.decimal : v.num / v.den;
563
- return { type: "decimal", decimal: Math.round(value * 1e3) / 1e3 };
668
+ if (value === 0) {
669
+ return { type: "decimal", decimal: 0 };
670
+ }
671
+ const absValue = Math.abs(value);
672
+ if (absValue >= 1e3) {
673
+ return { type: "decimal", decimal: Math.round(value) };
674
+ }
675
+ const magnitude = Math.floor(Math.log10(absValue));
676
+ const scale = Math.pow(10, precision - 1 - magnitude);
677
+ const rounded = Math.round(value * scale) / scale;
678
+ return { type: "decimal", decimal: rounded };
679
+ };
680
+ var formatOutputValue = (value, unitDef, precision = 3) => {
681
+ if (unitDef.fractions?.enabled) {
682
+ const denominators = unitDef.fractions.denominators ?? DEFAULT_DENOMINATORS;
683
+ const maxWhole = unitDef.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
684
+ const fraction = approximateFraction(
685
+ value,
686
+ denominators,
687
+ DEFAULT_FRACTION_ACCURACY,
688
+ maxWhole
689
+ );
690
+ if (fraction) {
691
+ return fraction;
692
+ }
693
+ }
694
+ return toRoundedDecimal({ type: "decimal", decimal: value }, precision);
564
695
  };
565
696
  function multiplyQuantityValue(value, factor) {
566
697
  if (value.type === "fixed") {
@@ -594,6 +725,143 @@ function getAverageValue(q) {
594
725
  }
595
726
  }
596
727
 
728
+ // src/units/compatibility.ts
729
+ function areUnitsGroupable(u1, u2) {
730
+ if (u1.name === u2.name) {
731
+ return true;
732
+ }
733
+ if (u1.type === "other" || u2.type === "other") {
734
+ return false;
735
+ }
736
+ if (u1.type === u2.type && u1.system === u2.system) {
737
+ return true;
738
+ }
739
+ if (u1.type === u2.type) {
740
+ if (u1.system === "ambiguous" && u2.system === "metric" && u1.toBaseBySystem?.metric !== void 0) {
741
+ return true;
742
+ }
743
+ if (u2.system === "ambiguous" && u1.system === "metric" && u2.toBaseBySystem?.metric !== void 0) {
744
+ return true;
745
+ }
746
+ }
747
+ return false;
748
+ }
749
+ function areUnitsConvertible(u1, u2) {
750
+ if (u1.name === u2.name) return true;
751
+ if (u1.type === "other" || u2.type === "other") return false;
752
+ return u1.type === u2.type;
753
+ }
754
+ function isUnitCompatibleWithSystem(unit, system) {
755
+ if (unit.system === system) return true;
756
+ if (unit.system === "ambiguous") {
757
+ if (unit.toBaseBySystem) {
758
+ return system in unit.toBaseBySystem;
759
+ }
760
+ if (system === "metric") return true;
761
+ }
762
+ if (unit.system === "metric" && system === "JP") {
763
+ return true;
764
+ }
765
+ return false;
766
+ }
767
+
768
+ // src/units/conversion.ts
769
+ var EPSILON = 0.01;
770
+ var DEFAULT_MAX_VALUE = 999;
771
+ function isCloseToInteger(value) {
772
+ return Math.abs(value - Math.round(value)) < EPSILON;
773
+ }
774
+ function getMaxValue(unit) {
775
+ return unit.maxValue ?? DEFAULT_MAX_VALUE;
776
+ }
777
+ function isValueInRange(value, unit) {
778
+ const maxValue = getMaxValue(unit);
779
+ if (value >= 1 && value <= maxValue) {
780
+ return true;
781
+ }
782
+ if (value > 0 && value < 1 && unit.fractions?.enabled) {
783
+ const denominators = unit.fractions.denominators ?? DEFAULT_DENOMINATORS;
784
+ const maxWhole = unit.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
785
+ const fraction = approximateFraction(
786
+ value,
787
+ denominators,
788
+ DEFAULT_FRACTION_ACCURACY,
789
+ maxWhole
790
+ );
791
+ return fraction !== null;
792
+ }
793
+ return false;
794
+ }
795
+ function findBestUnit(valueInBase, unitType, system, inputUnits) {
796
+ const inputUnitNames = new Set(inputUnits.map((u) => u.name));
797
+ const candidates = units.filter(
798
+ (u) => u.type === unitType && isUnitCompatibleWithSystem(u, system) && (u.isBestUnit !== false || inputUnitNames.has(u.name))
799
+ );
800
+ if (candidates.length === 0) {
801
+ const fallbackUnit = inputUnits[0];
802
+ return {
803
+ unit: fallbackUnit,
804
+ value: valueInBase / getToBase(fallbackUnit, system)
805
+ };
806
+ }
807
+ const candidatesWithValues = candidates.map((unit) => ({
808
+ unit,
809
+ value: valueInBase / getToBase(unit, system)
810
+ }));
811
+ const inRange = candidatesWithValues.filter(
812
+ (c) => isValueInRange(c.value, c.unit)
813
+ );
814
+ if (inRange.length > 0) {
815
+ const integersInInputFamily = inRange.filter(
816
+ (c) => isCloseToInteger(c.value) && inputUnitNames.has(c.unit.name)
817
+ );
818
+ if (integersInInputFamily.length > 0) {
819
+ return integersInInputFamily.sort((a2, b) => a2.value - b.value)[0];
820
+ }
821
+ const integersAny = inRange.filter((c) => isCloseToInteger(c.value));
822
+ if (integersAny.length > 0) {
823
+ return integersAny.sort((a2, b) => a2.value - b.value)[0];
824
+ }
825
+ return inRange.sort((a2, b) => {
826
+ const aInFamily = inputUnitNames.has(a2.unit.name) ? 0 : 1;
827
+ const bInFamily = inputUnitNames.has(b.unit.name) ? 0 : 1;
828
+ if (aInFamily !== bInFamily) return aInFamily - bInFamily;
829
+ return a2.value - b.value;
830
+ })[0];
831
+ }
832
+ return candidatesWithValues.sort((a2, b) => {
833
+ const aMaxValue = getMaxValue(a2.unit);
834
+ const bMaxValue = getMaxValue(b.unit);
835
+ const aDistance = a2.value < 1 ? 1 - a2.value : a2.value - aMaxValue;
836
+ const bDistance = b.value < 1 ? 1 - b.value : b.value - bMaxValue;
837
+ return aDistance - bDistance;
838
+ })[0];
839
+ }
840
+ function getUnitRatio(q1, q2) {
841
+ const q1Value = getAverageValue(q1.quantity);
842
+ const q2Value = getAverageValue(q2.quantity);
843
+ const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
844
+ if (typeof q1Value !== "number" || typeof q2Value !== "number") {
845
+ throw Error(
846
+ "One of both values is not a number, so a ratio cannot be computed"
847
+ );
848
+ }
849
+ return (0, import_big2.default)(q1Value).times(factor).div(q2Value);
850
+ }
851
+ function getBaseUnitRatio(q, qRef) {
852
+ if ("toBase" in q.unit && "toBase" in qRef.unit) {
853
+ return q.unit.toBase / qRef.unit.toBase;
854
+ } else {
855
+ return 1;
856
+ }
857
+ }
858
+ function getToBase(unit, system) {
859
+ if (unit.system === "ambiguous" && system && unit.toBaseBySystem) {
860
+ return unit.toBaseBySystem[system] ?? unit.toBase;
861
+ }
862
+ return unit.toBase;
863
+ }
864
+
597
865
  // src/errors.ts
598
866
  var ReferencedItemCannotBeRedefinedError = class extends Error {
599
867
  constructor(item_type, item_name, new_modifier) {
@@ -732,11 +1000,6 @@ function normalizeAllUnits(q) {
732
1000
  return newQ;
733
1001
  }
734
1002
  }
735
- var convertQuantityValue = (value, def, targetDef) => {
736
- if (def.name === targetDef.name) return value;
737
- const factor = def.toBase / targetDef.toBase;
738
- return multiplyQuantityValue(value, factor);
739
- };
740
1003
  function getDefaultQuantityValue() {
741
1004
  return { type: "fixed", value: { type: "decimal", decimal: 0 } };
742
1005
  }
@@ -763,7 +1026,7 @@ function addQuantityValues(v1, v2) {
763
1026
  );
764
1027
  return { type: "range", min: newMin, max: newMax };
765
1028
  }
766
- function addQuantities(q1, q2) {
1029
+ function addQuantities(q1, q2, system) {
767
1030
  const v1 = q1.quantity;
768
1031
  const v2 = q2.quantity;
769
1032
  if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
@@ -781,35 +1044,129 @@ function addQuantities(q1, q2) {
781
1044
  if ((q2.unit?.name === "" || q2.unit === void 0) && q1.unit !== void 0) {
782
1045
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
783
1046
  }
784
- if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
1047
+ if (!q1.unit && !q2.unit) {
1048
+ return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
1049
+ }
1050
+ if (q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
1051
+ if (unit1Def) {
1052
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unit1Def.system) ? unit1Def.system : "US");
1053
+ return addAndFindBestUnit(v1, v2, unit1Def, unit1Def, effectiveSystem, [
1054
+ unit1Def
1055
+ ]);
1056
+ }
785
1057
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
786
1058
  }
787
1059
  if (unit1Def && unit2Def) {
788
- if (unit1Def.type !== unit2Def.type) {
1060
+ if (!areUnitsConvertible(unit1Def, unit2Def)) {
789
1061
  throw new IncompatibleUnitsError(
790
1062
  `${unit1Def.type} (${q1.unit?.name})`,
791
1063
  `${unit2Def.type} (${q2.unit?.name})`
792
1064
  );
793
1065
  }
794
- let targetUnitDef;
795
- if (unit1Def.system !== unit2Def.system) {
796
- const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def;
797
- targetUnitDef = units.filter((u) => u.type === metricUnitDef.type && u.system === "metric").reduce(
798
- (prev, current) => prev.toBase > current.toBase ? prev : current
799
- );
800
- } else {
801
- targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def;
1066
+ let effectiveSystem = system;
1067
+ if (!effectiveSystem) {
1068
+ if (unit1Def.system === "metric" || unit2Def.system === "metric") {
1069
+ effectiveSystem = "metric";
1070
+ } else {
1071
+ if (unit1Def.system === "JP" && unit2Def.system === "JP") {
1072
+ effectiveSystem = "JP";
1073
+ } else {
1074
+ const unit1SupportsUS = unit1Def.system === "US" || unit1Def.system === "ambiguous" && unit1Def.toBaseBySystem && "US" in unit1Def.toBaseBySystem;
1075
+ const unit2SupportsUS = unit2Def.system === "US" || unit2Def.system === "ambiguous" && unit2Def.toBaseBySystem && "US" in unit2Def.toBaseBySystem;
1076
+ effectiveSystem = unit1SupportsUS && unit2SupportsUS ? "US" : "metric";
1077
+ }
1078
+ }
802
1079
  }
803
- const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);
804
- const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);
805
- const targetUnit = { name: targetUnitDef.name };
806
- return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit);
1080
+ return addAndFindBestUnit(v1, v2, unit1Def, unit2Def, effectiveSystem, [
1081
+ unit1Def,
1082
+ unit2Def
1083
+ ]);
807
1084
  }
808
1085
  throw new IncompatibleUnitsError(
809
1086
  q1.unit?.name,
810
1087
  q2.unit?.name
811
1088
  );
812
1089
  }
1090
+ function addAndFindBestUnit(v1, v2, unit1Def, unit2Def, system, inputUnits) {
1091
+ const toBase1 = getToBase(unit1Def, system);
1092
+ const toBase2 = getToBase(unit2Def, system);
1093
+ let sumInBase;
1094
+ if (v1.type === "fixed" && v2.type === "fixed") {
1095
+ const val1 = getNumericValue(v1.value);
1096
+ const val2 = getNumericValue(v2.value);
1097
+ sumInBase = val1 * toBase1 + val2 * toBase2;
1098
+ } else {
1099
+ const avg1 = getAverageValue(v1);
1100
+ const avg2 = getAverageValue(v2);
1101
+ sumInBase = avg1 * toBase1 + avg2 * toBase2;
1102
+ }
1103
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1104
+ sumInBase,
1105
+ unit1Def.type,
1106
+ system,
1107
+ inputUnits
1108
+ );
1109
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1110
+ if (v1.type === "range" || v2.type === "range") {
1111
+ const r1 = v1.type === "range" ? v1 : { type: "range", min: v1.value, max: v1.value };
1112
+ const r2 = v2.type === "range" ? v2 : { type: "range", min: v2.value, max: v2.value };
1113
+ const minInBase = getNumericValue(r1.min) * toBase1 + getNumericValue(r2.min) * toBase2;
1114
+ const maxInBase = getNumericValue(r1.max) * toBase1 + getNumericValue(r2.max) * toBase2;
1115
+ const bestToBase = getToBase(bestUnit, system);
1116
+ const minValue = minInBase / bestToBase;
1117
+ const maxValue = maxInBase / bestToBase;
1118
+ return {
1119
+ quantity: {
1120
+ type: "range",
1121
+ min: formatOutputValue(minValue, bestUnit),
1122
+ max: formatOutputValue(maxValue, bestUnit)
1123
+ },
1124
+ unit: { name: bestUnit.name }
1125
+ };
1126
+ }
1127
+ return {
1128
+ quantity: { type: "fixed", value: formattedValue },
1129
+ unit: { name: bestUnit.name }
1130
+ };
1131
+ }
1132
+ function convertQuantityToSystem(quantity, system) {
1133
+ const unitDef = resolveUnit(
1134
+ typeof quantity.unit === "string" ? quantity.unit : quantity.unit?.name
1135
+ );
1136
+ if (unitDef.type === "other" || !("toBase" in unitDef)) {
1137
+ return void 0;
1138
+ }
1139
+ const avgValue = getAverageValue(quantity.quantity);
1140
+ if (typeof avgValue !== "number") {
1141
+ return void 0;
1142
+ }
1143
+ const toBase = getToBase(unitDef, system);
1144
+ const valueInBase = avgValue * toBase;
1145
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1146
+ valueInBase,
1147
+ unitDef.type,
1148
+ system,
1149
+ [unitDef]
1150
+ );
1151
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1152
+ if (quantity.quantity.type === "range") {
1153
+ const bestToBase = getToBase(bestUnit, system);
1154
+ const minValue = getNumericValue(quantity.quantity.min) * toBase / bestToBase;
1155
+ const maxValue = getNumericValue(quantity.quantity.max) * toBase / bestToBase;
1156
+ return {
1157
+ quantity: {
1158
+ type: "range",
1159
+ min: formatOutputValue(minValue, bestUnit),
1160
+ max: formatOutputValue(maxValue, bestUnit)
1161
+ },
1162
+ unit: { name: bestUnit.name }
1163
+ };
1164
+ }
1165
+ return {
1166
+ quantity: { type: "fixed", value: formattedValue },
1167
+ unit: { name: bestUnit.name }
1168
+ };
1169
+ }
813
1170
  function toPlainUnit(quantity) {
814
1171
  if (isQuantity(quantity))
815
1172
  return quantity.unit ? { ...quantity, unit: quantity.unit.name } : quantity;
@@ -916,6 +1273,53 @@ var flattenPlainUnitGroup = (summed) => {
916
1273
  ];
917
1274
  }
918
1275
  };
1276
+ function applyBestUnit(q, system) {
1277
+ if (!q.unit?.name) {
1278
+ return q;
1279
+ }
1280
+ const unitDef = resolveUnit(q.unit.name);
1281
+ if (unitDef.type === "other") {
1282
+ return q;
1283
+ }
1284
+ if (q.quantity.type === "fixed" && q.quantity.value.type === "text") {
1285
+ return q;
1286
+ }
1287
+ const avgValue = getAverageValue(q.quantity);
1288
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unitDef.system) ? unitDef.system : "US");
1289
+ const toBase = getToBase(unitDef, effectiveSystem);
1290
+ const valueInBase = avgValue * toBase;
1291
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1292
+ valueInBase,
1293
+ unitDef.type,
1294
+ effectiveSystem,
1295
+ [unitDef]
1296
+ );
1297
+ const originalCanonicalName = normalizeUnit(q.unit.name)?.name;
1298
+ if (bestUnit.name === originalCanonicalName) {
1299
+ return q;
1300
+ }
1301
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1302
+ if (q.quantity.type === "range") {
1303
+ const bestToBase = getToBase(bestUnit, effectiveSystem);
1304
+ const minValue = getNumericValue(q.quantity.min) * toBase / bestToBase;
1305
+ const maxValue = getNumericValue(q.quantity.max) * toBase / bestToBase;
1306
+ return {
1307
+ quantity: {
1308
+ type: "range",
1309
+ min: formatOutputValue(minValue, bestUnit),
1310
+ max: formatOutputValue(maxValue, bestUnit)
1311
+ },
1312
+ unit: { name: bestUnit.name }
1313
+ };
1314
+ }
1315
+ return {
1316
+ quantity: {
1317
+ type: "fixed",
1318
+ value: formattedValue
1319
+ },
1320
+ unit: { name: bestUnit.name }
1321
+ };
1322
+ }
919
1323
 
920
1324
  // src/utils/parser_helpers.ts
921
1325
  function flushPendingNote(section, noteItems) {
@@ -1119,6 +1523,18 @@ function extractMetadata(content) {
1119
1523
  const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar);
1120
1524
  if (stringMetaValue) metadata[metaVar] = stringMetaValue;
1121
1525
  }
1526
+ let unitSystem;
1527
+ const unitSystemRaw = parseSimpleMetaVar(metadataContent, "unit system");
1528
+ if (unitSystemRaw) {
1529
+ metadata["unit system"] = unitSystemRaw;
1530
+ const unitSystemMap = {
1531
+ metric: "metric",
1532
+ us: "US",
1533
+ uk: "UK",
1534
+ jp: "JP"
1535
+ };
1536
+ unitSystem = unitSystemMap[unitSystemRaw.toLowerCase()];
1537
+ }
1122
1538
  for (const metaVar of ["serves", "yield", "servings"]) {
1123
1539
  const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1124
1540
  if (scalingMetaValue && scalingMetaValue[1]) {
@@ -1130,7 +1546,7 @@ function extractMetadata(content) {
1130
1546
  const listMetaValue = parseListMetaVar(metadataContent, metaVar);
1131
1547
  if (listMetaValue) metadata[metaVar] = listMetaValue;
1132
1548
  }
1133
- return { metadata, servings };
1549
+ return { metadata, servings, unitSystem };
1134
1550
  }
1135
1551
  function isPositiveIntegerString(str) {
1136
1552
  return /^\d+$/.test(str);
@@ -1313,44 +1729,14 @@ var Section = class {
1313
1729
  // src/quantities/alternatives.ts
1314
1730
  var import_big3 = __toESM(require("big.js"), 1);
1315
1731
 
1316
- // src/units/conversion.ts
1317
- var import_big2 = __toESM(require("big.js"), 1);
1318
- function getUnitRatio(q1, q2) {
1319
- const q1Value = getAverageValue(q1.quantity);
1320
- const q2Value = getAverageValue(q2.quantity);
1321
- const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
1322
- if (typeof q1Value !== "number" || typeof q2Value !== "number") {
1323
- throw Error(
1324
- "One of both values is not a number, so a ratio cannot be computed"
1325
- );
1326
- }
1327
- return (0, import_big2.default)(q1Value).times(factor).div(q2Value);
1328
- }
1329
- function getBaseUnitRatio(q, qRef) {
1330
- if ("toBase" in q.unit && "toBase" in qRef.unit) {
1331
- return q.unit.toBase / qRef.unit.toBase;
1332
- } else {
1333
- return 1;
1334
- }
1335
- }
1336
-
1337
1732
  // src/units/lookup.ts
1338
- function areUnitsCompatible(u1, u2) {
1339
- if (u1.name === u2.name) {
1340
- return true;
1341
- }
1342
- if (u1.type !== "other" && u1.type === u2.type && u1.system === u2.system) {
1343
- return true;
1344
- }
1345
- return false;
1346
- }
1347
1733
  function findListWithCompatibleQuantity(list, quantity) {
1348
1734
  const quantityWithUnitDef = {
1349
1735
  ...quantity,
1350
1736
  unit: resolveUnit(quantity.unit?.name)
1351
1737
  };
1352
1738
  return list.find(
1353
- (l) => l.some((lq) => areUnitsCompatible(lq.unit, quantityWithUnitDef.unit))
1739
+ (l) => l.some((lq) => areUnitsGroupable(lq.unit, quantityWithUnitDef.unit))
1354
1740
  );
1355
1741
  }
1356
1742
  function findCompatibleQuantityWithinList(list, quantity) {
@@ -1407,11 +1793,9 @@ function getEquivalentUnitsLists(...quantities) {
1407
1793
  });
1408
1794
  function findLinkIndexForUnits(lists, unitsToCheck) {
1409
1795
  return lists.findIndex((l) => {
1410
- const listItem = l.map((q) => resolveUnit(q.unit?.name));
1796
+ const listItems = l.map((q) => resolveUnit(q.unit?.name));
1411
1797
  return unitsToCheck.some(
1412
- (u) => listItem.some(
1413
- (lu) => lu.name === u?.name || lu.system === u?.system && lu.type === u?.type && lu.type !== "other"
1414
- )
1798
+ (u) => u && listItems.some((lu) => areUnitsGroupable(lu, u))
1415
1799
  );
1416
1800
  });
1417
1801
  }
@@ -1423,16 +1807,18 @@ function getEquivalentUnitsLists(...quantities) {
1423
1807
  unit: resolveUnit(v.unit?.name, v.unit?.integerProtected)
1424
1808
  };
1425
1809
  const commonQuantity = og.or.find(
1426
- (q) => isQuantity(q) && areUnitsCompatible(q.unit, normalizedV.unit)
1810
+ (q) => isQuantity(q) && areUnitsGroupable(q.unit, normalizedV.unit)
1427
1811
  );
1428
1812
  if (commonQuantity) {
1429
1813
  acc.push(normalizedV);
1430
- unitRatio = getUnitRatio(normalizedV, commonQuantity);
1814
+ if (!unitRatio) {
1815
+ unitRatio = getUnitRatio(normalizedV, commonQuantity);
1816
+ }
1431
1817
  }
1432
1818
  return acc;
1433
1819
  }, []);
1434
1820
  for (const newQ of og.or) {
1435
- if (commonUnitList.some((q) => areUnitsCompatible(q.unit, newQ.unit))) {
1821
+ if (commonUnitList.some((q) => areUnitsGroupable(q.unit, newQ.unit))) {
1436
1822
  continue;
1437
1823
  } else {
1438
1824
  const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio);
@@ -1549,7 +1935,7 @@ function reduceOrsToFirstEquivalent(unitList, quantities) {
1549
1935
  return reduceToQuantity(qListModified[0]);
1550
1936
  });
1551
1937
  }
1552
- function addQuantitiesOrGroups(...quantities) {
1938
+ function addQuantitiesOrGroups(quantities, system) {
1553
1939
  if (quantities.length === 0)
1554
1940
  return {
1555
1941
  sum: {
@@ -1579,7 +1965,7 @@ function addQuantitiesOrGroups(...quantities) {
1579
1965
  unit: resolveUnit(nextQ.unit?.name)
1580
1966
  });
1581
1967
  } else {
1582
- const sumQ = addQuantities(existingQ, nextQ);
1968
+ const sumQ = addQuantities(existingQ, nextQ, system);
1583
1969
  existingQ.quantity = sumQ.quantity;
1584
1970
  existingQ.unit = resolveUnit(sumQ.unit?.name);
1585
1971
  }
@@ -1589,7 +1975,7 @@ function addQuantitiesOrGroups(...quantities) {
1589
1975
  }
1590
1976
  return { sum: { and: sum }, unitsLists };
1591
1977
  }
1592
- function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1978
+ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists, system) {
1593
1979
  const sumQuantities = isAndGroup(sum) ? sum.and : [sum];
1594
1980
  const result = [];
1595
1981
  const processedQuantities = /* @__PURE__ */ new Set();
@@ -1617,9 +2003,19 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1617
2003
  }
1618
2004
  return main.reduce((acc, v) => {
1619
2005
  const mainInList = findCompatibleQuantityWithinList(list, v);
2006
+ const conversionRatio = getBaseUnitRatio(v, mainInList);
2007
+ const valueInOriginalUnit = (0, import_big3.default)(getAverageValue(v.quantity)).times(
2008
+ conversionRatio
2009
+ );
1620
2010
  const newValue = {
1621
2011
  quantity: multiplyQuantityValue(
1622
- v.quantity,
2012
+ {
2013
+ type: "fixed",
2014
+ value: {
2015
+ type: "decimal",
2016
+ decimal: valueInOriginalUnit.toNumber()
2017
+ }
2018
+ },
1623
2019
  (0, import_big3.default)(getAverageValue(equiv.quantity)).div(
1624
2020
  getAverageValue(mainInList.quantity)
1625
2021
  )
@@ -1628,7 +2024,7 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1628
2024
  if (equiv.unit && !isNoUnit(equiv.unit)) {
1629
2025
  newValue.unit = { name: equiv.unit.name };
1630
2026
  }
1631
- return addQuantities(acc, newValue);
2027
+ return addQuantities(acc, newValue, system);
1632
2028
  }, initialValue);
1633
2029
  });
1634
2030
  if (main.length + equivalents.length > 1) {
@@ -1645,12 +2041,16 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1645
2041
  sumQuantities.filter((q) => !processedQuantities.has(q)).forEach((q) => result.push(deNormalizeQuantity(q)));
1646
2042
  return result;
1647
2043
  }
1648
- function addEquivalentsAndSimplify(...quantities) {
2044
+ function addEquivalentsAndSimplify(quantities, system) {
1649
2045
  if (quantities.length === 1) {
1650
2046
  return toPlainUnit(quantities[0]);
1651
2047
  }
1652
- const { sum, unitsLists } = addQuantitiesOrGroups(...quantities);
1653
- const regrouped = regroupQuantitiesAndExpandEquivalents(sum, unitsLists);
2048
+ const { sum, unitsLists } = addQuantitiesOrGroups(quantities, system);
2049
+ const regrouped = regroupQuantitiesAndExpandEquivalents(
2050
+ sum,
2051
+ unitsLists,
2052
+ system
2053
+ );
1654
2054
  if (regrouped.length === 1) {
1655
2055
  return toPlainUnit(regrouped[0]);
1656
2056
  } else {
@@ -1710,6 +2110,15 @@ var _Recipe = class _Recipe {
1710
2110
  this.parse(content);
1711
2111
  }
1712
2112
  }
2113
+ /**
2114
+ * Gets the unit system specified in the recipe metadata.
2115
+ * Used for resolving ambiguous units like tsp, tbsp, cup, etc.
2116
+ *
2117
+ * @returns The unit system if specified, or undefined to use defaults
2118
+ */
2119
+ get unitSystem() {
2120
+ return _Recipe.unitSystems.get(this);
2121
+ }
1713
2122
  /**
1714
2123
  * Gets the current item count for this recipe.
1715
2124
  */
@@ -1881,7 +2290,7 @@ var _Recipe = class _Recipe {
1881
2290
  alternative.note = note;
1882
2291
  }
1883
2292
  if (itemQuantity) {
1884
- alternative.itemQuantity = itemQuantity;
2293
+ Object.assign(alternative, itemQuantity);
1885
2294
  }
1886
2295
  alternatives.push(alternative);
1887
2296
  testString = groups.ingredientAlternative || "";
@@ -1989,7 +2398,7 @@ var _Recipe = class _Recipe {
1989
2398
  displayName
1990
2399
  };
1991
2400
  if (itemQuantity) {
1992
- alternative.itemQuantity = itemQuantity;
2401
+ Object.assign(alternative, itemQuantity);
1993
2402
  }
1994
2403
  const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
1995
2404
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
@@ -2125,28 +2534,28 @@ var _Recipe = class _Recipe {
2125
2534
  for (const alt of allAlts) {
2126
2535
  referencedIndices.add(alt.index);
2127
2536
  }
2128
- if (!alternative.itemQuantity) continue;
2537
+ if (!alternative.quantity) continue;
2129
2538
  const baseQty = {
2130
- quantity: alternative.itemQuantity.quantity,
2131
- ...alternative.itemQuantity.unit && {
2132
- unit: alternative.itemQuantity.unit
2539
+ quantity: alternative.quantity,
2540
+ ...alternative.unit && {
2541
+ unit: alternative.unit
2133
2542
  }
2134
2543
  };
2135
- const quantityEntry = alternative.itemQuantity.equivalents?.length ? { or: [baseQty, ...alternative.itemQuantity.equivalents] } : baseQty;
2544
+ const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
2136
2545
  let alternativeRefs;
2137
2546
  if (!hasExplicitChoice && allAlts.length > 1) {
2138
2547
  alternativeRefs = allAlts.filter(
2139
2548
  (alt) => isGrouped ? alt.itemId !== item.id : alt.index !== alternative.index
2140
2549
  ).map((otherAlt) => {
2141
2550
  const ref = { index: otherAlt.index };
2142
- if (otherAlt.itemQuantity) {
2551
+ if (otherAlt.quantity) {
2143
2552
  const altQty = {
2144
- quantity: otherAlt.itemQuantity.quantity,
2145
- ...otherAlt.itemQuantity.unit && {
2146
- unit: otherAlt.itemQuantity.unit.name
2553
+ quantity: otherAlt.quantity,
2554
+ ...otherAlt.unit && {
2555
+ unit: otherAlt.unit.name
2147
2556
  },
2148
- ...otherAlt.itemQuantity.equivalents && {
2149
- equivalents: otherAlt.itemQuantity.equivalents.map(
2557
+ ...otherAlt.equivalents && {
2558
+ equivalents: otherAlt.equivalents.map(
2150
2559
  (eq) => toPlainUnit(eq)
2151
2560
  )
2152
2561
  }
@@ -2159,14 +2568,10 @@ var _Recipe = class _Recipe {
2159
2568
  const altIndices = getAlternativeSignature(alternativeRefs) ?? "";
2160
2569
  let signature;
2161
2570
  if (isGrouped) {
2162
- const resolvedUnit = resolveUnit(
2163
- alternative.itemQuantity.unit?.name
2164
- );
2571
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
2165
2572
  signature = `group:${item.group}|${altIndices}|${resolvedUnit.type}`;
2166
2573
  } else if (altIndices) {
2167
- const resolvedUnit = resolveUnit(
2168
- alternative.itemQuantity.unit?.name
2169
- );
2574
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
2170
2575
  signature = `${altIndices}|${resolvedUnit.type}}`;
2171
2576
  } else {
2172
2577
  signature = null;
@@ -2222,13 +2627,16 @@ var _Recipe = class _Recipe {
2222
2627
  if (groupsForIng) {
2223
2628
  const quantityGroups = [];
2224
2629
  for (const [, group] of groupsForIng) {
2225
- const summed = addEquivalentsAndSimplify(...group.quantities);
2630
+ const summed = addEquivalentsAndSimplify(
2631
+ group.quantities,
2632
+ this.unitSystem
2633
+ );
2226
2634
  const flattened = flattenPlainUnitGroup(summed);
2227
2635
  const alternatives = group.alternativeQuantities.size > 0 ? [...group.alternativeQuantities].map(([altIdx, altQtys]) => ({
2228
2636
  index: altIdx,
2229
2637
  ...altQtys.length > 0 && {
2230
2638
  quantities: flattenPlainUnitGroup(
2231
- addEquivalentsAndSimplify(...altQtys)
2639
+ addEquivalentsAndSimplify(altQtys, this.unitSystem)
2232
2640
  ).flatMap(
2233
2641
  /* v8 ignore next -- item.and branch requires complex nested AND-with-equivalents structure */
2234
2642
  (item) => "quantity" in item ? [item] : item.and
@@ -2267,9 +2675,10 @@ var _Recipe = class _Recipe {
2267
2675
  */
2268
2676
  parse(content) {
2269
2677
  const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
2270
- const { metadata, servings } = extractMetadata(content);
2678
+ const { metadata, servings, unitSystem } = extractMetadata(content);
2271
2679
  this.metadata = metadata;
2272
2680
  this.servings = servings;
2681
+ if (unitSystem) _Recipe.unitSystems.set(this, unitSystem);
2273
2682
  let blankLineBefore = true;
2274
2683
  let section = new Section();
2275
2684
  const items = [];
@@ -2427,18 +2836,19 @@ var _Recipe = class _Recipe {
2427
2836
  if (originalServings === void 0 || originalServings === 0) {
2428
2837
  originalServings = 1;
2429
2838
  }
2839
+ const unitSystem = this.unitSystem;
2430
2840
  function scaleAlternativesBy(alternatives, factor2) {
2431
2841
  for (const alternative of alternatives) {
2432
- if (alternative.itemQuantity) {
2433
- const scaleFactor = alternative.itemQuantity.scalable ? (0, import_big4.default)(factor2) : 1;
2434
- if (alternative.itemQuantity.quantity.type !== "fixed" || alternative.itemQuantity.quantity.value.type !== "text") {
2435
- alternative.itemQuantity.quantity = multiplyQuantityValue(
2436
- alternative.itemQuantity.quantity,
2842
+ if (alternative.quantity) {
2843
+ const scaleFactor = alternative.scalable ? (0, import_big4.default)(factor2) : 1;
2844
+ if (alternative.quantity.type !== "fixed" || alternative.quantity.value.type !== "text") {
2845
+ alternative.quantity = multiplyQuantityValue(
2846
+ alternative.quantity,
2437
2847
  scaleFactor
2438
2848
  );
2439
2849
  }
2440
- if (alternative.itemQuantity.equivalents) {
2441
- alternative.itemQuantity.equivalents = alternative.itemQuantity.equivalents.map(
2850
+ if (alternative.equivalents) {
2851
+ alternative.equivalents = alternative.equivalents.map(
2442
2852
  (altQuantity) => {
2443
2853
  if (altQuantity.quantity.type === "fixed" && altQuantity.quantity.value.type === "text") {
2444
2854
  return altQuantity;
@@ -2454,6 +2864,20 @@ var _Recipe = class _Recipe {
2454
2864
  }
2455
2865
  );
2456
2866
  }
2867
+ const optimizedPrimary = applyBestUnit(
2868
+ {
2869
+ quantity: alternative.quantity,
2870
+ unit: alternative.unit
2871
+ },
2872
+ unitSystem
2873
+ );
2874
+ alternative.quantity = optimizedPrimary.quantity;
2875
+ alternative.unit = optimizedPrimary.unit;
2876
+ if (alternative.equivalents) {
2877
+ alternative.equivalents = alternative.equivalents.map(
2878
+ (eq) => applyBestUnit(eq, unitSystem)
2879
+ );
2880
+ }
2457
2881
  }
2458
2882
  }
2459
2883
  }
@@ -2514,6 +2938,161 @@ var _Recipe = class _Recipe {
2514
2938
  }
2515
2939
  return newRecipe;
2516
2940
  }
2941
+ /**
2942
+ * Converts all ingredient quantities in the recipe to a target unit system.
2943
+ *
2944
+ * @param system - The target unit system to convert to (metric, US, UK, JP)
2945
+ * @param method - How to handle existing quantities:
2946
+ * - "keep": Keep all existing equivalents (swap if needed, or add converted)
2947
+ * - "replace": Replace primary with target system quantity, discard equivalent used for conversion
2948
+ * - "remove": Only keep target system quantity, delete all equivalents
2949
+ * @returns A new Recipe instance with converted quantities
2950
+ *
2951
+ * @example
2952
+ * ```typescript
2953
+ * // Convert a recipe to metric, keeping original units as equivalents
2954
+ * const metricRecipe = recipe.convertTo("metric", "keep");
2955
+ *
2956
+ * // Convert to US units, removing all other equivalents
2957
+ * const usRecipe = recipe.convertTo("US", "remove");
2958
+ * ```
2959
+ */
2960
+ convertTo(system, method) {
2961
+ const newRecipe = this.clone();
2962
+ function buildNewPrimary(convertedQty, oldPrimary, remainingEquivalents, scalable, integerProtected, source) {
2963
+ const newUnit = integerProtected && convertedQty.unit ? { name: convertedQty.unit.name, integerProtected: true } : convertedQty.unit;
2964
+ const newPrimary = {
2965
+ quantity: convertedQty.quantity,
2966
+ unit: newUnit,
2967
+ scalable
2968
+ };
2969
+ if (method === "remove") {
2970
+ return newPrimary;
2971
+ } else if (method === "replace") {
2972
+ if (remainingEquivalents.length > 0) {
2973
+ newPrimary.equivalents = remainingEquivalents;
2974
+ if (source === "converted") newPrimary.equivalents.push(oldPrimary);
2975
+ }
2976
+ } else {
2977
+ newPrimary.equivalents = [oldPrimary, ...remainingEquivalents];
2978
+ }
2979
+ return newPrimary;
2980
+ }
2981
+ function convertAlternativeQuantity(alternative) {
2982
+ const primaryUnit = resolveUnit(alternative.unit?.name);
2983
+ const equivalents = alternative.equivalents ?? [];
2984
+ const oldPrimary = {
2985
+ quantity: alternative.quantity,
2986
+ unit: alternative.unit
2987
+ };
2988
+ if (primaryUnit.type !== "other" && isUnitCompatibleWithSystem(primaryUnit, system)) {
2989
+ if (method === "remove") {
2990
+ return {
2991
+ quantity: alternative.quantity,
2992
+ unit: alternative.unit,
2993
+ scalable: alternative.scalable
2994
+ };
2995
+ }
2996
+ return {
2997
+ quantity: alternative.quantity,
2998
+ unit: alternative.unit,
2999
+ scalable: alternative.scalable,
3000
+ equivalents
3001
+ };
3002
+ }
3003
+ const targetEquivIndex = equivalents.findIndex((eq) => {
3004
+ const eqUnit = resolveUnit(eq.unit?.name);
3005
+ return eqUnit.type !== "other" && isUnitCompatibleWithSystem(eqUnit, system);
3006
+ });
3007
+ if (targetEquivIndex !== -1) {
3008
+ const targetEquiv = equivalents[targetEquivIndex];
3009
+ const remainingEquivalents = equivalents.filter(
3010
+ (_, i2) => i2 !== targetEquivIndex
3011
+ );
3012
+ return buildNewPrimary(
3013
+ targetEquiv,
3014
+ oldPrimary,
3015
+ remainingEquivalents,
3016
+ alternative.scalable,
3017
+ targetEquiv.unit?.integerProtected,
3018
+ "swapped"
3019
+ );
3020
+ }
3021
+ const converted = convertQuantityToSystem(oldPrimary, system);
3022
+ if (converted && converted.unit) {
3023
+ return buildNewPrimary(
3024
+ converted,
3025
+ oldPrimary,
3026
+ equivalents,
3027
+ alternative.scalable,
3028
+ alternative.unit?.integerProtected,
3029
+ "swapped"
3030
+ );
3031
+ }
3032
+ for (let i2 = 0; i2 < equivalents.length; i2++) {
3033
+ const equiv = equivalents[i2];
3034
+ const convertedEquiv = convertQuantityToSystem(equiv, system);
3035
+ if (convertedEquiv && convertedEquiv.unit) {
3036
+ const remainingEquivalents = method === "keep" ? equivalents : equivalents.filter((_, idx) => idx !== i2);
3037
+ return buildNewPrimary(
3038
+ convertedEquiv,
3039
+ oldPrimary,
3040
+ remainingEquivalents,
3041
+ alternative.scalable,
3042
+ equiv.unit?.integerProtected,
3043
+ "converted"
3044
+ );
3045
+ }
3046
+ }
3047
+ if (method === "remove") {
3048
+ return {
3049
+ quantity: alternative.quantity,
3050
+ unit: alternative.unit,
3051
+ scalable: alternative.scalable
3052
+ };
3053
+ } else {
3054
+ return {
3055
+ quantity: alternative.quantity,
3056
+ unit: alternative.unit,
3057
+ scalable: alternative.scalable,
3058
+ equivalents
3059
+ };
3060
+ }
3061
+ }
3062
+ function convertAlternatives(alternatives) {
3063
+ for (const alternative of alternatives) {
3064
+ if (alternative.quantity) {
3065
+ const converted = convertAlternativeQuantity(
3066
+ alternative
3067
+ );
3068
+ alternative.quantity = converted.quantity;
3069
+ alternative.unit = converted.unit;
3070
+ alternative.scalable = converted.scalable;
3071
+ alternative.equivalents = converted.equivalents;
3072
+ }
3073
+ }
3074
+ }
3075
+ for (const section of newRecipe.sections) {
3076
+ for (const step of section.content.filter(
3077
+ (item) => item.type === "step"
3078
+ )) {
3079
+ for (const item of step.items.filter(
3080
+ (item2) => item2.type === "ingredient"
3081
+ )) {
3082
+ convertAlternatives(item.alternatives);
3083
+ }
3084
+ }
3085
+ }
3086
+ for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3087
+ convertAlternatives(alternatives);
3088
+ }
3089
+ for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3090
+ convertAlternatives(alternatives);
3091
+ }
3092
+ newRecipe._populate_ingredient_quantities();
3093
+ if (method !== "keep") _Recipe.unitSystems.set(newRecipe, system);
3094
+ return newRecipe;
3095
+ }
2517
3096
  /**
2518
3097
  * Gets the number of servings for the recipe.
2519
3098
  * @private
@@ -2547,6 +3126,11 @@ var _Recipe = class _Recipe {
2547
3126
  return newRecipe;
2548
3127
  }
2549
3128
  };
3129
+ /**
3130
+ * External storage for unit system (not a property on instances).
3131
+ * Used for resolving ambiguous units during quantity addition.
3132
+ */
3133
+ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
2550
3134
  /**
2551
3135
  * External storage for item count (not a property on instances).
2552
3136
  * Used for giving ID numbers to items during parsing.
@@ -2598,10 +3182,10 @@ var ShoppingList = class {
2598
3182
  existing.quantityTotal
2599
3183
  );
2600
3184
  const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.and : [existingQuantityTotalExtended];
2601
- existing.quantityTotal = addEquivalentsAndSimplify(
3185
+ existing.quantityTotal = addEquivalentsAndSimplify([
2602
3186
  ...existingQuantities,
2603
3187
  ...newQuantities
2604
- );
3188
+ ]);
2605
3189
  return;
2606
3190
  } catch {
2607
3191
  }
@@ -2652,7 +3236,7 @@ var ShoppingList = class {
2652
3236
  (q) => extendAllUnits(q)
2653
3237
  );
2654
3238
  const totalQuantity = addEquivalentsAndSimplify(
2655
- ...extendedQuantities
3239
+ extendedQuantities
2656
3240
  );
2657
3241
  addIngredientQuantity(ingredient.name, totalQuantity);
2658
3242
  }
@@ -2948,12 +3532,12 @@ var ShoppingCart = class {
2948
3532
  alternative.quantity = scaledQuantity;
2949
3533
  const matchOptions = normalizedOptions2.filter(
2950
3534
  (option) => option.sizes.some(
2951
- (s) => areUnitsCompatible(alternative.unit, s.unit)
3535
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2952
3536
  )
2953
3537
  );
2954
3538
  if (matchOptions.length > 0) {
2955
3539
  const findCompatibleSize = (option) => option.sizes.find(
2956
- (s) => areUnitsCompatible(alternative.unit, s.unit)
3540
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2957
3541
  );
2958
3542
  if (matchOptions.length == 1) {
2959
3543
  const matchedOption = matchOptions[0];
@@ -3055,10 +3639,37 @@ var ShoppingCart = class {
3055
3639
  };
3056
3640
 
3057
3641
  // src/utils/render_helpers.ts
3058
- function formatNumericValue(value) {
3642
+ var VULGAR_FRACTIONS = {
3643
+ "1/2": "\xBD",
3644
+ "1/3": "\u2153",
3645
+ "2/3": "\u2154",
3646
+ "1/4": "\xBC",
3647
+ "3/4": "\xBE",
3648
+ "1/8": "\u215B",
3649
+ "3/8": "\u215C",
3650
+ "5/8": "\u215D",
3651
+ "7/8": "\u215E"
3652
+ };
3653
+ function renderFractionAsVulgar(num, den) {
3654
+ const wholePart = Math.floor(num / den);
3655
+ const remainder = num % den;
3656
+ if (remainder === 0) {
3657
+ return String(wholePart);
3658
+ }
3659
+ const fractionKey = `${remainder}/${den}`;
3660
+ const vulgar = VULGAR_FRACTIONS[fractionKey];
3661
+ if (wholePart > 0) {
3662
+ return vulgar ? `${wholePart}${vulgar}` : `${wholePart} ${remainder}/${den}`;
3663
+ }
3664
+ return vulgar ?? `${num}/${den}`;
3665
+ }
3666
+ function formatNumericValue(value, useVulgar = true) {
3059
3667
  if (value.type === "decimal") {
3060
3668
  return String(value.decimal);
3061
3669
  }
3670
+ if (useVulgar) {
3671
+ return renderFractionAsVulgar(value.num, value.den);
3672
+ }
3062
3673
  return `${value.num}/${value.den}`;
3063
3674
  }
3064
3675
  function formatSingleValue(value) {
@@ -3125,6 +3736,7 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
3125
3736
  Section,
3126
3737
  ShoppingCart,
3127
3738
  ShoppingList,
3739
+ convertQuantityToSystem,
3128
3740
  formatExtendedQuantity,
3129
3741
  formatItemQuantity,
3130
3742
  formatNumericValue,
@@ -3136,11 +3748,15 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
3136
3748
  isAlternativeSelected,
3137
3749
  isAndGroup,
3138
3750
  isGroupedItem,
3139
- isSimpleGroup
3751
+ isSimpleGroup,
3752
+ renderFractionAsVulgar
3140
3753
  });
3141
3754
  /* v8 ignore else -- @preserve */
3755
+ /* v8 ignore next -- @preserve: defensive fallback for ambiguous units without toBaseBySystem */
3756
+ /* v8 ignore start -- @preserve: defensive fallback that shouldn't happen with valid inputs */
3142
3757
  // v8 ignore else -- @preserve
3143
- /* v8 ignore else -- expliciting error type -- @preserve */
3144
3758
  // v8 ignore if -- @preserve
3759
+ /* v8 ignore else -- expliciting error type -- @preserve */
3145
3760
  /* v8 ignore if -- @preserve */
3761
+ // v8 ignore next -- @preserve
3146
3762
  //# sourceMappingURL=index.cjs.map