@tmlmt/cooklang-parser 3.0.0-alpha.9 → 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,12 +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
43
  ShoppingList: () => ShoppingList,
43
- isAlternativeSelected: () => isAlternativeSelected
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
44
61
  });
45
62
  module.exports = __toCommonJS(index_exports);
46
63
 
@@ -105,7 +122,7 @@ var CategoryConfig = class {
105
122
  }
106
123
  };
107
124
 
108
- // src/classes/product_catalog.ts
125
+ // src/classes/pantry.ts
109
126
  var import_smol_toml = __toESM(require("smol-toml"), 1);
110
127
 
111
128
  // node_modules/.pnpm/human-regex@2.2.0/node_modules/human-regex/dist/human-regex.esm.js
@@ -307,14 +324,19 @@ var i = (() => {
307
324
  })();
308
325
 
309
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
+ );
310
333
  var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
311
- 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();
312
334
  var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
313
335
  var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
314
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();
315
337
  var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
316
- 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();
317
- 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();
318
340
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
319
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();
320
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();
@@ -329,12 +351,58 @@ var tokensRegex = new RegExp(
329
351
  ].map((r2) => r2.source).join("|"),
330
352
  "gu"
331
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
+ );
332
368
  var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
333
369
  var blockCommentRegex = d().literal("[-").anyCharacter().zeroOrMore().lazy().literal("-]").whitespace().zeroOrMore().global().toRegExp();
334
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();
335
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();
336
372
  var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
337
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
+ );
338
406
 
339
407
  // src/units/definitions.ts
340
408
  var units = [
@@ -344,7 +412,8 @@ var units = [
344
412
  type: "mass",
345
413
  system: "metric",
346
414
  aliases: ["gram", "grams", "grammes"],
347
- toBase: 1
415
+ toBase: 1,
416
+ maxValue: 999
348
417
  },
349
418
  {
350
419
  name: "kg",
@@ -353,20 +422,28 @@ var units = [
353
422
  aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"],
354
423
  toBase: 1e3
355
424
  },
356
- // Mass (Imperial)
425
+ // Mass (US/UK - identical in both systems)
357
426
  {
358
427
  name: "oz",
359
428
  type: "mass",
360
- system: "imperial",
429
+ system: "ambiguous",
361
430
  aliases: ["ounce", "ounces"],
362
- 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] }
363
437
  },
364
438
  {
365
439
  name: "lb",
366
440
  type: "mass",
367
- system: "imperial",
441
+ system: "ambiguous",
368
442
  aliases: ["pound", "pounds"],
369
- 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] }
370
447
  },
371
448
  // Volume (Metric)
372
449
  {
@@ -374,21 +451,26 @@ var units = [
374
451
  type: "volume",
375
452
  system: "metric",
376
453
  aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"],
377
- toBase: 1
454
+ toBase: 1,
455
+ maxValue: 999
378
456
  },
379
457
  {
380
458
  name: "cl",
381
459
  type: "volume",
382
460
  system: "metric",
383
461
  aliases: ["centiliter", "centiliters", "centilitre", "centilitres"],
384
- toBase: 10
462
+ toBase: 10,
463
+ isBestUnit: false
464
+ // exists but not a "best" candidate
385
465
  },
386
466
  {
387
467
  name: "dl",
388
468
  type: "volume",
389
469
  system: "metric",
390
470
  aliases: ["deciliter", "deciliters", "decilitre", "decilitres"],
391
- toBase: 100
471
+ toBase: 100,
472
+ isBestUnit: false
473
+ // exists but not a "best" candidate
392
474
  },
393
475
  {
394
476
  name: "l",
@@ -397,55 +479,102 @@ var units = [
397
479
  aliases: ["liter", "liters", "litre", "litres"],
398
480
  toBase: 1e3
399
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)
400
492
  {
401
493
  name: "tsp",
402
494
  type: "volume",
403
- system: "metric",
495
+ system: "ambiguous",
404
496
  aliases: ["teaspoon", "teaspoons"],
405
- 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] }
406
503
  },
407
504
  {
408
505
  name: "tbsp",
409
506
  type: "volume",
410
- system: "metric",
507
+ system: "ambiguous",
411
508
  aliases: ["tablespoon", "tablespoons"],
412
- 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 }
413
515
  },
414
- // Volume (Imperial)
516
+ // Volume (Ambiguous: US/UK only)
415
517
  {
416
518
  name: "fl-oz",
417
519
  type: "volume",
418
- system: "imperial",
520
+ system: "ambiguous",
419
521
  aliases: ["fluid ounce", "fluid ounces"],
420
- 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] }
421
528
  },
422
529
  {
423
530
  name: "cup",
424
531
  type: "volume",
425
- system: "imperial",
532
+ system: "ambiguous",
426
533
  aliases: ["cups"],
427
- 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 }
428
540
  },
429
541
  {
430
542
  name: "pint",
431
543
  type: "volume",
432
- system: "imperial",
544
+ system: "ambiguous",
433
545
  aliases: ["pints"],
434
- 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
435
554
  },
436
555
  {
437
556
  name: "quart",
438
557
  type: "volume",
439
- system: "imperial",
558
+ system: "ambiguous",
440
559
  aliases: ["quarts"],
441
- 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
442
568
  },
443
569
  {
444
570
  name: "gallon",
445
571
  type: "volume",
446
- system: "imperial",
572
+ system: "ambiguous",
447
573
  aliases: ["gallons"],
448
- toBase: 3785.41
574
+ toBase: 3785.41,
575
+ // default: US
576
+ toBaseBySystem: { US: 3785.41, UK: 4546.09 },
577
+ fractions: { enabled: true, denominators: [2] }
449
578
  },
450
579
  // Count units (no conversion, but recognized as a type)
451
580
  {
@@ -453,7 +582,8 @@ var units = [
453
582
  type: "count",
454
583
  system: "metric",
455
584
  aliases: ["pieces", "pc"],
456
- toBase: 1
585
+ toBase: 1,
586
+ maxValue: 999
457
587
  }
458
588
  ];
459
589
  var unitMap = /* @__PURE__ */ new Map();
@@ -477,8 +607,14 @@ function isNoUnit(unit) {
477
607
  return resolveUnit(unit.name).name === NO_UNIT;
478
608
  }
479
609
 
610
+ // src/units/conversion.ts
611
+ var import_big2 = __toESM(require("big.js"), 1);
612
+
480
613
  // src/quantities/numeric.ts
481
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;
482
618
  function gcd(a2, b) {
483
619
  return b === 0 ? a2 : gcd(b, a2 % b);
484
620
  }
@@ -499,6 +635,41 @@ function simplifyFraction(num, den) {
499
635
  return { type: "fraction", num: simplifiedNum, den: simplifiedDen };
500
636
  }
501
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
+ }
502
673
  function getNumericValue(v) {
503
674
  if (v.type === "decimal") {
504
675
  return v.decimal;
@@ -547,9 +718,35 @@ function addNumericValues(val1, val2) {
547
718
  };
548
719
  }
549
720
  }
550
- var toRoundedDecimal = (v) => {
721
+ var toRoundedDecimal = (v, precision = 3) => {
551
722
  const value = v.type === "decimal" ? v.decimal : v.num / v.den;
552
- 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);
553
750
  };
