@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.js CHANGED
@@ -302,7 +302,8 @@ var units = [
302
302
  type: "mass",
303
303
  system: "metric",
304
304
  aliases: ["gram", "grams", "grammes"],
305
- toBase: 1
305
+ toBase: 1,
306
+ maxValue: 999
306
307
  },
307
308
  {
308
309
  name: "kg",
@@ -311,20 +312,28 @@ var units = [
311
312
  aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"],
312
313
  toBase: 1e3
313
314
  },
314
- // Mass (Imperial)
315
+ // Mass (US/UK - identical in both systems)
315
316
  {
316
317
  name: "oz",
317
318
  type: "mass",
318
- system: "imperial",
319
+ system: "ambiguous",
319
320
  aliases: ["ounce", "ounces"],
320
- toBase: 28.3495
321
+ toBase: 28.3495,
322
+ // default: US (same as UK)
323
+ toBaseBySystem: { US: 28.3495, UK: 28.3495 },
324
+ maxValue: 31,
325
+ // 16 oz = 1 lb, allow a bit more
326
+ fractions: { enabled: true, denominators: [2] }
321
327
  },
322
328
  {
323
329
  name: "lb",
324
330
  type: "mass",
325
- system: "imperial",
331
+ system: "ambiguous",
326
332
  aliases: ["pound", "pounds"],
327
- toBase: 453.592
333
+ toBase: 453.592,
334
+ // default: US (same as UK)
335
+ toBaseBySystem: { US: 453.592, UK: 453.592 },
336
+ fractions: { enabled: true, denominators: [2, 4] }
328
337
  },
329
338
  // Volume (Metric)
330
339
  {
@@ -332,21 +341,26 @@ var units = [
332
341
  type: "volume",
333
342
  system: "metric",
334
343
  aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"],
335
- toBase: 1
344
+ toBase: 1,
345
+ maxValue: 999
336
346
  },
337
347
  {
338
348
  name: "cl",
339
349
  type: "volume",
340
350
  system: "metric",
341
351
  aliases: ["centiliter", "centiliters", "centilitre", "centilitres"],
342
- toBase: 10
352
+ toBase: 10,
353
+ isBestUnit: false
354
+ // exists but not a "best" candidate
343
355
  },
344
356
  {
345
357
  name: "dl",
346
358
  type: "volume",
347
359
  system: "metric",
348
360
  aliases: ["deciliter", "deciliters", "decilitre", "decilitres"],
349
- toBase: 100
361
+ toBase: 100,
362
+ isBestUnit: false
363
+ // exists but not a "best" candidate
350
364
  },
351
365
  {
352
366
  name: "l",
@@ -355,55 +369,102 @@ var units = [
355
369
  aliases: ["liter", "liters", "litre", "litres"],
356
370
  toBase: 1e3
357
371
  },
372
+ // Volume (JP)
373
+ {
374
+ name: "go",
375
+ type: "volume",
376
+ system: "JP",
377
+ aliases: ["gou", "goo", "\u5408", "rice cup"],
378
+ toBase: 180,
379
+ maxValue: 10
380
+ },
381
+ // Volume (Ambiguous: metric/US/UK)
358
382
  {
359
383
  name: "tsp",
360
384
  type: "volume",
361
- system: "metric",
385
+ system: "ambiguous",
362
386
  aliases: ["teaspoon", "teaspoons"],
363
- toBase: 5
387
+ toBase: 5,
388
+ // default: metric
389
+ toBaseBySystem: { metric: 5, US: 4.929, UK: 5.919, JP: 5 },
390
+ maxValue: 5,
391
+ // 3 tsp = 1 tbsp (but allow a bit more)
392
+ fractions: { enabled: true, denominators: [2, 3, 4, 8] }
364
393
  },
365
394
  {
366
395
  name: "tbsp",
367
396
  type: "volume",
368
- system: "metric",
397
+ system: "ambiguous",
369
398
  aliases: ["tablespoon", "tablespoons"],
370
- toBase: 15
399
+ toBase: 15,
400
+ // default: metric
401
+ toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758, JP: 15 },
402
+ maxValue: 4,
403
+ // ~16 tbsp = 1 cup
404
+ fractions: { enabled: true }
371
405
  },
372
- // Volume (Imperial)
406
+ // Volume (Ambiguous: US/UK only)
373
407
  {
374
408
  name: "fl-oz",
375
409
  type: "volume",
376
- system: "imperial",
410
+ system: "ambiguous",
377
411
  aliases: ["fluid ounce", "fluid ounces"],
378
- toBase: 29.5735
412
+ toBase: 29.5735,
413
+ // default: US
414
+ toBaseBySystem: { US: 29.5735, UK: 28.4131 },
415
+ maxValue: 15,
416
+ // 8 fl-oz ~ 1 cup, allow more
417
+ fractions: { enabled: true, denominators: [2] }
379
418
  },
380
419
  {
381
420
  name: "cup",
382
421
  type: "volume",
383
- system: "imperial",
422
+ system: "ambiguous",
384
423
  aliases: ["cups"],
385
- toBase: 236.588
424
+ toBase: 236.588,
425
+ // default: US
426
+ toBaseBySystem: { US: 236.588, UK: 284.131 },
427
+ maxValue: 15,
428
+ // upgrade to gallons above 15 cups
429
+ fractions: { enabled: true }
386
430
  },
387
431
  {
388
432
  name: "pint",
389
433
  type: "volume",
390
- system: "imperial",
434
+ system: "ambiguous",
391
435
  aliases: ["pints"],
392
- toBase: 473.176
436
+ toBase: 473.176,
437
+ // default: US
438
+ toBaseBySystem: { US: 473.176, UK: 568.261 },
439
+ maxValue: 3,
440
+ // 2 pints = 1 quart
441
+ fractions: { enabled: true, denominators: [2] },
442
+ isBestUnit: false
443
+ // exists but not a "best" candidate
393
444
  },
394
445
  {
395
446
  name: "quart",
396
447
  type: "volume",
397
- system: "imperial",
448
+ system: "ambiguous",
398
449
  aliases: ["quarts"],
399
- toBase: 946.353
450
+ toBase: 946.353,
451
+ // default: US
452
+ toBaseBySystem: { US: 946.353, UK: 1136.52 },
453
+ maxValue: 3,
454
+ // 4 quarts = 1 gallon
455
+ fractions: { enabled: true, denominators: [2] },
456
+ isBestUnit: false
457
+ // exists but not a "best" candidate
400
458
  },
401
459
  {
402
460
  name: "gallon",
403
461
  type: "volume",
404
- system: "imperial",
462
+ system: "ambiguous",
405
463
  aliases: ["gallons"],
406
- toBase: 3785.41
464
+ toBase: 3785.41,
465
+ // default: US
466
+ toBaseBySystem: { US: 3785.41, UK: 4546.09 },
467
+ fractions: { enabled: true, denominators: [2] }
407
468
  },
408
469
  // Count units (no conversion, but recognized as a type)
409
470
  {
@@ -411,7 +472,8 @@ var units = [
411
472
  type: "count",
412
473
  system: "metric",
413
474
  aliases: ["pieces", "pc"],
414
- toBase: 1
475
+ toBase: 1,
476
+ maxValue: 999
415
477
  }
416
478
  ];
417
479
  var unitMap = /* @__PURE__ */ new Map();
@@ -435,8 +497,14 @@ function isNoUnit(unit) {
435
497
  return resolveUnit(unit.name).name === NO_UNIT;
436
498
  }
437
499
 
500
+ // src/units/conversion.ts
501
+ import Big2 from "big.js";
502
+
438
503
  // src/quantities/numeric.ts
439
504
  import Big from "big.js";
505
+ var DEFAULT_DENOMINATORS = [2, 3, 4];
506
+ var DEFAULT_FRACTION_ACCURACY = 0.05;
507
+ var DEFAULT_MAX_WHOLE = 4;
440
508
  function gcd(a2, b) {
441
509
  return b === 0 ? a2 : gcd(b, a2 % b);
442
510
  }
@@ -457,6 +525,41 @@ function simplifyFraction(num, den) {
457
525
  return { type: "fraction", num: simplifiedNum, den: simplifiedDen };
458
526
  }
459
527
  }
528
+ function approximateFraction(value, denominators = DEFAULT_DENOMINATORS, accuracy = DEFAULT_FRACTION_ACCURACY, maxWhole = DEFAULT_MAX_WHOLE) {
529
+ if (value <= 0 || !Number.isFinite(value)) {
530
+ return null;
531
+ }
532
+ const wholePart = Math.floor(value);
533
+ if (wholePart > maxWhole) {
534
+ return null;
535
+ }
536
+ const fractionalPart = value - wholePart;
537
+ if (fractionalPart < 1e-4) {
538
+ return null;
539
+ }
540
+ let bestFraction = null;
541
+ for (const den of denominators) {
542
+ const exactNum = value * den;
543
+ const roundedNum = Math.round(exactNum);
544
+ if (roundedNum === 0) continue;
545
+ const approximatedValue = roundedNum / den;
546
+ const relativeError = Math.abs(approximatedValue - value) / value;
547
+ if (relativeError <= accuracy) {
548
+ if (!bestFraction || relativeError < bestFraction.error) {
549
+ bestFraction = { num: roundedNum, den, error: relativeError };
550
+ }
551
+ }
552
+ }
553
+ if (!bestFraction) {
554
+ return null;
555
+ }
556
+ const commonDivisor = gcd(bestFraction.num, bestFraction.den);
557
+ return {
558
+ type: "fraction",
559
+ num: bestFraction.num / commonDivisor,
560
+ den: bestFraction.den / commonDivisor
561
+ };
562
+ }
460
563
  function getNumericValue(v) {
461
564
  if (v.type === "decimal") {
462
565
  return v.decimal;
@@ -505,9 +608,35 @@ function addNumericValues(val1, val2) {
505
608
  };
506
609
  }
507
610
  }
508
- var toRoundedDecimal = (v) => {
611
+ var toRoundedDecimal = (v, precision = 3) => {
509
612
  const value = v.type === "decimal" ? v.decimal : v.num / v.den;
510
- return { type: "decimal", decimal: Math.round(value * 1e3) / 1e3 };
613
+ if (value === 0) {
614
+ return { type: "decimal", decimal: 0 };
615
+ }
616
+ const absValue = Math.abs(value);
617
+ if (absValue >= 1e3) {
618
+ return { type: "decimal", decimal: Math.round(value) };
619
+ }
620
+ const magnitude = Math.floor(Math.log10(absValue));
621
+ const scale = Math.pow(10, precision - 1 - magnitude);
622
+ const rounded = Math.round(value * scale) / scale;
623
+ return { type: "decimal", decimal: rounded };
624
+ };
625
+ var formatOutputValue = (value, unitDef, precision = 3) => {
626
+ if (unitDef.fractions?.enabled) {
627
+ const denominators = unitDef.fractions.denominators ?? DEFAULT_DENOMINATORS;
628
+ const maxWhole = unitDef.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
629
+ const fraction = approximateFraction(
630
+ value,
631
+ denominators,
632
+ DEFAULT_FRACTION_ACCURACY,
633
+ maxWhole
634
+ );
635
+ if (fraction) {
636
+ return fraction;
637
+ }
638
+ }
639
+ return toRoundedDecimal({ type: "decimal", decimal: value }, precision);
511
640
  };
512
641
  function multiplyQuantityValue(value, factor) {
513
642
  if (value.type === "fixed") {
@@ -541,6 +670,143 @@ function getAverageValue(q) {
541
670
  }
542
671
  }
543
672
 
673
+ // src/units/compatibility.ts
674
+ function areUnitsGroupable(u1, u2) {
675
+ if (u1.name === u2.name) {
676
+ return true;
677
+ }
678
+ if (u1.type === "other" || u2.type === "other") {
679
+ return false;
680
+ }
681
+ if (u1.type === u2.type && u1.system === u2.system) {
682
+ return true;
683
+ }
684
+ if (u1.type === u2.type) {
685
+ if (u1.system === "ambiguous" && u2.system === "metric" && u1.toBaseBySystem?.metric !== void 0) {
686
+ return true;
687
+ }
688
+ if (u2.system === "ambiguous" && u1.system === "metric" && u2.toBaseBySystem?.metric !== void 0) {
689
+ return true;
690
+ }
691
+ }
692
+ return false;
693
+ }
694
+ function areUnitsConvertible(u1, u2) {
695
+ if (u1.name === u2.name) return true;
696
+ if (u1.type === "other" || u2.type === "other") return false;
697
+ return u1.type === u2.type;
698
+ }
699
+ function isUnitCompatibleWithSystem(unit, system) {
700
+ if (unit.system === system) return true;
701
+ if (unit.system === "ambiguous") {
702
+ if (unit.toBaseBySystem) {
703
+ return system in unit.toBaseBySystem;
704
+ }
705
+ if (system === "metric") return true;
706
+ }
707
+ if (unit.system === "metric" && system === "JP") {
708
+ return true;
709
+ }
710
+ return false;
711
+ }
712
+
713
+ // src/units/conversion.ts
714
+ var EPSILON = 0.01;
715
+ var DEFAULT_MAX_VALUE = 999;
716
+ function isCloseToInteger(value) {
717
+ return Math.abs(value - Math.round(value)) < EPSILON;
718
+ }
719
+ function getMaxValue(unit) {
720
+ return unit.maxValue ?? DEFAULT_MAX_VALUE;
721
+ }
722
+ function isValueInRange(value, unit) {
723
+ const maxValue = getMaxValue(unit);
724
+ if (value >= 1 && value <= maxValue) {
725
+ return true;
726
+ }
727
+ if (value > 0 && value < 1 && unit.fractions?.enabled) {
728
+ const denominators = unit.fractions.denominators ?? DEFAULT_DENOMINATORS;
729
+ const maxWhole = unit.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
730
+ const fraction = approximateFraction(
731
+ value,
732
+ denominators,
733
+ DEFAULT_FRACTION_ACCURACY,
734
+ maxWhole
735
+ );
736
+ return fraction !== null;
737
+ }
738
+ return false;
739
+ }
740
+ function findBestUnit(valueInBase, unitType, system, inputUnits) {
741
+ const inputUnitNames = new Set(inputUnits.map((u) => u.name));
742
+ const candidates = units.filter(
743
+ (u) => u.type === unitType && isUnitCompatibleWithSystem(u, system) && (u.isBestUnit !== false || inputUnitNames.has(u.name))
744
+ );
745
+ if (candidates.length === 0) {
746
+ const fallbackUnit = inputUnits[0];
747
+ return {
748
+ unit: fallbackUnit,
749
+ value: valueInBase / getToBase(fallbackUnit, system)
750
+ };
751
+ }
752
+ const candidatesWithValues = candidates.map((unit) => ({
753
+ unit,
754
+ value: valueInBase / getToBase(unit, system)
755
+ }));
756
+ const inRange = candidatesWithValues.filter(
757
+ (c) => isValueInRange(c.value, c.unit)
758
+ );
759
+ if (inRange.length > 0) {
760
+ const integersInInputFamily = inRange.filter(
761
+ (c) => isCloseToInteger(c.value) && inputUnitNames.has(c.unit.name)
762
+ );
763
+ if (integersInInputFamily.length > 0) {
764
+ return integersInInputFamily.sort((a2, b) => a2.value - b.value)[0];
765
+ }
766
+ const integersAny = inRange.filter((c) => isCloseToInteger(c.value));
767
+ if (integersAny.length > 0) {
768
+ return integersAny.sort((a2, b) => a2.value - b.value)[0];
769
+ }
770
+ return inRange.sort((a2, b) => {
771
+ const aInFamily = inputUnitNames.has(a2.unit.name) ? 0 : 1;
772
+ const bInFamily = inputUnitNames.has(b.unit.name) ? 0 : 1;
773
+ if (aInFamily !== bInFamily) return aInFamily - bInFamily;
774
+ return a2.value - b.value;
775
+ })[0];
776
+ }
777
+ return candidatesWithValues.sort((a2, b) => {
778
+ const aMaxValue = getMaxValue(a2.unit);
779
+ const bMaxValue = getMaxValue(b.unit);
780
+ const aDistance = a2.value < 1 ? 1 - a2.value : a2.value - aMaxValue;
781
+ const bDistance = b.value < 1 ? 1 - b.value : b.value - bMaxValue;
782
+ return aDistance - bDistance;
783
+ })[0];
784
+ }
785
+ function getUnitRatio(q1, q2) {
786
+ const q1Value = getAverageValue(q1.quantity);
787
+ const q2Value = getAverageValue(q2.quantity);
788
+ const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
789
+ if (typeof q1Value !== "number" || typeof q2Value !== "number") {
790
+ throw Error(
791
+ "One of both values is not a number, so a ratio cannot be computed"
792
+ );
793
+ }
794
+ return Big2(q1Value).times(factor).div(q2Value);
795
+ }
796
+ function getBaseUnitRatio(q, qRef) {
797
+ if ("toBase" in q.unit && "toBase" in qRef.unit) {
798
+ return q.unit.toBase / qRef.unit.toBase;
799
+ } else {
800
+ return 1;
801
+ }
802
+ }
803
+ function getToBase(unit, system) {
804
+ if (unit.system === "ambiguous" && system && unit.toBaseBySystem) {
805
+ return unit.toBaseBySystem[system] ?? unit.toBase;
806
+ }
807
+ return unit.toBase;
808
+ }
809
+
544
810
  // src/errors.ts
545
811
  var ReferencedItemCannotBeRedefinedError = class extends Error {
546
812
  constructor(item_type, item_name, new_modifier) {
@@ -679,11 +945,6 @@ function normalizeAllUnits(q) {
679
945
  return newQ;
680
946
  }
681
947
  }
682
- var convertQuantityValue = (value, def, targetDef) => {
683
- if (def.name === targetDef.name) return value;
684
- const factor = def.toBase / targetDef.toBase;
685
- return multiplyQuantityValue(value, factor);
686
- };
687
948
  function getDefaultQuantityValue() {
688
949
  return { type: "fixed", value: { type: "decimal", decimal: 0 } };
689
950
  }
@@ -710,7 +971,7 @@ function addQuantityValues(v1, v2) {
710
971
  );
711
972
  return { type: "range", min: newMin, max: newMax };
712
973
  }
713
- function addQuantities(q1, q2) {
974
+ function addQuantities(q1, q2, system) {
714
975
  const v1 = q1.quantity;
715
976
  const v2 = q2.quantity;
716
977
  if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
@@ -728,35 +989,129 @@ function addQuantities(q1, q2) {
728
989
  if ((q2.unit?.name === "" || q2.unit === void 0) && q1.unit !== void 0) {
729
990
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
730
991
  }
731
- if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
992
+ if (!q1.unit && !q2.unit) {
993
+ return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
994
+ }
995
+ if (q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
996
+ if (unit1Def) {
997
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unit1Def.system) ? unit1Def.system : "US");
998
+ return addAndFindBestUnit(v1, v2, unit1Def, unit1Def, effectiveSystem, [
999
+ unit1Def
1000
+ ]);
1001
+ }
732
1002
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
733
1003
  }
734
1004
  if (unit1Def && unit2Def) {
735
- if (unit1Def.type !== unit2Def.type) {
1005
+ if (!areUnitsConvertible(unit1Def, unit2Def)) {
736
1006
  throw new IncompatibleUnitsError(
737
1007
  `${unit1Def.type} (${q1.unit?.name})`,
738
1008
  `${unit2Def.type} (${q2.unit?.name})`
739
1009
  );
740
1010
  }
741
- let targetUnitDef;
742
- if (unit1Def.system !== unit2Def.system) {
743
- const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def;
744
- targetUnitDef = units.filter((u) => u.type === metricUnitDef.type && u.system === "metric").reduce(
745
- (prev, current) => prev.toBase > current.toBase ? prev : current
746
- );
747
- } else {
748
- targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def;
1011
+ let effectiveSystem = system;
1012
+ if (!effectiveSystem) {
1013
+ if (unit1Def.system === "metric" || unit2Def.system === "metric") {
1014
+ effectiveSystem = "metric";
1015
+ } else {
1016
+ if (unit1Def.system === "JP" && unit2Def.system === "JP") {
1017
+ effectiveSystem = "JP";
1018
+ } else {
1019
+ const unit1SupportsUS = unit1Def.system === "US" || unit1Def.system === "ambiguous" && unit1Def.toBaseBySystem && "US" in unit1Def.toBaseBySystem;
1020
+ const unit2SupportsUS = unit2Def.system === "US" || unit2Def.system === "ambiguous" && unit2Def.toBaseBySystem && "US" in unit2Def.toBaseBySystem;
1021
+ effectiveSystem = unit1SupportsUS && unit2SupportsUS ? "US" : "metric";
1022
+ }
1023
+ }
749
1024
  }
750
- const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);
751
- const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);
752
- const targetUnit = { name: targetUnitDef.name };
753
- return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit);
1025
+ return addAndFindBestUnit(v1, v2, unit1Def, unit2Def, effectiveSystem, [
1026
+ unit1Def,
1027
+ unit2Def
1028
+ ]);
754
1029
  }
755
1030
  throw new IncompatibleUnitsError(
756
1031
  q1.unit?.name,
757
1032
  q2.unit?.name
758
1033
  );
759
1034
  }
1035
+ function addAndFindBestUnit(v1, v2, unit1Def, unit2Def, system, inputUnits) {
1036
+ const toBase1 = getToBase(unit1Def, system);
1037
+ const toBase2 = getToBase(unit2Def, system);
1038
+ let sumInBase;
1039
+ if (v1.type === "fixed" && v2.type === "fixed") {
1040
+ const val1 = getNumericValue(v1.value);
1041
+ const val2 = getNumericValue(v2.value);
1042
+ sumInBase = val1 * toBase1 + val2 * toBase2;
1043
+ } else {
1044
+ const avg1 = getAverageValue(v1);
1045
+ const avg2 = getAverageValue(v2);
1046
+ sumInBase = avg1 * toBase1 + avg2 * toBase2;
1047
+ }
1048
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1049
+ sumInBase,
1050
+ unit1Def.type,
1051
+ system,
1052
+ inputUnits
1053
+ );
1054
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1055
+ if (v1.type === "range" || v2.type === "range") {
1056
+ const r1 = v1.type === "range" ? v1 : { type: "range", min: v1.value, max: v1.value };
1057
+ const r2 = v2.type === "range" ? v2 : { type: "range", min: v2.value, max: v2.value };
1058
+ const minInBase = getNumericValue(r1.min) * toBase1 + getNumericValue(r2.min) * toBase2;
1059
+ const maxInBase = getNumericValue(r1.max) * toBase1 + getNumericValue(r2.max) * toBase2;
1060
+ const bestToBase = getToBase(bestUnit, system);
1061
+ const minValue = minInBase / bestToBase;
1062
+ const maxValue = maxInBase / bestToBase;
1063
+ return {
1064
+ quantity: {
1065
+ type: "range",
1066
+ min: formatOutputValue(minValue, bestUnit),
1067
+ max: formatOutputValue(maxValue, bestUnit)
1068
+ },
1069
+ unit: { name: bestUnit.name }
1070
+ };
1071
+ }
1072
+ return {
1073
+ quantity: { type: "fixed", value: formattedValue },
1074
+ unit: { name: bestUnit.name }
1075
+ };
1076
+ }
1077
+ function convertQuantityToSystem(quantity, system) {
1078
+ const unitDef = resolveUnit(
1079
+ typeof quantity.unit === "string" ? quantity.unit : quantity.unit?.name
1080
+ );
1081
+ if (unitDef.type === "other" || !("toBase" in unitDef)) {
1082
+ return void 0;
1083
+ }
1084
+ const avgValue = getAverageValue(quantity.quantity);
1085
+ if (typeof avgValue !== "number") {
1086
+ return void 0;
1087
+ }
1088
+ const toBase = getToBase(unitDef, system);
1089
+ const valueInBase = avgValue * toBase;
1090
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1091
+ valueInBase,
1092
+ unitDef.type,
1093
+ system,
1094
+ [unitDef]
1095
+ );
1096
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1097
+ if (quantity.quantity.type === "range") {
1098
+ const bestToBase = getToBase(bestUnit, system);
1099
+ const minValue = getNumericValue(quantity.quantity.min) * toBase / bestToBase;
1100
+ const maxValue = getNumericValue(quantity.quantity.max) * toBase / bestToBase;
1101
+ return {
1102
+ quantity: {
1103
+ type: "range",
1104
+ min: formatOutputValue(minValue, bestUnit),
1105
+ max: formatOutputValue(maxValue, bestUnit)
1106
+ },
1107
+ unit: { name: bestUnit.name }
1108
+ };
1109
+ }
1110
+ return {
1111
+ quantity: { type: "fixed", value: formattedValue },
1112
+ unit: { name: bestUnit.name }
1113
+ };
1114
+ }
760
1115
  function toPlainUnit(quantity) {
761
1116
  if (isQuantity(quantity))
762
1117
  return quantity.unit ? { ...quantity, unit: quantity.unit.name } : quantity;
@@ -863,6 +1218,53 @@ var flattenPlainUnitGroup = (summed) => {
863
1218
  ];
864
1219
  }
