@tmlmt/cooklang-parser 3.0.0-alpha.8 → 3.0.0-elpha.22

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
@@ -35,11 +35,29 @@ __export(index_exports, {
35
35
  CategoryConfig: () => CategoryConfig,
36
36
  NoProductCatalogForCartError: () => NoProductCatalogForCartError,
37
37
  NoShoppingListForCartError: () => NoShoppingListForCartError,
38
+ Pantry: () => Pantry,
38
39
  ProductCatalog: () => ProductCatalog,
39
40
  Recipe: () => Recipe,
40
41
  Section: () => Section,
41
42
  ShoppingCart: () => ShoppingCart,
42
- ShoppingList: () => ShoppingList
43
+ ShoppingList: () => ShoppingList,
44
+ convertQuantityToSystem: () => convertQuantityToSystem,
45
+ formatExtendedQuantity: () => formatExtendedQuantity,
46
+ formatItemQuantity: () => formatItemQuantity,
47
+ formatNumericValue: () => formatNumericValue,
48
+ formatQuantity: () => formatQuantity,
49
+ formatQuantityWithUnit: () => formatQuantityWithUnit,
50
+ formatSingleValue: () => formatSingleValue,
51
+ formatUnit: () => formatUnit,
52
+ getEffectiveChoices: () => getEffectiveChoices,
53
+ hasAlternatives: () => hasAlternatives,
54
+ isAlternativeSelected: () => isAlternativeSelected,
55
+ isAndGroup: () => isAndGroup,
56
+ isGroupedItem: () => isGroupedItem,
57
+ isSectionActive: () => isSectionActive,
58
+ isSimpleGroup: () => isSimpleGroup,
59
+ isStepActive: () => isStepActive,
60
+ renderFractionAsVulgar: () => renderFractionAsVulgar
43
61
  });
44
62
  module.exports = __toCommonJS(index_exports);
45
63
 
@@ -104,7 +122,7 @@ var CategoryConfig = class {
104
122
  }
105
123
  };
106
124
 
107
- // src/classes/product_catalog.ts
125
+ // src/classes/pantry.ts
108
126
  var import_smol_toml = __toESM(require("smol-toml"), 1);
109
127
 
110
128
  // node_modules/.pnpm/human-regex@2.2.0/node_modules/human-regex/dist/human-regex.esm.js
@@ -306,14 +324,19 @@ var i = (() => {
306
324
  })();
307
325
 
308
326
  // src/regex.ts
327
+ var metadataKeyRegex = /^([^:\n]+?):/gm;
328
+ var numericValueRegex = /^-?\d+(\.\d+)?$/;
329
+ var nestedMetaVarRegex = (varName) => new RegExp(
330
+ `^${varName}:\\s*\\r?\\n((?:[ ]+.+(?:\\r?\\n|$))+)`,
331
+ "m"
332
+ );
309
333
  var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
310
- var scalingMetaValueRegex = (varName) => d().startAnchor().literal(varName).literal(":").anyOf("\\t ").zeroOrMore().startCaptureGroup().startCaptureGroup().notAnyOf(",\\n").oneOrMore().endGroup().startGroup().literal(",").whitespace().zeroOrMore().startCaptureGroup().anyCharacter().oneOrMore().endGroup().endGroup().optional().endGroup().endAnchor().multiline().toRegExp();
311
334
  var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
312
335
  var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