554
751
  function multiplyQuantityValue(value, factor) {
555
752
  if (value.type === "fixed") {
@@ -583,6 +780,143 @@ function getAverageValue(q) {
583
780
  }
584
781
  }
585
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
+
586
920
  // src/errors.ts
587
921
  var ReferencedItemCannotBeRedefinedError = class extends Error {
588
922
  constructor(item_type, item_name, new_modifier) {
@@ -613,7 +947,7 @@ var NoProductMatchError = class extends Error {
613
947
  constructor(item_name, code) {
614
948
  const messageMap = {
615
949
  incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`,
616
- 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`,
617
951
  textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`,
618
952
  noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`,
619
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`
@@ -652,6 +986,20 @@ var InvalidQuantityFormat = class extends Error {
652
986
  this.name = "InvalidQuantityFormat";
653
987
  }
654
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
+ };
655
1003
 
656
1004
  // src/utils/type_guards.ts
657
1005
  function isGroup(x) {
@@ -661,11 +1009,14 @@ function isOrGroup(x) {
661
1009
  return isGroup(x) && "or" in x;
662
1010
  }
663
1011
  function isAndGroup(x) {
664
- return isGroup(x) && "and" in x;
1012
+ return "and" in x;
665
1013
  }
666
1014
  function isQuantity(x) {
667
1015
  return x && typeof x === "object" && "quantity" in x;
668
1016
  }
1017
+ function isSimpleGroup(entry) {
1018
+ return "quantity" in entry;
1019
+ }
669
1020
  function isNumericValueIntegerLike(v) {
670
1021
  if (v.type === "decimal") return Number.isInteger(v.decimal);
671
1022
  return v.num % v.den === 0;
@@ -677,23 +1028,11 @@ function isValueIntegerLike(q) {
677
1028
  }
678
1029
  return isNumericValueIntegerLike(q.min) && isNumericValueIntegerLike(q.max);
679
1030
  }
1031
+ function hasAlternatives(entry) {
1032
+ return "alternatives" in entry && Array.isArray(entry.alternatives) && entry.alternatives.length > 0;
1033
+ }
680
1034
 
681
1035
  // src/quantities/mutations.ts
682
- function extendAllUnits(q) {
683
- if (isAndGroup(q)) {
684
- return { and: q.and.map(extendAllUnits) };
685
- } else if (isOrGroup(q)) {
686
- return { or: q.or.map(extendAllUnits) };
687
- } else {
688
- const newQ = {
689
- quantity: q.quantity
690
- };
691
- if (q.unit) {
692
- newQ.unit = { name: q.unit };
693
- }
694
- return newQ;
695
- }
696
- }
697
1036
  function normalizeAllUnits(q) {
698
1037
  if (isAndGroup(q)) {
699
1038
  return { and: q.and.map(normalizeAllUnits) };
@@ -715,11 +1054,6 @@ function normalizeAllUnits(q) {
715
1054
  return newQ;
716
1055
  }
717
1056
  }
718
- var convertQuantityValue = (value, def, targetDef) => {
719
- if (def.name === targetDef.name) return value;
720
- const factor = def.toBase / targetDef.toBase;
721
- return multiplyQuantityValue(value, factor);
722
- };
723
1057
  function getDefaultQuantityValue() {
724
1058
  return { type: "fixed", value: { type: "decimal", decimal: 0 } };
725
1059
  }
@@ -746,7 +1080,7 @@ function addQuantityValues(v1, v2) {
746
1080
  );
747
1081
  return { type: "range", min: newMin, max: newMax };
748
1082
  }
749
- function addQuantities(q1, q2) {
1083
+ function addQuantities(q1, q2, system) {
750
1084
  const v1 = q1.quantity;
751
1085
  const v2 = q2.quantity;
752
1086
  if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
@@ -764,35 +1098,129 @@ function addQuantities(q1, q2) {
764
1098
  if ((q2.unit?.name === "" || q2.unit === void 0) && q1.unit !== void 0) {
765
1099
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
766
1100
  }
767
- 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
+ }
768
1111
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
769
1112
  }
770
1113
  if (unit1Def && unit2Def) {
771
- if (unit1Def.type !== unit2Def.type) {
1114
+ if (!areUnitsConvertible(unit1Def, unit2Def)) {
772
1115
  throw new IncompatibleUnitsError(
773
1116
  `${unit1Def.type} (${q1.unit?.name})`,
774
1117
  `${unit2Def.type} (${q2.unit?.name})`
775
1118
  );
776
1119
  }
777
- let targetUnitDef;
778
- if (unit1Def.system !== unit2Def.system) {
779
- const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def;
780
- targetUnitDef = units.filter((u) => u.type === metricUnitDef.type && u.system === "metric").reduce(
781
- (prev, current) => prev.toBase > current.toBase ? prev : current
782
- );
783
- } else {
784
- 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
+ }
785
1133
  }
786
- const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);
787
- const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);
788
- const targetUnit = { name: targetUnitDef.name };
789
- return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit);
1134
+ return addAndFindBestUnit(v1, v2, unit1Def, unit2Def, effectiveSystem, [
1135
+ unit1Def,
1136
+ unit2Def
1137
+ ]);
790
1138
  }
791
1139
  throw new IncompatibleUnitsError(
792
1140
  q1.unit?.name,
793
1141
  q2.unit?.name
794
1142
  );
795
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
+ }
796
1224
  function toPlainUnit(quantity) {
797
1225
  if (isQuantity(quantity))
798
1226
  return quantity.unit ? { ...quantity, unit: quantity.unit.name } : quantity;
@@ -869,36 +1297,126 @@ var flattenPlainUnitGroup = (summed) => {
869
1297
  }
870
1298
  } else if (isAndGroup(summed)) {
871
1299
  const andEntries = [];
1300
+ const standaloneEntries = [];
872
1301
  const equivalentsList = [];
873
1302
  for (const entry of summed.and) {
874
1303
  if (isOrGroup(entry)) {
875
1304
  const orEntries = entry.or;
876
- andEntries.push({
877
- quantity: orEntries[0].quantity,
878
- ...orEntries[0].unit && { unit: orEntries[0].unit }
879
- });
880
- equivalentsList.push(...orEntries.slice(1));
881
- } else if (isQuantity(entry)) {
882
- andEntries.push({
883
- quantity: entry.quantity,
884
- ...entry.unit && { unit: entry.unit }
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;
1315
+ andEntries.push({
1316
+ quantity: primary.quantity,
1317
+ ...primary.unit && { unit: primary.unit }
1318
+ });
1319
+ }
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 }
885
1332
  });
886
1333
  }
887
1334
  }
888
1335
  if (equivalentsList.length === 0) {
889
- return andEntries;
1336
+ return [...andEntries, ...standaloneEntries];
890
1337
  }
891
- const result = {
1338
+ const result = [];
1339
+ result.push({
892
1340
  and: andEntries,
893
1341
  equivalents: equivalentsList
894
- };
895
- return [result];
1342
+ });
1343
+ result.push(...standaloneEntries);
1344
+ return result;
896
1345
  } else {
897
1346
  return [
898
1347
  { quantity: summed.quantity, ...summed.unit && { unit: summed.unit } }
899
1348
  ];
900
1349
  }
901
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
+ }
902
1420
 
903
1421
  // src/utils/parser_helpers.ts
904
1422
  function flushPendingNote(section, noteItems) {
@@ -908,9 +1426,12 @@ function flushPendingNote(section, noteItems) {
908
1426
  }
909
1427
  return noteItems;
910
1428
  }
911
- function flushPendingItems(section, items) {
1429
+ function flushPendingItems(section, items, stepVariants, stepOptional) {
912
1430
  if (items.length > 0) {
913
- 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);
914
1435
  items.length = 0;
915
1436
  return true;
916
1437
  }
@@ -1029,7 +1550,7 @@ function stringifyFixedValue(quantity) {
1029
1550
  return String(quantity.value.decimal);
1030
1551
  else return quantity.value.text;
1031
1552
  }
1032
- function parseQuantityInput(input_str) {
1553
+ function parseQuantityValue(input_str) {
1033
1554
  const clean_str = String(input_str).trim();
1034
1555
  if (rangeRegex.test(clean_str)) {
1035
1556
  const range_parts = clean_str.split("-");
@@ -1039,19 +1560,253 @@ function parseQuantityInput(input_str) {
1039
1560
  }
1040
1561
  return { type: "fixed", value: parseFixedValue(clean_str) };
1041
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
+ }
1042
1728
  function parseSimpleMetaVar(content, varName) {
1043
1729
  const varMatch = content.match(
1044
1730
  new RegExp(`^${varName}:\\s*(.*(?:\\r?\\n\\s+.*)*)+`, "m")
1045
1731
  );
1046
1732
  return varMatch ? varMatch[1]?.trim().replace(/\s*\r?\n\s+/g, " ") : void 0;
1047
1733
  }
1048
- function parseScalingMetaVar(content, varName) {
1049
- const varMatch = content.match(scalingMetaValueRegex(varName));
1050
- if (!varMatch) return void 0;
1051
- if (isNaN(Number(varMatch[2]?.trim()))) {
1052
- 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;
1053
1808
  }
1054
- return [Number(varMatch[2]?.trim()), varMatch[1].trim()];
1809
+ return void 0;
1055
1810
  }
1056
1811
  function parseListMetaVar(content, varName) {
1057
1812
  const listMatch = content.match(
@@ -1067,6 +1822,115 @@ function parseListMetaVar(content, varName) {
1067
1822
  return listMatch[2].split("\n").filter((line) => line.trim() !== "").map((line) => line.replace(/^\s*-\s*/, "").trim());
1068
1823
  }
1069
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
+ }
1070
1934
  function extractMetadata(content) {
1071
1935
  const metadata = {};
1072
1936
  let servings = void 0;
@@ -1074,20 +1938,49 @@ function extractMetadata(content) {
1074
1938
  if (!metadataContent) {
1075
1939
  return { metadata };
1076
1940
  }
1077
- for (const metaVar of [
1941
+ const handledKeys = /* @__PURE__ */ new Set([
1942
+ // Simple string fields
1078
1943
  "title",
1079
- "source",
1080
- "source.name",
1081
- "source.url",
1082
1944
  "author",
1083
- "source.author",
1084
- "prep time",
1085
- "time.prep",
1945
+ "locale",
1946
+ "introduction",
1947
+ "description",
1948
+ "course",
1949
+ "category",
1950
+ "diet",
1951
+ "cuisine",
1952
+ "difficulty",
1953
+ // Source fields
1954
+ "source",
1955
+ "source.name",
1956
+ "source.url",
1957
+ "source.author",
1958
+ // Time fields
1959
+ "prep time",
1960
+ "time.prep",
1086
1961
  "cook time",
1087
1962
  "time.cook",
1088
1963
  "time required",
1089
1964
  "time",
1090
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",
1091
1984
  "locale",
1092
1985
  "introduction",
1093
1986
  "description",
@@ -1095,25 +1988,97 @@ function extractMetadata(content) {
1095
1988
  "category",
1096
1989
  "diet",
1097
1990
  "cuisine",
1098
- "difficulty",
1099
- "image",
1100
- "picture"
1991
+ "difficulty"
1101
1992
  ]) {
1993
+ if (metaVar === "description" || metaVar === "introduction") {
1994
+ const blockValue = parseBlockScalarMetaVar(metadataContent, metaVar);
1995
+ if (blockValue) {
1996
+ metadata[metaVar] = blockValue;
1997
+ continue;
1998
+ }
1999
+ }
1102
2000
  const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar);
1103
2001
  if (stringMetaValue) metadata[metaVar] = stringMetaValue;
1104
2002
  }
1105
- for (const metaVar of ["serves", "yield", "servings"]) {
1106
- const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1107
- if (scalingMetaValue && scalingMetaValue[1]) {
1108
- metadata[metaVar] = scalingMetaValue[1];
1109
- 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;
1110
2067
  }
1111
2068
  }
1112
- for (const metaVar of ["tags", "images", "pictures"]) {
1113
- const listMetaValue = parseListMetaVar(metadataContent, metaVar);
1114
- 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
+ }
1115
2080
  }
1116
- return { metadata, servings };
2081
+ return { metadata, servings, unitSystem };
1117
2082
  }
1118
2083
  function isPositiveIntegerString(str) {
1119
2084
  return /^\d+$/.test(str);
@@ -1127,10 +2092,230 @@ function unionOfSets(s1, s2) {
1127
2092
  }
1128
2093
  function getAlternativeSignature(alternatives) {
1129
2094
  if (!alternatives || alternatives.length === 0) return null;
1130
- 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(",");
1131
2096
  }
1132
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
+
1133
2317
  // src/classes/product_catalog.ts
2318
+ var import_smol_toml2 = __toESM(require("smol-toml"), 1);
1134
2319
  var ProductCatalog = class {
1135
2320
  constructor(tomlContent) {
1136
2321
  __publicField(this, "products", []);
@@ -1142,7 +2327,7 @@ var ProductCatalog = class {
1142
2327
  * @returns A parsed list of `ProductOption`.
1143
2328
  */
1144
2329
  parse(tomlContent) {
1145
- const catalogRaw = import_smol_toml.default.parse(tomlContent);
2330
+ const catalogRaw = import_smol_toml2.default.parse(tomlContent);
1146
2331
  this.products = [];
1147
2332
  if (!this.isValidTomlContent(catalogRaw)) {
1148
2333
  throw new InvalidProductCatalogFormat();
@@ -1159,7 +2344,7 @@ var ProductCatalog = class {
1159
2344
  const sizeStrings = Array.isArray(size) ? size : [size];
1160
2345
  const sizes = sizeStrings.map((sizeStr) => {
1161
2346
  const sizeAndUnitRaw = sizeStr.split("%");
1162
- const sizeParsed = parseQuantityInput(
2347
+ const sizeParsed = parseQuantityValue(
1163
2348
  sizeAndUnitRaw[0]
1164
2349
  );
1165
2350
  const productSize = { size: sizeParsed };
@@ -1215,7 +2400,7 @@ var ProductCatalog = class {
1215
2400
  size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
1216
2401
  };
1217
2402
  }
1218
- return import_smol_toml.default.stringify(grouped);
2403
+ return import_smol_toml2.default.stringify(grouped);
1219
2404
  }
1220
2405
  /**
1221
2406
  * Adds a product to the catalog.
@@ -1272,8 +2457,10 @@ var Section = class {
1272
2457
  /**
1273
2458
  * Creates an instance of Section.
1274
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.
1275
2462
  */
1276
- constructor(name = "") {
2463
+ constructor(name = "", variants, optional) {
1277
2464
  /**
1278
2465
  * The name of the section. Can be an empty string for the default (first) section.
1279
2466
  * @defaultValue `""`
@@ -1281,7 +2468,13 @@ var Section = class {
1281
2468
  __publicField(this, "name");
1282
2469
  /** An array of steps and notes that make up the content of the section. */
1283
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");
1284
2475
  this.name = name;
2476
+ if (variants) this.variants = variants;
2477
+ if (optional) this.optional = true;
1285
2478
  }
1286
2479
  /**
1287
2480
  * Checks if the section is blank (has no name and no content).
@@ -1294,46 +2487,16 @@ var Section = class {
1294
2487
  };
1295
2488
 
1296
2489
  // src/quantities/alternatives.ts
1297
- var import_big3 = __toESM(require("big.js"), 1);
1298
-
1299
- // src/units/conversion.ts
1300
- var import_big2 = __toESM(require("big.js"), 1);
1301
- function getUnitRatio(q1, q2) {
1302
- const q1Value = getAverageValue(q1.quantity);
1303
- const q2Value = getAverageValue(q2.quantity);
1304
- const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
1305
- if (typeof q1Value !== "number" || typeof q2Value !== "number") {
1306
- throw Error(
1307
- "One of both values is not a number, so a ratio cannot be computed"
1308
- );
1309
- }
1310
- return (0, import_big2.default)(q1Value).times(factor).div(q2Value);
1311
- }
1312
- function getBaseUnitRatio(q, qRef) {
1313
- if ("toBase" in q.unit && "toBase" in qRef.unit) {
1314
- return q.unit.toBase / qRef.unit.toBase;
1315
- } else {
1316
- return 1;
1317
- }
1318
- }
2490
+ var import_big4 = __toESM(require("big.js"), 1);
1319
2491
 
1320
2492
  // src/units/lookup.ts
1321
- function areUnitsCompatible(u1, u2) {
1322
- if (u1.name === u2.name) {
1323
- return true;
1324
- }
1325
- if (u1.type !== "other" && u1.type === u2.type && u1.system === u2.system) {
1326
- return true;
1327
- }
1328
- return false;
1329
- }
1330
2493
  function findListWithCompatibleQuantity(list, quantity) {
1331
2494
  const quantityWithUnitDef = {
1332
2495
  ...quantity,
1333
2496
  unit: resolveUnit(quantity.unit?.name)
1334
2497
  };
1335
2498
  return list.find(
1336
- (l) => l.some((lq) => areUnitsCompatible(lq.unit, quantityWithUnitDef.unit))
2499
+ (l) => l.some((lq) => areUnitsGroupable(lq.unit, quantityWithUnitDef.unit))
1337
2500
  );
1338
2501
  }
1339
2502
  function findCompatibleQuantityWithinList(list, quantity) {
@@ -1347,10 +2510,14 @@ function findCompatibleQuantityWithinList(list, quantity) {
1347
2510
  }
1348
2511
 
1349
2512
  // src/utils/general.ts
2513
+ var import_big3 = __toESM(require("big.js"), 1);
1350
2514
  var legacyDeepClone = (v) => {
1351
2515
  if (v === null || typeof v !== "object") {
1352
2516
  return v;
1353
2517
  }
2518
+ if (v instanceof import_big3.default) {
2519
+ return new import_big3.default(v);
2520
+ }
1354
2521
  if (v instanceof Map) {
1355
2522
  return new Map(
1356
2523
  Array.from(v.entries()).map(([k, val]) => [
@@ -1360,7 +2527,9 @@ var legacyDeepClone = (v) => {
1360
2527
  );
1361
2528
  }
1362
2529
  if (v instanceof Set) {
1363
- return new Set(Array.from(v).map((val) => legacyDeepClone(val)));
2530
+ return new Set(
2531
+ Array.from(v).map((val) => legacyDeepClone(val))
2532
+ );
1364
2533
  }
1365
2534
  if (v instanceof Date) {
1366
2535
  return new Date(v.getTime());
@@ -1374,7 +2543,7 @@ var legacyDeepClone = (v) => {
1374
2543
  }
1375
2544
  return cloned;
1376
2545
  };
1377
- var deepClone = (v) => typeof structuredClone === "function" ? structuredClone(v) : legacyDeepClone(v);
2546
+ var deepClone = (v) => legacyDeepClone(v);
1378
2547
 
1379
2548
  // src/quantities/alternatives.ts
1380
2549
  function getEquivalentUnitsLists(...quantities) {
@@ -1382,7 +2551,6 @@ function getEquivalentUnitsLists(...quantities) {
1382
2551
  const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.or.length > 1);
1383
2552
  const unitLists = [];
1384
2553
  const normalizeOrGroup = (og) => ({
1385
- ...og,
1386
2554
  or: og.or.map((q) => ({
1387
2555
  ...q,
1388
2556
  unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
@@ -1390,11 +2558,9 @@ function getEquivalentUnitsLists(...quantities) {
1390
2558
  });
1391
2559
  function findLinkIndexForUnits(lists, unitsToCheck) {
1392
2560
  return lists.findIndex((l) => {
1393
- const listItem = l.map((q) => resolveUnit(q.unit?.name));
2561
+ const listItems = l.map((q) => resolveUnit(q.unit?.name));
1394
2562
  return unitsToCheck.some(
1395
- (u) => listItem.some(
1396
- (lu) => lu.name === u?.name || lu.system === u?.system && lu.type === u?.type && lu.type !== "other"
1397
- )
2563
+ (u) => u && listItems.some((lu) => areUnitsGroupable(lu, u))
1398
2564
  );
1399
2565
  });
1400
2566
  }
@@ -1406,16 +2572,18 @@ function getEquivalentUnitsLists(...quantities) {
1406
2572
  unit: resolveUnit(v.unit?.name, v.unit?.integerProtected)
1407
2573
  };
1408
2574
  const commonQuantity = og.or.find(
1409
- (q) => isQuantity(q) && areUnitsCompatible(q.unit, normalizedV.unit)
2575
+ (q) => isQuantity(q) && areUnitsGroupable(q.unit, normalizedV.unit)
1410
2576
  );
1411
2577
  if (commonQuantity) {
1412
2578
  acc.push(normalizedV);
1413
- unitRatio = getUnitRatio(normalizedV, commonQuantity);
2579
+ if (!unitRatio) {
2580
+ unitRatio = getUnitRatio(normalizedV, commonQuantity);
2581
+ }
1414
2582
  }
1415
2583
  return acc;
1416
2584
  }, []);
1417
2585
  for (const newQ of og.or) {
1418
- if (commonUnitList.some((q) => areUnitsCompatible(q.unit, newQ.unit))) {
2586
+ if (commonUnitList.some((q) => areUnitsGroupable(q.unit, newQ.unit))) {
1419
2587
  continue;
1420
2588
  } else {
1421
2589
  const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio);
@@ -1532,7 +2700,7 @@ function reduceOrsToFirstEquivalent(unitList, quantities) {
1532
2700
  return reduceToQuantity(qListModified[0]);
1533
2701
  });
1534
2702
  }
1535
- function addQuantitiesOrGroups(...quantities) {
2703
+ function addQuantitiesOrGroups(quantities, system) {
1536
2704
  if (quantities.length === 0)
1537
2705
  return {
1538
2706
  sum: {
@@ -1562,7 +2730,7 @@ function addQuantitiesOrGroups(...quantities) {
1562
2730
  unit: resolveUnit(nextQ.unit?.name)
1563
2731
  });
1564
2732
  } else {
1565
- const sumQ = addQuantities(existingQ, nextQ);
2733
+ const sumQ = addQuantities(existingQ, nextQ, system);
1566
2734
  existingQ.quantity = sumQ.quantity;
1567
2735
  existingQ.unit = resolveUnit(sumQ.unit?.name);
1568
2736
  }
@@ -1572,7 +2740,7 @@ function addQuantitiesOrGroups(...quantities) {
1572
2740
  }
1573
2741
  return { sum: { and: sum }, unitsLists };
1574
2742
  }
1575
- function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
2743
+ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists, system) {
1576
2744
  const sumQuantities = isAndGroup(sum) ? sum.and : [sum];
1577
2745
  const result = [];
1578
2746
  const processedQuantities = /* @__PURE__ */ new Set();
@@ -1600,10 +2768,20 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1600
2768
  }
1601
2769
  return main.reduce((acc, v) => {
1602
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
+ );
1603
2775
  const newValue = {
1604
2776
  quantity: multiplyQuantityValue(
1605
- v.quantity,
1606
- (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(
1607
2785
  getAverageValue(mainInList.quantity)
1608
2786
  )
1609
2787
  )
@@ -1611,7 +2789,7 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1611
2789
  if (equiv.unit && !isNoUnit(equiv.unit)) {
1612
2790
  newValue.unit = { name: equiv.unit.name };
1613
2791
  }
1614
- return addQuantities(acc, newValue);
2792
+ return addQuantities(acc, newValue, system);
1615
2793
  }, initialValue);
1616
2794
  });
1617
2795
  if (main.length + equivalents.length > 1) {
@@ -1628,21 +2806,66 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1628
2806
  sumQuantities.filter((q) => !processedQuantities.has(q)).forEach((q) => result.push(deNormalizeQuantity(q)));
1629
2807
  return result;
1630
2808
  }
1631
- function addEquivalentsAndSimplify(...quantities) {
2809
+ function addEquivalentsAndSimplify(quantities, system) {
1632
2810
  if (quantities.length === 1) {
1633
2811
  return toPlainUnit(quantities[0]);
1634
2812
  }
1635
- const { sum, unitsLists } = addQuantitiesOrGroups(...quantities);
1636
- const regrouped = regroupQuantitiesAndExpandEquivalents(sum, unitsLists);
2813
+ const { sum, unitsLists } = addQuantitiesOrGroups(quantities, system);
2814
+ const regrouped = regroupQuantitiesAndExpandEquivalents(
2815
+ sum,
2816
+ unitsLists,
2817
+ system
2818
+ );
1637
2819
  if (regrouped.length === 1) {
1638
2820
  return toPlainUnit(regrouped[0]);
1639
2821
  } else {
1640
2822
  return { and: regrouped.map(toPlainUnit) };
1641
2823
  }
1642
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
+ }
2863
+ }
2864
+ return equivalents.length > 0 ? equivalents : void 0;
2865
+ }
1643
2866
 
1644
2867
  // src/classes/recipe.ts
1645
- var import_big4 = __toESM(require("big.js"), 1);
2868
+ var import_big5 = __toESM(require("big.js"), 1);
1646
2869
  var _Recipe = class _Recipe {
1647
2870
  /**
1648
2871
  * Creates a new Recipe instance.
@@ -1658,7 +2881,8 @@ var _Recipe = class _Recipe {
1658
2881
  */
1659
2882
  __publicField(this, "choices", {
1660
2883
  ingredientItems: /* @__PURE__ */ new Map(),
1661
- ingredientGroups: /* @__PURE__ */ new Map()
2884
+ ingredientGroups: /* @__PURE__ */ new Map(),
2885
+ variants: []
1662
2886
  });
1663
2887
  /**
1664
2888
  * The parsed recipe ingredients.
@@ -1689,10 +2913,20 @@ var _Recipe = class _Recipe {
1689
2913
  */
1690
2914
  __publicField(this, "servings");
1691
2915
  _Recipe.itemCounts.set(this, 0);
2916
+ _Recipe.subgroupIndices.set(this, /* @__PURE__ */ new Map());
1692
2917
  if (content) {
1693
2918
  this.parse(content);
1694
2919
  }
1695
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
+ }
1696
2930
  /**
1697
2931
  * Gets the current item count for this recipe.
1698
2932
  */
@@ -1715,27 +2949,17 @@ var _Recipe = class _Recipe {
1715
2949
  */
1716
2950
  _parseArbitraryScalable(regexMatchGroups, intoArray) {
1717
2951
  if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
1718
- const quantityMatch = regexMatchGroups.arbitraryQuantity?.trim().match(quantityAlternativeRegex);
1719
- if (quantityMatch?.groups) {
1720
- const value = parseQuantityInput(quantityMatch.groups.quantity);
1721
- const unit = quantityMatch.groups.unit;
1722
- const name = regexMatchGroups.arbitraryName || void 0;
1723
- if (!value || value.type === "fixed" && value.value.type === "text") {
1724
- throw new InvalidQuantityFormat(
1725
- regexMatchGroups.arbitraryQuantity?.trim(),
1726
- "Arbitrary quantities must have a numerical value"
1727
- );
1728
- }
1729
- const arbitrary = {
1730
- quantity: value
1731
- };
1732
- if (name) arbitrary.name = name;
1733
- if (unit) arbitrary.unit = unit;
1734
- intoArray.push({
1735
- type: "arbitrary",
1736
- index: this.arbitraries.push(arbitrary) - 1
1737
- });
1738
- }
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
+ });
1739
2963
  }
1740
2964
  /**
1741
2965
  * Parses text for arbitrary scalables and returns NoteItem array.
@@ -1749,13 +2973,13 @@ var _Recipe = class _Recipe {
1749
2973
  for (const match of text.matchAll(globalRegex)) {
1750
2974
  const idx = match.index;
1751
2975
  if (idx > cursor) {
1752
- noteItems.push({ type: "text", value: text.slice(cursor, idx) });
2976
+ noteItems.push(...parseMarkdownSegments(text.slice(cursor, idx)));
1753
2977
  }
1754
2978
  this._parseArbitraryScalable(match.groups, noteItems);
1755
2979
  cursor = idx + match[0].length;
1756
2980
  }
1757
2981
  if (cursor < text.length) {
1758
- noteItems.push({ type: "text", value: text.slice(cursor) });
2982
+ noteItems.push(...parseMarkdownSegments(text.slice(cursor)));
1759
2983
  }
1760
2984
  return noteItems;
1761
2985
  }
@@ -1763,7 +2987,7 @@ var _Recipe = class _Recipe {
1763
2987
  let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
1764
2988
  const quantities = [];
1765
2989
  while (quantityMatch?.groups) {
1766
- const value = quantityMatch.groups.quantity ? parseQuantityInput(quantityMatch.groups.quantity) : void 0;
2990
+ const value = quantityMatch.groups.quantity ? parseQuantityValue(quantityMatch.groups.quantity) : void 0;
1767
2991
  const unit = quantityMatch.groups.unit;
1768
2992
  if (value) {
1769
2993
  const newQuantity = { quantity: value };
@@ -1864,7 +3088,7 @@ var _Recipe = class _Recipe {
1864
3088
  alternative.note = note;
1865
3089
  }
1866
3090
  if (itemQuantity) {
1867
- alternative.itemQuantity = itemQuantity;
3091
+ Object.assign(alternative, itemQuantity);
1868
3092
  }
1869
3093
  alternatives.push(alternative);
1870
3094
  testString = groups.ingredientAlternative || "";
@@ -1907,6 +3131,7 @@ var _Recipe = class _Recipe {
1907
3131
  if (!match?.groups) return;
1908
3132
  const groups = match.groups;
1909
3133
  const groupKey = groups.gIngredientGroupKey;
3134
+ const subgroupKey = groups.gIngredientSubgroupKey;
1910
3135
  let name = groups.gmIngredientName || groups.gsIngredientName;
1911
3136
  const preparation = groups.gIngredientPreparation;
1912
3137
  const modifiers = groups.gIngredientModifiers;
@@ -1972,9 +3197,14 @@ var _Recipe = class _Recipe {
1972
3197
  displayName
1973
3198
  };
1974
3199
  if (itemQuantity) {
1975
- alternative.itemQuantity = itemQuantity;
3200
+ Object.assign(alternative, itemQuantity);
3201
+ }
3202
+ const note = groups.gIngredientNote?.trim();
3203
+ if (note) {
3204
+ alternative.note = note;
1976
3205
  }
1977
- const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
3206
+ const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3207
+ const existingAlternativesFlat = existingSubgroups?.flat();
1978
3208
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
1979
3209
  const ingredient = ingredients[ingredientIdx];
1980
3210
  if (ingredient) {
@@ -1985,8 +3215,8 @@ var _Recipe = class _Recipe {
1985
3215
  }
1986
3216
  }
1987
3217
  }
1988
- if (existingAlternatives) {
1989
- for (const alt of existingAlternatives) {
3218
+ if (existingAlternativesFlat) {
3219
+ for (const alt of existingAlternativesFlat) {
1990
3220
  upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
1991
3221
  upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
1992
3222
  }
@@ -1998,14 +3228,35 @@ var _Recipe = class _Recipe {
1998
3228
  group: groupKey,
1999
3229
  alternatives: [alternative]
2000
3230
  };
3231
+ if (subgroupKey !== void 0) {
3232
+ newItem.subgroup = subgroupKey;
3233
+ }
2001
3234
  items.push(newItem);
2002
3235
  const choiceAlternative = deepClone(alternative);
2003
3236
  choiceAlternative.itemId = id;
2004
3237
  const existingChoice = this.choices.ingredientGroups.get(groupKey);
3238
+ const sgMap = _Recipe.subgroupIndices.get(this);
2005
3239
  if (!existingChoice) {
2006
- 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
+ }
2007
3258
  } else {
2008
- existingChoice.push(choiceAlternative);
3259
+ existingChoice.push([choiceAlternative]);
2009
3260
  }
2010
3261
  }
2011
3262
  /**
@@ -2019,7 +3270,7 @@ var _Recipe = class _Recipe {
2019
3270
  * Quantities are grouped by their alternative signature and summed using addEquivalentsAndSimplify.
2020
3271
  * @internal
2021
3272
  */
2022
- _populate_ingredient_quantities() {
3273
+ _populateIngredientQuantities() {
2023
3274
  for (const ing of this.ingredients) {
2024
3275
  delete ing.quantities;
2025
3276
  delete ing.usedAsPrimary;
@@ -2040,35 +3291,13 @@ var _Recipe = class _Recipe {
2040
3291
  }
2041
3292
  }
2042
3293
  }
2043
- /**
2044
- * Gets ingredients with their quantities populated, optionally filtered by section/step
2045
- * and respecting user choices for alternatives.
2046
- *
2047
- * When no options are provided, returns all recipe ingredients with quantities
2048
- * calculated using primary alternatives (same as after parsing).
2049
- *
2050
- * @param options - Options for filtering and choice selection:
2051
- * - `section`: Filter to a specific section (Section object or 0-based index)
2052
- * - `step`: Filter to a specific step (Step object or 0-based index)
2053
- * - `choices`: Choices for alternative ingredients (defaults to primary)
2054
- * @returns Array of Ingredient objects with quantities populated
2055
- *
2056
- * @example
2057
- * ```typescript
2058
- * // Get all ingredients with primary alternatives
2059
- * const ingredients = recipe.getIngredientQuantities();
2060
- *
2061
- * // Get ingredients for a specific section
2062
- * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
2063
- *
2064
- * // Get ingredients with specific choices applied
2065
- * const withChoices = recipe.getIngredientQuantities({
2066
- * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
2067
- * });
2068
- * ```
2069
- */
2070
- getIngredientQuantities(options) {
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) {
2071
3298
  const { section, step, choices } = options || {};
3299
+ const activeVariant = choices?.variant;
3300
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
2072
3301
  const sectionsToProcess = section !== void 0 ? (() => {
2073
3302
  const idx = typeof section === "number" ? section : this.sections.indexOf(section);
2074
3303
  return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
@@ -2076,80 +3305,155 @@ var _Recipe = class _Recipe {
2076
3305
  const ingredientGroups = /* @__PURE__ */ new Map();
2077
3306
  const selectedIndices = /* @__PURE__ */ new Set();
2078
3307
  const referencedIndices = /* @__PURE__ */ new Set();
3308
+ const dynamicOptionalIndices = /* @__PURE__ */ new Set();
2079
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
+ }
2080
3317
  const allSteps = currentSection.content.filter(
2081
3318
  (item) => item.type === "step"
2082
3319
  );
3320
+ const isOptionalSection = currentSection.optional === true;
2083
3321
  const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
2084
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;
2085
3331
  for (const item of currentStep.items.filter(
2086
3332
  (item2) => item2.type === "ingredient"
2087
3333
  )) {
2088
3334
  const isGrouped = "group" in item && item.group !== void 0;
2089
- const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3335
+ const groupSubgroups = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
2090
3336
  let selectedAltIndex = 0;
2091
- let isSelected = false;
2092
- let hasExplicitChoice = false;
3337
+ let isSelected;
3338
+ let hasExplicitChoice;
2093
3339
  if (isGrouped) {
2094
3340
  const groupChoice = choices?.ingredientGroups?.get(item.group);
2095
3341
  hasExplicitChoice = groupChoice !== void 0;
2096
- const targetIndex = groupChoice ?? 0;
2097
- isSelected = groupAlternatives?.[targetIndex]?.itemId === item.id;
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;
3366
+ }
2098
3367
  } else {
2099
3368
  const itemChoice = choices?.ingredientItems?.get(item.id);
2100
3369
  hasExplicitChoice = itemChoice !== void 0;
2101
- selectedAltIndex = itemChoice ?? 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;
3382
+ }
2102
3383
  isSelected = true;
2103
3384
  }
2104
3385
  const alternative = item.alternatives[selectedAltIndex];
2105
3386
  if (!alternative || !isSelected) continue;
2106
3387
  selectedIndices.add(alternative.index);
2107
- const allAlts = isGrouped ? groupAlternatives : item.alternatives;
2108
- for (const alt of allAlts) {
3388
+ if (isOptionalStep) {
3389
+ dynamicOptionalIndices.add(alternative.index);
3390
+ }
3391
+ const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3392
+ for (const alt of allAltsFlat) {
2109
3393
  referencedIndices.add(alt.index);
2110
3394
  }
2111
- if (!alternative.itemQuantity) continue;
3395
+ if (!alternative.quantity) continue;
2112
3396
  const baseQty = {
2113
- quantity: alternative.itemQuantity.quantity,
2114
- ...alternative.itemQuantity.unit && {
2115
- unit: alternative.itemQuantity.unit
3397
+ quantity: alternative.quantity,
3398
+ ...alternative.unit && {
3399
+ unit: alternative.unit
2116
3400
  }
2117
3401
  };
2118
- const quantityEntry = alternative.itemQuantity.equivalents?.length ? { or: [baseQty, ...alternative.itemQuantity.equivalents] } : baseQty;
3402
+ const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
2119
3403
  let alternativeRefs;
2120
- if (!hasExplicitChoice && allAlts.length > 1) {
2121
- alternativeRefs = allAlts.filter(
2122
- (alt) => isGrouped ? alt.itemId !== item.id : alt.index !== alternative.index
2123
- ).map((otherAlt) => {
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
3412
+ };
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];
3426
+ }
3427
+ return ref;
3428
+ })
3429
+ );
3430
+ } else if (!hasExplicitChoice && !isGrouped && allAltsFlat.length > 1) {
3431
+ alternativeRefs = allAltsFlat.filter((alt) => alt.index !== alternative.index).map((otherAlt) => {
2124
3432
  const ref = { index: otherAlt.index };
2125
- if (otherAlt.itemQuantity) {
3433
+ if (otherAlt.quantity) {
2126
3434
  const altQty = {
2127
- quantity: otherAlt.itemQuantity.quantity,
2128
- ...otherAlt.itemQuantity.unit && {
2129
- unit: otherAlt.itemQuantity.unit.name
3435
+ quantity: otherAlt.quantity,
3436
+ ...otherAlt.unit && {
3437
+ unit: otherAlt.unit.name
2130
3438
  },
2131
- ...otherAlt.itemQuantity.equivalents && {
2132
- equivalents: otherAlt.itemQuantity.equivalents.map(
3439
+ ...otherAlt.equivalents && {
3440
+ equivalents: otherAlt.equivalents.map(
2133
3441
  (eq) => toPlainUnit(eq)
2134
3442
  )
2135
3443
  }
2136
3444
  };
2137
3445
  ref.quantities = [altQty];
2138
3446
  }
2139
- return ref;
3447
+ return [ref];
2140
3448
  });
2141
3449
  }
2142
3450
  const altIndices = getAlternativeSignature(alternativeRefs) ?? "";
2143
3451
  let signature;
2144
3452
  if (isGrouped) {
2145
- const resolvedUnit = resolveUnit(
2146
- alternative.itemQuantity.unit?.name
2147
- );
3453
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
2148
3454
  signature = `group:${item.group}|${altIndices}|${resolvedUnit.type}`;
2149
3455
  } else if (altIndices) {
2150
- const resolvedUnit = resolveUnit(
2151
- alternative.itemQuantity.unit?.name
2152
- );
3456
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
2153
3457
  signature = `${altIndices}|${resolvedUnit.type}}`;
2154
3458
  } else {
2155
3459
  signature = null;
@@ -2161,42 +3465,145 @@ var _Recipe = class _Recipe {
2161
3465
  if (!groupsForIng.has(signature)) {
2162
3466
  groupsForIng.set(signature, {
2163
3467
  quantities: [],
2164
- alternativeQuantities: /* @__PURE__ */ new Map()
3468
+ alternativeQuantities: /* @__PURE__ */ new Map(),
3469
+ alternativeSubgroups: []
2165
3470
  });
2166
3471
  }
2167
3472
  const group = groupsForIng.get(signature);
2168
3473
  group.quantities.push(quantityEntry);
2169
- for (const ref of alternativeRefs ?? []) {
2170
- if (!group.alternativeQuantities.has(ref.index)) {
2171
- group.alternativeQuantities.set(ref.index, []);
2172
- }
2173
- for (const altQty of ref.quantities ?? []) {
2174
- const extended = toExtendedUnit({
2175
- quantity: altQty.quantity,
2176
- unit: altQty.unit
2177
- });
2178
- if (altQty.equivalents?.length) {
2179
- const eqEntries = [
2180
- extended,
2181
- ...altQty.equivalents.map((eq) => toExtendedUnit(eq))
2182
- ];
2183
- group.alternativeQuantities.get(ref.index).push({ or: eqEntries });
2184
- } else {
2185
- group.alternativeQuantities.get(ref.index).push(extended);
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) {
3481
+ if (!group.alternativeQuantities.has(ref.index)) {
3482
+ group.alternativeQuantities.set(ref.index, []);
3483
+ }
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);
3497
+ }
2186
3498
  }
2187
3499
  }
2188
3500
  }
2189
3501
  }
2190
3502
  }
2191
3503
  }
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);
2192
3534
  const result = [];
2193
3535
  for (let index = 0; index < this.ingredients.length; index++) {
2194
3536
  if (!referencedIndices.has(index)) continue;
2195
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);
3549
+ }
3550
+ }
3551
+ }
3552
+ result.push({
3553
+ name: orig.name,
3554
+ ...usedAsPrimary && { usedAsPrimary: true },
3555
+ ...flags && { flags },
3556
+ quantities
3557
+ });
3558
+ }
3559
+ return result;
3560
+ }
3561
+ /**
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();
3578
+ *
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
+ * ```
3587
+ */
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
+ }
2196
3603
  const ing = {
2197
3604
  name: orig.name,
2198
3605
  ...orig.preparation && { preparation: orig.preparation },
2199
- ...orig.flags && { flags: orig.flags },
3606
+ ...flags && { flags },
2200
3607
  ...orig.extras && { extras: orig.extras }
2201
3608
  };
2202
3609
  if (selectedIndices.has(index)) {
@@ -2205,19 +3612,30 @@ var _Recipe = class _Recipe {
2205
3612
  if (groupsForIng) {
2206
3613
  const quantityGroups = [];
2207
3614
  for (const [, group] of groupsForIng) {
2208
- const summed = addEquivalentsAndSimplify(...group.quantities);
3615
+ const summed = addEquivalentsAndSimplify(
3616
+ group.quantities,
3617
+ this.unitSystem
3618
+ );
2209
3619
  const flattened = flattenPlainUnitGroup(summed);
2210
- const alternatives = group.alternativeQuantities.size > 0 ? [...group.alternativeQuantities].map(([altIdx, altQtys]) => ({
2211
- index: altIdx,
2212
- ...altQtys.length > 0 && {
2213
- quantities: flattenPlainUnitGroup(
2214
- addEquivalentsAndSimplify(...altQtys)
2215
- ).flatMap(
2216
- /* v8 ignore next -- item.and branch requires complex nested AND-with-equivalents structure */
2217
- (item) => "quantity" in item ? [item] : item.and
2218
- )
2219
- }
2220
- })) : void 0;
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
+ }
2221
3639
  for (const gq of flattened) {
2222
3640
  if ("and" in gq) {
2223
3641
  quantityGroups.push({
@@ -2244,23 +3662,82 @@ var _Recipe = class _Recipe {
2244
3662
  }
2245
3663
  return result;
2246
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
+ }
3695
+ }
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
+ }
3712
+ }
3713
+ }
3714
+ return this.cookware.filter(
3715
+ (cw, idx) => cookwareIndices.has(idx) && !cw.flags?.includes("hidden")
3716
+ );
3717
+ }
2247
3718
  /**
2248
3719
  * Parses a recipe from a string.
2249
3720
  * @param content - The recipe content to parse.
2250
3721
  */
2251
3722
  parse(content) {
2252
3723
  const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
2253
- const { metadata, servings } = extractMetadata(content);
3724
+ const { metadata, servings, unitSystem } = extractMetadata(content);
2254
3725
  this.metadata = metadata;
2255
3726
  this.servings = servings;
3727
+ if (unitSystem) _Recipe.unitSystems.set(this, unitSystem);
2256
3728
  let blankLineBefore = true;
2257
3729
  let section = new Section();
2258
3730
  const items = [];
2259
3731
  let noteText = "";
2260
3732
  let inNote = false;
3733
+ let stepVariants;
3734
+ let stepOptional;
3735
+ const discoveredVariants = /* @__PURE__ */ new Set();
2261
3736
  for (const line of cleanContent) {
2262
3737
  if (line.trim().length === 0) {
2263
- flushPendingItems(section, items);
3738
+ flushPendingItems(section, items, stepVariants, stepOptional);
3739
+ stepVariants = void 0;
3740
+ stepOptional = void 0;
2264
3741
  flushPendingNote(
2265
3742
  section,
2266
3743
  noteText ? this._parseNoteText(noteText) : []
@@ -2271,26 +3748,42 @@ var _Recipe = class _Recipe {
2271
3748
  continue;
2272
3749
  }
2273
3750
  if (line.startsWith("=")) {
2274
- flushPendingItems(section, items);
3751
+ flushPendingItems(section, items, stepVariants, stepOptional);
3752
+ stepVariants = void 0;
3753
+ stepOptional = void 0;
2275
3754
  flushPendingNote(
2276
3755
  section,
2277
3756
  noteText ? this._parseNoteText(noteText) : []
2278
3757
  );
2279
3758
  noteText = "";
2280
- if (this.sections.length === 0 && section.isBlank()) {
2281
- section.name = line.replace(/^=+|=+$/g, "").trim();
2282
- } else {
2283
- if (!section.isBlank()) {
2284
- 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;
2285
3772
  }
2286
- section = new Section(line.replace(/^=+|=+$/g, "").trim());
3773
+ sectionName = sectionName.slice(sectionVarMatch[0].length).trim();
2287
3774
  }
3775
+ if (!section.isBlank()) {
3776
+ this.sections.push(section);
3777
+ }
3778
+ section = new Section(sectionName, sectionVariants, sectionOptional);
2288
3779
  blankLineBefore = true;
2289
3780
  inNote = false;
2290
3781
  continue;
2291
3782
  }
2292
3783
  if (blankLineBefore && line.startsWith(">")) {
2293
- flushPendingItems(section, items);
3784
+ flushPendingItems(section, items, stepVariants, stepOptional);
3785
+ stepVariants = void 0;
3786
+ stepOptional = void 0;
2294
3787
  noteText = line.substring(1).trim();
2295
3788
  inNote = true;
2296
3789
  blankLineBefore = false;
@@ -2305,11 +3798,31 @@ var _Recipe = class _Recipe {
2305
3798
  blankLineBefore = false;
2306
3799
  continue;
2307
3800
  }
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
+ }
2308
3821
  let cursor = 0;
2309
- for (const match of line.matchAll(tokensRegex)) {
3822
+ for (const match of currentLine.matchAll(tokensRegex)) {
2310
3823
  const idx = match.index;
2311
3824
  if (idx > cursor) {
2312
- items.push({ type: "text", value: line.slice(cursor, idx) });
3825
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor, idx)));
2313
3826
  }
2314
3827
  const groups = match.groups;
2315
3828
  if (groups.mIngredientName || groups.sIngredientName) {
@@ -2328,7 +3841,7 @@ var _Recipe = class _Recipe {
2328
3841
  if (modifiers !== void 0 && modifiers.includes("-")) {
2329
3842
  flags.push("hidden");
2330
3843
  }
2331
- const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
3844
+ const quantity = quantityRaw ? parseQuantityValue(quantityRaw) : void 0;
2332
3845
  const newCookware = {
2333
3846
  name
2334
3847
  };
@@ -2360,7 +3873,7 @@ var _Recipe = class _Recipe {
2360
3873
  throw new Error("Timer missing unit");
2361
3874
  }
2362
3875
  const name = groups.timerName || void 0;
2363
- const duration = parseQuantityInput(durationStr);
3876
+ const duration = parseQuantityValue(durationStr);
2364
3877
  const timerObj = {
2365
3878
  name,
2366
3879
  duration,
@@ -2370,17 +3883,22 @@ var _Recipe = class _Recipe {
2370
3883
  }
2371
3884
  cursor = idx + match[0].length;
2372
3885
  }
2373
- if (cursor < line.length) {
2374
- items.push({ type: "text", value: line.slice(cursor) });
3886
+ if (cursor < currentLine.length) {
3887
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor)));
2375
3888
  }
2376
3889
  blankLineBefore = false;
2377
3890
  }
2378
- flushPendingItems(section, items);
3891
+ flushPendingItems(section, items, stepVariants, stepOptional);
2379
3892
  flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
2380
3893
  if (!section.isBlank()) {
2381
3894
  this.sections.push(section);
2382
3895
  }
2383
- 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();
2384
3902
  }
2385
3903
  /**
2386
3904
  * Scales the recipe to a new number of servings. In practice, it calls
@@ -2395,7 +3913,7 @@ var _Recipe = class _Recipe {
2395
3913
  if (originalServings === void 0 || originalServings === 0) {
2396
3914
  originalServings = 1;
2397
3915
  }
2398
- const factor = (0, import_big4.default)(newServings).div(originalServings);
3916
+ const factor = (0, import_big5.default)(newServings).div(originalServings);
2399
3917
  return this.scaleBy(factor);
2400
3918
  }
2401
3919
  /**
@@ -2410,18 +3928,19 @@ var _Recipe = class _Recipe {
2410
3928
  if (originalServings === void 0 || originalServings === 0) {
2411
3929
  originalServings = 1;
2412
3930
  }
3931
+ const unitSystem = this.unitSystem;
2413
3932
  function scaleAlternativesBy(alternatives, factor2) {
2414
3933
  for (const alternative of alternatives) {
2415
- if (alternative.itemQuantity) {
2416
- const scaleFactor = alternative.itemQuantity.scalable ? (0, import_big4.default)(factor2) : 1;
2417
- if (alternative.itemQuantity.quantity.type !== "fixed" || alternative.itemQuantity.quantity.value.type !== "text") {
2418
- alternative.itemQuantity.quantity = multiplyQuantityValue(
2419
- 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,
2420
3939
  scaleFactor
2421
3940
  );
2422
3941
  }
2423
- if (alternative.itemQuantity.equivalents) {
2424
- alternative.itemQuantity.equivalents = alternative.itemQuantity.equivalents.map(
3942
+ if (alternative.equivalents) {
3943
+ alternative.equivalents = alternative.equivalents.map(
2425
3944
  (altQuantity) => {
2426
3945
  if (altQuantity.quantity.type === "fixed" && altQuantity.quantity.value.type === "text") {
2427
3946
  return altQuantity;
@@ -2437,6 +3956,20 @@ var _Recipe = class _Recipe {
2437
3956
  }
2438
3957
  );
2439
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
+ }
2440
3973
  }
2441
3974
  }
2442
3975
  }
@@ -2451,8 +3984,10 @@ var _Recipe = class _Recipe {
2451
3984
  }
2452
3985
  }
2453
3986
  }
2454
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
2455
- scaleAlternativesBy(alternatives, factor);
3987
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3988
+ for (const subgroup of subgroups) {
3989
+ scaleAlternativesBy(subgroup, factor);
3990
+ }
2456
3991
  }
2457
3992
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
2458
3993
  scaleAlternativesBy(alternatives, factor);
@@ -2462,39 +3997,198 @@ var _Recipe = class _Recipe {
2462
3997
  arbitrary.quantity,
2463
3998
  factor
2464
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;
2465
4006
  }
2466
- newRecipe._populate_ingredient_quantities();
2467
- newRecipe.servings = (0, import_big4.default)(originalServings).times(factor).toNumber();
2468
- if (newRecipe.metadata.servings && this.metadata.servings) {
2469
- if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
2470
- const servingsValue = parseFloat(
2471
- String(this.metadata.servings).replace(",", ".")
2472
- );
2473
- newRecipe.metadata.servings = String(
2474
- (0, import_big4.default)(servingsValue).times(factor).toNumber()
2475
- );
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();
2476
4012
  }
2477
4013
  }
2478
4014
  if (newRecipe.metadata.yield && this.metadata.yield) {
2479
- if (floatRegex.test(String(this.metadata.yield).replace(",", ".").trim())) {
2480
- const yieldValue = parseFloat(
2481
- 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
2482
4021
  );
2483
- newRecipe.metadata.yield = String(
2484
- (0, import_big4.default)(yieldValue).times(factor).toNumber()
4022
+ const optimized = applyBestUnit(
4023
+ { quantity: scaledQuantity, unit: original.unit },
4024
+ unitSystem
2485
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;
2486
4033
  }
2487
4034
  }
2488
- if (newRecipe.metadata.serves && this.metadata.serves) {
2489
- if (floatRegex.test(String(this.metadata.serves).replace(",", ".").trim())) {
2490
- const servesValue = parseFloat(
2491
- String(this.metadata.serves).replace(",", ".")
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];
4074
+ }
4075
+ return newPrimary;
4076
+ }
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
4107
+ );
4108
+ return buildNewPrimary(
4109
+ targetEquiv,
4110
+ oldPrimary,
4111
+ remainingEquivalents,
4112
+ alternative.scalable,
4113
+ targetEquiv.unit?.integerProtected,
4114
+ "swapped"
2492
4115
  );
2493
- newRecipe.metadata.serves = String(
2494
- (0, import_big4.default)(servesValue).times(factor).toNumber()
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"
2495
4126
  );
2496
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
+ }
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
+ }
2497
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);
2498
4192
  return newRecipe;
2499
4193
  }
2500
4194
  /**
@@ -2519,7 +4213,11 @@ var _Recipe = class _Recipe {
2519
4213
  newRecipe.metadata = deepClone(this.metadata);
2520
4214
  newRecipe.ingredients = deepClone(this.ingredients);
2521
4215
  newRecipe.sections = this.sections.map((section) => {
2522
- const newSection = new Section(section.name);
4216
+ const newSection = new Section(
4217
+ section.name,
4218
+ section.variants,
4219
+ section.optional
4220
+ );
2523
4221
  newSection.content = deepClone(section.content);
2524
4222
  return newSection;
2525
4223
  });
@@ -2530,21 +4228,30 @@ var _Recipe = class _Recipe {
2530
4228
  return newRecipe;
2531
4229
  }
2532
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());
2533
4236
  /**
2534
4237
  * External storage for item count (not a property on instances).
2535
4238
  * Used for giving ID numbers to items during parsing.
2536
4239
  */
2537
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());
2538
4246
  var Recipe = _Recipe;
2539
4247
 
2540
4248
  // src/classes/shopping_list.ts
2541
4249
  var ShoppingList = class {
2542
4250
  /**
2543
4251
  * Creates a new ShoppingList instance
2544
- * @param category_config_str - The category configuration to parse.
4252
+ * @param categoryConfigStr - The category configuration to parse.
2545
4253
  */
2546
- constructor(category_config_str) {
2547
- // TODO: backport type change
4254
+ constructor(categoryConfigStr) {
2548
4255
  /**
2549
4256
  * The ingredients in the shopping list.
2550
4257
  */
@@ -2556,43 +4263,43 @@ var ShoppingList = class {
2556
4263
  /**
2557
4264
  * The category configuration for the shopping list.
2558
4265
  */
2559
- __publicField(this, "category_config");
4266
+ __publicField(this, "categoryConfig");
2560
4267
  /**
2561
4268
  * The categorized ingredients in the shopping list.
2562
4269
  */
2563
4270
  __publicField(this, "categories");
2564
- if (category_config_str) {
2565
- 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);
2566
4293
  }
2567
4294
  }
2568
- calculate_ingredients() {
4295
+ calculateIngredients() {
2569
4296
  this.ingredients = [];
2570
- const addIngredientQuantity = (name, quantityTotal) => {
2571
- const quantityTotalExtended = extendAllUnits(quantityTotal);
2572
- const newQuantities = isAndGroup(quantityTotalExtended) ? quantityTotalExtended.and : [quantityTotalExtended];
2573
- const existing = this.ingredients.find((i2) => i2.name === name);
2574
- if (existing) {
2575
- if (!existing.quantityTotal) {
2576
- existing.quantityTotal = quantityTotal;
2577
- return;
2578
- }
2579
- try {
2580
- const existingQuantityTotalExtended = extendAllUnits(
2581
- existing.quantityTotal
2582
- );
2583
- const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.and : [existingQuantityTotalExtended];
2584
- existing.quantityTotal = addEquivalentsAndSimplify(
2585
- ...existingQuantities,
2586
- ...newQuantities
2587
- );
2588
- return;
2589
- } catch {
2590
- }
4297
+ const rawQuantitiesMap = /* @__PURE__ */ new Map();
4298
+ const nameOrder = [];
4299
+ const trackName = (name) => {
4300
+ if (!nameOrder.includes(name)) {
4301
+ nameOrder.push(name);
2591
4302
  }
2592
- this.ingredients.push({
2593
- name,
2594
- quantityTotal
2595
- });
2596
4303
  };
2597
4304
  for (const addedRecipe of this.recipes) {
2598
4305
  let scaledRecipe;
@@ -2602,48 +4309,245 @@ var ShoppingList = class {
2602
4309
  } else {
2603
4310
  scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
2604
4311
  }
2605
- const ingredients = scaledRecipe.getIngredientQuantities({
4312
+ const rawGroups = scaledRecipe.getRawQuantityGroups({
2606
4313
  choices: addedRecipe.choices
2607
4314
  });
2608
- for (const ingredient of ingredients) {
2609
- if (ingredient.flags && ingredient.flags.includes("hidden")) {
4315
+ for (const group of rawGroups) {
4316
+ if (group.flags?.includes("hidden") || !group.usedAsPrimary) {
2610
4317
  continue;
2611
4318
  }
2612
- if (!ingredient.usedAsPrimary) {
2613
- continue;
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);
2614
4349
  }
2615
- if (ingredient.quantities && ingredient.quantities.length > 0) {
2616
- const allQuantities = [];
2617
- for (const qGroup of ingredient.quantities) {
2618
- if ("and" in qGroup) {
2619
- for (const qty of qGroup.and) {
2620
- allQuantities.push(qty);
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
+ });
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;
2621
4437
  }
2622
4438
  } else {
2623
- const plainQty = {
2624
- quantity: qGroup.quantity
2625
- };
2626
- if (qGroup.unit) plainQty.unit = qGroup.unit;
2627
- if (qGroup.equivalents) plainQty.equivalents = qGroup.equivalents;
2628
- allQuantities.push(plainQty);
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
+ }
2629
4496
  }
2630
4497
  }
2631
- if (allQuantities.length === 1) {
2632
- addIngredientQuantity(ingredient.name, allQuantities[0]);
2633
- } else {
2634
- const extendedQuantities = allQuantities.map(
2635
- (q) => extendAllUnits(q)
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
2636
4528
  );
2637
- const totalQuantity = addEquivalentsAndSimplify(
2638
- ...extendedQuantities
4529
+ const recomputed = recomputeEquivalents(
4530
+ [entry],
4531
+ ratioMap,
4532
+ equivUnits
2639
4533
  );
2640
- addIngredientQuantity(ingredient.name, totalQuantity);
4534
+ entry.equivalents = recomputed;
2641
4535
  }
2642
- } else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
2643
- this.ingredients.push({ name: ingredient.name });
2644
4536
  }
2645
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
+ }
2646
4549
  }
4550
+ this.resultingPantry = clonedPantry;
2647
4551
  }
2648
4552
  /**
2649
4553
  * Adds a recipe to the shopping list, then automatically
@@ -2652,7 +4556,7 @@ var ShoppingList = class {
2652
4556
  * @param options - Options for adding the recipe.
2653
4557
  * @throws Error if the recipe has alternatives without corresponding choices.
2654
4558
  */
2655
- add_recipe(recipe, options = {}) {
4559
+ addRecipe(recipe, options = {}) {
2656
4560
  const errorMessage = this.getUnresolvedAlternativesError(
2657
4561
  recipe,
2658
4562
  options.choices
@@ -2681,7 +4585,7 @@ var ShoppingList = class {
2681
4585
  });
2682
4586
  }
2683
4587
  }
2684
- this.calculate_ingredients();
4588
+ this.calculateIngredients();
2685
4589
  this.categorize();
2686
4590
  }
2687
4591
  /**
@@ -2721,27 +4625,62 @@ var ShoppingList = class {
2721
4625
  }
2722
4626
  /**
2723
4627
  * Removes a recipe from the shopping list, then automatically
2724
- * recalculates the quantities and recategorize the ingredients.s
4628
+ * recalculates the quantities and recategorize the ingredients.
2725
4629
  * @param index - The index of the recipe to remove.
2726
4630
  */
2727
- remove_recipe(index) {
4631
+ removeRecipe(index) {
2728
4632
  if (index < 0 || index >= this.recipes.length) {
2729
4633
  throw new Error("Index out of bounds");
2730
4634
  }
2731
4635
  this.recipes.splice(index, 1);
2732
- this.calculate_ingredients();
4636
+ this.calculateIngredients();
4637
+ this.categorize();
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();
2733
4659
  this.categorize();
2734
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
+ }
2735
4670
  /**
2736
4671
  * Sets the category configuration for the shopping list
2737
4672
  * and automatically categorize current ingredients from the list.
4673
+ * Also propagates the configuration to the pantry if one is set.
2738
4674
  * @param config - The category configuration to parse.
2739
4675
  */
2740
- set_category_config(config) {
4676
+ setCategoryConfig(config) {
2741
4677
  if (typeof config === "string")
2742
- this.category_config = new CategoryConfig(config);
2743
- else if (config instanceof CategoryConfig) this.category_config = config;
4678
+ this.categoryConfig = new CategoryConfig(config);
4679
+ else if (config instanceof CategoryConfig) this.categoryConfig = config;
2744
4680
  else throw new Error("Invalid category configuration");
4681
+ if (this.pantry) {
4682
+ this.pantry.setCategoryConfig(this.categoryConfig);
4683
+ }
2745
4684
  this.categorize();
2746
4685
  }
2747
4686
  /**
@@ -2749,17 +4688,17 @@ var ShoppingList = class {
2749
4688
  * Will use the category config if any, otherwise all ingredients will be placed in the "other" category
2750
4689
  */
2751
4690
  categorize() {
2752
- if (!this.category_config) {
4691
+ if (!this.categoryConfig) {
2753
4692
  this.categories = { other: this.ingredients };
2754
4693
  return;
2755
4694
  }
2756
4695
  const categories = { other: [] };
2757
- for (const category of this.category_config.categories) {
4696
+ for (const category of this.categoryConfig.categories) {
2758
4697
  categories[category.name] = [];
2759
4698
  }
2760
4699
  for (const ingredient of this.ingredients) {
2761
4700
  let found = false;
2762
- for (const category of this.category_config.categories) {
4701
+ for (const category of this.categoryConfig.categories) {
2763
4702
  for (const categoryIngredient of category.ingredients) {
2764
4703
  if (categoryIngredient.aliases.includes(ingredient.name)) {
2765
4704
  categories[category.name].push(ingredient);
@@ -2823,7 +4762,6 @@ var ShoppingCart = class {
2823
4762
  setProductCatalog(catalog) {
2824
4763
  this.productCatalog = catalog;
2825
4764
  }
2826
- // TODO: harmonize recipe name to use underscores
2827
4765
  /**
2828
4766
  * Sets the shopping list to build the cart from.
2829
4767
  * To use if a shopping list was not provided at the creation of the instance
@@ -2887,8 +4825,27 @@ var ShoppingCart = class {
2887
4825
  getOptimumMatch(ingredient, options) {
2888
4826
  if (options.length === 0)
2889
4827
  throw new NoProductMatchError(ingredient.name, "noProduct");
2890
- if (!ingredient.quantityTotal)
4828
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
2891
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
+ }
2892
4849
  const normalizedOptions = options.map(
2893
4850
  (option) => ({
2894
4851
  ...option,
@@ -2904,7 +4861,7 @@ var ShoppingCart = class {
2904
4861
  })
2905
4862
  })
2906
4863
  );
2907
- const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal);
4864
+ const normalizedQuantityTotal = normalizeAllUnits(quantityTotal);
2908
4865
  function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
2909
4866
  if (isAndGroup(normalizedQuantities)) {
2910
4867
  for (const q of normalizedQuantities.and) {
@@ -2931,12 +4888,12 @@ var ShoppingCart = class {
2931
4888
  alternative.quantity = scaledQuantity;
2932
4889
  const matchOptions = normalizedOptions2.filter(
2933
4890
  (option) => option.sizes.some(
2934
- (s) => areUnitsCompatible(alternative.unit, s.unit)
4891
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2935
4892
  )
2936
4893
  );
2937
4894
  if (matchOptions.length > 0) {
2938
4895
  const findCompatibleSize = (option) => option.sizes.find(
2939
- (s) => areUnitsCompatible(alternative.unit, s.unit)
4896
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2940
4897
  );
2941
4898
  if (matchOptions.length == 1) {
2942
4899
  const matchedOption = matchOptions[0];
@@ -3038,34 +4995,177 @@ var ShoppingCart = class {
3038
4995
  };
3039
4996
 
3040
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
+ }
3041
5072
  function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
3042
5073
  if (item.group) {
3043
5074
  const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
3044
- const groupAlternatives = recipe.choices.ingredientGroups.get(item.group);
3045
- if (groupAlternatives && selectedIndex2 && selectedIndex2 < groupAlternatives.length) {
3046
- const selectedItemId = groupAlternatives[selectedIndex2]?.itemId;
3047
- return selectedItemId === item.id;
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);
3048
5079
  }
3049
5080
  return false;
3050
5081
  }
3051
5082
  const selectedIndex = choices?.ingredientItems?.get(item.id);
3052
5083
  return alternativeIndex === selectedIndex;
3053
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
+ }
3054
5127
  // Annotate the CommonJS export names for ESM import in node:
3055
5128
  0 && (module.exports = {
3056
5129
  CategoryConfig,
3057
5130
  NoProductCatalogForCartError,
3058
5131
  NoShoppingListForCartError,
5132
+ Pantry,
3059
5133
  ProductCatalog,
3060
5134
  Recipe,
3061
5135
  Section,
3062
5136
  ShoppingCart,
3063
5137
  ShoppingList,
3064
- isAlternativeSelected
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
3065
5155
  });
3066
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 */
3067
5159
  // v8 ignore else -- @preserve
3068
- /* v8 ignore else -- expliciting error type -- @preserve */
3069
5160
  // v8 ignore if -- @preserve
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
3070
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 */
3071
5171
  //# sourceMappingURL=index.cjs.map