865
1220
  };
1221
+ function applyBestUnit(q, system) {
1222
+ if (!q.unit?.name) {
1223
+ return q;
1224
+ }
1225
+ const unitDef = resolveUnit(q.unit.name);
1226
+ if (unitDef.type === "other") {
1227
+ return q;
1228
+ }
1229
+ if (q.quantity.type === "fixed" && q.quantity.value.type === "text") {
1230
+ return q;
1231
+ }
1232
+ const avgValue = getAverageValue(q.quantity);
1233
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unitDef.system) ? unitDef.system : "US");
1234
+ const toBase = getToBase(unitDef, effectiveSystem);
1235
+ const valueInBase = avgValue * toBase;
1236
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1237
+ valueInBase,
1238
+ unitDef.type,
1239
+ effectiveSystem,
1240
+ [unitDef]
1241
+ );
1242
+ const originalCanonicalName = normalizeUnit(q.unit.name)?.name;
1243
+ if (bestUnit.name === originalCanonicalName) {
1244
+ return q;
1245
+ }
1246
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1247
+ if (q.quantity.type === "range") {
1248
+ const bestToBase = getToBase(bestUnit, effectiveSystem);
1249
+ const minValue = getNumericValue(q.quantity.min) * toBase / bestToBase;
1250
+ const maxValue = getNumericValue(q.quantity.max) * toBase / bestToBase;
1251
+ return {
1252
+ quantity: {
1253
+ type: "range",
1254
+ min: formatOutputValue(minValue, bestUnit),
1255
+ max: formatOutputValue(maxValue, bestUnit)
1256
+ },
1257
+ unit: { name: bestUnit.name }
1258
+ };
1259
+ }
1260
+ return {
1261
+ quantity: {
1262
+ type: "fixed",
1263
+ value: formattedValue
1264
+ },
1265
+ unit: { name: bestUnit.name }
1266
+ };
1267
+ }
866
1268
 