313
336
  var ingredientWithAlternativeRegex = d().literal("@").startNamedGroup("ingredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("ingredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("mIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("sIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("ingredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("ingredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("ingredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().startGroup().literal("[").startNamedGroup("ingredientNote").notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().startNamedGroup("ingredientAlternative").startGroup().literal("|").startGroup().anyOf("@\\-&?").zeroOrMore().endGroup().optional().startGroup().literal("./").endGroup().optional().startGroup().startGroup().startGroup().notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startGroup().notAnyOf(nonWordChar).oneOrMore().endGroup().endGroup().startGroup().literal("{").startGroup().literal("=").exactly(1).endGroup().optional().startGroup().notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startGroup().notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().startGroup().literal("[").startGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().endGroup().zeroOrMore().endGroup().toRegExp();
314
337
  var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
315
- var quantityAlternativeRegex = d().startNamedGroup("quantity").notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("unit").notAnyOf("|}").oneOrMore().endGroup().endGroup().optional().startGroup().literal("|").startNamedGroup("alternative").startGroup().notAnyOf("}").oneOrMore().endGroup().zeroOrMore().endGroup().endGroup().optional().toRegExp();
316
- var ingredientWithGroupKeyRegex = d().literal("@|").startNamedGroup("gIngredientGroupKey").notAnyOf(nonWordCharStrict).oneOrMore().endGroup().literal("|").startNamedGroup("gIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("gIngredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("gmIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("gsIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("gIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("gIngredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("gIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
338
+ var quantityAlternativeRegex = d().startNamedGroup("quantity").notAnyOf("{}|%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("unit").notAnyOf("|}").oneOrMore().endGroup().endGroup().optional().startGroup().literal("|").startNamedGroup("alternative").startGroup().notAnyOf("}").oneOrMore().endGroup().zeroOrMore().endGroup().endGroup().optional().toRegExp();
339
+ var ingredientWithGroupKeyRegex = d().literal("@|").startNamedGroup("gIngredientGroupKey").notAnyOf(nonWordCharStrict + "/").oneOrMore().endGroup().startGroup().literal("/").startNamedGroup("gIngredientSubgroupKey").notAnyOf(nonWordCharStrict).oneOrMore().endGroup().endGroup().optional().literal("|").startNamedGroup("gIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("gIngredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("gmIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("gsIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("gIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("gIngredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("gIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().startGroup().literal("[").startNamedGroup("gIngredientNote").notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().toRegExp();
317
340
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
318
341
  var cookwareRegex = d().literal("#").startNamedGroup("cookwareModifiers").anyOf("\\-&?").zeroOrMore().endGroup().startGroup().startGroup().startNamedGroup("mCookwareName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\})").endGroup().or().startNamedGroup("sCookwareName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("cookwareQuantity").anyCharacter().zeroOrMore().lazy().endGroup().literal("}").endGroup().optional().toRegExp();
319
342
  var timerRegex = d().literal("~").startNamedGroup("timerName").anyCharacter().zeroOrMore().lazy().endGroup().literal("{").startNamedGroup("timerQuantity").anyCharacter().oneOrMore().lazy().endGroup().startGroup().literal("%").startNamedGroup("timerUnit").anyCharacter().oneOrMore().lazy().endGroup().endGroup().optional().literal("}").toRegExp();
@@ -328,12 +351,58 @@ var tokensRegex = new RegExp(
328
351
  ].map((r2) => r2.source).join("|"),
329
352
  "gu"
330
353
  );
354
+ var yieldPrefixPart = d().startAnchor().literal("yield").literal(":").anyOf(" ").zeroOrMore().startNamedGroup("servingsPrefix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().lazy().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().toRegExp();
355
+ var yieldSuffixPart = d().anyOf(" ").zeroOrMore().startNamedGroup("servingsSuffix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
356
+ var yieldMetaValueWithUnitRegex = new RegExp(
357
+ yieldPrefixPart.source + arbitraryScalableRegex.source + yieldSuffixPart.source,
358
+ "m"
359
+ );
360
+ var yieldMetaValueAsQuantityRegex = d().startAnchor().literal("yield:").anyOf(" ").zeroOrMore().startNamedGroup("quantity").notAnyOf("{}|%\\n\\r").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("unit").notAnyOf("\\n\\r|}").oneOrMore().endGroup().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
361
+ var yieldMetaValueRegex = new RegExp(
362
+ [
363
+ yieldMetaValueWithUnitRegex.source,
364
+ yieldMetaValueAsQuantityRegex.source
365
+ ].join("|"),
366
+ "m"
367
+ );
331
368
  var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
332
369
  var blockCommentRegex = d().literal("[-").anyCharacter().zeroOrMore().lazy().literal("-]").whitespace().zeroOrMore().global().toRegExp();
333
370
  var shoppingListRegex = d().literal("[").startNamedGroup("name").anyCharacter().oneOrMore().endGroup().literal("]").newline().startNamedGroup("items").anyCharacter().zeroOrMore().lazy().endGroup().startGroup().newline().newline().or().endAnchor().endGroup().global().toRegExp();
334
371
  var rangeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().literal("-").digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
335
372
  var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
336
373
  var floatRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
374
+ var variantTagRegex = d().startAnchor().literal("[").startNamedGroup("variantOptionalPrefix").literal("?").endGroup().optional().startNamedGroup("variantNames").notAnyOf("\\]").oneOrMore().endGroup().optional().literal("]").whitespace().zeroOrMore().toRegExp();
375
+ var mdEscaped = d().literal("\\").startCaptureGroup().anyOf("*_`").endGroup();
376
+ var mdInlineCode = d().literal("`").startCaptureGroup().notAnyOf("`").oneOrMore().lazy().endGroup().literal("`");
377
+ var mdLink = d().literal("[").startCaptureGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("](").startCaptureGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")");
378
+ var mdTripleAsterisk = d().literal("***").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("***");
379
+ var mdTripleUnderscore = d().literal("___").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("___");
380
+ var mdBoldAstItalicUnd = d().literal("**_").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("_**");
381
+ var mdBoldUndItalicAst = d().literal("__*").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("*__");
382
+ var mdItalicAstBoldUnd = d().literal("*__").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("__*");
383
+ var mdItalicUndBoldAst = d().literal("_**").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("**_");
384
+ var mdBoldAsterisk = d().literal("**").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("**");
385
+ var mdBoldUnderscore = d().wordBoundary().literal("__").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("__").wordBoundary();
386
+ var mdItalicAsterisk = d().literal("*").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("*");
387
+ var mdItalicUnderscore = d().wordBoundary().literal("_").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("_").wordBoundary();
388
+ var markdownRegex = new RegExp(
389
+ [
390
+ mdEscaped,
391
+ mdInlineCode,
392
+ mdLink,
393
+ mdTripleAsterisk,
394
+ mdTripleUnderscore,
395
+ mdBoldAstItalicUnd,
396
+ mdBoldUndItalicAst,
397
+ mdItalicAstBoldUnd,
398
+ mdItalicUndBoldAst,
399
+ mdBoldAsterisk,
400
+ mdBoldUnderscore,
401
+ mdItalicAsterisk,
402
+ mdItalicUnderscore
403
+ ].map((r2) => r2.toRegExp().source).join("|"),
404
+ "g"
405
+ );
337
406
 
338
407
  // src/units/definitions.ts
339
408
  var units = [
@@ -343,7 +412,8 @@ var units = [
343
412
  type: "mass",
344
413
  system: "metric",
345
414
  aliases: ["gram", "grams", "grammes"],
346
- toBase: 1
415
+ toBase: 1,
416
+ maxValue: 999
347
417
  },
348
418
  {
349
419
  name: "kg",
@@ -352,20 +422,28 @@ var units = [
352
422
  aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"],
353
423
  toBase: 1e3
354
424
  },
355
- // Mass (Imperial)
425
+ // Mass (US/UK - identical in both systems)
356
426
  {
357
427
  name: "oz",
358
428
  type: "mass",
359
- system: "imperial",
429
+ system: "ambiguous",
360
430
  aliases: ["ounce", "ounces"],
361
- toBase: 28.3495
431
+ toBase: 28.3495,
432
+ // default: US (same as UK)
433
+ toBaseBySystem: { US: 28.3495, UK: 28.3495 },
434
+ maxValue: 31,
435
+ // 16 oz = 1 lb, allow a bit more
436
+ fractions: { enabled: true, denominators: [2] }
362
437
  },
363
438
  {
364
439
  name: "lb",
365
440
  type: "mass",
366
- system: "imperial",
441
+ system: "ambiguous",
367
442
  aliases: ["pound", "pounds"],
368
- toBase: 453.592
443
+ toBase: 453.592,
444
+ // default: US (same as UK)
445
+ toBaseBySystem: { US: 453.592, UK: 453.592 },
446
+ fractions: { enabled: true, denominators: [2, 4] }
369
447
  },
370
448
  // Volume (Metric)
371
449
  {
@@ -373,21 +451,26 @@ var units = [
373
451
  type: "volume",
374
452
  system: "metric",
375
453
  aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"],
376
- toBase: 1
454
+ toBase: 1,
455
+ maxValue: 999
377
456
  },
378
457
  {
379
458
  name: "cl",
380
459
  type: "volume",
381
460
  system: "metric",
382
461
  aliases: ["centiliter", "centiliters", "centilitre", "centilitres"],
383
- toBase: 10
462
+ toBase: 10,
463
+ isBestUnit: false
464
+ // exists but not a "best" candidate
384
465
  },
385
466
  {
386
467
  name: "dl",
387
468
  type: "volume",
388
469
  system: "metric",
389
470
  aliases: ["deciliter", "deciliters", "decilitre", "decilitres"],
390
- toBase: 100
471
+ toBase: 100,
472
+ isBestUnit: false
473
+ // exists but not a "best" candidate
391
474
  },
392
475
  {
393
476
  name: "l",
@@ -396,55 +479,102 @@ var units = [
396
479
  aliases: ["liter", "liters", "litre", "litres"],
397
480
  toBase: 1e3
398
481
  },
482
+ // Volume (JP)
483
+ {
484
+ name: "go",
485
+ type: "volume",
486
+ system: "JP",
487
+ aliases: ["gou", "goo", "\u5408", "rice cup"],
488
+ toBase: 180,
489
+ maxValue: 10
490
+ },
491
+ // Volume (Ambiguous: metric/US/UK)
399
492
  {
400
493
  name: "tsp",
401
494
  type: "volume",
402
- system: "metric",
495
+ system: "ambiguous",
403
496
  aliases: ["teaspoon", "teaspoons"],
404
- toBase: 5
497
+ toBase: 5,
498
+ // default: metric
499
+ toBaseBySystem: { metric: 5, US: 4.929, UK: 5.919, JP: 5 },
500
+ maxValue: 5,
501
+ // 3 tsp = 1 tbsp (but allow a bit more)
502
+ fractions: { enabled: true, denominators: [2, 3, 4, 8] }
405
503
  },
406
504
  {
407
505
  name: "tbsp",
408
506
  type: "volume",
409
- system: "metric",
507
+ system: "ambiguous",
410
508
  aliases: ["tablespoon", "tablespoons"],
411
- toBase: 15
509
+ toBase: 15,
510
+ // default: metric
511
+ toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758, JP: 15 },
512
+ maxValue: 4,
513
+ // ~16 tbsp = 1 cup
514
+ fractions: { enabled: true }
412
515
  },
413
- // Volume (Imperial)
516
+ // Volume (Ambiguous: US/UK only)
414
517
  {
415
518
  name: "fl-oz",
416
519
  type: "volume",
417
- system: "imperial",
520
+ system: "ambiguous",
418
521
  aliases: ["fluid ounce", "fluid ounces"],
419
- toBase: 29.5735
522
+ toBase: 29.5735,
523
+ // default: US
524
+ toBaseBySystem: { US: 29.5735, UK: 28.4131 },
525
+ maxValue: 15,
526
+ // 8 fl-oz ~ 1 cup, allow more
527
+ fractions: { enabled: true, denominators: [2] }
420
528
  },
421
529
  {
422
530
  name: "cup",
423
531
  type: "volume",
424
- system: "imperial",
532
+ system: "ambiguous",
425
533
  aliases: ["cups"],
426
- toBase: 236.588
534
+ toBase: 236.588,
535
+ // default: US
536
+ toBaseBySystem: { US: 236.588, UK: 284.131 },
537
+ maxValue: 15,
538
+ // upgrade to gallons above 15 cups
539
+ fractions: { enabled: true }
427
540
  },
428
541
  {
429
542
  name: "pint",
430
543
  type: "volume",
431
- system: "imperial",
544
+ system: "ambiguous",
432
545
  aliases: ["pints"],
433
- toBase: 473.176
546
+ toBase: 473.176,
547
+ // default: US
548
+ toBaseBySystem: { US: 473.176, UK: 568.261 },
549
+ maxValue: 3,
550
+ // 2 pints = 1 quart
551
+ fractions: { enabled: true, denominators: [2] },
552
+ isBestUnit: false
553
+ // exists but not a "best" candidate
434
554
  },
435
555
  {
436
556
  name: "quart",
437
557
  type: "volume",
438
- system: "imperial",
558
+ system: "ambiguous",
439
559
  aliases: ["quarts"],
440
- toBase: 946.353
560
+ toBase: 946.353,
561
+ // default: US
562
+ toBaseBySystem: { US: 946.353, UK: 1136.52 },
563
+ maxValue: 3,
564
+ // 4 quarts = 1 gallon
565
+ fractions: { enabled: true, denominators: [2] },
566
+ isBestUnit: false
567
+ // exists but not a "best" candidate
441
568
  },
442
569
  {
443
570
  name: "gallon",
444
571
  type: "volume",
445
- system: "imperial",
572
+ system: "ambiguous",
446
573
  aliases: ["gallons"],
447
- toBase: 3785.41
574
+ toBase: 3785.41,
575
+ // default: US
576
+ toBaseBySystem: { US: 3785.41, UK: 4546.09 },
577
+ fractions: { enabled: true, denominators: [2] }
448
578
  },
449
579
  // Count units (no conversion, but recognized as a type)
450
580
  {
@@ -452,7 +582,8 @@ var units = [
452
582
  type: "count",
453
583
  system: "metric",
454
584
  aliases: ["pieces", "pc"],
455
- toBase: 1
585
+ toBase: 1,
586
+ maxValue: 999
456
587
  }
457
588
  ];
458
589
  var unitMap = /* @__PURE__ */ new Map();
@@ -476,8 +607,14 @@ function isNoUnit(unit) {
476
607
  return resolveUnit(unit.name).name === NO_UNIT;
477
608
  }
478
609
 
610
+ // src/units/conversion.ts
611
+ var import_big2 = __toESM(require("big.js"), 1);
612
+
479
613
  // src/quantities/numeric.ts
480
614
  var import_big = __toESM(require("big.js"), 1);
615
+ var DEFAULT_DENOMINATORS = [2, 3, 4];
616
+ var DEFAULT_FRACTION_ACCURACY = 0.05;
617
+ var DEFAULT_MAX_WHOLE = 4;
481
618
  function gcd(a2, b) {
482
619
  return b === 0 ? a2 : gcd(b, a2 % b);
483
620
  }
@@ -498,6 +635,41 @@ function simplifyFraction(num, den) {
498
635
  return { type: "fraction", num: simplifiedNum, den: simplifiedDen };
499
636
  }
500
637
  }
638
+ function approximateFraction(value, denominators = DEFAULT_DENOMINATORS, accuracy = DEFAULT_FRACTION_ACCURACY, maxWhole = DEFAULT_MAX_WHOLE) {
639
+ if (value <= 0 || !Number.isFinite(value)) {
640
+ return null;
641
+ }
642
+ const wholePart = Math.floor(value);
643
+ if (wholePart > maxWhole) {
644
+ return null;
645
+ }
646
+ const fractionalPart = value - wholePart;
647
+ if (fractionalPart < 1e-4) {
648
+ return null;
649
+ }
650
+ let bestFraction = null;
651
+ for (const den of denominators) {
652
+ const exactNum = value * den;
653
+ const roundedNum = Math.round(exactNum);
654
+ if (roundedNum === 0) continue;
655
+ const approximatedValue = roundedNum / den;
656
+ const relativeError = Math.abs(approximatedValue - value) / value;
657
+ if (relativeError <= accuracy) {
658
+ if (!bestFraction || relativeError < bestFraction.error) {
659
+ bestFraction = { num: roundedNum, den, error: relativeError };
660
+ }
661
+ }
662
+ }
663
+ if (!bestFraction) {
664
+ return null;
665
+ }
666
+ const commonDivisor = gcd(bestFraction.num, bestFraction.den);
667
+ return {
668
+ type: "fraction",
669
+ num: bestFraction.num / commonDivisor,
670
+ den: bestFraction.den / commonDivisor
671
+ };
672
+ }
501
673
  function getNumericValue(v) {
502
674
  if (v.type === "decimal") {
503
675
  return v.decimal;
@@ -546,9 +718,35 @@ function addNumericValues(val1, val2) {
546
718
  };
547
719
  }
548
720
  }
549
- var toRoundedDecimal = (v) => {
721
+ var toRoundedDecimal = (v, precision = 3) => {
550
722
  const value = v.type === "decimal" ? v.decimal : v.num / v.den;
551
- return { type: "decimal", decimal: Math.round(value * 1e3) / 1e3 };
723
+ if (value === 0) {
724
+ return { type: "decimal", decimal: 0 };
725
+ }
726
+ const absValue = Math.abs(value);
727
+ if (absValue >= 1e3) {
728
+ return { type: "decimal", decimal: Math.round(value) };
729
+ }
730
+ const magnitude = Math.floor(Math.log10(absValue));
731
+ const scale = Math.pow(10, precision - 1 - magnitude);
732
+ const rounded = Math.round(value * scale) / scale;
733
+ return { type: "decimal", decimal: rounded };
734
+ };
735
+ var formatOutputValue = (value, unitDef, precision = 3) => {
736
+ if (unitDef.fractions?.enabled) {
737
+ const denominators = unitDef.fractions.denominators ?? DEFAULT_DENOMINATORS;
738
+ const maxWhole = unitDef.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
739
+ const fraction = approximateFraction(
740
+ value,
741
+ denominators,
742
+ DEFAULT_FRACTION_ACCURACY,
743
+ maxWhole
744
+ );
745
+ if (fraction) {
746
+ return fraction;
747
+ }
748
+ }
749
+ return toRoundedDecimal({ type: "decimal", decimal: value }, precision);
552
750
  };
553
751
  function multiplyQuantityValue(value, factor) {
554
752
  if (value.type === "fixed") {
@@ -582,6 +780,143 @@ function getAverageValue(q) {
582
780
  }
583
781
  }
584
782
 
783
+ // src/units/compatibility.ts
784
+ function areUnitsGroupable(u1, u2) {
785
+ if (u1.name === u2.name) {
786
+ return true;
787
+ }
788
+ if (u1.type === "other" || u2.type === "other") {
789
+ return false;
790
+ }
791
+ if (u1.type === u2.type && u1.system === u2.system) {
792
+ return true;
793
+ }
794
+ if (u1.type === u2.type) {
795
+ if (u1.system === "ambiguous" && u2.system === "metric" && u1.toBaseBySystem?.metric !== void 0) {
796
+ return true;
797
+ }
798
+ if (u2.system === "ambiguous" && u1.system === "metric" && u2.toBaseBySystem?.metric !== void 0) {
799
+ return true;
800
+ }
801
+ }
802
+ return false;
803
+ }
804
+ function areUnitsConvertible(u1, u2) {
805
+ if (u1.name === u2.name) return true;
806
+ if (u1.type === "other" || u2.type === "other") return false;
807
+ return u1.type === u2.type;
808
+ }
809
+ function isUnitCompatibleWithSystem(unit, system) {
810
+ if (unit.system === system) return true;
811
+ if (unit.system === "ambiguous") {
812
+ if (unit.toBaseBySystem) {
813
+ return system in unit.toBaseBySystem;
814
+ }
815
+ if (system === "metric") return true;
816
+ }
817
+ if (unit.system === "metric" && system === "JP") {
818
+ return true;
819
+ }
820
+ return false;
821
+ }
822
+
823
+ // src/units/conversion.ts
824
+ var EPSILON = 0.01;
825
+ var DEFAULT_MAX_VALUE = 999;
826
+ function isCloseToInteger(value) {
827
+ return Math.abs(value - Math.round(value)) < EPSILON;
828
+ }
829
+ function getMaxValue(unit) {
830
+ return unit.maxValue ?? DEFAULT_MAX_VALUE;
831
+ }
832
+ function isValueInRange(value, unit) {
833
+ const maxValue = getMaxValue(unit);
834
+ if (value >= 1 && value <= maxValue) {
835
+ return true;
836
+ }
837
+ if (value > 0 && value < 1 && unit.fractions?.enabled) {
838
+ const denominators = unit.fractions.denominators ?? DEFAULT_DENOMINATORS;
839
+ const maxWhole = unit.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
840
+ const fraction = approximateFraction(
841
+ value,
842
+ denominators,
843
+ DEFAULT_FRACTION_ACCURACY,
844
+ maxWhole
845
+ );
846
+ return fraction !== null;
847
+ }
848
+ return false;
849
+ }
850
+ function findBestUnit(valueInBase, unitType, system, inputUnits) {
851
+ const inputUnitNames = new Set(inputUnits.map((u) => u.name));
852
+ const candidates = units.filter(
853
+ (u) => u.type === unitType && isUnitCompatibleWithSystem(u, system) && (u.isBestUnit !== false || inputUnitNames.has(u.name))
854
+ );
855
+ if (candidates.length === 0) {
856
+ const fallbackUnit = inputUnits[0];
857
+ return {
858
+ unit: fallbackUnit,
859
+ value: valueInBase / getToBase(fallbackUnit, system)
860
+ };
861
+ }
862
+ const candidatesWithValues = candidates.map((unit) => ({
863
+ unit,
864
+ value: valueInBase / getToBase(unit, system)
865
+ }));
866
+ const inRange = candidatesWithValues.filter(
867
+ (c) => isValueInRange(c.value, c.unit)
868
+ );
869
+ if (inRange.length > 0) {
870
+ const integersInInputFamily = inRange.filter(
871
+ (c) => isCloseToInteger(c.value) && inputUnitNames.has(c.unit.name)
872
+ );
873
+ if (integersInInputFamily.length > 0) {
874
+ return integersInInputFamily.sort((a2, b) => a2.value - b.value)[0];
875
+ }
876
+ const integersAny = inRange.filter((c) => isCloseToInteger(c.value));
877
+ if (integersAny.length > 0) {
878
+ return integersAny.sort((a2, b) => a2.value - b.value)[0];
879
+ }
880
+ return inRange.sort((a2, b) => {
881
+ const aInFamily = inputUnitNames.has(a2.unit.name) ? 0 : 1;
882
+ const bInFamily = inputUnitNames.has(b.unit.name) ? 0 : 1;
883
+ if (aInFamily !== bInFamily) return aInFamily - bInFamily;
884
+ return a2.value - b.value;
885
+ })[0];
886
+ }
887
+ return candidatesWithValues.sort((a2, b) => {
888
+ const aMaxValue = getMaxValue(a2.unit);
889
+ const bMaxValue = getMaxValue(b.unit);
890
+ const aDistance = a2.value < 1 ? 1 - a2.value : a2.value - aMaxValue;
891
+ const bDistance = b.value < 1 ? 1 - b.value : b.value - bMaxValue;
892
+ return aDistance - bDistance;
893
+ })[0];
894
+ }
895
+ function getUnitRatio(q1, q2) {
896
+ const q1Value = getAverageValue(q1.quantity);
897
+ const q2Value = getAverageValue(q2.quantity);
898
+ const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
899
+ if (typeof q1Value !== "number" || typeof q2Value !== "number") {
900
+ throw Error(
901
+ "One of both values is not a number, so a ratio cannot be computed"
902
+ );
903
+ }
904
+ return (0, import_big2.default)(q1Value).times(factor).div(q2Value);
905
+ }
906
+ function getBaseUnitRatio(q, qRef) {
907
+ if ("toBase" in q.unit && "toBase" in qRef.unit) {
908
+ return q.unit.toBase / qRef.unit.toBase;
909
+ } else {
910
+ return 1;
911
+ }
912
+ }
913
+ function getToBase(unit, system) {
914
+ if (unit.system === "ambiguous" && system && unit.toBaseBySystem) {
915
+ return unit.toBaseBySystem[system] ?? unit.toBase;
916
+ }
917
+ return unit.toBase;
918
+ }
919
+
585
920
  // src/errors.ts
586
921
  var ReferencedItemCannotBeRedefinedError = class extends Error {
587
922
  constructor(item_type, item_name, new_modifier) {
@@ -612,7 +947,7 @@ var NoProductMatchError = class extends Error {
612
947
  constructor(item_name, code) {
613
948
  const messageMap = {
614
949
  incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`,
615
- noProduct: "No product was found linked to ingredient name ${item_name} in the shopping list",
950
+ noProduct: `No product was found linked to ingredient name ${item_name} in the shopping list`,
616
951
  textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`,
617
952
  noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`,
618
953
  textValue_incompatibleUnits: `Multiple alternative quantities were provided for ingredient ${item_name} in the shopping list but they were either text values or no product in catalog were found to have compatible units`
@@ -651,20 +986,37 @@ var InvalidQuantityFormat = class extends Error {
651
986
  this.name = "InvalidQuantityFormat";
652
987
  }
653
988
  };
989
+ var NoTabAsIndentError = class extends Error {
990
+ constructor() {
991
+ super(
992
+ `Tabs are not allowed for indentation in metadata blocks. Please use spaces only.`
993
+ );
994
+ this.name = "NoTabAsIndentError";
995
+ }
996
+ };
997
+ var BadIndentationError = class extends Error {
998
+ constructor() {
999
+ super(`Bad identation of a nested block. Please use spaces only.`);
1000
+ this.name = "BadIndentationError";
1001
+ }
1002
+ };
654
1003
 
655
1004
  // src/utils/type_guards.ts
656
1005
  function isGroup(x) {
657
- return x && "type" in x;
1006
+ return "and" in x || "or" in x;
658
1007
  }
659
1008
  function isOrGroup(x) {
660
- return isGroup(x) && x.type === "or";
1009
+ return isGroup(x) && "or" in x;
661
1010
  }
662
1011
  function isAndGroup(x) {
663
- return isGroup(x) && x.type === "and";
1012
+ return "and" in x;
664
1013
  }
665
1014
  function isQuantity(x) {
666
1015
  return x && typeof x === "object" && "quantity" in x;
667
1016
  }
1017
+ function isSimpleGroup(entry) {
1018
+ return "quantity" in entry;
1019
+ }
668
1020
  function isNumericValueIntegerLike(v) {
669
1021
  if (v.type === "decimal") return Number.isInteger(v.decimal);
670
1022
  return v.num % v.den === 0;
@@ -676,37 +1028,32 @@ function isValueIntegerLike(q) {
676
1028
  }
677
1029
  return isNumericValueIntegerLike(q.min) && isNumericValueIntegerLike(q.max);
678
1030
  }
1031
+ function hasAlternatives(entry) {
1032
+ return "alternatives" in entry && Array.isArray(entry.alternatives) && entry.alternatives.length > 0;
1033
+ }
679
1034
 
680
1035
  // src/quantities/mutations.ts
681
- function extendAllUnits(q) {
682
- if (isGroup(q)) {
683
- return { ...q, entries: q.entries.map(extendAllUnits) };
684
- } else {
685
- const newQ = {
686
- quantity: q.quantity
687
- };
688
- if (q.unit) {
689
- newQ.unit = { name: q.unit };
690
- }
691
- return newQ;
692
- }
693
- }
694
1036
  function normalizeAllUnits(q) {
695
- if (isGroup(q)) {
696
- return { ...q, entries: q.entries.map(normalizeAllUnits) };
1037
+ if (isAndGroup(q)) {
1038
+ return { and: q.and.map(normalizeAllUnits) };
1039
+ } else if (isOrGroup(q)) {
1040
+ return { or: q.or.map(normalizeAllUnits) };
697
1041
  } else {
698
1042
  const newQ = {
699
1043
  quantity: q.quantity,
700
1044
  unit: resolveUnit(q.unit)
701
1045
  };
1046
+ if (q.equivalents && q.equivalents.length > 0) {
1047
+ const equivalentsNormalized = q.equivalents.map(
1048
+ (eq) => normalizeAllUnits(eq)
1049
+ );
1050
+ return {
1051
+ or: [newQ, ...equivalentsNormalized]
1052
+ };
1053
+ }
702
1054
  return newQ;
703
1055
  }
704
1056
  }
705
- var convertQuantityValue = (value, def, targetDef) => {
706
- if (def.name === targetDef.name) return value;
707
- const factor = def.toBase / targetDef.toBase;
708
- return multiplyQuantityValue(value, factor);
709
- };
710
1057
  function getDefaultQuantityValue() {
711
1058
  return { type: "fixed", value: { type: "decimal", decimal: 0 } };
712
1059
  }
@@ -733,7 +1080,7 @@ function addQuantityValues(v1, v2) {
733
1080
  );
734
1081
  return { type: "range", min: newMin, max: newMax };
735
1082
  }
736
- function addQuantities(q1, q2) {
1083
+ function addQuantities(q1, q2, system) {
737
1084
  const v1 = q1.quantity;
738
1085
  const v2 = q2.quantity;
739
1086
  if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
@@ -751,55 +1098,149 @@ function addQuantities(q1, q2) {
751
1098
  if ((q2.unit?.name === "" || q2.unit === void 0) && q1.unit !== void 0) {
752
1099
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
753
1100
  }
754
- if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
1101
+ if (!q1.unit && !q2.unit) {
1102
+ return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
1103
+ }
1104
+ if (q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
1105
+ if (unit1Def) {
1106
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unit1Def.system) ? unit1Def.system : "US");
1107
+ return addAndFindBestUnit(v1, v2, unit1Def, unit1Def, effectiveSystem, [
1108
+ unit1Def
1109
+ ]);
1110
+ }
755
1111
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
756
1112
  }
757
1113
  if (unit1Def && unit2Def) {
758
- if (unit1Def.type !== unit2Def.type) {
1114
+ if (!areUnitsConvertible(unit1Def, unit2Def)) {
759
1115
  throw new IncompatibleUnitsError(
760
1116
  `${unit1Def.type} (${q1.unit?.name})`,
761
1117
  `${unit2Def.type} (${q2.unit?.name})`
762
1118
  );
763
1119
  }
764
- let targetUnitDef;
765
- if (unit1Def.system !== unit2Def.system) {
766
- const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def;
767
- targetUnitDef = units.filter((u) => u.type === metricUnitDef.type && u.system === "metric").reduce(
768
- (prev, current) => prev.toBase > current.toBase ? prev : current
769
- );
770
- } else {
771
- targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def;
1120
+ let effectiveSystem = system;
1121
+ if (!effectiveSystem) {
1122
+ if (unit1Def.system === "metric" || unit2Def.system === "metric") {
1123
+ effectiveSystem = "metric";
1124
+ } else {
1125
+ if (unit1Def.system === "JP" && unit2Def.system === "JP") {
1126
+ effectiveSystem = "JP";
1127
+ } else {
1128
+ const unit1SupportsUS = unit1Def.system === "US" || unit1Def.system === "ambiguous" && unit1Def.toBaseBySystem && "US" in unit1Def.toBaseBySystem;
1129
+ const unit2SupportsUS = unit2Def.system === "US" || unit2Def.system === "ambiguous" && unit2Def.toBaseBySystem && "US" in unit2Def.toBaseBySystem;
1130
+ effectiveSystem = unit1SupportsUS && unit2SupportsUS ? "US" : "metric";
1131
+ }
1132
+ }
772
1133
  }
773
- const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);
774
- const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);
775
- const targetUnit = { name: targetUnitDef.name };
776
- return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit);
1134
+ return addAndFindBestUnit(v1, v2, unit1Def, unit2Def, effectiveSystem, [
1135
+ unit1Def,
1136
+ unit2Def
1137
+ ]);
777
1138
  }
778
1139
  throw new IncompatibleUnitsError(
779
1140
  q1.unit?.name,
780
1141
  q2.unit?.name
781
1142
  );
782
1143
  }
1144
+ function addAndFindBestUnit(v1, v2, unit1Def, unit2Def, system, inputUnits) {
1145
+ const toBase1 = getToBase(unit1Def, system);
1146
+ const toBase2 = getToBase(unit2Def, system);
1147
+ let sumInBase;
1148
+ if (v1.type === "fixed" && v2.type === "fixed") {
1149
+ const val1 = getNumericValue(v1.value);
1150
+ const val2 = getNumericValue(v2.value);
1151
+ sumInBase = val1 * toBase1 + val2 * toBase2;
1152
+ } else {
1153
+ const avg1 = getAverageValue(v1);
1154
+ const avg2 = getAverageValue(v2);
1155
+ sumInBase = avg1 * toBase1 + avg2 * toBase2;
1156
+ }
1157
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1158
+ sumInBase,
1159
+ unit1Def.type,
1160
+ system,
1161
+ inputUnits
1162
+ );
1163
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1164
+ if (v1.type === "range" || v2.type === "range") {
1165
+ const r1 = v1.type === "range" ? v1 : { type: "range", min: v1.value, max: v1.value };
1166
+ const r2 = v2.type === "range" ? v2 : { type: "range", min: v2.value, max: v2.value };
1167
+ const minInBase = getNumericValue(r1.min) * toBase1 + getNumericValue(r2.min) * toBase2;
1168
+ const maxInBase = getNumericValue(r1.max) * toBase1 + getNumericValue(r2.max) * toBase2;
1169
+ const bestToBase = getToBase(bestUnit, system);
1170
+ const minValue = minInBase / bestToBase;
1171
+ const maxValue = maxInBase / bestToBase;
1172
+ return {
1173
+ quantity: {
1174
+ type: "range",
1175
+ min: formatOutputValue(minValue, bestUnit),
1176
+ max: formatOutputValue(maxValue, bestUnit)
1177
+ },
1178
+ unit: { name: bestUnit.name }
1179
+ };
1180
+ }
1181
+ return {
1182
+ quantity: { type: "fixed", value: formattedValue },
1183
+ unit: { name: bestUnit.name }
1184
+ };
1185
+ }
1186
+ function convertQuantityToSystem(quantity, system) {
1187
+ const unitDef = resolveUnit(
1188
+ typeof quantity.unit === "string" ? quantity.unit : quantity.unit?.name
1189
+ );
1190
+ if (unitDef.type === "other" || !("toBase" in unitDef)) {
1191
+ return void 0;
1192
+ }
1193
+ const avgValue = getAverageValue(quantity.quantity);
1194
+ if (typeof avgValue !== "number") {
1195
+ return void 0;
1196
+ }
1197
+ const toBase = getToBase(unitDef, system);
1198
+ const valueInBase = avgValue * toBase;
1199
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1200
+ valueInBase,
1201
+ unitDef.type,
1202
+ system,
1203
+ [unitDef]
1204
+ );
1205
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1206
+ if (quantity.quantity.type === "range") {
1207
+ const bestToBase = getToBase(bestUnit, system);
1208
+ const minValue = getNumericValue(quantity.quantity.min) * toBase / bestToBase;
1209
+ const maxValue = getNumericValue(quantity.quantity.max) * toBase / bestToBase;
1210
+ return {
1211
+ quantity: {
1212
+ type: "range",
1213
+ min: formatOutputValue(minValue, bestUnit),
1214
+ max: formatOutputValue(maxValue, bestUnit)
1215
+ },
1216
+ unit: { name: bestUnit.name }
1217
+ };
1218
+ }
1219
+ return {
1220
+ quantity: { type: "fixed", value: formattedValue },
1221
+ unit: { name: bestUnit.name }
1222
+ };
1223
+ }
783
1224
  function toPlainUnit(quantity) {
784
1225
  if (isQuantity(quantity))
785
1226
  return quantity.unit ? { ...quantity, unit: quantity.unit.name } : quantity;
786
- else {
1227
+ else if (isOrGroup(quantity)) {
1228
+ return {
1229
+ or: quantity.or.map(toPlainUnit)
1230
+ };
1231
+ } else {
787
1232
  return {
788
- ...quantity,
789
- entries: quantity.entries.map(toPlainUnit)
1233
+ and: quantity.and.map(toPlainUnit)
790
1234
  };
791
1235
  }
792
1236
  }
793
1237
  function toExtendedUnit(q) {
794
1238
  if (isQuantity(q)) {
795
1239
  return q.unit ? { ...q, unit: { name: q.unit } } : q;
1240
+ } else if (isOrGroup(q)) {
1241
+ return { or: q.or.map(toExtendedUnit) };
796
1242
  } else {
797
- return {
798
- ...q,
799
- entries: q.entries.map(
800
- (entry) => isQuantity(entry) ? toExtendedUnit(entry) : toExtendedUnit(entry)
801
- )
802
- };
1243
+ return { and: q.and.map(toExtendedUnit) };
803
1244
  }
804
1245
  }
805
1246
  function deNormalizeQuantity(q) {
@@ -813,26 +1254,24 @@ function deNormalizeQuantity(q) {
813
1254
  }
814
1255
  var flattenPlainUnitGroup = (summed) => {
815
1256
  if (isOrGroup(summed)) {
816
- const entries = summed.entries;
1257
+ const entries = summed.or;
817
1258
  const andGroupEntry = entries.find(
818
- (e2) => isGroup(e2) && e2.type === "and"
1259
+ (e2) => isAndGroup(e2)
819
1260
  );
820
1261
  if (andGroupEntry) {
821
1262
  const andEntries = [];
822
- for (const entry of andGroupEntry.entries) {
823
- if (isQuantity(entry)) {
824
- andEntries.push({
825
- quantity: entry.quantity,
826
- unit: entry.unit
827
- });
828
- }
1263
+ const addGroupEntryContent = andGroupEntry.and;
1264
+ for (const entry of addGroupEntryContent) {
1265
+ andEntries.push({
1266
+ quantity: entry.quantity,
1267
+ ...entry.unit && { unit: entry.unit }
1268
+ });
829
1269
  }
830
1270
  const equivalentsList = entries.filter((e2) => isQuantity(e2)).map((e2) => ({ quantity: e2.quantity, unit: e2.unit }));
831
1271
  if (equivalentsList.length > 0) {
832
1272
  return [
833
1273
  {
834
- type: "and",
835
- entries: andEntries,
1274
+ and: andEntries,
836
1275
  equivalents: equivalentsList
837
1276
  }
838
1277
  ];
@@ -856,41 +1295,128 @@ var flattenPlainUnitGroup = (summed) => {
856
1295
  const first = entries[0];
857
1296
  return [{ quantity: first.quantity, unit: first.unit }];
858
1297
  }
859
- } else if (isGroup(summed)) {
1298
+ } else if (isAndGroup(summed)) {
860
1299
  const andEntries = [];
1300
+ const standaloneEntries = [];
861
1301
  const equivalentsList = [];
862
- for (const entry of summed.entries) {
1302
+ for (const entry of summed.and) {
863
1303
  if (isOrGroup(entry)) {
864
- const orEntries = entry.entries.filter(
865
- (e2) => isQuantity(e2)
866
- );
867
- if (orEntries.length > 0) {
1304
+ const orEntries = entry.or;
1305
+ const firstEntry = orEntries[0];
1306
+ if (isAndGroup(firstEntry)) {
1307
+ for (const nestedEntry of firstEntry.and) {
1308
+ andEntries.push({
1309
+ quantity: nestedEntry.quantity,
1310
+ ...nestedEntry.unit && { unit: nestedEntry.unit }
1311
+ });
1312
+ }
1313
+ } else {
1314
+ const primary = firstEntry;
868
1315
  andEntries.push({
869
- quantity: orEntries[0].quantity,
870
- unit: orEntries[0].unit
1316
+ quantity: primary.quantity,
1317
+ ...primary.unit && { unit: primary.unit }
871
1318
  });
872
- equivalentsList.push(...orEntries.slice(1));
873
1319
  }
874
- } else if (isQuantity(entry)) {
875
- andEntries.push({
876
- quantity: entry.quantity,
877
- unit: entry.unit
1320
+ const equivEntries = orEntries.slice(1).filter((e2) => isQuantity(e2));
1321
+ equivalentsList.push(
1322
+ ...equivEntries.map((e2) => ({
1323
+ quantity: e2.quantity,
1324
+ ...e2.unit && { unit: e2.unit }
1325
+ }))
1326
+ );
1327
+ } else {
1328
+ const simpleQuantityEntry = entry;
1329
+ standaloneEntries.push({
1330
+ quantity: simpleQuantityEntry.quantity,
1331
+ ...simpleQuantityEntry.unit && { unit: simpleQuantityEntry.unit }
878
1332
  });
879
1333
  }
880
1334
  }
881
1335
  if (equivalentsList.length === 0) {
882
- return andEntries;
1336
+ return [...andEntries, ...standaloneEntries];
883
1337
  }
884
- const result = {
885
- type: "and",
886
- entries: andEntries,
1338
+ const result = [];
1339
+ result.push({
1340
+ and: andEntries,
887
1341
  equivalents: equivalentsList
888
- };
889
- return [result];
1342
+ });
1343
+ result.push(...standaloneEntries);
1344
+ return result;
890
1345
  } else {
891
- return [{ quantity: summed.quantity, unit: summed.unit }];
1346
+ return [
1347
+ { quantity: summed.quantity, ...summed.unit && { unit: summed.unit } }
1348
+ ];
892
1349
  }
893
1350
  };
1351
+ function applyBestUnit(q, system) {
1352
+ const extended = { quantity: q.quantity };
1353
+ if (q.unit) {
1354
+ extended.unit = typeof q.unit === "string" ? { name: q.unit } : q.unit;
1355
+ }
1356
+ if (!extended.unit?.name) {
1357
+ return q;
1358
+ }
1359
+ const unitDef = resolveUnit(extended.unit.name);
1360
+ if (unitDef.type === "other") {
1361
+ return q;
1362
+ }
1363
+ if (extended.quantity.type === "fixed" && extended.quantity.value.type === "text") {
1364
+ return q;
1365
+ }
1366
+ const avgValue = getAverageValue(extended.quantity);
1367
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unitDef.system) ? unitDef.system : "US");
1368
+ const toBase = getToBase(unitDef, effectiveSystem);
1369
+ const valueInBase = avgValue * toBase;
1370
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1371
+ valueInBase,
1372
+ unitDef.type,
1373
+ effectiveSystem,
1374
+ [unitDef]
1375
+ );
1376
+ const originalCanonicalName = normalizeUnit(extended.unit.name)?.name;
1377
+ if (bestUnit.name === originalCanonicalName) {
1378
+ return q;
1379
+ }
1380
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1381
+ if (extended.quantity.type === "range") {
1382
+ const bestToBase = getToBase(bestUnit, effectiveSystem);
1383
+ const minValue = getNumericValue(extended.quantity.min) * toBase / bestToBase;
1384
+ const maxValue = getNumericValue(extended.quantity.max) * toBase / bestToBase;
1385
+ return {
1386
+ quantity: {
1387
+ type: "range",
1388
+ min: formatOutputValue(minValue, bestUnit),
1389
+ max: formatOutputValue(maxValue, bestUnit)
1390
+ },
1391
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1392
+ };
1393
+ }
1394
+ return {
1395
+ quantity: {
1396
+ type: "fixed",
1397
+ value: formattedValue
1398
+ },
1399
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1400
+ };
1401
+ }
1402
+ function subtractQuantities(q1, q2, options = {}) {
1403
+ const { clampToZero = true, system } = options;
1404
+ const negatedQ2 = {
1405
+ ...q2,
1406
+ quantity: multiplyQuantityValue(q2.quantity, -1)
1407
+ };
1408
+ const result = addQuantities(q1, negatedQ2, system);
1409
+ if (clampToZero) {
1410
+ const avg = getAverageValue(result.quantity);
1411
+ if (typeof avg === "number" && avg < 0) {
1412
+ return {
1413
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 0 } },
1414
+ unit: result.unit
1415
+ };
1416
+ }
1417
+ }
1418
+ return result;
1419
+ }
894
1420
 
895
1421
  // src/utils/parser_helpers.ts
896
1422
  function flushPendingNote(section, noteItems) {
@@ -900,9 +1426,12 @@ function flushPendingNote(section, noteItems) {
900
1426
  }
901
1427
  return noteItems;
902
1428
  }
903
- function flushPendingItems(section, items) {
1429
+ function flushPendingItems(section, items, stepVariants, stepOptional) {
904
1430
  if (items.length > 0) {
905
- section.content.push({ type: "step", items: [...items] });
1431
+ const step = { type: "step", items: [...items] };
1432
+ if (stepVariants) step.variants = stepVariants;
1433
+ if (stepOptional) step.optional = true;
1434
+ section.content.push(step);
906
1435
  items.length = 0;
907
1436
  return true;
908
1437
  }
@@ -1021,7 +1550,7 @@ function stringifyFixedValue(quantity) {
1021
1550
  return String(quantity.value.decimal);
1022
1551
  else return quantity.value.text;
1023
1552
  }
1024
- function parseQuantityInput(input_str) {
1553
+ function parseQuantityValue(input_str) {
1025
1554
  const clean_str = String(input_str).trim();
1026
1555
  if (rangeRegex.test(clean_str)) {
1027
1556
  const range_parts = clean_str.split("-");
@@ -1031,19 +1560,253 @@ function parseQuantityInput(input_str) {
1031
1560
  }
1032
1561
  return { type: "fixed", value: parseFixedValue(clean_str) };
1033
1562
  }
1563
+ function parseQuantityWithUnit(input) {
1564
+ const trimmed = input.trim();
1565
+ const separatorIndex = trimmed.indexOf("%");
1566
+ if (separatorIndex === -1) {
1567
+ return { value: parseQuantityValue(trimmed) };
1568
+ }
1569
+ const valuePart = trimmed.slice(0, separatorIndex).trim();
1570
+ const unitPart = trimmed.slice(separatorIndex + 1).trim();
1571
+ return {
1572
+ value: parseQuantityValue(valuePart),
1573
+ unit: unitPart || void 0
1574
+ };
1575
+ }
1576
+ function parseDateFromFormat(input, format) {
1577
+ const delimiterMatch = format.match(/[^A-Za-z]/);
1578
+ if (!delimiterMatch) {
1579
+ throw new Error(`Invalid date format: ${format}. No delimiter found.`);
1580
+ }
1581
+ const delimiter = delimiterMatch[0];
1582
+ const formatParts = format.split(delimiter);
1583
+ const inputParts = input.trim().split(delimiter);
1584
+ if (formatParts.length !== 3 || inputParts.length !== 3) {
1585
+ throw new Error(
1586
+ `Invalid date input "${input}" for format "${format}". Expected 3 parts.`
1587
+ );
1588
+ }
1589
+ let day = 0, month = 0, year = 0;
1590
+ for (let i2 = 0; i2 < 3; i2++) {
1591
+ const token = formatParts[i2].toUpperCase();
1592
+ const value = parseInt(inputParts[i2], 10);
1593
+ if (isNaN(value)) {
1594
+ throw new Error(
1595
+ `Invalid date input "${input}": non-numeric part "${inputParts[i2]}".`
1596
+ );
1597
+ }
1598
+ if (token === "DD") day = value;
1599
+ else if (token === "MM") month = value;
1600
+ else if (token === "YYYY") year = value;
1601
+ else
1602
+ throw new Error(
1603
+ `Unknown token "${formatParts[i2]}" in format "${format}"`
1604
+ );
1605
+ }
1606
+ const date = new Date(year, month - 1, day);
1607
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1608
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1609
+ }
1610
+ return date;
1611
+ }
1612
+ function disambiguateDayMonth(first, second, year) {
1613
+ if (second > 12 && first <= 12) {
1614
+ return [second, first, year];
1615
+ }
1616
+ return [first, second, year];
1617
+ }
1618
+ function parseFuzzyDate(input) {
1619
+ const trimmed = input.trim();
1620
+ const delimiterMatch = trimmed.match(/[./-]/);
1621
+ if (!delimiterMatch) {
1622
+ throw new Error(`Cannot parse date "${input}": no delimiter found.`);
1623
+ }
1624
+ const delimiter = delimiterMatch[0];
1625
+ const parts = trimmed.split(delimiter);
1626
+ if (parts.length !== 3) {
1627
+ throw new Error(
1628
+ `Cannot parse date "${input}": expected 3 parts, got ${parts.length}.`
1629
+ );
1630
+ }
1631
+ const nums = parts.map((p) => parseInt(p, 10));
1632
+ if (nums.some((n2) => isNaN(n2))) {
1633
+ throw new Error(`Cannot parse date "${input}": non-numeric parts found.`);
1634
+ }
1635
+ let day, month, year;
1636
+ if (nums[0] >= 1e3) {
1637
+ year = nums[0];
1638
+ month = nums[1];
1639
+ day = nums[2];
1640
+ } else if (nums[2] >= 1e3) {
1641
+ [day, month, year] = disambiguateDayMonth(nums[0], nums[1], nums[2]);
1642
+ } else {
1643
+ if (nums[2] >= 100)
1644
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1645
+ [day, month] = disambiguateDayMonth(nums[0], nums[1], 0);
1646
+ year = 2e3 + nums[2];
1647
+ }
1648
+ const date = new Date(year, month - 1, day);
1649
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1650
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1651
+ }
1652
+ return date;
1653
+ }
1654
+ function parseMarkdownSegments(text) {
1655
+ const items = [];
1656
+ let cursor = 0;
1657
+ for (const match of text.matchAll(markdownRegex)) {
1658
+ const idx = match.index;
1659
+ if (idx > cursor) {
1660
+ items.push({ type: "text", value: text.slice(cursor, idx) });
1661
+ }
1662
+ const [
1663
+ ,
1664
+ escaped,
1665
+ // group 1: escaped character
1666
+ code,
1667
+ // group 2: inline code
1668
+ linkText,
1669
+ // group 3: link text
1670
+ linkUrl,
1671
+ // group 4: link url
1672
+ tripleAst,
1673
+ // group 5: ***bold+italic***
1674
+ tripleUnd,
1675
+ // group 6: ___bold+italic___
1676
+ astUnd,
1677
+ // group 7: **_bold+italic_**
1678
+ undAst,
1679
+ // group 8: __*bold+italic*__
1680
+ astUndUnd,
1681
+ // group 9: *__bold+italic__*
1682
+ undAstAst,
1683
+ // group 10: _**bold+italic**_
1684
+ boldAst,
1685
+ // group 11: **bold**
1686
+ boldUnd,
1687
+ // group 12: __bold__
1688
+ italicAst,
1689
+ // group 13: *italic*
1690
+ italicUnd
1691
+ // group 14: _italic_
1692
+ ] = match;
1693
+ let value;
1694
+ let attribute;
1695
+ let href;
1696
+ if (escaped !== void 0) {
1697
+ items.push({ type: "text", value: escaped });
1698
+ cursor = idx + match[0].length;
1699
+ continue;
1700
+ } else if (code !== void 0) {
1701
+ value = code;
1702
+ attribute = "code";
1703
+ } else if (linkText !== void 0) {
1704
+ value = linkText;
1705
+ attribute = "link";
1706
+ href = linkUrl;
1707
+ } else if (tripleAst !== void 0 || tripleUnd !== void 0 || astUnd !== void 0 || undAst !== void 0 || astUndUnd !== void 0 || undAstAst !== void 0) {
1708
+ value = tripleAst ?? tripleUnd ?? astUnd ?? undAst ?? astUndUnd ?? undAstAst;
1709
+ attribute = "bold+italic";
1710
+ } else if (boldAst !== void 0 || boldUnd !== void 0) {
1711
+ value = boldAst ?? boldUnd;
1712
+ attribute = "bold";
1713
+ } else {
1714
+ value = italicAst ?? italicUnd;
1715
+ attribute = "italic";
1716
+ }
1717
+ const item = { type: "text", value };
1718
+ if (attribute) item.attribute = attribute;
1719
+ if (href) item.href = href;
1720
+ items.push(item);
1721
+ cursor = idx + match[0].length;
1722
+ }
1723
+ if (cursor < text.length) {
1724
+ items.push({ type: "text", value: text.slice(cursor) });
1725
+ }
1726
+ return items;
1727
+ }
1034
1728
  function parseSimpleMetaVar(content, varName) {
1035
1729
  const varMatch = content.match(
1036
1730
  new RegExp(`^${varName}:\\s*(.*(?:\\r?\\n\\s+.*)*)+`, "m")
1037
1731
  );
1038
1732
  return varMatch ? varMatch[1]?.trim().replace(/\s*\r?\n\s+/g, " ") : void 0;
1039
1733
  }
1040
- function parseScalingMetaVar(content, varName) {
1041
- const varMatch = content.match(scalingMetaValueRegex(varName));
1042
- if (!varMatch) return void 0;
1043
- if (isNaN(Number(varMatch[2]?.trim()))) {
1044
- throw new Error("Scaling variables should be numbers");
1734
+ function parseBlockScalarMetaVar(content, varName) {
1735
+ const match = content.match(
1736
+ new RegExp(
1737
+ `^${varName}:\\s*([|>])\\s*\\r?\\n((?:(?:[ ]+.*|\\s*)(?:\\r?\\n|$))+)`,
1738
+ "m"
1739
+ )
1740
+ );
1741
+ if (!match) return void 0;
1742
+ const style = match[1];
1743
+ const rawBlock = match[2];
1744
+ const lines = rawBlock.split(/\r?\n/);
1745
+ const firstNonEmpty = lines.find((l) => l.trim() !== "");
1746
+ if (!firstNonEmpty) return void 0;
1747
+ const baseIndent = firstNonEmpty.match(/^([ ]*)/)[1].length;
1748
+ const stripped = lines.map((line) => line.trim() === "" ? "" : line.slice(baseIndent)).join("\n").replace(/\n+$/, "");
1749
+ if (style === "|") {
1750
+ return stripped;
1751
+ }
1752
+ return stripped.replace(/\n\n/g, "\0").replace(/\n/g, " ").replace(/\0/g, "\n");
1753
+ }
1754
+ function parseArbitraryQuantity(raw) {
1755
+ const quantityMatch = raw.trim().match(quantityAlternativeRegex);
1756
+ if (!quantityMatch?.groups) {
1757
+ throw new InvalidQuantityFormat(
1758
+ raw,
1759
+ "Arbitrary quantities must have a numerical value"
1760
+ );
1761
+ }
1762
+ const value = parseQuantityValue(quantityMatch.groups.quantity);
1763
+ const unit = quantityMatch.groups.unit;
1764
+ if (!value || value.type === "fixed" && value.value.type === "text") {
1765
+ throw new InvalidQuantityFormat(
1766
+ raw,
1767
+ "Arbitrary quantities must have a numerical value"
1768
+ );
1769
+ }
1770
+ const arbitrary = {
1771
+ quantity: value
1772
+ };
1773
+ if (unit) arbitrary.unit = unit;
1774
+ return arbitrary;
1775
+ }
1776
+ function parseServingsMetaVar(content, varName) {
1777
+ const raw = parseSimpleMetaVar(content, varName);
1778
+ if (raw === void 0) return void 0;
1779
+ const num = Number(raw);
1780
+ if (isNaN(num)) {
1781
+ return { numericValue: 1, rawValue: raw };
1782
+ }
1783
+ return { numericValue: num, rawValue: num };
1784
+ }
1785
+ function parseYieldMetaVar(content) {
1786
+ const match = content.match(yieldMetaValueRegex);
1787
+ if (!match) return void 0;
1788
+ if (match.groups?.arbitraryQuantity) {
1789
+ const parsed = parseArbitraryQuantity(match.groups.arbitraryQuantity);
1790
+ const result = {
1791
+ quantity: parsed.quantity
1792
+ };
1793
+ if (parsed.unit) result.unit = parsed.unit;
1794
+ if (match.groups.servingsPrefix) {
1795
+ result.textBefore = match.groups.servingsPrefix;
1796
+ }
1797
+ if (match.groups.servingsSuffix) {
1798
+ result.textAfter = match.groups.servingsSuffix;
1799
+ }
1800
+ return result;
1801
+ }
1802
+ if (match.groups?.quantity) {
1803
+ const result = {
1804
+ quantity: parseQuantityValue(match.groups.quantity)
1805
+ };
1806
+ if (match.groups.unit) result.unit = match.groups.unit;
1807
+ return result;
1045
1808
  }
1046
- return [Number(varMatch[2]?.trim()), varMatch[1].trim()];
1809
+ return void 0;
1047
1810
  }
1048
1811
  function parseListMetaVar(content, varName) {
1049
1812
  const listMatch = content.match(
@@ -1059,6 +1822,115 @@ function parseListMetaVar(content, varName) {
1059
1822
  return listMatch[2].split("\n").filter((line) => line.trim() !== "").map((line) => line.replace(/^\s*-\s*/, "").trim());
1060
1823
  }
1061
1824
  }
1825
+ function extractAllMetadataKeys(content) {
1826
+ const keys = [];
1827
+ for (const match of content.matchAll(metadataKeyRegex)) {
1828
+ keys.push(match[1].trim());
1829
+ }
1830
+ return [...new Set(keys)];
1831
+ }
1832
+ function parseNestedMetaVar(content, varName) {
1833
+ const match = content.match(nestedMetaVarRegex(varName));
1834
+ if (!match) return void 0;
1835
+ const nestedContent = match[1];
1836
+ return parseNestedBlock(nestedContent);
1837
+ }
1838
+ function parseNestedBlock(content) {
1839
+ const lines = content.split(/\r?\n/).filter((line) => line.trim() !== "");
1840
+ if (lines.length === 0) return void 0;
1841
+ const baseIndentMatch = lines[0].match(/^(\s*)/);
1842
+ if (baseIndentMatch?.[0]?.includes(" ")) {
1843
+ throw new NoTabAsIndentError();
1844
+ }
1845
+ const baseIndent = baseIndentMatch?.[1]?.length;
1846
+ if (lines[0].trim().startsWith("- ")) return void 0;
1847
+ const result = {};
1848
+ let i2 = 0;
1849
+ while (i2 < lines.length) {
1850
+ const line = lines[i2];
1851
+ const leadingWhitespace = line.match(/^(\s*)/)?.[1];
1852
+ if (leadingWhitespace && leadingWhitespace.includes(" ")) {
1853
+ throw new NoTabAsIndentError();
1854
+ }
1855
+ const currentIndent = leadingWhitespace.length;
1856
+ if (currentIndent < baseIndent) {
1857
+ break;
1858
+ }
1859
+ if (currentIndent !== baseIndent) {
1860
+ throw new BadIndentationError();
1861
+ }
1862
+ const keyValueMatch = line.match(/^[ ]*([^:\n]+?):\s*(.*)$/);
1863
+ if (!keyValueMatch) {
1864
+ i2++;
1865
+ continue;
1866
+ }
1867
+ const key = keyValueMatch[1].trim();
1868
+ const rawValue = keyValueMatch[2].trim();
1869
+ if (rawValue === "") {
1870
+ const childLines = [];
1871
+ let j = i2 + 1;
1872
+ while (j < lines.length) {
1873
+ const childLine = lines[j];
1874
+ const childIndent = childLine.match(/^([ ]*)/)?.[1]?.length;
1875
+ if (childIndent && childIndent > baseIndent) {
1876
+ childLines.push(childLine);
1877
+ j++;
1878
+ } else {
1879
+ break;
1880
+ }
1881
+ }
1882
+ if (childLines.length > 0) {
1883
+ const firstChildTrimmed = childLines[0].trim();
1884
+ if (firstChildTrimmed.startsWith("- ")) {
1885
+ const reconstructedContent = `${key}:
1886
+ ${childLines.join("\n")}`;
1887
+ const listResult = parseListMetaVar(reconstructedContent, key);
1888
+ if (listResult) {
1889
+ result[key] = listResult.map(
1890
+ (item) => parseMetadataValue(item)
1891
+ );
1892
+ }
1893
+ } else {
1894
+ const childContent = childLines.join("\n");
1895
+ const nested = parseNestedBlock(childContent);
1896
+ if (nested) {
1897
+ result[key] = nested;
1898
+ }
1899
+ }
1900
+ }
1901
+ i2 = j;
1902
+ } else {
1903
+ result[key] = parseMetadataValue(rawValue);
1904
+ i2++;
1905
+ }
1906
+ }
1907
+ return result;
1908
+ }
1909
+ function parseMetadataValue(rawValue) {
1910
+ if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
1911
+ return rawValue.slice(1, -1).split(",").map((item) => item.trim());
1912
+ }
1913
+ if (numericValueRegex.test(rawValue)) {
1914
+ return Number(rawValue);
1915
+ }
1916
+ return rawValue;
1917
+ }
1918
+ function parseAnyMetaVar(content, varName) {
1919
+ const nested = parseNestedMetaVar(content, varName);
1920
+ if (nested) return nested;
1921
+ const list = parseListMetaVar(content, varName);
1922
+ if (list) return list;
1923
+ const simple = parseSimpleMetaVar(content, varName);
1924
+ if (simple) return parseMetadataValue(simple);
1925
+ return void 0;
1926
+ }
1927
+ function getNumericValueFromYield(v) {
1928
+ if (v.quantity.type === "fixed" && v.quantity.value.type !== "text") {
1929
+ return getNumericValue(v.quantity.value);
1930
+ }
1931
+ if (v.quantity.type === "range") return getNumericValue(v.quantity.min);
1932
+ return 1;
1933
+ }
1062
1934
  function extractMetadata(content) {
1063
1935
  const metadata = {};
1064
1936
  let servings = void 0;
@@ -1066,13 +1938,24 @@ function extractMetadata(content) {
1066
1938
  if (!metadataContent) {
1067
1939
  return { metadata };
1068
1940
  }
1069
- for (const metaVar of [
1941
+ const handledKeys = /* @__PURE__ */ new Set([
1942
+ // Simple string fields
1070
1943
  "title",
1944
+ "author",
1945
+ "locale",
1946
+ "introduction",
1947
+ "description",
1948
+ "course",
1949
+ "category",
1950
+ "diet",
1951
+ "cuisine",
1952
+ "difficulty",
1953
+ // Source fields
1071
1954
  "source",
1072
1955
  "source.name",
1073
1956
  "source.url",
1074
- "author",
1075
1957
  "source.author",
1958
+ // Time fields
1076
1959
  "prep time",
1077
1960
  "time.prep",
1078
1961
  "cook time",
@@ -1080,6 +1963,24 @@ function extractMetadata(content) {
1080
1963
  "time required",
1081
1964
  "time",
1082
1965
  "duration",
1966
+ // Image fields
1967
+ "image",
1968
+ "picture",
1969
+ "images",
1970
+ "pictures",
1971
+ // Unit system
1972
+ "unit system",
1973
+ // Scaling fields
1974
+ "servings",
1975
+ "yield",
1976
+ "serves",
1977
+ // List fields
1978
+ "tags",
1979
+ "variants"
1980
+ ]);
1981
+ for (const metaVar of [
1982
+ "title",
1983
+ "author",
1083
1984
  "locale",
1084
1985
  "introduction",
1085
1986
  "description",
@@ -1087,25 +1988,97 @@ function extractMetadata(content) {
1087
1988
  "category",
1088
1989
  "diet",
1089
1990
  "cuisine",
1090
- "difficulty",
1091
- "image",
1092
- "picture"
1991
+ "difficulty"
1093
1992
  ]) {
1993
+ if (metaVar === "description" || metaVar === "introduction") {
1994
+ const blockValue = parseBlockScalarMetaVar(metadataContent, metaVar);
1995
+ if (blockValue) {
1996
+ metadata[metaVar] = blockValue;
1997
+ continue;
1998
+ }
1999
+ }
1094
2000
  const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar);
1095
2001
  if (stringMetaValue) metadata[metaVar] = stringMetaValue;
1096
2002
  }
1097
- for (const metaVar of ["serves", "yield", "servings"]) {
1098
- const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1099
- if (scalingMetaValue && scalingMetaValue[1]) {
1100
- metadata[metaVar] = scalingMetaValue[1];
1101
- servings = scalingMetaValue[0];
2003
+ const sourceNested = parseNestedMetaVar(metadataContent, "source");
2004
+ const sourceTxt = parseSimpleMetaVar(metadataContent, "source");
2005
+ const sourceName = parseSimpleMetaVar(metadataContent, "source.name");
2006
+ const sourceUrl = parseSimpleMetaVar(metadataContent, "source.url");
2007
+ const sourceAuthor = parseSimpleMetaVar(metadataContent, "source.author");
2008
+ if (sourceNested) {
2009
+ const source = {};
2010
+ if (typeof sourceNested.name === "string") source.name = sourceNested.name;
2011
+ if (typeof sourceNested.url === "string") source.url = sourceNested.url;
2012
+ if (typeof sourceNested.author === "string")
2013
+ source.author = sourceNested.author;
2014
+ if (Object.keys(source).length > 0) metadata.source = source;
2015
+ } else if (sourceName || sourceAuthor || sourceUrl) {
2016
+ const source = {};
2017
+ if (sourceName) source.name = sourceName;
2018
+ if (sourceUrl) source.url = sourceUrl;
2019
+ if (sourceAuthor) source.author = sourceAuthor;
2020
+ metadata.source = source;
2021
+ } else if (sourceTxt) {
2022
+ metadata.source = sourceTxt;
2023
+ }
2024
+ const timeNested = parseNestedMetaVar(metadataContent, "time");
2025
+ const prepTime = parseSimpleMetaVar(metadataContent, "prep time") ?? parseSimpleMetaVar(metadataContent, "time.prep");
2026
+ const cookTime = parseSimpleMetaVar(metadataContent, "cook time") ?? parseSimpleMetaVar(metadataContent, "time.cook");
2027
+ const totalTime = parseSimpleMetaVar(metadataContent, "time required") ?? parseSimpleMetaVar(metadataContent, "time") ?? parseSimpleMetaVar(metadataContent, "duration");
2028
+ if (timeNested) {
2029
+ const time = {};
2030
+ if (typeof timeNested.prep === "string") time.prep = timeNested.prep;
2031
+ if (typeof timeNested.cook === "string") time.cook = timeNested.cook;
2032
+ if (typeof timeNested.total === "string") time.total = timeNested.total;
2033
+ if (Object.keys(time).length > 0) metadata.time = time;
2034
+ } else if (prepTime || cookTime || totalTime) {
2035
+ const time = {};
2036
+ if (prepTime) time.prep = prepTime;
2037
+ if (cookTime) time.cook = cookTime;
2038
+ if (totalTime) time.total = totalTime;
2039
+ metadata.time = time;
2040
+ }
2041
+ const image = parseSimpleMetaVar(metadataContent, "image") ?? parseSimpleMetaVar(metadataContent, "picture");
2042
+ if (image) metadata.image = image;
2043
+ const images = parseListMetaVar(metadataContent, "images") ?? parseListMetaVar(metadataContent, "pictures");
2044
+ if (images) metadata.images = images;
2045
+ let unitSystem;
2046
+ const unitSystemRaw = parseSimpleMetaVar(metadataContent, "unit system");
2047
+ if (unitSystemRaw) {
2048
+ metadata.unitSystem = unitSystemRaw;
2049
+ const unitSystemMap = {
2050
+ metric: "metric",
2051
+ us: "US",
2052
+ uk: "UK",
2053
+ jp: "JP"
2054
+ };
2055
+ unitSystem = unitSystemMap[unitSystemRaw.toLowerCase()];
2056
+ }
2057
+ const yieldValue = parseYieldMetaVar(metadataContent);
2058
+ if (yieldValue) {
2059
+ metadata.yield = yieldValue;
2060
+ servings = getNumericValueFromYield(yieldValue);
2061
+ }
2062
+ for (const metaVar of ["serves", "servings"]) {
2063
+ const result = parseServingsMetaVar(metadataContent, metaVar);
2064
+ if (result !== void 0) {
2065
+ metadata[metaVar] = result.rawValue;
2066
+ servings = result.numericValue;
1102
2067
  }
1103
2068
  }
1104
- for (const metaVar of ["tags", "images", "pictures"]) {
1105
- const listMetaValue = parseListMetaVar(metadataContent, metaVar);
1106
- if (listMetaValue) metadata[metaVar] = listMetaValue;
2069
+ const tags = parseListMetaVar(metadataContent, "tags");
2070
+ if (tags) metadata.tags = tags;
2071
+ const variants = parseListMetaVar(metadataContent, "variants");
2072
+ if (variants) metadata.variants = variants;
2073
+ const allKeys = extractAllMetadataKeys(metadataContent);
2074
+ for (const key of allKeys) {
2075
+ if (handledKeys.has(key)) continue;
2076
+ const value = parseAnyMetaVar(metadataContent, key);
2077
+ if (value !== void 0) {
2078
+ metadata[key] = value;
2079
+ }
1107
2080
  }
1108
- return { metadata, servings };
2081
+ return { metadata, servings, unitSystem };
1109
2082
  }
1110
2083
  function isPositiveIntegerString(str) {
1111
2084
  return /^\d+$/.test(str);
@@ -1119,10 +2092,230 @@ function unionOfSets(s1, s2) {
1119
2092
  }
1120
2093
  function getAlternativeSignature(alternatives) {
1121
2094
  if (!alternatives || alternatives.length === 0) return null;
1122
- return alternatives.map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
2095
+ return alternatives.flat().map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
1123
2096
  }
1124
2097
 
2098
+ // src/classes/pantry.ts
2099
+ var Pantry = class {
2100
+ /**
2101
+ * Creates a new Pantry instance.
2102
+ * @param tomlContent - Optional TOML content to parse.
2103
+ * @param options - Optional configuration options.
2104
+ */
2105
+ constructor(tomlContent, options = {}) {
2106
+ /**
2107
+ * The parsed pantry items.
2108
+ */
2109
+ __publicField(this, "items", []);
2110
+ /**
2111
+ * Options for date parsing and other configuration.
2112
+ */
2113
+ __publicField(this, "options");
2114
+ /**
2115
+ * Optional category configuration for alias-based lookups.
2116
+ */
2117
+ __publicField(this, "categoryConfig");
2118
+ this.options = options;
2119
+ if (tomlContent) {
2120
+ this.parse(tomlContent);
2121
+ }
2122
+ }
2123
+ /**
2124
+ * Parses a TOML string into pantry items.
2125
+ * @param tomlContent - The TOML string to parse.
2126
+ * @returns The parsed list of pantry items.
2127
+ */
2128
+ parse(tomlContent) {
2129
+ const raw = import_smol_toml.default.parse(tomlContent);
2130
+ this.items = [];
2131
+ for (const [location, locationData] of Object.entries(raw)) {
2132
+ const locationTable = locationData;
2133
+ for (const [itemName, itemData] of Object.entries(locationTable)) {
2134
+ const item = this.parseItem(
2135
+ itemName,
2136
+ location,
2137
+ itemData
2138
+ );
2139
+ this.items.push(item);
2140
+ }
2141
+ }
2142
+ return this.items;
2143
+ }
2144
+ /**
2145
+ * Parses a single pantry item from its TOML representation.
2146
+ */
2147
+ parseItem(name, location, data) {
2148
+ const item = { name, location };
2149
+ if (typeof data === "string") {
2150
+ const parsed = parseQuantityWithUnit(data);
2151
+ item.quantity = parsed.value;
2152
+ if (parsed.unit) item.unit = parsed.unit;
2153
+ } else {
2154
+ if (data.quantity) {
2155
+ const parsed = parseQuantityWithUnit(data.quantity);
2156
+ item.quantity = parsed.value;
2157
+ if (parsed.unit) item.unit = parsed.unit;
2158
+ }
2159
+ if (data.low) {
2160
+ const parsed = parseQuantityWithUnit(data.low);
2161
+ item.low = parsed.value;
2162
+ if (parsed.unit) item.lowUnit = parsed.unit;
2163
+ }
2164
+ if (data.bought) {
2165
+ item.bought = this.parseDate(data.bought);
2166
+ }
2167
+ if (data.expire) {
2168
+ item.expire = this.parseDate(data.expire);
2169
+ }
2170
+ }
2171
+ return item;
2172
+ }
2173
+ /**
2174
+ * Parses a date string using the configured format or fuzzy detection.
2175
+ */
2176
+ parseDate(input) {
2177
+ if (this.options.dateFormat) {
2178
+ return parseDateFromFormat(input, this.options.dateFormat);
2179
+ }
2180
+ return parseFuzzyDate(input);
2181
+ }
2182
+ /**
2183
+ * Sets a category configuration for alias-based item lookups.
2184
+ * @param config - The category configuration to use.
2185
+ */
2186
+ setCategoryConfig(config) {
2187
+ this.categoryConfig = config;
2188
+ }
2189
+ /**
2190
+ * Finds a pantry item by name, using exact match first, then alias lookup
2191
+ * via the stored CategoryConfig.
2192
+ * @param name - The name to search for.
2193
+ * @returns The matching pantry item, or undefined if not found.
2194
+ */
2195
+ findItem(name) {
2196
+ const lowerName = name.toLowerCase();
2197
+ const exact = this.items.find(
2198
+ (item) => item.name.toLowerCase() === lowerName
2199
+ );
2200
+ if (exact) return exact;
2201
+ if (this.categoryConfig) {
2202
+ for (const category of this.categoryConfig.categories) {
2203
+ for (const catIngredient of category.ingredients) {
2204
+ if (catIngredient.aliases.some(
2205
+ (alias) => alias.toLowerCase() === lowerName
2206
+ )) {
2207
+ const canonicalName = catIngredient.name.toLowerCase();
2208
+ const byCanonical = this.items.find(
2209
+ (item) => item.name.toLowerCase() === canonicalName
2210
+ );
2211
+ if (byCanonical) return byCanonical;
2212
+ for (const alias of catIngredient.aliases) {
2213
+ const byAlias = this.items.find(
2214
+ (item) => item.name.toLowerCase() === alias.toLowerCase()
2215
+ );
2216
+ if (byAlias) return byAlias;
2217
+ }
2218
+ }
2219
+ }
2220
+ }
2221
+ }
2222
+ return void 0;
2223
+ }
2224
+ /**
2225
+ * Gets the numeric value of a pantry item's quantity, optionally converted to base units.
2226
+ * Returns undefined if the quantity has a text value or is not set.
2227
+ */
2228
+ getItemNumericValue(quantity, unit) {
2229
+ if (!quantity) return void 0;
2230
+ let numericValue;
2231
+ if (quantity.type === "fixed") {
2232
+ if (quantity.value.type === "text") return void 0;
2233
+ numericValue = getNumericValue(quantity.value);
2234
+ } else {
2235
+ numericValue = (getNumericValue(quantity.min) + getNumericValue(quantity.max)) / 2;
2236
+ }
2237
+ if (unit) {
2238
+ const unitDef = normalizeUnit(unit);
2239
+ if (unitDef) {
2240
+ const toBase = getToBase(unitDef);
2241
+ numericValue *= toBase;
2242
+ }
2243
+ }
2244
+ return numericValue;
2245
+ }
2246
+ /**
2247
+ * Returns all items that are depleted (quantity = 0) or below their low threshold.
2248
+ * @returns An array of depleted pantry items.
2249
+ */
2250
+ getDepletedItems() {
2251
+ return this.items.filter((item) => this.isItemLow(item));
2252
+ }
2253
+ /**
2254
+ * Returns all items whose expiration date is within `nbDays` days from today
2255
+ * (or already passed).
2256
+ * @param nbDays - Number of days ahead to check. Defaults to 0 (already expired).
2257
+ * @returns An array of expired pantry items.
2258
+ */
2259
+ getExpiredItems(nbDays = 0) {
2260
+ return this.items.filter((item) => this.isItemExpired(item, nbDays));
2261
+ }
2262
+ /**
2263
+ * Checks if a specific item is low (quantity = 0 or below `low` threshold).
2264
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2265
+ * @returns true if the item is low, false otherwise. Returns false if item not found.
2266
+ */
2267
+ isLow(itemName) {
2268
+ const item = this.findItem(itemName);
2269
+ if (!item) return false;
2270
+ return this.isItemLow(item);
2271
+ }
2272
+ /**
2273
+ * Checks if a specific item is expired or expires within `nbDays` days.
2274
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2275
+ * @param nbDays - Number of days ahead to check. Defaults to 0.
2276
+ * @returns true if the item is expired, false otherwise. Returns false if item not found.
2277
+ */
2278
+ isExpired(itemName, nbDays = 0) {
2279
+ const item = this.findItem(itemName);
2280
+ if (!item) return false;
2281
+ return this.isItemExpired(item, nbDays);
2282
+ }
2283
+ /**
2284
+ * Internal: checks if a pantry item is low.
2285
+ */
2286
+ isItemLow(item) {
2287
+ if (!item.quantity) return false;
2288
+ const qtyValue = this.getItemNumericValue(item.quantity, item.unit);
2289
+ if (qtyValue === void 0) return false;
2290
+ if (qtyValue === 0) return true;
2291
+ if (item.low) {
2292
+ const lowValue = this.getItemNumericValue(item.low, item.lowUnit);
2293
+ if (lowValue !== void 0 && qtyValue <= lowValue) return true;
2294
+ }
2295
+ return false;
2296
+ }
2297
+ /**
2298
+ * Internal: checks if a pantry item is expired.
2299
+ */
2300
+ isItemExpired(item, nbDays) {
2301
+ if (!item.expire) return false;
2302
+ const now = /* @__PURE__ */ new Date();
2303
+ const cutoff = new Date(
2304
+ now.getFullYear(),
2305
+ now.getMonth(),
2306
+ now.getDate() + nbDays
2307
+ );
2308
+ const expireDay = new Date(
2309
+ item.expire.getFullYear(),
2310
+ item.expire.getMonth(),
2311
+ item.expire.getDate()
2312
+ );
2313
+ return expireDay <= cutoff;
2314
+ }
2315
+ };
2316
+
1125
2317
  // src/classes/product_catalog.ts
2318
+ var import_smol_toml2 = __toESM(require("smol-toml"), 1);
1126
2319
  var ProductCatalog = class {
1127
2320
  constructor(tomlContent) {
1128
2321
  __publicField(this, "products", []);
@@ -1134,7 +2327,7 @@ var ProductCatalog = class {
1134
2327
  * @returns A parsed list of `ProductOption`.
1135
2328
  */
1136
2329
  parse(tomlContent) {
1137
- const catalogRaw = import_smol_toml.default.parse(tomlContent);
2330
+ const catalogRaw = import_smol_toml2.default.parse(tomlContent);
1138
2331
  this.products = [];
1139
2332
  if (!this.isValidTomlContent(catalogRaw)) {
1140
2333
  throw new InvalidProductCatalogFormat();
@@ -1151,7 +2344,7 @@ var ProductCatalog = class {
1151
2344
  const sizeStrings = Array.isArray(size) ? size : [size];
1152
2345
  const sizes = sizeStrings.map((sizeStr) => {
1153
2346
  const sizeAndUnitRaw = sizeStr.split("%");
1154
- const sizeParsed = parseQuantityInput(
2347
+ const sizeParsed = parseQuantityValue(
1155
2348
  sizeAndUnitRaw[0]
1156
2349
  );
1157
2350
  const productSize = { size: sizeParsed };
@@ -1207,7 +2400,7 @@ var ProductCatalog = class {
1207
2400
  size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
1208
2401
  };
1209
2402
  }
1210
- return import_smol_toml.default.stringify(grouped);
2403
+ return import_smol_toml2.default.stringify(grouped);
1211
2404
  }
1212
2405
  /**
1213
2406
  * Adds a product to the catalog.
@@ -1264,8 +2457,10 @@ var Section = class {
1264
2457
  /**
1265
2458
  * Creates an instance of Section.
1266
2459
  * @param name - The name of the section. Defaults to an empty string.
2460
+ * @param variants - Optional variant names for this section.
2461
+ * @param optional - Whether the section is optional.
1267
2462
  */
1268
- constructor(name = "") {
2463
+ constructor(name = "", variants, optional) {
1269
2464
  /**
1270
2465
  * The name of the section. Can be an empty string for the default (first) section.
1271
2466
  * @defaultValue `""`
@@ -1273,7 +2468,13 @@ var Section = class {
1273
2468
  __publicField(this, "name");
1274
2469
  /** An array of steps and notes that make up the content of the section. */
1275
2470
  __publicField(this, "content", []);
2471
+ /** Optional list of variant names this section belongs to. */
2472
+ __publicField(this, "variants");
2473
+ /** Whether the section has been marked as optional ([?]) */
2474
+ __publicField(this, "optional");
1276
2475
  this.name = name;
2476
+ if (variants) this.variants = variants;
2477
+ if (optional) this.optional = true;
1277
2478
  }
1278
2479
  /**
1279
2480
  * Checks if the section is blank (has no name and no content).
@@ -1286,46 +2487,16 @@ var Section = class {
1286
2487
  };
1287
2488
 
1288
2489
  // src/quantities/alternatives.ts
1289
- var import_big3 = __toESM(require("big.js"), 1);
1290
-
1291
- // src/units/conversion.ts
1292
- var import_big2 = __toESM(require("big.js"), 1);
1293
- function getUnitRatio(q1, q2) {
1294
- const q1Value = getAverageValue(q1.quantity);
1295
- const q2Value = getAverageValue(q2.quantity);
1296
- const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
1297
- if (typeof q1Value !== "number" || typeof q2Value !== "number") {
1298
- throw Error(
1299
- "One of both values is not a number, so a ratio cannot be computed"
1300
- );
1301
- }
1302
- return (0, import_big2.default)(q1Value).times(factor).div(q2Value);
1303
- }
1304
- function getBaseUnitRatio(q, qRef) {
1305
- if ("toBase" in q.unit && "toBase" in qRef.unit) {
1306
- return q.unit.toBase / qRef.unit.toBase;
1307
- } else {
1308
- return 1;
1309
- }
1310
- }
2490
+ var import_big4 = __toESM(require("big.js"), 1);
1311
2491
 
1312
2492
  // src/units/lookup.ts
1313
- function areUnitsCompatible(u1, u2) {
1314
- if (u1.name === u2.name) {
1315
- return true;
1316
- }
1317
- if (u1.type !== "other" && u1.type === u2.type && u1.system === u2.system) {
1318
- return true;
1319
- }
1320
- return false;
1321
- }
1322
2493
  function findListWithCompatibleQuantity(list, quantity) {
1323
2494
  const quantityWithUnitDef = {
1324
2495
  ...quantity,
1325
2496
  unit: resolveUnit(quantity.unit?.name)
1326
2497
  };
1327
2498
  return list.find(
1328
- (l) => l.some((lq) => areUnitsCompatible(lq.unit, quantityWithUnitDef.unit))
2499
+ (l) => l.some((lq) => areUnitsGroupable(lq.unit, quantityWithUnitDef.unit))
1329
2500
  );
1330
2501
  }
1331
2502
  function findCompatibleQuantityWithinList(list, quantity) {
@@ -1339,10 +2510,14 @@ function findCompatibleQuantityWithinList(list, quantity) {
1339
2510
  }
1340
2511
 
1341
2512
  // src/utils/general.ts
2513
+ var import_big3 = __toESM(require("big.js"), 1);
1342
2514
  var legacyDeepClone = (v) => {
1343
2515
  if (v === null || typeof v !== "object") {
1344
2516
  return v;
1345
2517
  }
2518
+ if (v instanceof import_big3.default) {
2519
+ return new import_big3.default(v);
2520
+ }
1346
2521
  if (v instanceof Map) {
1347
2522
  return new Map(
1348
2523
  Array.from(v.entries()).map(([k, val]) => [
@@ -1352,7 +2527,9 @@ var legacyDeepClone = (v) => {
1352
2527
  );
1353
2528
  }
1354
2529
  if (v instanceof Set) {
1355
- return new Set(Array.from(v).map((val) => legacyDeepClone(val)));
2530
+ return new Set(
2531
+ Array.from(v).map((val) => legacyDeepClone(val))
2532
+ );
1356
2533
  }
1357
2534
  if (v instanceof Date) {
1358
2535
  return new Date(v.getTime());
@@ -1366,27 +2543,24 @@ var legacyDeepClone = (v) => {
1366
2543
  }
1367
2544
  return cloned;
1368
2545
  };
1369
- var deepClone = (v) => typeof structuredClone === "function" ? structuredClone(v) : legacyDeepClone(v);
2546
+ var deepClone = (v) => legacyDeepClone(v);
1370
2547
 
1371
2548
  // src/quantities/alternatives.ts
1372
2549
  function getEquivalentUnitsLists(...quantities) {
1373
2550
  const quantitiesCopy = deepClone(quantities);
1374
- const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.entries.length > 1);
2551
+ const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.or.length > 1);
1375
2552
  const unitLists = [];
1376
2553
  const normalizeOrGroup = (og) => ({
1377
- ...og,
1378
- entries: og.entries.map((q) => ({
2554
+ or: og.or.map((q) => ({
1379
2555
  ...q,
1380
2556
  unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
1381
2557
  }))
1382
2558
  });
1383
2559
  function findLinkIndexForUnits(lists, unitsToCheck) {
1384
2560
  return lists.findIndex((l) => {
1385
- const listItem = l.map((q) => resolveUnit(q.unit?.name));
2561
+ const listItems = l.map((q) => resolveUnit(q.unit?.name));
1386
2562
  return unitsToCheck.some(
1387
- (u) => listItem.some(
1388
- (lu) => lu.name === u?.name || lu.system === u?.system && lu.type === u?.type && lu.type !== "other"
1389
- )
2563
+ (u) => u && listItems.some((lu) => areUnitsGroupable(lu, u))
1390
2564
  );
1391
2565
  });
1392
2566
  }
@@ -1397,17 +2571,19 @@ function getEquivalentUnitsLists(...quantities) {
1397
2571
  ...v,
1398
2572
  unit: resolveUnit(v.unit?.name, v.unit?.integerProtected)
1399
2573
  };
1400
- const commonQuantity = og.entries.find(
1401
- (q) => isQuantity(q) && areUnitsCompatible(q.unit, normalizedV.unit)
2574
+ const commonQuantity = og.or.find(
2575
+ (q) => isQuantity(q) && areUnitsGroupable(q.unit, normalizedV.unit)
1402
2576
  );
1403
2577
  if (commonQuantity) {
1404
2578
  acc.push(normalizedV);
1405
- unitRatio = getUnitRatio(normalizedV, commonQuantity);
2579
+ if (!unitRatio) {
2580
+ unitRatio = getUnitRatio(normalizedV, commonQuantity);
2581
+ }
1406
2582
  }
1407
2583
  return acc;
1408
2584
  }, []);
1409
- for (const newQ of og.entries) {
1410
- if (commonUnitList.some((q) => areUnitsCompatible(q.unit, newQ.unit))) {
2585
+ for (const newQ of og.or) {
2586
+ if (commonUnitList.some((q) => areUnitsGroupable(q.unit, newQ.unit))) {
1411
2587
  continue;
1412
2588
  } else {
1413
2589
  const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio);
@@ -1417,10 +2593,10 @@ function getEquivalentUnitsLists(...quantities) {
1417
2593
  }
1418
2594
  for (const orGroup of OrGroups) {
1419
2595
  const orGroupModified = normalizeOrGroup(orGroup);
1420
- const units2 = orGroupModified.entries.map((q) => q.unit);
2596
+ const units2 = orGroupModified.or.map((q) => q.unit);
1421
2597
  const linkIndex = findLinkIndexForUnits(unitLists, units2);
1422
2598
  if (linkIndex === -1) {
1423
- unitLists.push(orGroupModified.entries);
2599
+ unitLists.push(orGroupModified.or);
1424
2600
  } else {
1425
2601
  mergeOrGroupIntoList(unitLists, linkIndex, orGroupModified);
1426
2602
  }
@@ -1516,7 +2692,7 @@ function reduceOrsToFirstEquivalent(unitList, quantities) {
1516
2692
  return quantities.map((q) => {
1517
2693
  if (isQuantity(q)) return reduceToQuantity(q);
1518
2694
  const qListModified = sortUnitList(
1519
- q.entries.map((qq) => ({
2695
+ q.or.map((qq) => ({
1520
2696
  ...qq,
1521
2697
  unit: resolveUnit(qq.unit?.name, qq.unit?.integerProtected)
1522
2698
  }))
@@ -1524,7 +2700,7 @@ function reduceOrsToFirstEquivalent(unitList, quantities) {
1524
2700
  return reduceToQuantity(qListModified[0]);
1525
2701
  });
1526
2702
  }
1527
- function addQuantitiesOrGroups(...quantities) {
2703
+ function addQuantitiesOrGroups(quantities, system) {
1528
2704
  if (quantities.length === 0)
1529
2705
  return {
1530
2706
  sum: {
@@ -1554,7 +2730,7 @@ function addQuantitiesOrGroups(...quantities) {
1554
2730
  unit: resolveUnit(nextQ.unit?.name)
1555
2731
  });
1556
2732
  } else {
1557
- const sumQ = addQuantities(existingQ, nextQ);
2733
+ const sumQ = addQuantities(existingQ, nextQ, system);
1558
2734
  existingQ.quantity = sumQ.quantity;
1559
2735
  existingQ.unit = resolveUnit(sumQ.unit?.name);
1560
2736
  }
@@ -1562,10 +2738,10 @@ function addQuantitiesOrGroups(...quantities) {
1562
2738
  if (sum.length === 1) {
1563
2739
  return { sum: sum[0], unitsLists };
1564
2740
  }
1565
- return { sum: { type: "and", entries: sum }, unitsLists };
2741
+ return { sum: { and: sum }, unitsLists };
1566
2742
  }
1567
- function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1568
- const sumQuantities = isGroup(sum) ? sum.entries : [sum];
2743
+ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists, system) {
2744
+ const sumQuantities = isAndGroup(sum) ? sum.and : [sum];
1569
2745
  const result = [];
1570
2746
  const processedQuantities = /* @__PURE__ */ new Set();
1571
2747
  for (const list of unitsLists) {
@@ -1592,10 +2768,20 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1592
2768
  }
1593
2769
  return main.reduce((acc, v) => {
1594
2770
  const mainInList = findCompatibleQuantityWithinList(list, v);
2771
+ const conversionRatio = getBaseUnitRatio(v, mainInList);
2772
+ const valueInOriginalUnit = (0, import_big4.default)(getAverageValue(v.quantity)).times(
2773
+ conversionRatio
2774
+ );
1595
2775
  const newValue = {
1596
2776
  quantity: multiplyQuantityValue(
1597
- v.quantity,
1598
- (0, import_big3.default)(getAverageValue(equiv.quantity)).div(
2777
+ {
2778
+ type: "fixed",
2779
+ value: {
2780
+ type: "decimal",
2781
+ decimal: valueInOriginalUnit.toNumber()
2782
+ }
2783
+ },
2784
+ (0, import_big4.default)(getAverageValue(equiv.quantity)).div(
1599
2785
  getAverageValue(mainInList.quantity)
1600
2786
  )
1601
2787
  )
@@ -1603,17 +2789,15 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1603
2789
  if (equiv.unit && !isNoUnit(equiv.unit)) {
1604
2790
  newValue.unit = { name: equiv.unit.name };
1605
2791
  }
1606
- return addQuantities(acc, newValue);
2792
+ return addQuantities(acc, newValue, system);
1607
2793
  }, initialValue);
1608
2794
  });
1609
2795
  if (main.length + equivalents.length > 1) {
1610
2796
  const resultMain = main.length > 1 ? {
1611
- type: "and",
1612
- entries: main.map(deNormalizeQuantity)
2797
+ and: main.map(deNormalizeQuantity)
1613
2798
  } : deNormalizeQuantity(main[0]);
1614
2799
  result.push({
1615
- type: "or",
1616
- entries: [resultMain, ...equivalents]
2800
+ or: [resultMain, ...equivalents]
1617
2801
  });
1618
2802
  } else {
1619
2803
  result.push(deNormalizeQuantity(main[0]));
@@ -1622,21 +2806,66 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1622
2806
  sumQuantities.filter((q) => !processedQuantities.has(q)).forEach((q) => result.push(deNormalizeQuantity(q)));
1623
2807
  return result;
1624
2808
  }
1625
- function addEquivalentsAndSimplify(...quantities) {
2809
+ function addEquivalentsAndSimplify(quantities, system) {
1626
2810
  if (quantities.length === 1) {
1627
2811
  return toPlainUnit(quantities[0]);
1628
2812
  }
1629
- const { sum, unitsLists } = addQuantitiesOrGroups(...quantities);
1630
- const regrouped = regroupQuantitiesAndExpandEquivalents(sum, unitsLists);
2813
+ const { sum, unitsLists } = addQuantitiesOrGroups(quantities, system);
2814
+ const regrouped = regroupQuantitiesAndExpandEquivalents(
2815
+ sum,
2816
+ unitsLists,
2817
+ system
2818
+ );
1631
2819
  if (regrouped.length === 1) {
1632
2820
  return toPlainUnit(regrouped[0]);
1633
2821
  } else {
1634
- return { type: "and", entries: regrouped.map(toPlainUnit) };
2822
+ return { and: regrouped.map(toPlainUnit) };
2823
+ }
2824
+ }
2825
+ function buildEquivalenceRatioMap(unitsLists) {
2826
+ const ratioMap = {};
2827
+ for (const list of unitsLists) {
2828
+ for (const equiv of list) {
2829
+ const equivValue = getAverageValue(equiv.quantity);
2830
+ for (const primary of list) {
2831
+ if (primary === equiv) continue;
2832
+ const primaryValue = getAverageValue(primary.quantity);
2833
+ const equivUnit = normalizeUnit(equiv.unit.name)?.name ?? equiv.unit.name;
2834
+ const primaryUnit = normalizeUnit(primary.unit.name)?.name ?? primary.unit.name;
2835
+ ratioMap[equivUnit] ?? (ratioMap[equivUnit] = {});
2836
+ ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue;
2837
+ }
2838
+ }
2839
+ }
2840
+ return ratioMap;
2841
+ }
2842
+ function recomputeEquivalents(primaries, ratioMap, equivUnits) {
2843
+ const equivalents = [];
2844
+ for (const equivUnit of equivUnits) {
2845
+ const ratios = ratioMap[normalizeUnit(equivUnit)?.name ?? equivUnit];
2846
+ let total = 0;
2847
+ for (const primary of primaries) {
2848
+ const pUnit = normalizeUnit(primary.unit ?? NO_UNIT)?.name ?? primary.unit ?? NO_UNIT;
2849
+ const ratio = ratios[pUnit];
2850
+ if (ratio === void 0) continue;
2851
+ const pValue = getAverageValue(primary.quantity);
2852
+ total += pValue * ratio;
2853
+ }
2854
+ if (total > 0) {
2855
+ equivalents.push({
2856
+ quantity: {
2857
+ type: "fixed",
2858
+ value: { type: "decimal", decimal: total }
2859
+ },
2860
+ ...equivUnit !== "" && { unit: equivUnit }
2861
+ });
2862
+ }
1635
2863
  }
2864
+ return equivalents.length > 0 ? equivalents : void 0;
1636
2865
  }
1637
2866
 
1638
2867
  // src/classes/recipe.ts
1639
- var import_big4 = __toESM(require("big.js"), 1);
2868
+ var import_big5 = __toESM(require("big.js"), 1);
1640
2869
  var _Recipe = class _Recipe {
1641
2870
  /**
1642
2871
  * Creates a new Recipe instance.
@@ -1648,12 +2877,12 @@ var _Recipe = class _Recipe {
1648
2877
  */
1649
2878
  __publicField(this, "metadata", {});
1650
2879
  /**
1651
- * The default or manual choice of alternative ingredients.
1652
- * Contains the full context including alternatives list and active selection index.
2880
+ * The possible choices of alternative ingredients for this recipe.
1653
2881
  */
1654
2882
  __publicField(this, "choices", {
1655
2883
  ingredientItems: /* @__PURE__ */ new Map(),
1656
- ingredientGroups: /* @__PURE__ */ new Map()
2884
+ ingredientGroups: /* @__PURE__ */ new Map(),
2885
+ variants: []
1657
2886
  });
1658
2887
  /**
1659
2888
  * The parsed recipe ingredients.
@@ -1684,10 +2913,20 @@ var _Recipe = class _Recipe {
1684
2913
  */
1685
2914
  __publicField(this, "servings");
1686
2915
  _Recipe.itemCounts.set(this, 0);
2916
+ _Recipe.subgroupIndices.set(this, /* @__PURE__ */ new Map());
1687
2917
  if (content) {
1688
2918
  this.parse(content);
1689
2919
  }
1690
2920
  }
2921
+ /**
2922
+ * Gets the unit system specified in the recipe metadata.
2923
+ * Used for resolving ambiguous units like tsp, tbsp, cup, etc.
2924
+ *
2925
+ * @returns The unit system if specified, or undefined to use defaults
2926
+ */
2927
+ get unitSystem() {
2928
+ return _Recipe.unitSystems.get(this);
2929
+ }
1691
2930
  /**
1692
2931
  * Gets the current item count for this recipe.
1693
2932
  */
@@ -1710,27 +2949,17 @@ var _Recipe = class _Recipe {
1710
2949
  */
1711
2950
  _parseArbitraryScalable(regexMatchGroups, intoArray) {
1712
2951
  if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
1713
- const quantityMatch = regexMatchGroups.arbitraryQuantity?.trim().match(quantityAlternativeRegex);
1714
- if (quantityMatch?.groups) {
1715
- const value = quantityMatch.groups.quantity ? parseQuantityInput(quantityMatch.groups.quantity) : void 0;
1716
- const unit = quantityMatch.groups.unit;
1717
- const name = regexMatchGroups.arbitraryName || void 0;
1718
- if (!value || value.type === "fixed" && value.value.type === "text") {
1719
- throw new InvalidQuantityFormat(
1720
- regexMatchGroups.arbitraryQuantity?.trim(),
1721
- "Arbitrary quantities must have a numerical value"
1722
- );
1723
- }
1724
- const arbitrary = {
1725
- quantity: value
1726
- };
1727
- if (name) arbitrary.name = name;
1728
- if (unit) arbitrary.unit = unit;
1729
- intoArray.push({
1730
- type: "arbitrary",
1731
- index: this.arbitraries.push(arbitrary) - 1
1732
- });
1733
- }
2952
+ const parsed = parseArbitraryQuantity(regexMatchGroups.arbitraryQuantity);
2953
+ const name = regexMatchGroups.arbitraryName || void 0;
2954
+ const arbitrary = {
2955
+ quantity: parsed.quantity
2956
+ };
2957
+ if (name) arbitrary.name = name;
2958
+ if (parsed.unit) arbitrary.unit = parsed.unit;
2959
+ intoArray.push({
2960
+ type: "arbitrary",
2961
+ index: this.arbitraries.push(arbitrary) - 1
2962
+ });
1734
2963
  }
1735
2964
  /**
1736
2965
  * Parses text for arbitrary scalables and returns NoteItem array.
@@ -1744,13 +2973,13 @@ var _Recipe = class _Recipe {
1744
2973
  for (const match of text.matchAll(globalRegex)) {
1745
2974
  const idx = match.index;
1746
2975
  if (idx > cursor) {
1747
- noteItems.push({ type: "text", value: text.slice(cursor, idx) });
2976
+ noteItems.push(...parseMarkdownSegments(text.slice(cursor, idx)));
1748
2977
  }
1749
2978
  this._parseArbitraryScalable(match.groups, noteItems);
1750
2979
  cursor = idx + match[0].length;
1751
2980
  }
1752
2981
  if (cursor < text.length) {
1753
- noteItems.push({ type: "text", value: text.slice(cursor) });
2982
+ noteItems.push(...parseMarkdownSegments(text.slice(cursor)));
1754
2983
  }
1755
2984
  return noteItems;
1756
2985
  }
@@ -1758,7 +2987,7 @@ var _Recipe = class _Recipe {
1758
2987
  let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
1759
2988
  const quantities = [];
1760
2989
  while (quantityMatch?.groups) {
1761
- const value = quantityMatch.groups.quantity ? parseQuantityInput(quantityMatch.groups.quantity) : void 0;
2990
+ const value = quantityMatch.groups.quantity ? parseQuantityValue(quantityMatch.groups.quantity) : void 0;
1762
2991
  const unit = quantityMatch.groups.unit;
1763
2992
  if (value) {
1764
2993
  const newQuantity = { quantity: value };
@@ -1859,7 +3088,7 @@ var _Recipe = class _Recipe {
1859
3088
  alternative.note = note;
1860
3089
  }
1861
3090
  if (itemQuantity) {
1862
- alternative.itemQuantity = itemQuantity;
3091
+ Object.assign(alternative, itemQuantity);
1863
3092
  }
1864
3093
  alternatives.push(alternative);
1865
3094
  testString = groups.ingredientAlternative || "";
@@ -1902,6 +3131,7 @@ var _Recipe = class _Recipe {
1902
3131
  if (!match?.groups) return;
1903
3132
  const groups = match.groups;
1904
3133
  const groupKey = groups.gIngredientGroupKey;
3134
+ const subgroupKey = groups.gIngredientSubgroupKey;
1905
3135
  let name = groups.gmIngredientName || groups.gsIngredientName;
1906
3136
  const preparation = groups.gIngredientPreparation;
1907
3137
  const modifiers = groups.gIngredientModifiers;
@@ -1967,9 +3197,14 @@ var _Recipe = class _Recipe {
1967
3197
  displayName
1968
3198
  };
1969
3199
  if (itemQuantity) {
1970
- alternative.itemQuantity = itemQuantity;
3200
+ Object.assign(alternative, itemQuantity);
1971
3201
  }
1972
- const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
3202
+ const note = groups.gIngredientNote?.trim();
3203
+ if (note) {
3204
+ alternative.note = note;
3205
+ }
3206
+ const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3207
+ const existingAlternativesFlat = existingSubgroups?.flat();
1973
3208
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
1974
3209
  const ingredient = ingredients[ingredientIdx];
1975
3210
  if (ingredient) {
@@ -1980,8 +3215,8 @@ var _Recipe = class _Recipe {
1980
3215
  }
1981
3216
  }
1982
3217
  }
1983
- if (existingAlternatives) {
1984
- for (const alt of existingAlternatives) {
3218
+ if (existingAlternativesFlat) {
3219
+ for (const alt of existingAlternativesFlat) {
1985
3220
  upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
1986
3221
  upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
1987
3222
  }
@@ -1993,14 +3228,35 @@ var _Recipe = class _Recipe {
1993
3228
  group: groupKey,
1994
3229
  alternatives: [alternative]
1995
3230
  };
3231
+ if (subgroupKey !== void 0) {
3232
+ newItem.subgroup = subgroupKey;
3233
+ }
1996
3234
  items.push(newItem);
1997
3235
  const choiceAlternative = deepClone(alternative);
1998
3236
  choiceAlternative.itemId = id;
1999
3237
  const existingChoice = this.choices.ingredientGroups.get(groupKey);
3238
+ const sgMap = _Recipe.subgroupIndices.get(this);
2000
3239
  if (!existingChoice) {
2001
- this.choices.ingredientGroups.set(groupKey, [choiceAlternative]);
3240
+ this.choices.ingredientGroups.set(groupKey, [[choiceAlternative]]);
3241
+ if (subgroupKey !== void 0) {
3242
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, 0]]));
3243
+ }
3244
+ } else if (subgroupKey !== void 0) {
3245
+ const groupSgMap = sgMap.get(groupKey);
3246
+ const existingIdx = groupSgMap?.get(subgroupKey);
3247
+ if (existingIdx !== void 0) {
3248
+ existingChoice[existingIdx].push(choiceAlternative);
3249
+ } else {
3250
+ const newIdx = existingChoice.length;
3251
+ existingChoice.push([choiceAlternative]);
3252
+ if (!groupSgMap) {
3253
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, newIdx]]));
3254
+ } else {
3255
+ groupSgMap.set(subgroupKey, newIdx);
3256
+ }
3257
+ }
2002
3258
  } else {
2003
- existingChoice.push(choiceAlternative);
3259
+ existingChoice.push([choiceAlternative]);
2004
3260
  }
2005
3261
  }
2006
3262
  /**
@@ -2014,142 +3270,230 @@ var _Recipe = class _Recipe {
2014
3270
  * Quantities are grouped by their alternative signature and summed using addEquivalentsAndSimplify.
2015
3271
  * @internal
2016
3272
  */
2017
- _populate_ingredient_quantities() {
2018
- this.ingredients = this.ingredients.map((ing) => {
2019
- if (ing.quantities) {
2020
- delete ing.quantities;
3273
+ _populateIngredientQuantities() {
3274
+ for (const ing of this.ingredients) {
3275
+ delete ing.quantities;
3276
+ delete ing.usedAsPrimary;
3277
+ }
3278
+ const ingredientsWithQuantities = this.getIngredientQuantities();
3279
+ const matchedIndices = /* @__PURE__ */ new Set();
3280
+ for (const computed of ingredientsWithQuantities) {
3281
+ const idx = this.ingredients.findIndex(
3282
+ (ing2, i2) => ing2.name === computed.name && !matchedIndices.has(i2)
3283
+ );
3284
+ matchedIndices.add(idx);
3285
+ const ing = this.ingredients[idx];
3286
+ if (computed.quantities) {
3287
+ ing.quantities = computed.quantities;
2021
3288
  }
2022
- if (ing.usedAsPrimary) {
2023
- delete ing.usedAsPrimary;
3289
+ if (computed.usedAsPrimary) {
3290
+ ing.usedAsPrimary = true;
2024
3291
  }
2025
- return ing;
2026
- });
2027
- const seenGroups = /* @__PURE__ */ new Set();
3292
+ }
3293
+ }
3294
+ // Type for accumulated quantities (used internally by collectQuantityGroups)
3295
+ // Defined as a static type alias for the private method's return type
3296
+ /** @internal */
3297
+ collectQuantityGroups(options) {
3298
+ const { section, step, choices } = options || {};
3299
+ const activeVariant = choices?.variant;
3300
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3301
+ const sectionsToProcess = section !== void 0 ? (() => {
3302
+ const idx = typeof section === "number" ? section : this.sections.indexOf(section);
3303
+ return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
3304
+ })() : this.sections;
2028
3305
  const ingredientGroups = /* @__PURE__ */ new Map();
2029
- for (const section of this.sections) {
2030
- for (const step of section.content.filter(
3306
+ const selectedIndices = /* @__PURE__ */ new Set();
3307
+ const referencedIndices = /* @__PURE__ */ new Set();
3308
+ const dynamicOptionalIndices = /* @__PURE__ */ new Set();
3309
+ for (const currentSection of sectionsToProcess) {
3310
+ if (currentSection.variants) {
3311
+ if (isDefaultVariant) {
3312
+ if (!currentSection.variants.includes("*")) continue;
3313
+ } else {
3314
+ if (!currentSection.variants.includes(activeVariant)) continue;
3315
+ }
3316
+ }
3317
+ const allSteps = currentSection.content.filter(
2031
3318
  (item) => item.type === "step"
2032
- )) {
2033
- for (const item of step.items.filter(
3319
+ );
3320
+ const isOptionalSection = currentSection.optional === true;
3321
+ const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
3322
+ for (const currentStep of stepsToProcess) {
3323
+ if (currentStep.variants) {
3324
+ if (isDefaultVariant) {
3325
+ if (!currentStep.variants.includes("*")) continue;
3326
+ } else {
3327
+ if (!currentStep.variants.includes(activeVariant)) continue;
3328
+ }
3329
+ }
3330
+ const isOptionalStep = currentStep.optional === true || isOptionalSection;
3331
+ for (const item of currentStep.items.filter(
2034
3332
  (item2) => item2.type === "ingredient"
2035
3333
  )) {
2036
- const isGroupedItem = "group" in item && item.group !== void 0;
2037
- const isFirstInGroup = isGroupedItem && !seenGroups.has(item.group);
2038
- if (isGroupedItem) {
2039
- seenGroups.add(item.group);
2040
- }
2041
- const isPrimary = !isGroupedItem || isFirstInGroup;
2042
- const alternative = item.alternatives[0];
2043
- if (isPrimary) {
2044
- const primaryIngredient = this.ingredients[alternative.index];
2045
- if (primaryIngredient) {
2046
- primaryIngredient.usedAsPrimary = true;
3334
+ const isGrouped = "group" in item && item.group !== void 0;
3335
+ const groupSubgroups = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3336
+ let selectedAltIndex = 0;
3337
+ let isSelected;
3338
+ let hasExplicitChoice;
3339
+ if (isGrouped) {
3340
+ const groupChoice = choices?.ingredientGroups?.get(item.group);
3341
+ hasExplicitChoice = groupChoice !== void 0;
3342
+ if (!hasExplicitChoice && !isDefaultVariant) {
3343
+ const matchingSubgroupIdx = groupSubgroups?.findIndex(
3344
+ (sg) => sg.some(
3345
+ (alt) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3346
+ )
3347
+ );
3348
+ if (matchingSubgroupIdx !== void 0 && matchingSubgroupIdx >= 0) {
3349
+ const matchedSubgroup = groupSubgroups[matchingSubgroupIdx];
3350
+ isSelected = matchedSubgroup.some(
3351
+ (alt) => alt.itemId === item.id
3352
+ );
3353
+ hasExplicitChoice = true;
3354
+ selectedAltIndex = 0;
3355
+ } else {
3356
+ const targetSubgroupIndex = 0;
3357
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3358
+ isSelected = selectedSubgroup.some(
3359
+ (alt) => alt.itemId === item.id
3360
+ );
3361
+ }
3362
+ } else {
3363
+ const targetSubgroupIndex = groupChoice ?? 0;
3364
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3365
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
2047
3366
  }
2048
- }
2049
- if (!isPrimary || !alternative.itemQuantity) continue;
2050
- const allQuantities = [
2051
- {
2052
- quantity: alternative.itemQuantity.quantity,
2053
- unit: alternative.itemQuantity.unit
3367
+ } else {
3368
+ const itemChoice = choices?.ingredientItems?.get(item.id);
3369
+ hasExplicitChoice = itemChoice !== void 0;
3370
+ if (!hasExplicitChoice && !isDefaultVariant) {
3371
+ const matchingIndices = item.alternatives.map((alt, idx) => ({ alt, idx })).filter(
3372
+ ({ alt }) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3373
+ ).map(({ idx }) => idx);
3374
+ if (matchingIndices.length > 0) {
3375
+ selectedAltIndex = matchingIndices[0];
3376
+ hasExplicitChoice = true;
3377
+ } else {
3378
+ selectedAltIndex = itemChoice ?? 0;
3379
+ }
3380
+ } else {
3381
+ selectedAltIndex = itemChoice ?? 0;
2054
3382
  }
2055
- ];
2056
- if (alternative.itemQuantity.equivalents) {
2057
- allQuantities.push(...alternative.itemQuantity.equivalents);
3383
+ isSelected = true;
3384
+ }
3385
+ const alternative = item.alternatives[selectedAltIndex];
3386
+ if (!alternative || !isSelected) continue;
3387
+ selectedIndices.add(alternative.index);
3388
+ if (isOptionalStep) {
3389
+ dynamicOptionalIndices.add(alternative.index);
2058
3390
  }
2059
- const quantityEntry = allQuantities.length === 1 ? allQuantities[0] : { type: "or", entries: allQuantities };
2060
- const hasInlineAlternatives = item.alternatives.length > 1;
2061
- const hasGroupedAlternatives = isGroupedItem && this.choices.ingredientGroups.has(item.group);
3391
+ const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3392
+ for (const alt of allAltsFlat) {
3393
+ referencedIndices.add(alt.index);
3394
+ }
3395
+ if (!alternative.quantity) continue;
3396
+ const baseQty = {
3397
+ quantity: alternative.quantity,
3398
+ ...alternative.unit && {
3399
+ unit: alternative.unit
3400
+ }
3401
+ };
3402
+ const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
2062
3403
  let alternativeRefs;
2063
- if (hasInlineAlternatives) {
2064
- alternativeRefs = [];
2065
- for (let j = 1; j < item.alternatives.length; j++) {
2066
- const otherAlt = item.alternatives[j];
2067
- const newRef = {
2068
- index: otherAlt.index
2069
- };
2070
- if (otherAlt.itemQuantity) {
2071
- const altQty = {
2072
- quantity: otherAlt.itemQuantity.quantity
3404
+ if (!hasExplicitChoice && groupSubgroups && groupSubgroups.length > 1) {
3405
+ const currentSubgroupIdx = groupSubgroups.findIndex(
3406
+ (sg) => sg.some((alt) => alt.itemId === item.id)
3407
+ );
3408
+ alternativeRefs = groupSubgroups.filter((_, idx) => idx !== currentSubgroupIdx).map(
3409
+ (subgroup) => subgroup.map((otherAlt) => {
3410
+ const ref = {
3411
+ index: otherAlt.index
2073
3412
  };
2074
- if (otherAlt.itemQuantity.unit) {
2075
- altQty.unit = otherAlt.itemQuantity.unit.name;
2076
- }
2077
- if (otherAlt.itemQuantity.equivalents) {
2078
- altQty.equivalents = otherAlt.itemQuantity.equivalents.map(
2079
- (eq) => toPlainUnit(eq)
2080
- );
3413
+ if (otherAlt.quantity) {
3414
+ const altQty = {
3415
+ quantity: otherAlt.quantity,
3416
+ ...otherAlt.unit && {
3417
+ unit: otherAlt.unit.name
3418
+ },
3419
+ ...otherAlt.equivalents && {
3420
+ equivalents: otherAlt.equivalents.map(
3421
+ (eq) => toPlainUnit(eq)
3422
+ )
3423
+ }
3424
+ };
3425
+ ref.quantities = [altQty];
2081
3426
  }
2082
- newRef.quantities = [altQty];
2083
- }
2084
- alternativeRefs.push(newRef);
2085
- }
2086
- } else if (hasGroupedAlternatives) {
2087
- const groupAlternatives = this.choices.ingredientGroups.get(
2088
- item.group
3427
+ return ref;
3428
+ })
2089
3429
  );
2090
- alternativeRefs = [];
2091
- for (let j = 1; j < groupAlternatives.length; j++) {
2092
- const otherAlt = groupAlternatives[j];
2093
- if (otherAlt.itemQuantity) {
3430
+ } else if (!hasExplicitChoice && !isGrouped && allAltsFlat.length > 1) {
3431
+ alternativeRefs = allAltsFlat.filter((alt) => alt.index !== alternative.index).map((otherAlt) => {
3432
+ const ref = { index: otherAlt.index };
3433
+ if (otherAlt.quantity) {
2094
3434
  const altQty = {
2095
- quantity: otherAlt.itemQuantity.quantity
3435
+ quantity: otherAlt.quantity,
3436
+ ...otherAlt.unit && {
3437
+ unit: otherAlt.unit.name
3438
+ },
3439
+ ...otherAlt.equivalents && {
3440
+ equivalents: otherAlt.equivalents.map(
3441
+ (eq) => toPlainUnit(eq)
3442
+ )
3443
+ }
2096
3444
  };
2097
- if (otherAlt.itemQuantity.unit) {
2098
- altQty.unit = otherAlt.itemQuantity.unit.name;
2099
- }
2100
- if (otherAlt.itemQuantity.equivalents) {
2101
- altQty.equivalents = otherAlt.itemQuantity.equivalents.map(
2102
- (eq) => toPlainUnit(eq)
2103
- );
2104
- }
2105
- alternativeRefs.push({
2106
- index: otherAlt.index,
2107
- quantities: [altQty]
2108
- });
3445
+ ref.quantities = [altQty];
2109
3446
  }
2110
- }
2111
- if (alternativeRefs.length === 0) {
2112
- alternativeRefs = void 0;
2113
- }
3447
+ return [ref];
3448
+ });
3449
+ }
3450
+ const altIndices = getAlternativeSignature(alternativeRefs) ?? "";
3451
+ let signature;
3452
+ if (isGrouped) {
3453
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
3454
+ signature = `group:${item.group}|${altIndices}|${resolvedUnit.type}`;
3455
+ } else if (altIndices) {
3456
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
3457
+ signature = `${altIndices}|${resolvedUnit.type}}`;
3458
+ } else {
3459
+ signature = null;
2114
3460
  }
2115
3461
  if (!ingredientGroups.has(alternative.index)) {
2116
3462
  ingredientGroups.set(alternative.index, /* @__PURE__ */ new Map());
2117
3463
  }
2118
- const groupsForIngredient = ingredientGroups.get(alternative.index);
2119
- const baseSignature = getAlternativeSignature(alternativeRefs);
2120
- const signature = isGroupedItem ? `group:${item.group}|${baseSignature ?? ""}` : baseSignature;
2121
- if (!groupsForIngredient.has(signature)) {
2122
- groupsForIngredient.set(signature, {
3464
+ const groupsForIng = ingredientGroups.get(alternative.index);
3465
+ if (!groupsForIng.has(signature)) {
3466
+ groupsForIng.set(signature, {
3467
+ quantities: [],
2123
3468
  alternativeQuantities: /* @__PURE__ */ new Map(),
2124
- quantities: []
3469
+ alternativeSubgroups: []
2125
3470
  });
2126
3471
  }
2127
- const group = groupsForIngredient.get(signature);
3472
+ const group = groupsForIng.get(signature);
2128
3473
  group.quantities.push(quantityEntry);
2129
- if (alternativeRefs) {
2130
- for (const ref of alternativeRefs) {
3474
+ if (alternativeRefs && alternativeRefs.length > 0 && group.alternativeSubgroups.length === 0) {
3475
+ group.alternativeSubgroups = alternativeRefs.map(
3476
+ (subgroup) => subgroup.map((ref) => ref.index)
3477
+ );
3478
+ }
3479
+ for (const subgroup of alternativeRefs ?? []) {
3480
+ for (const ref of subgroup) {
2131
3481
  if (!group.alternativeQuantities.has(ref.index)) {
2132
3482
  group.alternativeQuantities.set(ref.index, []);
2133
3483
  }
2134
- if (ref.quantities && ref.quantities.length > 0) {
2135
- for (const altQty of ref.quantities) {
2136
- if (altQty.equivalents && altQty.equivalents.length > 0) {
2137
- const entries = [
2138
- toExtendedUnit({
2139
- quantity: altQty.quantity,
2140
- unit: altQty.unit
2141
- }),
2142
- ...altQty.equivalents.map((eq) => toExtendedUnit(eq))
2143
- ];
2144
- group.alternativeQuantities.get(ref.index).push({ type: "or", entries });
2145
- } else {
2146
- group.alternativeQuantities.get(ref.index).push(
2147
- toExtendedUnit({
2148
- quantity: altQty.quantity,
2149
- unit: altQty.unit
2150
- })
2151
- );
2152
- }
3484
+ for (const altQty of ref.quantities ?? []) {
3485
+ const extended = toExtendedUnit({
3486
+ quantity: altQty.quantity,
3487
+ unit: altQty.unit
3488
+ });
3489
+ if (altQty.equivalents?.length) {
3490
+ const eqEntries = [
3491
+ extended,
3492
+ ...altQty.equivalents.map((eq) => toExtendedUnit(eq))
3493
+ ];
3494
+ group.alternativeQuantities.get(ref.index).push({ or: eqEntries });
3495
+ } else {
3496
+ group.alternativeQuantities.get(ref.index).push(extended);
2153
3497
  }
2154
3498
  }
2155
3499
  }
@@ -2157,144 +3501,219 @@ var _Recipe = class _Recipe {
2157
3501
  }
2158
3502
  }
2159
3503
  }
2160
- for (const [index, groupsForIngredient] of ingredientGroups) {
2161
- const ingredient = this.ingredients[index];
2162
- const quantityGroups = [];
2163
- for (const [, group] of groupsForIngredient) {
2164
- const summedGroupQuantity = addEquivalentsAndSimplify(
2165
- ...group.quantities
2166
- );
2167
- const groupQuantities = flattenPlainUnitGroup(summedGroupQuantity);
2168
- let alternatives;
2169
- if (group.alternativeQuantities.size > 0) {
2170
- alternatives = [];
2171
- for (const [altIndex, altQuantities] of group.alternativeQuantities) {
2172
- const ref = { index: altIndex };
2173
- if (altQuantities.length > 0) {
2174
- const summedAltQuantity = addEquivalentsAndSimplify(
2175
- ...altQuantities
2176
- );
2177
- const flattenedAlt = flattenPlainUnitGroup(summedAltQuantity);
2178
- ref.quantities = flattenedAlt.flatMap((item) => {
2179
- if ("quantity" in item) {
2180
- return [item];
2181
- } else {
2182
- return item.entries;
2183
- }
2184
- });
2185
- }
2186
- alternatives.push(ref);
2187
- }
2188
- }
2189
- for (const gq of groupQuantities) {
2190
- if ("type" in gq && gq.type === "and") {
2191
- const andGroup = {
2192
- type: "and",
2193
- entries: gq.entries
2194
- };
2195
- if (gq.equivalents && gq.equivalents.length > 0) {
2196
- andGroup.equivalents = gq.equivalents;
2197
- }
2198
- if (alternatives && alternatives.length > 0) {
2199
- andGroup.alternatives = alternatives;
2200
- }
2201
- quantityGroups.push(andGroup);
2202
- } else {
2203
- const quantityGroup = gq;
2204
- if (alternatives && alternatives.length > 0) {
2205
- quantityGroup.alternatives = alternatives;
2206
- }
2207
- quantityGroups.push(quantityGroup);
3504
+ return {
3505
+ ingredientGroups,
3506
+ selectedIndices,
3507
+ referencedIndices,
3508
+ dynamicOptionalIndices
3509
+ };
3510
+ }
3511
+ /**
3512
+ * Gets the raw (unprocessed) quantity groups for each ingredient, before
3513
+ * any summation or equivalents simplification. This is useful for cross-recipe
3514
+ * aggregation (e.g., in {@link ShoppingList}), where quantities from multiple
3515
+ * recipes should be combined before processing.
3516
+ *
3517
+ * @param options - Options for filtering and choice selection (same as {@link getIngredientQuantities}).
3518
+ * @returns Array of {@link RawQuantityGroup} objects, one per ingredient with quantities.
3519
+ *
3520
+ * @example
3521
+ * ```typescript
3522
+ * const rawGroups = recipe.getRawQuantityGroups();
3523
+ * // Each group has: name, usedAsPrimary, flags, quantities[]
3524
+ * // quantities are the raw QuantityWithExtendedUnit or FlatOrGroup entries
3525
+ * ```
3526
+ */
3527
+ getRawQuantityGroups(options) {
3528
+ const {
3529
+ ingredientGroups,
3530
+ selectedIndices,
3531
+ referencedIndices,
3532
+ dynamicOptionalIndices
3533
+ } = this.collectQuantityGroups(options);
3534
+ const result = [];
3535
+ for (let index = 0; index < this.ingredients.length; index++) {
3536
+ if (!referencedIndices.has(index)) continue;
3537
+ const orig = this.ingredients[index];
3538
+ const usedAsPrimary = selectedIndices.has(index);
3539
+ let flags = orig.flags;
3540
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3541
+ flags = [...flags ?? [], "optional"];
3542
+ }
3543
+ const quantities = [];
3544
+ if (usedAsPrimary) {
3545
+ const groupsForIng = ingredientGroups.get(index);
3546
+ if (groupsForIng) {
3547
+ for (const [, group] of groupsForIng) {
3548
+ quantities.push(...group.quantities);
2208
3549
  }
2209
3550
  }
2210
3551
  }
2211
- if (quantityGroups.length > 0) {
2212
- ingredient.quantities = quantityGroups;
2213
- }
3552
+ result.push({
3553
+ name: orig.name,
3554
+ ...usedAsPrimary && { usedAsPrimary: true },
3555
+ ...flags && { flags },
3556
+ quantities
3557
+ });
2214
3558
  }
3559
+ return result;
2215
3560
  }
2216
3561
  /**
2217
- * Calculates ingredient quantities based on the provided choices.
2218
- * Returns a list of computed ingredients with their total quantities.
3562
+ * Gets ingredients with their quantities populated, optionally filtered by section/step
3563
+ * and respecting user choices for alternatives.
3564
+ *
3565
+ * When no options are provided, returns all recipe ingredients with quantities
3566
+ * calculated using primary alternatives (same as after parsing).
3567
+ *
3568
+ * @param options - Options for filtering and choice selection:
3569
+ * - `section`: Filter to a specific section (Section object or 0-based index)
3570
+ * - `step`: Filter to a specific step (Step object or 0-based index)
3571
+ * - `choices`: Choices for alternative ingredients (defaults to primary)
3572
+ * @returns Array of Ingredient objects with quantities populated
3573
+ *
3574
+ * @example
3575
+ * ```typescript
3576
+ * // Get all ingredients with primary alternatives
3577
+ * const ingredients = recipe.getIngredientQuantities();
2219
3578
  *
2220
- * @param choices - The recipe choices to apply when computing quantities.
2221
- * If not provided, uses the default choices (first alternative for each item).
2222
- * @returns An array of ComputedIngredient with quantityTotal calculated based on choices.
3579
+ * // Get ingredients for a specific section
3580
+ * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
3581
+ *
3582
+ * // Get ingredients with specific choices applied
3583
+ * const withChoices = recipe.getIngredientQuantities({
3584
+ * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
3585
+ * });
3586
+ * ```
2223
3587
  */
2224
- calc_ingredient_quantities(choices) {
2225
- const effectiveChoices = choices || {
2226
- ingredientItems: new Map(
2227
- Array.from(this.choices.ingredientItems.keys()).map((k) => [k, 0])
2228
- ),
2229
- ingredientGroups: new Map(
2230
- Array.from(this.choices.ingredientGroups.keys()).map((k) => [k, 0])
2231
- )
2232
- };
2233
- const ingredientQuantities = /* @__PURE__ */ new Map();
2234
- const selectedIngredientIndices = /* @__PURE__ */ new Set();
2235
- for (const section of this.sections) {
2236
- for (const step of section.content.filter(
2237
- (item) => item.type === "step"
2238
- )) {
2239
- for (const item of step.items.filter(
2240
- (item2) => item2.type === "ingredient"
2241
- )) {
2242
- for (let i2 = 0; i2 < item.alternatives.length; i2++) {
2243
- const alternative = item.alternatives[i2];
2244
- const isAlternativeChoiceItem = effectiveChoices.ingredientItems?.get(item.id) === i2;
2245
- const alternativeChoiceGroupIdx = item.group ? effectiveChoices.ingredientGroups?.get(item.group) : void 0;
2246
- const alternativeChoiceGroup = item.group ? this.choices.ingredientGroups.get(item.group) : void 0;
2247
- const isAlternativeChoiceGroup = alternativeChoiceGroup && alternativeChoiceGroupIdx !== void 0 ? alternativeChoiceGroup[alternativeChoiceGroupIdx]?.itemId === item.id : false;
2248
- const isSelected = !("group" in item) && (item.alternatives.length === 1 || isAlternativeChoiceItem) || isAlternativeChoiceGroup;
2249
- if (isSelected) {
2250
- selectedIngredientIndices.add(alternative.index);
2251
- if (alternative.itemQuantity) {
2252
- const allQuantities = [
2253
- {
2254
- quantity: alternative.itemQuantity.quantity,
2255
- unit: alternative.itemQuantity.unit
2256
- }
2257
- ];
2258
- if (alternative.itemQuantity.equivalents) {
2259
- allQuantities.push(...alternative.itemQuantity.equivalents);
2260
- }
2261
- const equivalents = allQuantities.length === 1 ? allQuantities[0] : {
2262
- type: "or",
2263
- entries: allQuantities
2264
- };
2265
- ingredientQuantities.set(alternative.index, [
2266
- ...ingredientQuantities.get(alternative.index) || [],
2267
- equivalents
2268
- ]);
3588
+ getIngredientQuantities(options) {
3589
+ const {
3590
+ ingredientGroups,
3591
+ selectedIndices,
3592
+ referencedIndices,
3593
+ dynamicOptionalIndices
3594
+ } = this.collectQuantityGroups(options);
3595
+ const result = [];
3596
+ for (let index = 0; index < this.ingredients.length; index++) {
3597
+ if (!referencedIndices.has(index)) continue;
3598
+ const orig = this.ingredients[index];
3599
+ let flags = orig.flags;
3600
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3601
+ flags = [...flags ?? [], "optional"];
3602
+ }
3603
+ const ing = {
3604
+ name: orig.name,
3605
+ ...orig.preparation && { preparation: orig.preparation },
3606
+ ...flags && { flags },
3607
+ ...orig.extras && { extras: orig.extras }
3608
+ };
3609
+ if (selectedIndices.has(index)) {
3610
+ ing.usedAsPrimary = true;
3611
+ const groupsForIng = ingredientGroups.get(index);
3612
+ if (groupsForIng) {
3613
+ const quantityGroups = [];
3614
+ for (const [, group] of groupsForIng) {
3615
+ const summed = addEquivalentsAndSimplify(
3616
+ group.quantities,
3617
+ this.unitSystem
3618
+ );
3619
+ const flattened = flattenPlainUnitGroup(summed);
3620
+ let alternatives;
3621
+ if (group.alternativeSubgroups.length > 0) {
3622
+ alternatives = group.alternativeSubgroups.map(
3623
+ (subgroupIndices) => subgroupIndices.map((altIdx) => {
3624
+ const altQtys = group.alternativeQuantities.get(altIdx);
3625
+ return {
3626
+ index: altIdx,
3627
+ ...altQtys.length > 0 && {
3628
+ quantities: flattenPlainUnitGroup(
3629
+ addEquivalentsAndSimplify(altQtys, this.unitSystem)
3630
+ ).flatMap(
3631
+ /* v8 ignore next -- item.and branch requires complex nested AND-with-equivalents structure */
3632
+ (item) => "quantity" in item ? [item] : item.and
3633
+ )
3634
+ }
3635
+ };
3636
+ })
3637
+ );
3638
+ }
3639
+ for (const gq of flattened) {
3640
+ if ("and" in gq) {
3641
+ quantityGroups.push({
3642
+ and: gq.and,
3643
+ ...gq.equivalents?.length && {
3644
+ equivalents: gq.equivalents
3645
+ },
3646
+ ...alternatives?.length && { alternatives }
3647
+ });
3648
+ } else {
3649
+ quantityGroups.push({
3650
+ ...gq,
3651
+ ...alternatives?.length && { alternatives }
3652
+ });
2269
3653
  }
2270
3654
  }
2271
3655
  }
3656
+ if (quantityGroups.length > 0) {
3657
+ ing.quantities = quantityGroups;
3658
+ }
2272
3659
  }
2273
3660
  }
3661
+ result.push(ing);
2274
3662
  }
2275
- const computedIngredients = [];
2276
- for (let index = 0; index < this.ingredients.length; index++) {
2277
- if (!selectedIngredientIndices.has(index)) continue;
2278
- const ing = this.ingredients[index];
2279
- const computed = {
2280
- name: ing.name
2281
- };
2282
- if (ing.preparation) {
2283
- computed.preparation = ing.preparation;
2284
- }
2285
- if (ing.flags) {
2286
- computed.flags = ing.flags;
2287
- }
2288
- if (ing.extras) {
2289
- computed.extras = ing.extras;
3663
+ return result;
3664
+ }
3665
+ /**
3666
+ * Returns the list of cookware items that are used in the active variant.
3667
+ * Cookware in steps/sections not matching the active variant are excluded.
3668
+ * Hidden cookware is always excluded.
3669
+ *
3670
+ * @param options - Options for filtering:
3671
+ * - `choices`: The choices to apply (only `variant` is used)
3672
+ * @returns Array of Cookware objects referenced by active steps
3673
+ *
3674
+ * @example
3675
+ * ```typescript
3676
+ * // Get all cookware for the default variant
3677
+ * const cookware = recipe.getCookwareForVariant();
3678
+ *
3679
+ * // Get cookware for a specific variant
3680
+ * const veganCookware = recipe.getCookwareForVariant({ choices: { variant: 'vegan' } });
3681
+ * ```
3682
+ */
3683
+ getCookwareForVariant(options) {
3684
+ const { choices } = options || {};
3685
+ const activeVariant = choices?.variant;
3686
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3687
+ const cookwareIndices = /* @__PURE__ */ new Set();
3688
+ for (const currentSection of this.sections) {
3689
+ if (currentSection.variants) {
3690
+ if (isDefaultVariant) {
3691
+ if (!currentSection.variants.includes("*")) continue;
3692
+ } else {
3693
+ if (!currentSection.variants.includes(activeVariant)) continue;
3694
+ }
2290
3695
  }
2291
- const quantities = ingredientQuantities.get(index);
2292
- if (quantities && quantities.length > 0) {
2293
- computed.quantityTotal = addEquivalentsAndSimplify(...quantities);
3696
+ const allSteps = currentSection.content.filter(
3697
+ (item) => item.type === "step"
3698
+ );
3699
+ for (const currentStep of allSteps) {
3700
+ if (currentStep.variants) {
3701
+ if (isDefaultVariant) {
3702
+ if (!currentStep.variants.includes("*")) continue;
3703
+ } else {
3704
+ if (!currentStep.variants.includes(activeVariant)) continue;
3705
+ }
3706
+ }
3707
+ for (const item of currentStep.items) {
3708
+ if (item.type === "cookware") {
3709
+ cookwareIndices.add(item.index);
3710
+ }
3711
+ }
2294
3712
  }
2295
- computedIngredients.push(computed);
2296
3713
  }
2297
- return computedIngredients;
3714
+ return this.cookware.filter(
3715
+ (cw, idx) => cookwareIndices.has(idx) && !cw.flags?.includes("hidden")
3716
+ );
2298
3717
  }
2299
3718
  /**
2300
3719
  * Parses a recipe from a string.
@@ -2302,17 +3721,23 @@ var _Recipe = class _Recipe {
2302
3721
  */
2303
3722
  parse(content) {
2304
3723
  const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
2305
- const { metadata, servings } = extractMetadata(content);
3724
+ const { metadata, servings, unitSystem } = extractMetadata(content);
2306
3725
  this.metadata = metadata;
2307
3726
  this.servings = servings;
3727
+ if (unitSystem) _Recipe.unitSystems.set(this, unitSystem);
2308
3728
  let blankLineBefore = true;
2309
3729
  let section = new Section();
2310
3730
  const items = [];
2311
3731
  let noteText = "";
2312
3732
  let inNote = false;
3733
+ let stepVariants;
3734
+ let stepOptional;
3735
+ const discoveredVariants = /* @__PURE__ */ new Set();
2313
3736
  for (const line of cleanContent) {
2314
3737
  if (line.trim().length === 0) {
2315
- flushPendingItems(section, items);
3738
+ flushPendingItems(section, items, stepVariants, stepOptional);
3739
+ stepVariants = void 0;
3740
+ stepOptional = void 0;
2316
3741
  flushPendingNote(
2317
3742
  section,
2318
3743
  noteText ? this._parseNoteText(noteText) : []
@@ -2323,30 +3748,42 @@ var _Recipe = class _Recipe {
2323
3748
  continue;
2324
3749
  }
2325
3750
  if (line.startsWith("=")) {
2326
- flushPendingItems(section, items);
3751
+ flushPendingItems(section, items, stepVariants, stepOptional);
3752
+ stepVariants = void 0;
3753
+ stepOptional = void 0;
2327
3754
  flushPendingNote(
2328
3755
  section,
2329
3756
  noteText ? this._parseNoteText(noteText) : []
2330
3757
  );
2331
3758
  noteText = "";
2332
- if (this.sections.length === 0 && section.isBlank()) {
2333
- section.name = line.replace(/^=+|=+$/g, "").trim();
2334
- } else {
2335
- if (!section.isBlank()) {
2336
- this.sections.push(section);
3759
+ let sectionName = line.replace(/^=+|=+$/g, "").trim();
3760
+ let sectionVariants;
3761
+ let sectionOptional;
3762
+ const sectionVarMatch = sectionName.match(variantTagRegex);
3763
+ if (sectionVarMatch?.groups) {
3764
+ const isOptionalPrefix = sectionVarMatch.groups.variantOptionalPrefix === "?";
3765
+ const names = (sectionVarMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3766
+ if (names.length > 0) {
3767
+ sectionVariants = names;
3768
+ for (const v of names) discoveredVariants.add(v);
3769
+ }
3770
+ if (isOptionalPrefix) {
3771
+ sectionOptional = true;
2337
3772
  }
2338
- section = new Section(line.replace(/^=+|=+$/g, "").trim());
3773
+ sectionName = sectionName.slice(sectionVarMatch[0].length).trim();
2339
3774
  }
3775
+ if (!section.isBlank()) {
3776
+ this.sections.push(section);
3777
+ }
3778
+ section = new Section(sectionName, sectionVariants, sectionOptional);
2340
3779
  blankLineBefore = true;
2341
3780
  inNote = false;
2342
3781
  continue;
2343
3782
  }
2344
3783
  if (blankLineBefore && line.startsWith(">")) {
2345
- flushPendingItems(section, items);
2346
- flushPendingNote(
2347
- section,
2348
- noteText ? this._parseNoteText(noteText) : []
2349
- );
3784
+ flushPendingItems(section, items, stepVariants, stepOptional);
3785
+ stepVariants = void 0;
3786
+ stepOptional = void 0;
2350
3787
  noteText = line.substring(1).trim();
2351
3788
  inNote = true;
2352
3789
  blankLineBefore = false;
@@ -2361,13 +3798,31 @@ var _Recipe = class _Recipe {
2361
3798
  blankLineBefore = false;
2362
3799
  continue;
2363
3800
  }
2364
- flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
2365
- noteText = "";
3801
+ let currentLine = line;
3802
+ if (items.length === 0) {
3803
+ const varMatch = currentLine.match(variantTagRegex);
3804
+ if (varMatch?.groups) {
3805
+ const isOptionalPrefix = varMatch.groups.variantOptionalPrefix === "?";
3806
+ const names = (varMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3807
+ if (names.length > 0) {
3808
+ stepVariants = names;
3809
+ for (const v of names) discoveredVariants.add(v);
3810
+ }
3811
+ if (isOptionalPrefix) {
3812
+ stepOptional = true;
3813
+ }
3814
+ currentLine = currentLine.slice(varMatch[0].length);
3815
+ if (currentLine.trim().length === 0) {
3816
+ blankLineBefore = false;
3817
+ continue;
3818
+ }
3819
+ }
3820
+ }
2366
3821
  let cursor = 0;
2367
- for (const match of line.matchAll(tokensRegex)) {
3822
+ for (const match of currentLine.matchAll(tokensRegex)) {
2368
3823
  const idx = match.index;
2369
3824
  if (idx > cursor) {
2370
- items.push({ type: "text", value: line.slice(cursor, idx) });
3825
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor, idx)));
2371
3826
  }
2372
3827
  const groups = match.groups;
2373
3828
  if (groups.mIngredientName || groups.sIngredientName) {
@@ -2386,7 +3841,7 @@ var _Recipe = class _Recipe {
2386
3841
  if (modifiers !== void 0 && modifiers.includes("-")) {
2387
3842
  flags.push("hidden");
2388
3843
  }
2389
- const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
3844
+ const quantity = quantityRaw ? parseQuantityValue(quantityRaw) : void 0;
2390
3845
  const newCookware = {
2391
3846
  name
2392
3847
  };
@@ -2418,7 +3873,7 @@ var _Recipe = class _Recipe {
2418
3873
  throw new Error("Timer missing unit");
2419
3874
  }
2420
3875
  const name = groups.timerName || void 0;
2421
- const duration = parseQuantityInput(durationStr);
3876
+ const duration = parseQuantityValue(durationStr);
2422
3877
  const timerObj = {
2423
3878
  name,
2424
3879
  duration,
@@ -2428,17 +3883,22 @@ var _Recipe = class _Recipe {
2428
3883
  }
2429
3884
  cursor = idx + match[0].length;
2430
3885
  }
2431
- if (cursor < line.length) {
2432
- items.push({ type: "text", value: line.slice(cursor) });
3886
+ if (cursor < currentLine.length) {
3887
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor)));
2433
3888
  }
2434
3889
  blankLineBefore = false;
2435
3890
  }
2436
- flushPendingItems(section, items);
3891
+ flushPendingItems(section, items, stepVariants, stepOptional);
2437
3892
  flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
2438
3893
  if (!section.isBlank()) {
2439
3894
  this.sections.push(section);
2440
3895
  }
2441
- this._populate_ingredient_quantities();
3896
+ const metaVariants = this.metadata.variants ?? [];
3897
+ const allVariants = /* @__PURE__ */ new Set([...metaVariants, ...discoveredVariants]);
3898
+ if (allVariants.size > 0) {
3899
+ this.choices.variants = [...allVariants];
3900
+ }
3901
+ this._populateIngredientQuantities();
2442
3902
  }
2443
3903
  /**
2444
3904
  * Scales the recipe to a new number of servings. In practice, it calls
@@ -2453,7 +3913,7 @@ var _Recipe = class _Recipe {
2453
3913
  if (originalServings === void 0 || originalServings === 0) {
2454
3914
  originalServings = 1;
2455
3915
  }
2456
- const factor = (0, import_big4.default)(newServings).div(originalServings);
3916
+ const factor = (0, import_big5.default)(newServings).div(originalServings);
2457
3917
  return this.scaleBy(factor);
2458
3918
  }
2459
3919
  /**
@@ -2468,18 +3928,19 @@ var _Recipe = class _Recipe {
2468
3928
  if (originalServings === void 0 || originalServings === 0) {
2469
3929
  originalServings = 1;
2470
3930
  }
3931
+ const unitSystem = this.unitSystem;
2471
3932
  function scaleAlternativesBy(alternatives, factor2) {
2472
3933
  for (const alternative of alternatives) {
2473
- if (alternative.itemQuantity) {
2474
- const scaleFactor = alternative.itemQuantity.scalable ? (0, import_big4.default)(factor2) : 1;
2475
- if (alternative.itemQuantity.quantity.type !== "fixed" || alternative.itemQuantity.quantity.value.type !== "text") {
2476
- alternative.itemQuantity.quantity = multiplyQuantityValue(
2477
- alternative.itemQuantity.quantity,
3934
+ if (alternative.quantity) {
3935
+ const scaleFactor = alternative.scalable ? (0, import_big5.default)(factor2) : 1;
3936
+ if (alternative.quantity.type !== "fixed" || alternative.quantity.value.type !== "text") {
3937
+ alternative.quantity = multiplyQuantityValue(
3938
+ alternative.quantity,
2478
3939
  scaleFactor
2479
3940
  );
2480
3941
  }
2481
- if (alternative.itemQuantity.equivalents) {
2482
- alternative.itemQuantity.equivalents = alternative.itemQuantity.equivalents.map(
3942
+ if (alternative.equivalents) {
3943
+ alternative.equivalents = alternative.equivalents.map(
2483
3944
  (altQuantity) => {
2484
3945
  if (altQuantity.quantity.type === "fixed" && altQuantity.quantity.value.type === "text") {
2485
3946
  return altQuantity;
@@ -2495,6 +3956,20 @@ var _Recipe = class _Recipe {
2495
3956
  }
2496
3957
  );
2497
3958
  }
3959
+ const optimizedPrimary = applyBestUnit(
3960
+ {
3961
+ quantity: alternative.quantity,
3962
+ unit: alternative.unit
3963
+ },
3964
+ unitSystem
3965
+ );
3966
+ alternative.quantity = optimizedPrimary.quantity;
3967
+ alternative.unit = optimizedPrimary.unit;
3968
+ if (alternative.equivalents) {
3969
+ alternative.equivalents = alternative.equivalents.map(
3970
+ (eq) => applyBestUnit(eq, unitSystem)
3971
+ );
3972
+ }
2498
3973
  }
2499
3974
  }
2500
3975
  }
@@ -2509,8 +3984,10 @@ var _Recipe = class _Recipe {
2509
3984
  }
2510
3985
  }
2511
3986
  }
2512
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
2513
- scaleAlternativesBy(alternatives, factor);
3987
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3988
+ for (const subgroup of subgroups) {
3989
+ scaleAlternativesBy(subgroup, factor);
3990
+ }
2514
3991
  }
2515
3992
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
2516
3993
  scaleAlternativesBy(alternatives, factor);
@@ -2520,39 +3997,198 @@ var _Recipe = class _Recipe {
2520
3997
  arbitrary.quantity,
2521
3998
  factor
2522
3999
  );
4000
+ const optimized = applyBestUnit(
4001
+ { quantity: arbitrary.quantity, unit: arbitrary.unit },
4002
+ unitSystem
4003
+ );
4004
+ arbitrary.quantity = optimized.quantity;
4005
+ arbitrary.unit = optimized.unit;
2523
4006
  }
2524
- newRecipe._populate_ingredient_quantities();
2525
- newRecipe.servings = (0, import_big4.default)(originalServings).times(factor).toNumber();
2526
- if (newRecipe.metadata.servings && this.metadata.servings) {
2527
- if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
2528
- const servingsValue = parseFloat(
2529
- String(this.metadata.servings).replace(",", ".")
2530
- );
2531
- newRecipe.metadata.servings = String(
2532
- (0, import_big4.default)(servingsValue).times(factor).toNumber()
2533
- );
4007
+ newRecipe._populateIngredientQuantities();
4008
+ newRecipe.servings = (0, import_big5.default)(originalServings).times(factor).toNumber();
4009
+ for (const metaVar of ["servings", "serves"]) {
4010
+ if (typeof newRecipe.metadata[metaVar] === "number") {
4011
+ newRecipe.metadata[metaVar] = (0, import_big5.default)(newRecipe.metadata[metaVar]).times(factor).toNumber();
2534
4012
  }
2535
4013
  }
2536
4014
  if (newRecipe.metadata.yield && this.metadata.yield) {
2537
- if (floatRegex.test(String(this.metadata.yield).replace(",", ".").trim())) {
2538
- const yieldValue = parseFloat(
2539
- String(this.metadata.yield).replace(",", ".")
4015
+ const original = this.metadata.yield;
4016
+ if (original.quantity.type === "fixed" && original.quantity.value.type === "text") {
4017
+ } else {
4018
+ const scaledQuantity = multiplyQuantityValue(
4019
+ original.quantity,
4020
+ factor
2540
4021
  );
2541
- newRecipe.metadata.yield = String(
2542
- (0, import_big4.default)(yieldValue).times(factor).toNumber()
4022
+ const optimized = applyBestUnit(
4023
+ { quantity: scaledQuantity, unit: original.unit },
4024
+ unitSystem
2543
4025
  );
4026
+ const scaled = {
4027
+ quantity: optimized.quantity
4028
+ };
4029
+ if (optimized.unit) scaled.unit = optimized.unit;
4030
+ if (original.textBefore) scaled.textBefore = original.textBefore;
4031
+ if (original.textAfter) scaled.textAfter = original.textAfter;
4032
+ newRecipe.metadata.yield = scaled;
4033
+ }
4034
+ }
4035
+ return newRecipe;
4036
+ }
4037
+ /**
4038
+ * Converts all ingredient quantities in the recipe to a target unit system.
4039
+ *
4040
+ * @param system - The target unit system to convert to (metric, US, UK, JP)
4041
+ * @param method - How to handle existing quantities:
4042
+ * - "keep": Keep all existing equivalents (swap if needed, or add converted)
4043
+ * - "replace": Replace primary with target system quantity, discard equivalent used for conversion
4044
+ * - "remove": Only keep target system quantity, delete all equivalents
4045
+ * @returns A new Recipe instance with converted quantities
4046
+ *
4047
+ * @example
4048
+ * ```typescript
4049
+ * // Convert a recipe to metric, keeping original units as equivalents
4050
+ * const metricRecipe = recipe.convertTo("metric", "keep");
4051
+ *
4052
+ * // Convert to US units, removing all other equivalents
4053
+ * const usRecipe = recipe.convertTo("US", "remove");
4054
+ * ```
4055
+ */
4056
+ convertTo(system, method) {
4057
+ const newRecipe = this.clone();
4058
+ function buildNewPrimary(convertedQty, oldPrimary, remainingEquivalents, scalable, integerProtected, source) {
4059
+ const newUnit = integerProtected && convertedQty.unit ? { name: convertedQty.unit.name, integerProtected: true } : convertedQty.unit;
4060
+ const newPrimary = {
4061
+ quantity: convertedQty.quantity,
4062
+ unit: newUnit,
4063
+ scalable
4064
+ };
4065
+ if (method === "remove") {
4066
+ return newPrimary;
4067
+ } else if (method === "replace") {
4068
+ if (source === "converted") remainingEquivalents.push(oldPrimary);
4069
+ if (remainingEquivalents.length > 0) {
4070
+ newPrimary.equivalents = remainingEquivalents;
4071
+ }
4072
+ } else {
4073
+ newPrimary.equivalents = [oldPrimary, ...remainingEquivalents];
2544
4074
  }
4075
+ return newPrimary;
2545
4076
  }
2546
- if (newRecipe.metadata.serves && this.metadata.serves) {
2547
- if (floatRegex.test(String(this.metadata.serves).replace(",", ".").trim())) {
2548
- const servesValue = parseFloat(
2549
- String(this.metadata.serves).replace(",", ".")
4077
+ function convertAlternativeQuantity(alternative) {
4078
+ const primaryUnit = resolveUnit(alternative.unit?.name);
4079
+ const equivalents = alternative.equivalents ?? [];
4080
+ const oldPrimary = {
4081
+ quantity: alternative.quantity,
4082
+ unit: alternative.unit
4083
+ };
4084
+ if (primaryUnit.type !== "other" && isUnitCompatibleWithSystem(primaryUnit, system)) {
4085
+ if (method === "remove") {
4086
+ return {
4087
+ quantity: alternative.quantity,
4088
+ unit: alternative.unit,
4089
+ scalable: alternative.scalable
4090
+ };
4091
+ }
4092
+ return {
4093
+ quantity: alternative.quantity,
4094
+ unit: alternative.unit,
4095
+ scalable: alternative.scalable,
4096
+ equivalents
4097
+ };
4098
+ }
4099
+ const targetEquivIndex = equivalents.findIndex((eq) => {
4100
+ const eqUnit = resolveUnit(eq.unit?.name);
4101
+ return eqUnit.type !== "other" && isUnitCompatibleWithSystem(eqUnit, system);
4102
+ });
4103
+ if (targetEquivIndex !== -1) {
4104
+ const targetEquiv = equivalents[targetEquivIndex];
4105
+ const remainingEquivalents = equivalents.filter(
4106
+ (_, i2) => i2 !== targetEquivIndex
2550
4107
  );
2551
- newRecipe.metadata.serves = String(
2552
- (0, import_big4.default)(servesValue).times(factor).toNumber()
4108
+ return buildNewPrimary(
4109
+ targetEquiv,
4110
+ oldPrimary,
4111
+ remainingEquivalents,
4112
+ alternative.scalable,
4113
+ targetEquiv.unit?.integerProtected,
4114
+ "swapped"
4115
+ );
4116
+ }
4117
+ const converted = convertQuantityToSystem(oldPrimary, system);
4118
+ if (converted && converted.unit) {
4119
+ return buildNewPrimary(
4120
+ converted,
4121
+ oldPrimary,
4122
+ equivalents,
4123
+ alternative.scalable,
4124
+ alternative.unit?.integerProtected,
4125
+ "swapped"
2553
4126
  );
2554
4127
  }
4128
+ for (let i2 = 0; i2 < equivalents.length; i2++) {
4129
+ const equiv = equivalents[i2];
4130
+ const convertedEquiv = convertQuantityToSystem(equiv, system);
4131
+ if (convertedEquiv && convertedEquiv.unit) {
4132
+ const remainingEquivalents = method === "keep" ? equivalents : equivalents.filter((_, idx) => idx !== i2);
4133
+ return buildNewPrimary(
4134
+ convertedEquiv,
4135
+ oldPrimary,
4136
+ remainingEquivalents,
4137
+ alternative.scalable,
4138
+ equiv.unit?.integerProtected,
4139
+ "converted"
4140
+ );
4141
+ }
4142
+ }
4143
+ if (method === "remove") {
4144
+ return {
4145
+ quantity: alternative.quantity,
4146
+ unit: alternative.unit,
4147
+ scalable: alternative.scalable
4148
+ };
4149
+ } else {
4150
+ return {
4151
+ quantity: alternative.quantity,
4152
+ unit: alternative.unit,
4153
+ scalable: alternative.scalable,
4154
+ equivalents
4155
+ };
4156
+ }
4157
+ }
4158
+ function convertAlternatives(alternatives) {
4159
+ for (const alternative of alternatives) {
4160
+ if (alternative.quantity) {
4161
+ const converted = convertAlternativeQuantity(
4162
+ alternative
4163
+ );
4164
+ alternative.quantity = converted.quantity;
4165
+ alternative.unit = converted.unit;
4166
+ alternative.scalable = converted.scalable;
4167
+ alternative.equivalents = converted.equivalents;
4168
+ }
4169
+ }
2555
4170
  }
4171
+ for (const section of newRecipe.sections) {
4172
+ for (const step of section.content.filter(
4173
+ (item) => item.type === "step"
4174
+ )) {
4175
+ for (const item of step.items.filter(
4176
+ (item2) => item2.type === "ingredient"
4177
+ )) {
4178
+ convertAlternatives(item.alternatives);
4179
+ }
4180
+ }
4181
+ }
4182
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
4183
+ for (const subgroup of subgroups) {
4184
+ convertAlternatives(subgroup);
4185
+ }
4186
+ }
4187
+ for (const alternatives of newRecipe.choices.ingredientItems.values()) {
4188
+ convertAlternatives(alternatives);
4189
+ }
4190
+ newRecipe._populateIngredientQuantities();
4191
+ if (method !== "keep") _Recipe.unitSystems.set(newRecipe, system);
2556
4192
  return newRecipe;
2557
4193
  }
2558
4194
  /**
@@ -2577,7 +4213,11 @@ var _Recipe = class _Recipe {
2577
4213
  newRecipe.metadata = deepClone(this.metadata);
2578
4214
  newRecipe.ingredients = deepClone(this.ingredients);
2579
4215
  newRecipe.sections = this.sections.map((section) => {
2580
- const newSection = new Section(section.name);
4216
+ const newSection = new Section(
4217
+ section.name,
4218
+ section.variants,
4219
+ section.optional
4220
+ );
2581
4221
  newSection.content = deepClone(section.content);
2582
4222
  return newSection;
2583
4223
  });
@@ -2588,21 +4228,30 @@ var _Recipe = class _Recipe {
2588
4228
  return newRecipe;
2589
4229
  }
2590
4230
  };
4231
+ /**
4232
+ * External storage for unit system (not a property on instances).
4233
+ * Used for resolving ambiguous units during quantity addition.
4234
+ */
4235
+ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
2591
4236
  /**
2592
4237
  * External storage for item count (not a property on instances).
2593
4238
  * Used for giving ID numbers to items during parsing.
2594
4239
  */
2595
4240
  __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
4241
+ /**
4242
+ * External storage for subgroup index tracking during parsing.
4243
+ * Maps groupKey → subgroupKey → index within the subgroups array.
4244
+ */
4245
+ __publicField(_Recipe, "subgroupIndices", /* @__PURE__ */ new WeakMap());
2596
4246
  var Recipe = _Recipe;
2597
4247
 
2598
4248
  // src/classes/shopping_list.ts
2599
4249
  var ShoppingList = class {
2600
4250
  /**
2601
4251
  * Creates a new ShoppingList instance
2602
- * @param category_config_str - The category configuration to parse.
4252
+ * @param categoryConfigStr - The category configuration to parse.
2603
4253
  */
2604
- constructor(category_config_str) {
2605
- // TODO: backport type change
4254
+ constructor(categoryConfigStr) {
2606
4255
  /**
2607
4256
  * The ingredients in the shopping list.
2608
4257
  */
@@ -2614,43 +4263,43 @@ var ShoppingList = class {
2614
4263
  /**
2615
4264
  * The category configuration for the shopping list.
2616
4265
  */
2617
- __publicField(this, "category_config");
4266
+ __publicField(this, "categoryConfig");
2618
4267
  /**
2619
4268
  * The categorized ingredients in the shopping list.
2620
4269
  */
2621
4270
  __publicField(this, "categories");
2622
- if (category_config_str) {
2623
- this.set_category_config(category_config_str);
4271
+ /**
4272
+ * The unit system to use for quantity simplification.
4273
+ * When set, overrides per-recipe unit systems.
4274
+ */
4275
+ __publicField(this, "unitSystem");
4276
+ /**
4277
+ * Per-ingredient equivalence ratio maps for recomputing equivalents
4278
+ * after pantry subtraction. Keyed by ingredient name.
4279
+ * @internal
4280
+ */
4281
+ __publicField(this, "equivalenceRatios", /* @__PURE__ */ new Map());
4282
+ /**
4283
+ * The original pantry (never mutated by recipe calculations).
4284
+ */
4285
+ __publicField(this, "pantry");
4286
+ /**
4287
+ * The pantry with quantities updated after subtracting recipe needs.
4288
+ * Recomputed on every {@link ShoppingList.calculateIngredients | calculateIngredients()} call.
4289
+ */
4290
+ __publicField(this, "resultingPantry");
4291
+ if (categoryConfigStr) {
4292
+ this.setCategoryConfig(categoryConfigStr);
2624
4293
  }
2625
4294
  }
2626
- calculate_ingredients() {
4295
+ calculateIngredients() {
2627
4296
  this.ingredients = [];
2628
- const addIngredientQuantity = (name, quantityTotal) => {
2629
- const quantityTotalExtended = extendAllUnits(quantityTotal);
2630
- const newQuantities = isAndGroup(quantityTotalExtended) ? quantityTotalExtended.entries : [quantityTotalExtended];
2631
- const existing = this.ingredients.find((i2) => i2.name === name);
2632
- if (existing) {
2633
- if (!existing.quantityTotal) {
2634
- existing.quantityTotal = quantityTotal;
2635
- return;
2636
- }
2637
- try {
2638
- const existingQuantityTotalExtended = extendAllUnits(
2639
- existing.quantityTotal
2640
- );
2641
- const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.entries : [existingQuantityTotalExtended];
2642
- existing.quantityTotal = addEquivalentsAndSimplify(
2643
- ...existingQuantities,
2644
- ...newQuantities
2645
- );
2646
- return;
2647
- } catch {
2648
- }
4297
+ const rawQuantitiesMap = /* @__PURE__ */ new Map();
4298
+ const nameOrder = [];
4299
+ const trackName = (name) => {
4300
+ if (!nameOrder.includes(name)) {
4301
+ nameOrder.push(name);
2649
4302
  }
2650
- this.ingredients.push({
2651
- name,
2652
- quantityTotal
2653
- });
2654
4303
  };
2655
4304
  for (const addedRecipe of this.recipes) {
2656
4305
  let scaledRecipe;
@@ -2660,28 +4309,261 @@ var ShoppingList = class {
2660
4309
  } else {
2661
4310
  scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
2662
4311
  }
2663
- const computedIngredients = scaledRecipe.calc_ingredient_quantities(
2664
- addedRecipe.choices
2665
- );
2666
- for (const ingredient of computedIngredients) {
2667
- if (ingredient.flags && ingredient.flags.includes("hidden")) {
4312
+ const rawGroups = scaledRecipe.getRawQuantityGroups({
4313
+ choices: addedRecipe.choices
4314
+ });
4315
+ for (const group of rawGroups) {
4316
+ if (group.flags?.includes("hidden") || !group.usedAsPrimary) {
2668
4317
  continue;
2669
4318
  }
2670
- if (ingredient.quantityTotal) {
2671
- addIngredientQuantity(ingredient.name, ingredient.quantityTotal);
2672
- } else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
2673
- this.ingredients.push({ name: ingredient.name });
4319
+ trackName(group.name);
4320
+ if (group.quantities.length > 0) {
4321
+ const existing = rawQuantitiesMap.get(group.name) ?? [];
4322
+ existing.push(...group.quantities);
4323
+ rawQuantitiesMap.set(group.name, existing);
4324
+ }
4325
+ }
4326
+ }
4327
+ this.equivalenceRatios.clear();
4328
+ for (const name of nameOrder) {
4329
+ const rawQuantities = rawQuantitiesMap.get(name);
4330
+ if (!rawQuantities || rawQuantities.length === 0) {
4331
+ this.ingredients.push({ name });
4332
+ continue;
4333
+ }
4334
+ const textEntries = [];
4335
+ const numericEntries = [];
4336
+ for (const q of rawQuantities) {
4337
+ if ("quantity" in q && q.quantity.type === "fixed" && q.quantity.value.type === "text") {
4338
+ textEntries.push(q);
4339
+ } else {
4340
+ numericEntries.push(q);
4341
+ }
4342
+ }
4343
+ if (numericEntries.length > 1) {
4344
+ const ratioMap = buildEquivalenceRatioMap(
4345
+ getEquivalentUnitsLists(...numericEntries)
4346
+ );
4347
+ if (Object.keys(ratioMap).length > 0) {
4348
+ this.equivalenceRatios.set(name, ratioMap);
2674
4349
  }
2675
4350
  }
4351
+ const resultQuantities = [];
4352
+ for (const t2 of textEntries) {
4353
+ resultQuantities.push(toPlainUnit(t2));
4354
+ }
4355
+ if (numericEntries.length > 0) {
4356
+ resultQuantities.push(
4357
+ ...flattenPlainUnitGroup(
4358
+ addEquivalentsAndSimplify(numericEntries, this.unitSystem)
4359
+ )
4360
+ );
4361
+ }
4362
+ this.ingredients.push({
4363
+ name,
4364
+ quantities: resultQuantities
4365
+ });
2676
4366
  }
4367
+ this.applyPantrySubtraction();
4368
+ }
4369
+ /**
4370
+ * Subtracts pantry item quantities from calculated ingredient quantities
4371
+ * and updates the resultingPantry to reflect consumed stock.
4372
+ */
4373
+ applyPantrySubtraction() {
4374
+ if (!this.pantry) {
4375
+ this.resultingPantry = void 0;
4376
+ return;
4377
+ }
4378
+ const clonedPantry = new Pantry();
4379
+ clonedPantry.items = deepClone(this.pantry.items);
4380
+ if (this.categoryConfig) {
4381
+ clonedPantry.setCategoryConfig(this.categoryConfig);
4382
+ }
4383
+ for (const ingredient of this.ingredients) {
4384
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
4385
+ continue;
4386
+ const pantryItem = clonedPantry.findItem(ingredient.name);
4387
+ if (!pantryItem || !pantryItem.quantity) continue;
4388
+ let pantryExtended = {
4389
+ quantity: pantryItem.quantity,
4390
+ ...pantryItem.unit && { unit: { name: pantryItem.unit } }
4391
+ };
4392
+ for (let i2 = 0; i2 < ingredient.quantities.length; i2++) {
4393
+ const entry = ingredient.quantities[i2];
4394
+ const leaves = "and" in entry ? entry.and : [entry];
4395
+ for (const leaf of leaves) {
4396
+ const ingredientExtended = toExtendedUnit(leaf);
4397
+ const leafHasUnit = leaf.unit !== void 0 && leaf.unit !== "";
4398
+ const pantryHasUnit = pantryExtended.unit !== void 0 && pantryExtended.unit.name !== "";
4399
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4400
+ const unitMismatch = leafHasUnit !== pantryHasUnit && ratioMap !== void 0;
4401
+ const leafDef = normalizeUnit(leaf.unit);
4402
+ const pantryDef = normalizeUnit(pantryExtended.unit?.name);
4403
+ if (unitMismatch) {
4404
+ const leafUnit = leaf.unit ?? NO_UNIT;
4405
+ const pantryUnit = pantryExtended.unit?.name ?? NO_UNIT;
4406
+ const ratioFromPantry = ratioMap[normalizeUnit(leafUnit)?.name ?? leafUnit]?.[normalizeUnit(pantryUnit)?.name ?? pantryUnit];
4407
+ if (ratioFromPantry !== void 0) {
4408
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4409
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4410
+ if (typeof pantryValue === "number" && typeof leafValue === "number") {
4411
+ const pantryInLeafUnits = pantryValue * ratioFromPantry;
4412
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4413
+ const remainingLeafValue = Math.max(
4414
+ leafValue - pantryInLeafUnits,
4415
+ 0
4416
+ );
4417
+ leaf.quantity = {
4418
+ type: "fixed",
4419
+ value: { type: "decimal", decimal: remainingLeafValue }
4420
+ };
4421
+ const consumedInPantryUnits = subtracted / ratioFromPantry;
4422
+ const remainingPantryValue = Math.max(
4423
+ pantryValue - consumedInPantryUnits,
4424
+ 0
4425
+ );
4426
+ pantryExtended = {
4427
+ quantity: {
4428
+ type: "fixed",
4429
+ value: {
4430
+ type: "decimal",
4431
+ decimal: remainingPantryValue
4432
+ }
4433
+ },
4434
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4435
+ };
4436
+ continue;
4437
+ }
4438
+ } else {
4439
+ continue;
4440
+ }
4441
+ } else if (leafDef && pantryDef && areUnitsConvertible(leafDef, pantryDef) || (leaf.unit ?? "").toLowerCase() === (pantryExtended.unit?.name ?? "").toLowerCase()) {
4442
+ const remaining = subtractQuantities(
4443
+ ingredientExtended,
4444
+ pantryExtended,
4445
+ { clampToZero: true }
4446
+ );
4447
+ const consumed = subtractQuantities(
4448
+ pantryExtended,
4449
+ ingredientExtended,
4450
+ { clampToZero: true }
4451
+ );
4452
+ pantryExtended = consumed;
4453
+ const updated = toPlainUnit(remaining);
4454
+ leaf.quantity = updated.quantity;
4455
+ leaf.unit = updated.unit;
4456
+ } else if (ratioMap) {
4457
+ const canonicalLeaf = normalizeUnit(leaf.unit)?.name ?? leaf.unit;
4458
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4459
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4460
+ if (typeof leafValue === "number" && typeof pantryValue === "number" && pantryDef) {
4461
+ for (const [equivUnit, ratios] of Object.entries(ratioMap)) {
4462
+ const ratio = ratios[canonicalLeaf];
4463
+ if (ratio === void 0) continue;
4464
+ const equivDef = normalizeUnit(equivUnit);
4465
+ if (!equivDef || !areUnitsConvertible(equivDef, pantryDef))
4466
+ continue;
4467
+ const pantryInEquiv = pantryValue * getToBase(pantryDef) / getToBase(equivDef);
4468
+ const pantryInLeafUnits = pantryInEquiv / ratio;
4469
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4470
+ const remainingLeafValue = Math.max(
4471
+ leafValue - pantryInLeafUnits,
4472
+ 0
4473
+ );
4474
+ leaf.quantity = {
4475
+ type: "fixed",
4476
+ value: { type: "decimal", decimal: remainingLeafValue }
4477
+ };
4478
+ const consumedInEquiv = subtracted * ratio;
4479
+ const consumedInPantryUnits = consumedInEquiv * getToBase(equivDef) / getToBase(pantryDef);
4480
+ const remainingPantryValue = Math.max(
4481
+ pantryValue - consumedInPantryUnits,
4482
+ 0
4483
+ );
4484
+ pantryExtended = {
4485
+ quantity: {
4486
+ type: "fixed",
4487
+ value: {
4488
+ type: "decimal",
4489
+ decimal: remainingPantryValue
4490
+ }
4491
+ },
4492
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4493
+ };
4494
+ break;
4495
+ }
4496
+ }
4497
+ }
4498
+ }
4499
+ if ("and" in entry) {
4500
+ const nonZero = entry.and.filter(
4501
+ (leaf) => leaf.quantity.type !== "fixed" || leaf.quantity.value.type !== "decimal" || leaf.quantity.value.decimal !== 0
4502
+ );
4503
+ entry.and.length = 0;
4504
+ entry.and.push(...nonZero);
4505
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4506
+ if (entry.equivalents && ratioMap) {
4507
+ const equivUnits = entry.equivalents.map((e2) => e2.unit);
4508
+ entry.equivalents = recomputeEquivalents(
4509
+ entry.and,
4510
+ ratioMap,
4511
+ equivUnits
4512
+ );
4513
+ }
4514
+ if (entry.and.length === 1) {
4515
+ const single = entry.and[0];
4516
+ ingredient.quantities[i2] = {
4517
+ quantity: single.quantity,
4518
+ ...single.unit && { unit: single.unit },
4519
+ ...entry.equivalents && { equivalents: entry.equivalents }
4520
+ };
4521
+ }
4522
+ } else if ("equivalents" in entry && entry.equivalents) {
4523
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4524
+ if (ratioMap) {
4525
+ const equivUnits = entry.equivalents.map(
4526
+ (e2) => e2.unit
4527
+ // equivalents always have units
4528
+ );
4529
+ const recomputed = recomputeEquivalents(
4530
+ [entry],
4531
+ ratioMap,
4532
+ equivUnits
4533
+ );
4534
+ entry.equivalents = recomputed;
4535
+ }
4536
+ }
4537
+ }
4538
+ ingredient.quantities = ingredient.quantities.filter((entry) => {
4539
+ if ("and" in entry) return entry.and.length > 0;
4540
+ return !(entry.quantity.type === "fixed" && entry.quantity.value.type === "decimal" && entry.quantity.value.decimal === 0);
4541
+ });
4542
+ if (ingredient.quantities.length === 0) {
4543
+ ingredient.quantities = void 0;
4544
+ }
4545
+ pantryItem.quantity = pantryExtended.quantity;
4546
+ if (pantryExtended.unit) {
4547
+ pantryItem.unit = pantryExtended.unit.name;
4548
+ }
4549
+ }
4550
+ this.resultingPantry = clonedPantry;
2677
4551
  }
2678
4552
  /**
2679
4553
  * Adds a recipe to the shopping list, then automatically
2680
4554
  * recalculates the quantities and recategorize the ingredients.
2681
4555
  * @param recipe - The recipe to add.
2682
4556
  * @param options - Options for adding the recipe.
4557
+ * @throws Error if the recipe has alternatives without corresponding choices.
2683
4558
  */
2684
- add_recipe(recipe, options = {}) {
4559
+ addRecipe(recipe, options = {}) {
4560
+ const errorMessage = this.getUnresolvedAlternativesError(
4561
+ recipe,
4562
+ options.choices
4563
+ );
4564
+ if (errorMessage) {
4565
+ throw new Error(errorMessage);
4566
+ }
2685
4567
  if (!options.scaling) {
2686
4568
  this.recipes.push({
2687
4569
  recipe,
@@ -2703,32 +4585,102 @@ var ShoppingList = class {
2703
4585
  });
2704
4586
  }
2705
4587
  }
2706
- this.calculate_ingredients();
4588
+ this.calculateIngredients();
2707
4589
  this.categorize();
2708
4590
  }
4591
+ /**
4592
+ * Checks if a recipe has unresolved alternatives (alternatives without provided choices).
4593
+ * @param recipe - The recipe to check.
4594
+ * @param choices - The choices provided for the recipe.
4595
+ * @returns An error message if there are unresolved alternatives, undefined otherwise.
4596
+ */
4597
+ getUnresolvedAlternativesError(recipe, choices) {
4598
+ const missingItems = [];
4599
+ const missingGroups = [];
4600
+ for (const itemId of recipe.choices.ingredientItems.keys()) {
4601
+ if (!choices?.ingredientItems?.has(itemId)) {
4602
+ missingItems.push(itemId);
4603
+ }
4604
+ }
4605
+ for (const groupId of recipe.choices.ingredientGroups.keys()) {
4606
+ if (!choices?.ingredientGroups?.has(groupId)) {
4607
+ missingGroups.push(groupId);
4608
+ }
4609
+ }
4610
+ if (missingItems.length === 0 && missingGroups.length === 0) {
4611
+ return void 0;
4612
+ }
4613
+ const parts = [];
4614
+ if (missingItems.length > 0) {
4615
+ parts.push(
4616
+ `ingredientItems: [${missingItems.map((i2) => `'${i2}'`).join(", ")}]`
4617
+ );
4618
+ }
4619
+ if (missingGroups.length > 0) {
4620
+ parts.push(
4621
+ `ingredientGroups: [${missingGroups.map((g) => `'${g}'`).join(", ")}]`
4622
+ );
4623
+ }
4624
+ return `Recipe has unresolved alternatives. Missing choices for: ${parts.join(", ")}`;
4625
+ }
2709
4626
  /**
2710
4627
  * Removes a recipe from the shopping list, then automatically
2711
- * recalculates the quantities and recategorize the ingredients.s
4628
+ * recalculates the quantities and recategorize the ingredients.
2712
4629
  * @param index - The index of the recipe to remove.
2713
4630
  */
2714
- remove_recipe(index) {
4631
+ removeRecipe(index) {
2715
4632
  if (index < 0 || index >= this.recipes.length) {
2716
4633
  throw new Error("Index out of bounds");
2717
4634
  }
2718
4635
  this.recipes.splice(index, 1);
2719
- this.calculate_ingredients();
4636
+ this.calculateIngredients();
2720
4637
  this.categorize();
2721
4638
  }
4639
+ /**
4640
+ * Adds a pantry to the shopping list. On-hand pantry quantities will be
4641
+ * subtracted from recipe ingredient needs on each recalculation.
4642
+ * @param pantry - A Pantry instance or a TOML string to parse.
4643
+ * @param options - Options for pantry parsing (only used when providing a TOML string).
4644
+ */
4645
+ addPantry(pantry, options) {
4646
+ if (typeof pantry === "string") {
4647
+ this.pantry = new Pantry(pantry, options);
4648
+ } else if (pantry instanceof Pantry) {
4649
+ this.pantry = pantry;
4650
+ } else {
4651
+ throw new Error(
4652
+ "Invalid pantry: expected a Pantry instance or TOML string"
4653
+ );
4654
+ }
4655
+ if (this.categoryConfig) {
4656
+ this.pantry.setCategoryConfig(this.categoryConfig);
4657
+ }
4658
+ this.calculateIngredients();
4659
+ this.categorize();
4660
+ }
4661
+ /**
4662
+ * Returns the resulting pantry with quantities updated to reflect
4663
+ * what was consumed by the shopping list's recipes.
4664
+ * Returns undefined if no pantry was added.
4665
+ * @returns The resulting Pantry, or undefined.
4666
+ */
4667
+ getPantry() {
4668
+ return this.resultingPantry;
4669
+ }
2722
4670
  /**
2723
4671
  * Sets the category configuration for the shopping list
2724
4672
  * and automatically categorize current ingredients from the list.
4673
+ * Also propagates the configuration to the pantry if one is set.
2725
4674
  * @param config - The category configuration to parse.
2726
4675
  */
2727
- set_category_config(config) {
4676
+ setCategoryConfig(config) {
2728
4677
  if (typeof config === "string")
2729
- this.category_config = new CategoryConfig(config);
2730
- else if (config instanceof CategoryConfig) this.category_config = config;
4678
+ this.categoryConfig = new CategoryConfig(config);
4679
+ else if (config instanceof CategoryConfig) this.categoryConfig = config;
2731
4680
  else throw new Error("Invalid category configuration");
4681
+ if (this.pantry) {
4682
+ this.pantry.setCategoryConfig(this.categoryConfig);
4683
+ }
2732
4684
  this.categorize();
2733
4685
  }
2734
4686
  /**
@@ -2736,17 +4688,17 @@ var ShoppingList = class {
2736
4688
  * Will use the category config if any, otherwise all ingredients will be placed in the "other" category
2737
4689
  */
2738
4690
  categorize() {
2739
- if (!this.category_config) {
4691
+ if (!this.categoryConfig) {
2740
4692
  this.categories = { other: this.ingredients };
2741
4693
  return;
2742
4694
  }
2743
4695
  const categories = { other: [] };
2744
- for (const category of this.category_config.categories) {
4696
+ for (const category of this.categoryConfig.categories) {
2745
4697
  categories[category.name] = [];
2746
4698
  }
2747
4699
  for (const ingredient of this.ingredients) {
2748
4700
  let found = false;
2749
- for (const category of this.category_config.categories) {
4701
+ for (const category of this.categoryConfig.categories) {
2750
4702
  for (const categoryIngredient of category.ingredients) {
2751
4703
  if (categoryIngredient.aliases.includes(ingredient.name)) {
2752
4704
  categories[category.name].push(ingredient);
@@ -2810,7 +4762,6 @@ var ShoppingCart = class {
2810
4762
  setProductCatalog(catalog) {
2811
4763
  this.productCatalog = catalog;
2812
4764
  }
2813
- // TODO: harmonize recipe name to use underscores
2814
4765
  /**
2815
4766
  * Sets the shopping list to build the cart from.
2816
4767
  * To use if a shopping list was not provided at the creation of the instance
@@ -2874,8 +4825,27 @@ var ShoppingCart = class {
2874
4825
  getOptimumMatch(ingredient, options) {
2875
4826
  if (options.length === 0)
2876
4827
  throw new NoProductMatchError(ingredient.name, "noProduct");
2877
- if (!ingredient.quantityTotal)
4828
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
2878
4829
  throw new NoProductMatchError(ingredient.name, "noQuantity");
4830
+ const allPlainEntries = [];
4831
+ for (const q of ingredient.quantities) {
4832
+ if ("and" in q) {
4833
+ allPlainEntries.push({ and: q.and });
4834
+ } else {
4835
+ const entry = {
4836
+ quantity: q.quantity,
4837
+ ...q.unit && { unit: q.unit },
4838
+ ...q.equivalents && { equivalents: q.equivalents }
4839
+ };
4840
+ allPlainEntries.push(entry);
4841
+ }
4842
+ }
4843
+ let quantityTotal;
4844
+ if (allPlainEntries.length === 1) {
4845
+ quantityTotal = allPlainEntries[0];
4846
+ } else {
4847
+ quantityTotal = { and: allPlainEntries };
4848
+ }
2879
4849
  const normalizedOptions = options.map(
2880
4850
  (option) => ({
2881
4851
  ...option,
@@ -2891,10 +4861,10 @@ var ShoppingCart = class {
2891
4861
  })
2892
4862
  })
2893
4863
  );
2894
- const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal);
4864
+ const normalizedQuantityTotal = normalizeAllUnits(quantityTotal);
2895
4865
  function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
2896
4866
  if (isAndGroup(normalizedQuantities)) {
2897
- for (const q of normalizedQuantities.entries) {
4867
+ for (const q of normalizedQuantities.and) {
2898
4868
  const result = getOptimumMatchForQuantityParts(
2899
4869
  q,
2900
4870
  normalizedOptions2,
@@ -2903,7 +4873,7 @@ var ShoppingCart = class {
2903
4873
  selection.push(...result);
2904
4874
  }
2905
4875
  } else {
2906
- const alternativeUnitsOfQuantity = isOrGroup(normalizedQuantities) ? normalizedQuantities.entries : [normalizedQuantities];
4876
+ const alternativeUnitsOfQuantity = isOrGroup(normalizedQuantities) ? normalizedQuantities.or : [normalizedQuantities];
2907
4877
  const solutions = [];
2908
4878
  const errors = /* @__PURE__ */ new Set();
2909
4879
  for (const alternative of alternativeUnitsOfQuantity) {
@@ -2918,12 +4888,12 @@ var ShoppingCart = class {
2918
4888
  alternative.quantity = scaledQuantity;
2919
4889
  const matchOptions = normalizedOptions2.filter(
2920
4890
  (option) => option.sizes.some(
2921
- (s) => areUnitsCompatible(alternative.unit, s.unit)
4891
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2922
4892
  )
2923
4893
  );
2924
4894
  if (matchOptions.length > 0) {
2925
4895
  const findCompatibleSize = (option) => option.sizes.find(
2926
- (s) => areUnitsCompatible(alternative.unit, s.unit)
4896
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2927
4897
  );
2928
4898
  if (matchOptions.length == 1) {
2929
4899
  const matchedOption = matchOptions[0];
@@ -3023,18 +4993,179 @@ var ShoppingCart = class {
3023
4993
  return this.summary;
3024
4994
  }
3025
4995
  };
4996
+
4997
+ // src/utils/render_helpers.ts
4998
+ var VULGAR_FRACTIONS = {
4999
+ "1/2": "\xBD",
5000
+ "1/3": "\u2153",
5001
+ "2/3": "\u2154",
5002
+ "1/4": "\xBC",
5003
+ "3/4": "\xBE",
5004
+ "1/8": "\u215B",
5005
+ "3/8": "\u215C",
5006
+ "5/8": "\u215D",
5007
+ "7/8": "\u215E"
5008
+ };
5009
+ function renderFractionAsVulgar(num, den) {
5010
+ const wholePart = Math.floor(num / den);
5011
+ const remainder = num % den;
5012
+ if (remainder === 0) {
5013
+ return String(wholePart);
5014
+ }
5015
+ const fractionKey = `${remainder}/${den}`;
5016
+ const vulgar = VULGAR_FRACTIONS[fractionKey];
5017
+ if (wholePart > 0) {
5018
+ return vulgar ? `${wholePart}${vulgar}` : `${wholePart} ${remainder}/${den}`;
5019
+ }
5020
+ return vulgar ?? `${num}/${den}`;
5021
+ }
5022
+ function formatNumericValue(value, useVulgar = true) {
5023
+ if (value.type === "decimal") {
5024
+ return String(value.decimal);
5025
+ }
5026
+ if (useVulgar) {
5027
+ return renderFractionAsVulgar(value.num, value.den);
5028
+ }
5029
+ return `${value.num}/${value.den}`;
5030
+ }
5031
+ function formatSingleValue(value) {
5032
+ if (value.type === "text") {
5033
+ return value.text;
5034
+ }
5035
+ return formatNumericValue(value);
5036
+ }
5037
+ function formatQuantity(quantity) {
5038
+ if (quantity.type === "fixed") {
5039
+ return formatSingleValue(quantity.value);
5040
+ }
5041
+ const minStr = formatNumericValue(quantity.min);
5042
+ const maxStr = formatNumericValue(quantity.max);
5043
+ return `${minStr}-${maxStr}`;
5044
+ }
5045
+ function formatUnit(unit) {
5046
+ if (!unit) return "";
5047
+ if (typeof unit === "string") return unit;
5048
+ return unit.name;
5049
+ }
5050
+ function formatQuantityWithUnit(quantity, unit) {
5051
+ if (!quantity) return "";
5052
+ const qty = formatQuantity(quantity);
5053
+ const unitStr = formatUnit(unit);
5054
+ return unitStr ? `${qty} ${unitStr}` : qty;
5055
+ }
5056
+ function formatExtendedQuantity(item) {
5057
+ return formatQuantityWithUnit(item.quantity, item.unit);
5058
+ }
5059
+ function formatItemQuantity(itemQuantity, separator = " | ") {
5060
+ const parts = [];
5061
+ parts.push(formatExtendedQuantity(itemQuantity));
5062
+ if (itemQuantity.equivalents) {
5063
+ for (const eq of itemQuantity.equivalents) {
5064
+ parts.push(formatExtendedQuantity(eq));
5065
+ }
5066
+ }
5067
+ return parts.join(separator);
5068
+ }
5069
+ function isGroupedItem(item) {
5070
+ return item.group !== void 0;
5071
+ }
5072
+ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
5073
+ if (item.group) {
5074
+ const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
5075
+ const groupSubgroups = recipe.choices.ingredientGroups.get(item.group);
5076
+ if (groupSubgroups && selectedIndex2 !== void 0 && selectedIndex2 < groupSubgroups.length) {
5077
+ const selectedSubgroup = groupSubgroups[selectedIndex2];
5078
+ return selectedSubgroup?.some((alt) => alt.itemId === item.id);
5079
+ }
5080
+ return false;
5081
+ }
5082
+ const selectedIndex = choices?.ingredientItems?.get(item.id);
5083
+ return alternativeIndex === selectedIndex;
5084
+ }
5085
+ function isSectionActive(section, variant) {
5086
+ if (!section.variants) return true;
5087
+ const isDefault = variant === void 0 || variant === "*";
5088
+ if (isDefault) {
5089
+ return section.variants.includes("*");
5090
+ }
5091
+ return section.variants.includes(variant);
5092
+ }
5093
+ function isStepActive(step, variant) {
5094
+ if (!step.variants) return true;
5095
+ const isDefault = variant === void 0 || variant === "*";
5096
+ if (isDefault) {
5097
+ return step.variants.includes("*");
5098
+ }
5099
+ return step.variants.includes(variant);
5100
+ }
5101
+ function getEffectiveChoices(recipe, variant) {
5102
+ const choices = { variant };
5103
+ if (variant === void 0 || variant === "*") return choices;
5104
+ const variantLower = variant.toLowerCase();
5105
+ for (const [itemId, alternatives] of recipe.choices.ingredientItems) {
5106
+ const matchIdx = alternatives.findIndex(
5107
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5108
+ );
5109
+ if (matchIdx >= 0) {
5110
+ if (!choices.ingredientItems) choices.ingredientItems = /* @__PURE__ */ new Map();
5111
+ choices.ingredientItems.set(itemId, matchIdx);
5112
+ }
5113
+ }
5114
+ for (const [groupId, subgroups] of recipe.choices.ingredientGroups) {
5115
+ const matchIdx = subgroups.findIndex(
5116
+ (sg) => sg.some(
5117
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5118
+ )
5119
+ );
5120
+ if (matchIdx >= 0) {
5121
+ if (!choices.ingredientGroups) choices.ingredientGroups = /* @__PURE__ */ new Map();
5122
+ choices.ingredientGroups.set(groupId, matchIdx);
5123
+ }
5124
+ }
5125
+ return choices;
5126
+ }
3026
5127
  // Annotate the CommonJS export names for ESM import in node:
3027
5128
  0 && (module.exports = {
3028
5129
  CategoryConfig,
3029
5130
  NoProductCatalogForCartError,
3030
5131
  NoShoppingListForCartError,
5132
+ Pantry,
3031
5133
  ProductCatalog,
3032
5134
  Recipe,
3033
5135
  Section,
3034
5136
  ShoppingCart,
3035
- ShoppingList
5137
+ ShoppingList,
5138
+ convertQuantityToSystem,
5139
+ formatExtendedQuantity,
5140
+ formatItemQuantity,
5141
+ formatNumericValue,
5142
+ formatQuantity,
5143
+ formatQuantityWithUnit,
5144
+ formatSingleValue,
5145
+ formatUnit,
5146
+ getEffectiveChoices,
5147
+ hasAlternatives,
5148
+ isAlternativeSelected,
5149
+ isAndGroup,
5150
+ isGroupedItem,
5151
+ isSectionActive,
5152
+ isSimpleGroup,
5153
+ isStepActive,
5154
+ renderFractionAsVulgar
3036
5155
  });
3037
5156
  /* v8 ignore else -- @preserve */
5157
+ /* v8 ignore next -- @preserve: defensive fallback for ambiguous units without toBaseBySystem */
5158
+ /* v8 ignore start -- @preserve: defensive fallback that shouldn't happen with valid inputs */
5159
+ // v8 ignore else -- @preserve
5160
+ // v8 ignore if -- @preserve
3038
5161
  /* v8 ignore else -- expliciting error type -- @preserve */
5162
+ /* v8 ignore next 4 -- @preserve: defensive guard; regex always matches */
5163
+ // v8 ignore if -- @preserve: defensive type guard
3039
5164
  /* v8 ignore if -- @preserve */
5165
+ // v8 ignore next -- @preserve
5166
+ // v8 ignore else -- @preserve: text quantities never reach the equivalence path
5167
+ // v8 ignore else --@preserve: defensive type guard
5168
+ // v8 ignore else -- @preserve: detection if
5169
+ /* v8 ignore else -- @preserve: only act when there are matches */
5170
+ /* v8 ignore else -- @preserve: initialization pattern */
3040
5171
  //# sourceMappingURL=index.cjs.map