867
1269
  // src/utils/parser_helpers.ts
868
1270
  function flushPendingNote(section, noteItems) {
@@ -1066,6 +1468,18 @@ function extractMetadata(content) {
1066
1468
  const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar);
1067
1469
  if (stringMetaValue) metadata[metaVar] = stringMetaValue;
1068
1470
  }
1471
+ let unitSystem;
1472
+ const unitSystemRaw = parseSimpleMetaVar(metadataContent, "unit system");
1473
+ if (unitSystemRaw) {
1474
+ metadata["unit system"] = unitSystemRaw;
1475
+ const unitSystemMap = {
1476
+ metric: "metric",
1477
+ us: "US",
1478
+ uk: "UK",
1479
+ jp: "JP"
1480
+ };
1481
+ unitSystem = unitSystemMap[unitSystemRaw.toLowerCase()];
1482
+ }
1069
1483
  for (const metaVar of ["serves", "yield", "servings"]) {
1070
1484
  const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1071
1485
  if (scalingMetaValue && scalingMetaValue[1]) {
@@ -1077,7 +1491,7 @@ function extractMetadata(content) {
1077
1491
  const listMetaValue = parseListMetaVar(metadataContent, metaVar);
1078
1492
  if (listMetaValue) metadata[metaVar] = listMetaValue;
1079
1493
  }
1080
- return { metadata, servings };
1494
+ return { metadata, servings, unitSystem };
1081
1495
  }
1082
1496
  function isPositiveIntegerString(str) {
1083
1497
  return /^\d+$/.test(str);
@@ -1260,44 +1674,14 @@ var Section = class {
1260
1674
  // src/quantities/alternatives.ts
1261
1675
  import Big3 from "big.js";
1262
1676
 
1263
- // src/units/conversion.ts
1264
- import Big2 from "big.js";
1265
- function getUnitRatio(q1, q2) {
1266
- const q1Value = getAverageValue(q1.quantity);
1267
- const q2Value = getAverageValue(q2.quantity);
1268
- const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
1269
- if (typeof q1Value !== "number" || typeof q2Value !== "number") {
1270
- throw Error(
1271
- "One of both values is not a number, so a ratio cannot be computed"
1272
- );
1273
- }
1274
- return Big2(q1Value).times(factor).div(q2Value);
1275
- }
1276
- function getBaseUnitRatio(q, qRef) {
1277
- if ("toBase" in q.unit && "toBase" in qRef.unit) {
1278
- return q.unit.toBase / qRef.unit.toBase;
1279
- } else {
1280
- return 1;
1281
- }
1282
- }
1283
-
1284
1677
  // src/units/lookup.ts
1285
- function areUnitsCompatible(u1, u2) {
1286
- if (u1.name === u2.name) {
1287
- return true;
1288
- }
1289
- if (u1.type !== "other" && u1.type === u2.type && u1.system === u2.system) {
1290
- return true;
1291
- }
1292
- return false;
1293
- }
1294
1678
  function findListWithCompatibleQuantity(list, quantity) {
1295
1679
  const quantityWithUnitDef = {
1296
1680
  ...quantity,
1297
1681
  unit: resolveUnit(quantity.unit?.name)
1298
1682
  };
1299
1683
  return list.find(
1300
- (l) => l.some((lq) => areUnitsCompatible(lq.unit, quantityWithUnitDef.unit))
1684
+ (l) => l.some((lq) => areUnitsGroupable(lq.unit, quantityWithUnitDef.unit))
1301
1685
  );
1302
1686
  }
1303
1687
  function findCompatibleQuantityWithinList(list, quantity) {
@@ -1354,11 +1738,9 @@ function getEquivalentUnitsLists(...quantities) {
1354
1738
  });
1355
1739
  function findLinkIndexForUnits(lists, unitsToCheck) {
1356
1740
  return lists.findIndex((l) => {
1357
- const listItem = l.map((q) => resolveUnit(q.unit?.name));
1741
+ const listItems = l.map((q) => resolveUnit(q.unit?.name));
1358
1742
  return unitsToCheck.some(
1359
- (u) => listItem.some(
1360
- (lu) => lu.name === u?.name || lu.system === u?.system && lu.type === u?.type && lu.type !== "other"
1361
- )
1743
+ (u) => u && listItems.some((lu) => areUnitsGroupable(lu, u))
1362
1744
  );
1363
1745
  });
1364
1746
  }
@@ -1370,16 +1752,18 @@ function getEquivalentUnitsLists(...quantities) {
1370
1752
  unit: resolveUnit(v.unit?.name, v.unit?.integerProtected)
1371
1753
  };
1372
1754
  const commonQuantity = og.or.find(
1373
- (q) => isQuantity(q) && areUnitsCompatible(q.unit, normalizedV.unit)
1755
+ (q) => isQuantity(q) && areUnitsGroupable(q.unit, normalizedV.unit)
1374
1756
  );
1375
1757
  if (commonQuantity) {
1376
1758
  acc.push(normalizedV);
1377
- unitRatio = getUnitRatio(normalizedV, commonQuantity);
1759
+ if (!unitRatio) {
1760
+ unitRatio = getUnitRatio(normalizedV, commonQuantity);
1761
+ }
1378
1762
  }
1379
1763
  return acc;
1380
1764
  }, []);
1381
1765
  for (const newQ of og.or) {
1382
- if (commonUnitList.some((q) => areUnitsCompatible(q.unit, newQ.unit))) {
1766
+ if (commonUnitList.some((q) => areUnitsGroupable(q.unit, newQ.unit))) {
1383
1767
  continue;
1384
1768
  } else {
1385
1769
  const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio);
@@ -1496,7 +1880,7 @@ function reduceOrsToFirstEquivalent(unitList, quantities) {
1496
1880
  return reduceToQuantity(qListModified[0]);
1497
1881
  });
1498
1882
  }
1499
- function addQuantitiesOrGroups(...quantities) {
1883
+ function addQuantitiesOrGroups(quantities, system) {
1500
1884
  if (quantities.length === 0)
1501
1885
  return {
1502
1886
  sum: {
@@ -1526,7 +1910,7 @@ function addQuantitiesOrGroups(...quantities) {
1526
1910
  unit: resolveUnit(nextQ.unit?.name)
1527
1911
  });
1528
1912
  } else {
1529
- const sumQ = addQuantities(existingQ, nextQ);
1913
+ const sumQ = addQuantities(existingQ, nextQ, system);
1530
1914
  existingQ.quantity = sumQ.quantity;
1531
1915
  existingQ.unit = resolveUnit(sumQ.unit?.name);
1532
1916
  }
@@ -1536,7 +1920,7 @@ function addQuantitiesOrGroups(...quantities) {
1536
1920
  }
1537
1921
  return { sum: { and: sum }, unitsLists };
1538
1922
  }
1539
- function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1923
+ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists, system) {
1540
1924
  const sumQuantities = isAndGroup(sum) ? sum.and : [sum];
1541
1925
  const result = [];
1542
1926
  const processedQuantities = /* @__PURE__ */ new Set();
@@ -1564,9 +1948,19 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1564
1948
  }
1565
1949
  return main.reduce((acc, v) => {
1566
1950
  const mainInList = findCompatibleQuantityWithinList(list, v);
1951
+ const conversionRatio = getBaseUnitRatio(v, mainInList);
1952
+ const valueInOriginalUnit = Big3(getAverageValue(v.quantity)).times(
1953
+ conversionRatio
1954
+ );
1567
1955
  const newValue = {
1568
1956
  quantity: multiplyQuantityValue(
1569
- v.quantity,
1957
+ {
1958
+ type: "fixed",
1959
+ value: {
1960
+ type: "decimal",
1961
+ decimal: valueInOriginalUnit.toNumber()
1962
+ }
1963
+ },
1570
1964
  Big3(getAverageValue(equiv.quantity)).div(
1571
1965
  getAverageValue(mainInList.quantity)
1572
1966
  )
@@ -1575,7 +1969,7 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1575
1969
  if (equiv.unit && !isNoUnit(equiv.unit)) {
1576
1970
  newValue.unit = { name: equiv.unit.name };
1577
1971
  }
1578
- return addQuantities(acc, newValue);
1972
+ return addQuantities(acc, newValue, system);
1579
1973
  }, initialValue);
1580
1974
  });
1581
1975
  if (main.length + equivalents.length > 1) {
@@ -1592,12 +1986,16 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1592
1986
  sumQuantities.filter((q) => !processedQuantities.has(q)).forEach((q) => result.push(deNormalizeQuantity(q)));
1593
1987
  return result;
1594
1988
  }
1595
- function addEquivalentsAndSimplify(...quantities) {
1989
+ function addEquivalentsAndSimplify(quantities, system) {
1596
1990
  if (quantities.length === 1) {
1597
1991
  return toPlainUnit(quantities[0]);
1598
1992
  }
1599
- const { sum, unitsLists } = addQuantitiesOrGroups(...quantities);
1600
- const regrouped = regroupQuantitiesAndExpandEquivalents(sum, unitsLists);
1993
+ const { sum, unitsLists } = addQuantitiesOrGroups(quantities, system);
1994
+ const regrouped = regroupQuantitiesAndExpandEquivalents(
1995
+ sum,
1996
+ unitsLists,
1997
+ system
1998
+ );
1601
1999
  if (regrouped.length === 1) {
1602
2000
  return toPlainUnit(regrouped[0]);
1603
2001
  } else {
@@ -1657,6 +2055,15 @@ var _Recipe = class _Recipe {
1657
2055
  this.parse(content);
1658
2056
  }
1659
2057
  }
2058
+ /**
2059
+ * Gets the unit system specified in the recipe metadata.
2060
+ * Used for resolving ambiguous units like tsp, tbsp, cup, etc.
2061
+ *
2062
+ * @returns The unit system if specified, or undefined to use defaults
2063
+ */
2064
+ get unitSystem() {
2065
+ return _Recipe.unitSystems.get(this);
2066
+ }
1660
2067
  /**
1661
2068
  * Gets the current item count for this recipe.
1662
2069
  */
@@ -1828,7 +2235,7 @@ var _Recipe = class _Recipe {
1828
2235
  alternative.note = note;
1829
2236
  }
1830
2237
  if (itemQuantity) {
1831
- alternative.itemQuantity = itemQuantity;
2238
+ Object.assign(alternative, itemQuantity);
1832
2239
  }
1833
2240
  alternatives.push(alternative);
1834
2241
  testString = groups.ingredientAlternative || "";
@@ -1936,7 +2343,7 @@ var _Recipe = class _Recipe {
1936
2343
  displayName
1937
2344
  };
1938
2345
  if (itemQuantity) {
1939
- alternative.itemQuantity = itemQuantity;
2346
+ Object.assign(alternative, itemQuantity);
1940
2347
  }
1941
2348
  const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
1942
2349
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
@@ -2072,28 +2479,28 @@ var _Recipe = class _Recipe {
2072
2479
  for (const alt of allAlts) {
2073
2480
  referencedIndices.add(alt.index);
2074
2481
  }
2075
- if (!alternative.itemQuantity) continue;
2482
+ if (!alternative.quantity) continue;
2076
2483
  const baseQty = {
2077
- quantity: alternative.itemQuantity.quantity,
2078
- ...alternative.itemQuantity.unit && {
2079
- unit: alternative.itemQuantity.unit
2484
+ quantity: alternative.quantity,
2485
+ ...alternative.unit && {
2486
+ unit: alternative.unit
2080
2487
  }
2081
2488
  };
2082
- const quantityEntry = alternative.itemQuantity.equivalents?.length ? { or: [baseQty, ...alternative.itemQuantity.equivalents] } : baseQty;
2489
+ const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
2083
2490
  let alternativeRefs;
2084
2491
  if (!hasExplicitChoice && allAlts.length > 1) {
2085
2492
  alternativeRefs = allAlts.filter(
2086
2493
  (alt) => isGrouped ? alt.itemId !== item.id : alt.index !== alternative.index
2087
2494
  ).map((otherAlt) => {
2088
2495
  const ref = { index: otherAlt.index };
2089
- if (otherAlt.itemQuantity) {
2496
+ if (otherAlt.quantity) {
2090
2497
  const altQty = {
2091
- quantity: otherAlt.itemQuantity.quantity,
2092
- ...otherAlt.itemQuantity.unit && {
2093
- unit: otherAlt.itemQuantity.unit.name
2498
+ quantity: otherAlt.quantity,
2499
+ ...otherAlt.unit && {
2500
+ unit: otherAlt.unit.name
2094
2501
  },
2095
- ...otherAlt.itemQuantity.equivalents && {
2096
- equivalents: otherAlt.itemQuantity.equivalents.map(
2502
+ ...otherAlt.equivalents && {
2503
+ equivalents: otherAlt.equivalents.map(
2097
2504
  (eq) => toPlainUnit(eq)
2098
2505
  )
2099
2506
  }
@@ -2106,14 +2513,10 @@ var _Recipe = class _Recipe {
2106
2513
  const altIndices = getAlternativeSignature(alternativeRefs) ?? "";
2107
2514
  let signature;
2108
2515
  if (isGrouped) {
2109
- const resolvedUnit = resolveUnit(
2110
- alternative.itemQuantity.unit?.name
2111
- );
2516
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
2112
2517
  signature = `group:${item.group}|${altIndices}|${resolvedUnit.type}`;
2113
2518
  } else if (altIndices) {
2114
- const resolvedUnit = resolveUnit(
2115
- alternative.itemQuantity.unit?.name
2116
- );
2519
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
2117
2520
  signature = `${altIndices}|${resolvedUnit.type}}`;
2118
2521
  } else {
2119
2522
  signature = null;
@@ -2169,13 +2572,16 @@ var _Recipe = class _Recipe {
2169
2572
  if (groupsForIng) {
2170
2573
  const quantityGroups = [];
2171
2574
  for (const [, group] of groupsForIng) {
2172
- const summed = addEquivalentsAndSimplify(...group.quantities);
2575
+ const summed = addEquivalentsAndSimplify(
2576
+ group.quantities,
2577
+ this.unitSystem
2578
+ );
2173
2579
  const flattened = flattenPlainUnitGroup(summed);
2174
2580
  const alternatives = group.alternativeQuantities.size > 0 ? [...group.alternativeQuantities].map(([altIdx, altQtys]) => ({
2175
2581
  index: altIdx,
2176
2582
  ...altQtys.length > 0 && {
2177
2583
  quantities: flattenPlainUnitGroup(
2178
- addEquivalentsAndSimplify(...altQtys)
2584
+ addEquivalentsAndSimplify(altQtys, this.unitSystem)
2179
2585
  ).flatMap(
2180
2586
  /* v8 ignore next -- item.and branch requires complex nested AND-with-equivalents structure */
2181
2587
  (item) => "quantity" in item ? [item] : item.and
@@ -2214,9 +2620,10 @@ var _Recipe = class _Recipe {
2214
2620
  */
2215
2621
  parse(content) {
2216
2622
  const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
2217
- const { metadata, servings } = extractMetadata(content);
2623
+ const { metadata, servings, unitSystem } = extractMetadata(content);
2218
2624
  this.metadata = metadata;
2219
2625
  this.servings = servings;
2626
+ if (unitSystem) _Recipe.unitSystems.set(this, unitSystem);
2220
2627
  let blankLineBefore = true;
2221
2628
  let section = new Section();
2222
2629
  const items = [];
@@ -2374,18 +2781,19 @@ var _Recipe = class _Recipe {
2374
2781
  if (originalServings === void 0 || originalServings === 0) {
2375
2782
  originalServings = 1;
2376
2783
  }
2784
+ const unitSystem = this.unitSystem;
2377
2785
  function scaleAlternativesBy(alternatives, factor2) {
2378
2786
  for (const alternative of alternatives) {
2379
- if (alternative.itemQuantity) {
2380
- const scaleFactor = alternative.itemQuantity.scalable ? Big4(factor2) : 1;
2381
- if (alternative.itemQuantity.quantity.type !== "fixed" || alternative.itemQuantity.quantity.value.type !== "text") {
2382
- alternative.itemQuantity.quantity = multiplyQuantityValue(
2383
- alternative.itemQuantity.quantity,
2787
+ if (alternative.quantity) {
2788
+ const scaleFactor = alternative.scalable ? Big4(factor2) : 1;
2789
+ if (alternative.quantity.type !== "fixed" || alternative.quantity.value.type !== "text") {
2790
+ alternative.quantity = multiplyQuantityValue(
2791
+ alternative.quantity,
2384
2792
  scaleFactor
2385
2793
  );
2386
2794
  }
2387
- if (alternative.itemQuantity.equivalents) {
2388
- alternative.itemQuantity.equivalents = alternative.itemQuantity.equivalents.map(
2795
+ if (alternative.equivalents) {
2796
+ alternative.equivalents = alternative.equivalents.map(
2389
2797
  (altQuantity) => {
2390
2798
  if (altQuantity.quantity.type === "fixed" && altQuantity.quantity.value.type === "text") {
2391
2799
  return altQuantity;
@@ -2401,6 +2809,20 @@ var _Recipe = class _Recipe {
2401
2809
  }
2402
2810
  );
2403
2811
  }
2812
+ const optimizedPrimary = applyBestUnit(
2813
+ {
2814
+ quantity: alternative.quantity,
2815
+ unit: alternative.unit
2816
+ },
2817
+ unitSystem
2818
+ );
2819
+ alternative.quantity = optimizedPrimary.quantity;
2820
+ alternative.unit = optimizedPrimary.unit;
2821
+ if (alternative.equivalents) {
2822
+ alternative.equivalents = alternative.equivalents.map(
2823
+ (eq) => applyBestUnit(eq, unitSystem)
2824
+ );
2825
+ }
2404
2826
  }
2405
2827
  }
2406
2828
  }
@@ -2461,6 +2883,161 @@ var _Recipe = class _Recipe {
2461
2883
  }
2462
2884
  return newRecipe;
2463
2885
  }
2886
+ /**
2887
+ * Converts all ingredient quantities in the recipe to a target unit system.
2888
+ *
2889
+ * @param system - The target unit system to convert to (metric, US, UK, JP)
2890
+ * @param method - How to handle existing quantities:
2891
+ * - "keep": Keep all existing equivalents (swap if needed, or add converted)
2892
+ * - "replace": Replace primary with target system quantity, discard equivalent used for conversion
2893
+ * - "remove": Only keep target system quantity, delete all equivalents
2894
+ * @returns A new Recipe instance with converted quantities
2895
+ *
2896
+ * @example
2897
+ * ```typescript
2898
+ * // Convert a recipe to metric, keeping original units as equivalents
2899
+ * const metricRecipe = recipe.convertTo("metric", "keep");
2900
+ *
2901
+ * // Convert to US units, removing all other equivalents
2902
+ * const usRecipe = recipe.convertTo("US", "remove");
2903
+ * ```
2904
+ */
2905
+ convertTo(system, method) {
2906
+ const newRecipe = this.clone();
2907
+ function buildNewPrimary(convertedQty, oldPrimary, remainingEquivalents, scalable, integerProtected, source) {
2908
+ const newUnit = integerProtected && convertedQty.unit ? { name: convertedQty.unit.name, integerProtected: true } : convertedQty.unit;
2909
+ const newPrimary = {
2910
+ quantity: convertedQty.quantity,
2911
+ unit: newUnit,
2912
+ scalable
2913
+ };
2914
+ if (method === "remove") {
2915
+ return newPrimary;
2916
+ } else if (method === "replace") {
2917
+ if (remainingEquivalents.length > 0) {
2918
+ newPrimary.equivalents = remainingEquivalents;
2919
+ if (source === "converted") newPrimary.equivalents.push(oldPrimary);
2920
+ }
2921
+ } else {
2922
+ newPrimary.equivalents = [oldPrimary, ...remainingEquivalents];
2923
+ }
2924
+ return newPrimary;
2925
+ }
2926
+ function convertAlternativeQuantity(alternative) {
2927
+ const primaryUnit = resolveUnit(alternative.unit?.name);
2928
+ const equivalents = alternative.equivalents ?? [];
2929
+ const oldPrimary = {
2930
+ quantity: alternative.quantity,
2931
+ unit: alternative.unit
2932
+ };
2933
+ if (primaryUnit.type !== "other" && isUnitCompatibleWithSystem(primaryUnit, system)) {
2934
+ if (method === "remove") {
2935
+ return {
2936
+ quantity: alternative.quantity,
2937
+ unit: alternative.unit,
2938
+ scalable: alternative.scalable
2939
+ };
2940
+ }
2941
+ return {
2942
+ quantity: alternative.quantity,
2943
+ unit: alternative.unit,
2944
+ scalable: alternative.scalable,
2945
+ equivalents
2946
+ };
2947
+ }
2948
+ const targetEquivIndex = equivalents.findIndex((eq) => {
2949
+ const eqUnit = resolveUnit(eq.unit?.name);
2950
+ return eqUnit.type !== "other" && isUnitCompatibleWithSystem(eqUnit, system);
2951
+ });
2952
+ if (targetEquivIndex !== -1) {
2953
+ const targetEquiv = equivalents[targetEquivIndex];
2954
+ const remainingEquivalents = equivalents.filter(
2955
+ (_, i2) => i2 !== targetEquivIndex
2956
+ );
2957
+ return buildNewPrimary(
2958
+ targetEquiv,
2959
+ oldPrimary,
2960
+ remainingEquivalents,
2961
+ alternative.scalable,
2962
+ targetEquiv.unit?.integerProtected,
2963
+ "swapped"
2964
+ );
2965
+ }
2966
+ const converted = convertQuantityToSystem(oldPrimary, system);
2967
+ if (converted && converted.unit) {
2968
+ return buildNewPrimary(
2969
+ converted,
2970
+ oldPrimary,
2971
+ equivalents,
2972
+ alternative.scalable,
2973
+ alternative.unit?.integerProtected,
2974
+ "swapped"
2975
+ );
2976
+ }
2977
+ for (let i2 = 0; i2 < equivalents.length; i2++) {
2978
+ const equiv = equivalents[i2];
2979
+ const convertedEquiv = convertQuantityToSystem(equiv, system);
2980
+ if (convertedEquiv && convertedEquiv.unit) {
2981
+ const remainingEquivalents = method === "keep" ? equivalents : equivalents.filter((_, idx) => idx !== i2);
2982
+ return buildNewPrimary(
2983
+ convertedEquiv,
2984
+ oldPrimary,
2985
+ remainingEquivalents,
2986
+ alternative.scalable,
2987
+ equiv.unit?.integerProtected,
2988
+ "converted"
2989
+ );
2990
+ }
2991
+ }
2992
+ if (method === "remove") {
2993
+ return {
2994
+ quantity: alternative.quantity,
2995
+ unit: alternative.unit,
2996
+ scalable: alternative.scalable
2997
+ };
2998
+ } else {
2999
+ return {
3000
+ quantity: alternative.quantity,
3001
+ unit: alternative.unit,
3002
+ scalable: alternative.scalable,
3003
+ equivalents
3004
+ };
3005
+ }
3006
+ }
3007
+ function convertAlternatives(alternatives) {
3008
+ for (const alternative of alternatives) {
3009
+ if (alternative.quantity) {
3010
+ const converted = convertAlternativeQuantity(
3011
+ alternative
3012
+ );
3013
+ alternative.quantity = converted.quantity;
3014
+ alternative.unit = converted.unit;
3015
+ alternative.scalable = converted.scalable;
3016
+ alternative.equivalents = converted.equivalents;
3017
+ }
3018
+ }
3019
+ }
3020
+ for (const section of newRecipe.sections) {
3021
+ for (const step of section.content.filter(
3022
+ (item) => item.type === "step"
3023
+ )) {
3024
+ for (const item of step.items.filter(
3025
+ (item2) => item2.type === "ingredient"
3026
+ )) {
3027
+ convertAlternatives(item.alternatives);
3028
+ }
3029
+ }
3030
+ }
3031
+ for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3032
+ convertAlternatives(alternatives);
3033
+ }
3034
+ for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3035
+ convertAlternatives(alternatives);
3036
+ }
3037
+ newRecipe._populate_ingredient_quantities();
3038
+ if (method !== "keep") _Recipe.unitSystems.set(newRecipe, system);
3039
+ return newRecipe;
3040
+ }
2464
3041
  /**
2465
3042
  * Gets the number of servings for the recipe.
2466
3043
  * @private
@@ -2494,6 +3071,11 @@ var _Recipe = class _Recipe {
2494
3071
  return newRecipe;
2495
3072
  }
2496
3073
  };
3074
+ /**
3075
+ * External storage for unit system (not a property on instances).
3076
+ * Used for resolving ambiguous units during quantity addition.
3077
+ */
3078
+ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
2497
3079
  /**
2498
3080
  * External storage for item count (not a property on instances).
2499
3081
  * Used for giving ID numbers to items during parsing.
@@ -2545,10 +3127,10 @@ var ShoppingList = class {
2545
3127
  existing.quantityTotal
2546
3128
  );
2547
3129
  const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.and : [existingQuantityTotalExtended];
2548
- existing.quantityTotal = addEquivalentsAndSimplify(
3130
+ existing.quantityTotal = addEquivalentsAndSimplify([
2549
3131
  ...existingQuantities,
2550
3132
  ...newQuantities
2551
- );
3133
+ ]);
2552
3134
  return;
2553
3135
  } catch {
2554
3136
  }
@@ -2599,7 +3181,7 @@ var ShoppingList = class {
2599
3181
  (q) => extendAllUnits(q)
2600
3182
  );
2601
3183
  const totalQuantity = addEquivalentsAndSimplify(
2602
- ...extendedQuantities
3184
+ extendedQuantities
2603
3185
  );
2604
3186
  addIngredientQuantity(ingredient.name, totalQuantity);
2605
3187
  }
@@ -2895,12 +3477,12 @@ var ShoppingCart = class {
2895
3477
  alternative.quantity = scaledQuantity;
2896
3478
  const matchOptions = normalizedOptions2.filter(
2897
3479
  (option) => option.sizes.some(
2898
- (s) => areUnitsCompatible(alternative.unit, s.unit)
3480
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2899
3481
  )
2900
3482
  );
2901
3483
  if (matchOptions.length > 0) {
2902
3484
  const findCompatibleSize = (option) => option.sizes.find(
2903
- (s) => areUnitsCompatible(alternative.unit, s.unit)
3485
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2904
3486
  );
2905
3487
  if (matchOptions.length == 1) {
2906
3488
  const matchedOption = matchOptions[0];
@@ -3002,10 +3584,37 @@ var ShoppingCart = class {
3002
3584
  };
3003
3585
 
3004
3586
  // src/utils/render_helpers.ts
3005
- function formatNumericValue(value) {
3587
+ var VULGAR_FRACTIONS = {
3588
+ "1/2": "\xBD",
3589
+ "1/3": "\u2153",
3590
+ "2/3": "\u2154",
3591
+ "1/4": "\xBC",
3592
+ "3/4": "\xBE",
3593
+ "1/8": "\u215B",
3594
+ "3/8": "\u215C",
3595
+ "5/8": "\u215D",
3596
+ "7/8": "\u215E"
3597
+ };
3598
+ function renderFractionAsVulgar(num, den) {
3599
+ const wholePart = Math.floor(num / den);
3600
+ const remainder = num % den;
3601
+ if (remainder === 0) {
3602
+ return String(wholePart);
3603
+ }
3604
+ const fractionKey = `${remainder}/${den}`;
3605
+ const vulgar = VULGAR_FRACTIONS[fractionKey];
3606
+ if (wholePart > 0) {
3607
+ return vulgar ? `${wholePart}${vulgar}` : `${wholePart} ${remainder}/${den}`;
3608
+ }
3609
+ return vulgar ?? `${num}/${den}`;
3610
+ }
3611
+ function formatNumericValue(value, useVulgar = true) {
3006
3612
  if (value.type === "decimal") {
3007
3613
  return String(value.decimal);
3008
3614
  }
3615
+ if (useVulgar) {
3616
+ return renderFractionAsVulgar(value.num, value.den);
3617
+ }
3009
3618
  return `${value.num}/${value.den}`;
3010
3619
  }
3011
3620
  function formatSingleValue(value) {
@@ -3071,6 +3680,7 @@ export {
3071
3680
  Section,
3072
3681
  ShoppingCart,
3073
3682
  ShoppingList,
3683
+ convertQuantityToSystem,
3074
3684
  formatExtendedQuantity,
3075
3685
  formatItemQuantity,
3076
3686
  formatNumericValue,
@@ -3082,11 +3692,15 @@ export {
3082
3692
  isAlternativeSelected,
3083
3693
  isAndGroup,
3084
3694
  isGroupedItem,
3085
- isSimpleGroup
3695
+ isSimpleGroup,
3696
+ renderFractionAsVulgar
3086
3697
  };
3087
3698
  /* v8 ignore else -- @preserve */
3699
+ /* v8 ignore next -- @preserve: defensive fallback for ambiguous units without toBaseBySystem */
3700
+ /* v8 ignore start -- @preserve: defensive fallback that shouldn't happen with valid inputs */
3088
3701
  // v8 ignore else -- @preserve
3089
- /* v8 ignore else -- expliciting error type -- @preserve */
3090
3702
  // v8 ignore if -- @preserve
3703
+ /* v8 ignore else -- expliciting error type -- @preserve */
3091
3704
  /* v8 ignore if -- @preserve */
3705
+ // v8 ignore next -- @preserve
3092
3706
  //# sourceMappingURL=index.js.map