@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.js CHANGED
@@ -63,7 +63,7 @@ var CategoryConfig = class {
63
63
  }
64
64
  };
65
65
 
66
- // src/classes/product_catalog.ts
66
+ // src/classes/pantry.ts
67
67
  import TOML from "smol-toml";
68
68
 
69
69
  // node_modules/.pnpm/human-regex@2.2.0/node_modules/human-regex/dist/human-regex.esm.js
@@ -265,14 +265,19 @@ var i = (() => {
265
265
  })();
266
266
 
267
267
  // src/regex.ts
268
+ var metadataKeyRegex = /^([^:\n]+?):/gm;
269
+ var numericValueRegex = /^-?\d+(\.\d+)?$/;
270
+ var nestedMetaVarRegex = (varName) => new RegExp(
271
+ `^${varName}:\\s*\\r?\\n((?:[ ]+.+(?:\\r?\\n|$))+)`,
272
+ "m"
273
+ );
268
274
  var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
269
- 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();
270
275
  var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
271
276
  var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
272
277
  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();
273
278
  var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
274
- 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();
275
- 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();
279
+ 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();
280
+ 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();
276
281
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
277
282
  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();
278
283
  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();
@@ -287,12 +292,58 @@ var tokensRegex = new RegExp(
287
292
  ].map((r2) => r2.source).join("|"),
288
293
  "gu"
289
294
  );
295
+ var yieldPrefixPart = d().startAnchor().literal("yield").literal(":").anyOf(" ").zeroOrMore().startNamedGroup("servingsPrefix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().lazy().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().toRegExp();
296
+ var yieldSuffixPart = d().anyOf(" ").zeroOrMore().startNamedGroup("servingsSuffix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
297
+ var yieldMetaValueWithUnitRegex = new RegExp(
298
+ yieldPrefixPart.source + arbitraryScalableRegex.source + yieldSuffixPart.source,
299
+ "m"
300
+ );
301
+ 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();
302
+ var yieldMetaValueRegex = new RegExp(
303
+ [
304
+ yieldMetaValueWithUnitRegex.source,
305
+ yieldMetaValueAsQuantityRegex.source
306
+ ].join("|"),
307
+ "m"
308
+ );
290
309
  var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
291
310
  var blockCommentRegex = d().literal("[-").anyCharacter().zeroOrMore().lazy().literal("-]").whitespace().zeroOrMore().global().toRegExp();
292
311
  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();
293
312
  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();
294
313
  var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
295
314
  var floatRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
315
+ var variantTagRegex = d().startAnchor().literal("[").startNamedGroup("variantOptionalPrefix").literal("?").endGroup().optional().startNamedGroup("variantNames").notAnyOf("\\]").oneOrMore().endGroup().optional().literal("]").whitespace().zeroOrMore().toRegExp();
316
+ var mdEscaped = d().literal("\\").startCaptureGroup().anyOf("*_`").endGroup();
317
+ var mdInlineCode = d().literal("`").startCaptureGroup().notAnyOf("`").oneOrMore().lazy().endGroup().literal("`");
318
+ var mdLink = d().literal("[").startCaptureGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("](").startCaptureGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")");
319
+ var mdTripleAsterisk = d().literal("***").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("***");
320
+ var mdTripleUnderscore = d().literal("___").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("___");
321
+ var mdBoldAstItalicUnd = d().literal("**_").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("_**");
322
+ var mdBoldUndItalicAst = d().literal("__*").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("*__");
323
+ var mdItalicAstBoldUnd = d().literal("*__").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("__*");
324
+ var mdItalicUndBoldAst = d().literal("_**").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("**_");
325
+ var mdBoldAsterisk = d().literal("**").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("**");
326
+ var mdBoldUnderscore = d().wordBoundary().literal("__").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("__").wordBoundary();
327
+ var mdItalicAsterisk = d().literal("*").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("*");
328
+ var mdItalicUnderscore = d().wordBoundary().literal("_").startCaptureGroup().anyCharacter().oneOrMore().lazy().endGroup().literal("_").wordBoundary();
329
+ var markdownRegex = new RegExp(
330
+ [
331
+ mdEscaped,
332
+ mdInlineCode,
333
+ mdLink,
334
+ mdTripleAsterisk,
335
+ mdTripleUnderscore,
336
+ mdBoldAstItalicUnd,
337
+ mdBoldUndItalicAst,
338
+ mdItalicAstBoldUnd,
339
+ mdItalicUndBoldAst,
340
+ mdBoldAsterisk,
341
+ mdBoldUnderscore,
342
+ mdItalicAsterisk,
343
+ mdItalicUnderscore
344
+ ].map((r2) => r2.toRegExp().source).join("|"),
345
+ "g"
346
+ );
296
347
 
297
348
  // src/units/definitions.ts
298
349
  var units = [
@@ -302,7 +353,8 @@ var units = [
302
353
  type: "mass",
303
354
  system: "metric",
304
355
  aliases: ["gram", "grams", "grammes"],
305
- toBase: 1
356
+ toBase: 1,
357
+ maxValue: 999
306
358
  },
307
359
  {
308
360
  name: "kg",
@@ -311,20 +363,28 @@ var units = [
311
363
  aliases: ["kilogram", "kilograms", "kilogrammes", "kilos", "kilo"],
312
364
  toBase: 1e3
313
365
  },
314
- // Mass (Imperial)
366
+ // Mass (US/UK - identical in both systems)
315
367
  {
316
368
  name: "oz",
317
369
  type: "mass",
318
- system: "imperial",
370
+ system: "ambiguous",
319
371
  aliases: ["ounce", "ounces"],
320
- toBase: 28.3495
372
+ toBase: 28.3495,
373
+ // default: US (same as UK)
374
+ toBaseBySystem: { US: 28.3495, UK: 28.3495 },
375
+ maxValue: 31,
376
+ // 16 oz = 1 lb, allow a bit more
377
+ fractions: { enabled: true, denominators: [2] }
321
378
  },
322
379
  {
323
380
  name: "lb",
324
381
  type: "mass",
325
- system: "imperial",
382
+ system: "ambiguous",
326
383
  aliases: ["pound", "pounds"],
327
- toBase: 453.592
384
+ toBase: 453.592,
385
+ // default: US (same as UK)
386
+ toBaseBySystem: { US: 453.592, UK: 453.592 },
387
+ fractions: { enabled: true, denominators: [2, 4] }
328
388
  },
329
389
  // Volume (Metric)
330
390
  {
@@ -332,21 +392,26 @@ var units = [
332
392
  type: "volume",
333
393
  system: "metric",
334
394
  aliases: ["milliliter", "milliliters", "millilitre", "millilitres", "cc"],
335
- toBase: 1
395
+ toBase: 1,
396
+ maxValue: 999
336
397
  },
337
398
  {
338
399
  name: "cl",
339
400
  type: "volume",
340
401
  system: "metric",
341
402
  aliases: ["centiliter", "centiliters", "centilitre", "centilitres"],
342
- toBase: 10
403
+ toBase: 10,
404
+ isBestUnit: false
405
+ // exists but not a "best" candidate
343
406
  },
344
407
  {
345
408
  name: "dl",
346
409
  type: "volume",
347
410
  system: "metric",
348
411
  aliases: ["deciliter", "deciliters", "decilitre", "decilitres"],
349
- toBase: 100
412
+ toBase: 100,
413
+ isBestUnit: false
414
+ // exists but not a "best" candidate
350
415
  },
351
416
  {
352
417
  name: "l",
@@ -355,55 +420,102 @@ var units = [
355
420
  aliases: ["liter", "liters", "litre", "litres"],
356
421
  toBase: 1e3
357
422
  },
423
+ // Volume (JP)
424
+ {
425
+ name: "go",
426
+ type: "volume",
427
+ system: "JP",
428
+ aliases: ["gou", "goo", "\u5408", "rice cup"],
429
+ toBase: 180,
430
+ maxValue: 10
431
+ },
432
+ // Volume (Ambiguous: metric/US/UK)
358
433
  {
359
434
  name: "tsp",
360
435
  type: "volume",
361
- system: "metric",
436
+ system: "ambiguous",
362
437
  aliases: ["teaspoon", "teaspoons"],
363
- toBase: 5
438
+ toBase: 5,
439
+ // default: metric
440
+ toBaseBySystem: { metric: 5, US: 4.929, UK: 5.919, JP: 5 },
441
+ maxValue: 5,
442
+ // 3 tsp = 1 tbsp (but allow a bit more)
443
+ fractions: { enabled: true, denominators: [2, 3, 4, 8] }
364
444
  },
365
445
  {
366
446
  name: "tbsp",
367
447
  type: "volume",
368
- system: "metric",
448
+ system: "ambiguous",
369
449
  aliases: ["tablespoon", "tablespoons"],
370
- toBase: 15
450
+ toBase: 15,
451
+ // default: metric
452
+ toBaseBySystem: { metric: 15, US: 14.787, UK: 17.758, JP: 15 },
453
+ maxValue: 4,
454
+ // ~16 tbsp = 1 cup
455
+ fractions: { enabled: true }
371
456
  },
372
- // Volume (Imperial)
457
+ // Volume (Ambiguous: US/UK only)
373
458
  {
374
459
  name: "fl-oz",
375
460
  type: "volume",
376
- system: "imperial",
461
+ system: "ambiguous",
377
462
  aliases: ["fluid ounce", "fluid ounces"],
378
- toBase: 29.5735
463
+ toBase: 29.5735,
464
+ // default: US
465
+ toBaseBySystem: { US: 29.5735, UK: 28.4131 },
466
+ maxValue: 15,
467
+ // 8 fl-oz ~ 1 cup, allow more
468
+ fractions: { enabled: true, denominators: [2] }
379
469
  },
380
470
  {
381
471
  name: "cup",
382
472
  type: "volume",
383
- system: "imperial",
473
+ system: "ambiguous",
384
474
  aliases: ["cups"],
385
- toBase: 236.588
475
+ toBase: 236.588,
476
+ // default: US
477
+ toBaseBySystem: { US: 236.588, UK: 284.131 },
478
+ maxValue: 15,
479
+ // upgrade to gallons above 15 cups
480
+ fractions: { enabled: true }
386
481
  },
387
482
  {
388
483
  name: "pint",
389
484
  type: "volume",
390
- system: "imperial",
485
+ system: "ambiguous",
391
486
  aliases: ["pints"],
392
- toBase: 473.176
487
+ toBase: 473.176,
488
+ // default: US
489
+ toBaseBySystem: { US: 473.176, UK: 568.261 },
490
+ maxValue: 3,
491
+ // 2 pints = 1 quart
492
+ fractions: { enabled: true, denominators: [2] },
493
+ isBestUnit: false
494
+ // exists but not a "best" candidate
393
495
  },
394
496
  {
395
497
  name: "quart",
396
498
  type: "volume",
397
- system: "imperial",
499
+ system: "ambiguous",
398
500
  aliases: ["quarts"],
399
- toBase: 946.353
501
+ toBase: 946.353,
502
+ // default: US
503
+ toBaseBySystem: { US: 946.353, UK: 1136.52 },
504
+ maxValue: 3,
505
+ // 4 quarts = 1 gallon
506
+ fractions: { enabled: true, denominators: [2] },
507
+ isBestUnit: false
508
+ // exists but not a "best" candidate
400
509
  },
401
510
  {
402
511
  name: "gallon",
403
512
  type: "volume",
404
- system: "imperial",
513
+ system: "ambiguous",
405
514
  aliases: ["gallons"],
406
- toBase: 3785.41
515
+ toBase: 3785.41,
516
+ // default: US
517
+ toBaseBySystem: { US: 3785.41, UK: 4546.09 },
518
+ fractions: { enabled: true, denominators: [2] }
407
519
  },
408
520
  // Count units (no conversion, but recognized as a type)
409
521
  {
@@ -411,7 +523,8 @@ var units = [
411
523
  type: "count",
412
524
  system: "metric",
413
525
  aliases: ["pieces", "pc"],
414
- toBase: 1
526
+ toBase: 1,
527
+ maxValue: 999
415
528
  }
416
529
  ];
417
530
  var unitMap = /* @__PURE__ */ new Map();
@@ -435,8 +548,14 @@ function isNoUnit(unit) {
435
548
  return resolveUnit(unit.name).name === NO_UNIT;
436
549
  }
437
550
 
551
+ // src/units/conversion.ts
552
+ import Big2 from "big.js";
553
+
438
554
  // src/quantities/numeric.ts
439
555
  import Big from "big.js";
556
+ var DEFAULT_DENOMINATORS = [2, 3, 4];
557
+ var DEFAULT_FRACTION_ACCURACY = 0.05;
558
+ var DEFAULT_MAX_WHOLE = 4;
440
559
  function gcd(a2, b) {
441
560
  return b === 0 ? a2 : gcd(b, a2 % b);
442
561
  }
@@ -457,6 +576,41 @@ function simplifyFraction(num, den) {
457
576
  return { type: "fraction", num: simplifiedNum, den: simplifiedDen };
458
577
  }
459
578
  }
579
+ function approximateFraction(value, denominators = DEFAULT_DENOMINATORS, accuracy = DEFAULT_FRACTION_ACCURACY, maxWhole = DEFAULT_MAX_WHOLE) {
580
+ if (value <= 0 || !Number.isFinite(value)) {
581
+ return null;
582
+ }
583
+ const wholePart = Math.floor(value);
584
+ if (wholePart > maxWhole) {
585
+ return null;
586
+ }
587
+ const fractionalPart = value - wholePart;
588
+ if (fractionalPart < 1e-4) {
589
+ return null;
590
+ }
591
+ let bestFraction = null;
592
+ for (const den of denominators) {
593
+ const exactNum = value * den;
594
+ const roundedNum = Math.round(exactNum);
595
+ if (roundedNum === 0) continue;
596
+ const approximatedValue = roundedNum / den;
597
+ const relativeError = Math.abs(approximatedValue - value) / value;
598
+ if (relativeError <= accuracy) {
599
+ if (!bestFraction || relativeError < bestFraction.error) {
600
+ bestFraction = { num: roundedNum, den, error: relativeError };
601
+ }
602
+ }
603
+ }
604
+ if (!bestFraction) {
605
+ return null;
606
+ }
607
+ const commonDivisor = gcd(bestFraction.num, bestFraction.den);
608
+ return {
609
+ type: "fraction",
610
+ num: bestFraction.num / commonDivisor,
611
+ den: bestFraction.den / commonDivisor
612
+ };
613
+ }
460
614
  function getNumericValue(v) {
461
615
  if (v.type === "decimal") {
462
616
  return v.decimal;
@@ -505,9 +659,35 @@ function addNumericValues(val1, val2) {
505
659
  };
506
660
  }
507
661
  }
508
- var toRoundedDecimal = (v) => {
662
+ var toRoundedDecimal = (v, precision = 3) => {
509
663
  const value = v.type === "decimal" ? v.decimal : v.num / v.den;
510
- return { type: "decimal", decimal: Math.round(value * 1e3) / 1e3 };
664
+ if (value === 0) {
665
+ return { type: "decimal", decimal: 0 };
666
+ }
667
+ const absValue = Math.abs(value);
668
+ if (absValue >= 1e3) {
669
+ return { type: "decimal", decimal: Math.round(value) };
670
+ }
671
+ const magnitude = Math.floor(Math.log10(absValue));
672
+ const scale = Math.pow(10, precision - 1 - magnitude);
673
+ const rounded = Math.round(value * scale) / scale;
674
+ return { type: "decimal", decimal: rounded };
675
+ };
676
+ var formatOutputValue = (value, unitDef, precision = 3) => {
677
+ if (unitDef.fractions?.enabled) {
678
+ const denominators = unitDef.fractions.denominators ?? DEFAULT_DENOMINATORS;
679
+ const maxWhole = unitDef.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
680
+ const fraction = approximateFraction(
681
+ value,
682
+ denominators,
683
+ DEFAULT_FRACTION_ACCURACY,
684
+ maxWhole
685
+ );
686
+ if (fraction) {
687
+ return fraction;
688
+ }
689
+ }
690
+ return toRoundedDecimal({ type: "decimal", decimal: value }, precision);
511
691
  };
512
692
  function multiplyQuantityValue(value, factor) {
513
693
  if (value.type === "fixed") {
@@ -541,6 +721,143 @@ function getAverageValue(q) {
541
721
  }
542
722
  }
543
723
 
724
+ // src/units/compatibility.ts
725
+ function areUnitsGroupable(u1, u2) {
726
+ if (u1.name === u2.name) {
727
+ return true;
728
+ }
729
+ if (u1.type === "other" || u2.type === "other") {
730
+ return false;
731
+ }
732
+ if (u1.type === u2.type && u1.system === u2.system) {
733
+ return true;
734
+ }
735
+ if (u1.type === u2.type) {
736
+ if (u1.system === "ambiguous" && u2.system === "metric" && u1.toBaseBySystem?.metric !== void 0) {
737
+ return true;
738
+ }
739
+ if (u2.system === "ambiguous" && u1.system === "metric" && u2.toBaseBySystem?.metric !== void 0) {
740
+ return true;
741
+ }
742
+ }
743
+ return false;
744
+ }
745
+ function areUnitsConvertible(u1, u2) {
746
+ if (u1.name === u2.name) return true;
747
+ if (u1.type === "other" || u2.type === "other") return false;
748
+ return u1.type === u2.type;
749
+ }
750
+ function isUnitCompatibleWithSystem(unit, system) {
751
+ if (unit.system === system) return true;
752
+ if (unit.system === "ambiguous") {
753
+ if (unit.toBaseBySystem) {
754
+ return system in unit.toBaseBySystem;
755
+ }
756
+ if (system === "metric") return true;
757
+ }
758
+ if (unit.system === "metric" && system === "JP") {
759
+ return true;
760
+ }
761
+ return false;
762
+ }
763
+
764
+ // src/units/conversion.ts
765
+ var EPSILON = 0.01;
766
+ var DEFAULT_MAX_VALUE = 999;
767
+ function isCloseToInteger(value) {
768
+ return Math.abs(value - Math.round(value)) < EPSILON;
769
+ }
770
+ function getMaxValue(unit) {
771
+ return unit.maxValue ?? DEFAULT_MAX_VALUE;
772
+ }
773
+ function isValueInRange(value, unit) {
774
+ const maxValue = getMaxValue(unit);
775
+ if (value >= 1 && value <= maxValue) {
776
+ return true;
777
+ }
778
+ if (value > 0 && value < 1 && unit.fractions?.enabled) {
779
+ const denominators = unit.fractions.denominators ?? DEFAULT_DENOMINATORS;
780
+ const maxWhole = unit.fractions.maxWhole ?? DEFAULT_MAX_WHOLE;
781
+ const fraction = approximateFraction(
782
+ value,
783
+ denominators,
784
+ DEFAULT_FRACTION_ACCURACY,
785
+ maxWhole
786
+ );
787
+ return fraction !== null;
788
+ }
789
+ return false;
790
+ }
791
+ function findBestUnit(valueInBase, unitType, system, inputUnits) {
792
+ const inputUnitNames = new Set(inputUnits.map((u) => u.name));
793
+ const candidates = units.filter(
794
+ (u) => u.type === unitType && isUnitCompatibleWithSystem(u, system) && (u.isBestUnit !== false || inputUnitNames.has(u.name))
795
+ );
796
+ if (candidates.length === 0) {
797
+ const fallbackUnit = inputUnits[0];
798
+ return {
799
+ unit: fallbackUnit,
800
+ value: valueInBase / getToBase(fallbackUnit, system)
801
+ };
802
+ }
803
+ const candidatesWithValues = candidates.map((unit) => ({
804
+ unit,
805
+ value: valueInBase / getToBase(unit, system)
806
+ }));
807
+ const inRange = candidatesWithValues.filter(
808
+ (c) => isValueInRange(c.value, c.unit)
809
+ );
810
+ if (inRange.length > 0) {
811
+ const integersInInputFamily = inRange.filter(
812
+ (c) => isCloseToInteger(c.value) && inputUnitNames.has(c.unit.name)
813
+ );
814
+ if (integersInInputFamily.length > 0) {
815
+ return integersInInputFamily.sort((a2, b) => a2.value - b.value)[0];
816
+ }
817
+ const integersAny = inRange.filter((c) => isCloseToInteger(c.value));
818
+ if (integersAny.length > 0) {
819
+ return integersAny.sort((a2, b) => a2.value - b.value)[0];
820
+ }
821
+ return inRange.sort((a2, b) => {
822
+ const aInFamily = inputUnitNames.has(a2.unit.name) ? 0 : 1;
823
+ const bInFamily = inputUnitNames.has(b.unit.name) ? 0 : 1;
824
+ if (aInFamily !== bInFamily) return aInFamily - bInFamily;
825
+ return a2.value - b.value;
826
+ })[0];
827
+ }
828
+ return candidatesWithValues.sort((a2, b) => {
829
+ const aMaxValue = getMaxValue(a2.unit);
830
+ const bMaxValue = getMaxValue(b.unit);
831
+ const aDistance = a2.value < 1 ? 1 - a2.value : a2.value - aMaxValue;
832
+ const bDistance = b.value < 1 ? 1 - b.value : b.value - bMaxValue;
833
+ return aDistance - bDistance;
834
+ })[0];
835
+ }
836
+ function getUnitRatio(q1, q2) {
837
+ const q1Value = getAverageValue(q1.quantity);
838
+ const q2Value = getAverageValue(q2.quantity);
839
+ const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
840
+ if (typeof q1Value !== "number" || typeof q2Value !== "number") {
841
+ throw Error(
842
+ "One of both values is not a number, so a ratio cannot be computed"
843
+ );
844
+ }
845
+ return Big2(q1Value).times(factor).div(q2Value);
846
+ }
847
+ function getBaseUnitRatio(q, qRef) {
848
+ if ("toBase" in q.unit && "toBase" in qRef.unit) {
849
+ return q.unit.toBase / qRef.unit.toBase;
850
+ } else {
851
+ return 1;
852
+ }
853
+ }
854
+ function getToBase(unit, system) {
855
+ if (unit.system === "ambiguous" && system && unit.toBaseBySystem) {
856
+ return unit.toBaseBySystem[system] ?? unit.toBase;
857
+ }
858
+ return unit.toBase;
859
+ }
860
+
544
861
  // src/errors.ts
545
862
  var ReferencedItemCannotBeRedefinedError = class extends Error {
546
863
  constructor(item_type, item_name, new_modifier) {
@@ -571,7 +888,7 @@ var NoProductMatchError = class extends Error {
571
888
  constructor(item_name, code) {
572
889
  const messageMap = {
573
890
  incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`,
574
- noProduct: "No product was found linked to ingredient name ${item_name} in the shopping list",
891
+ noProduct: `No product was found linked to ingredient name ${item_name} in the shopping list`,
575
892
  textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`,
576
893
  noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`,
577
894
  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`
@@ -610,6 +927,20 @@ var InvalidQuantityFormat = class extends Error {
610
927
  this.name = "InvalidQuantityFormat";
611
928
  }
612
929
  };
930
+ var NoTabAsIndentError = class extends Error {
931
+ constructor() {
932
+ super(
933
+ `Tabs are not allowed for indentation in metadata blocks. Please use spaces only.`
934
+ );
935
+ this.name = "NoTabAsIndentError";
936
+ }
937
+ };
938
+ var BadIndentationError = class extends Error {
939
+ constructor() {
940
+ super(`Bad identation of a nested block. Please use spaces only.`);
941
+ this.name = "BadIndentationError";
942
+ }
943
+ };
613
944
 
614
945
  // src/utils/type_guards.ts
615
946
  function isGroup(x) {
@@ -619,11 +950,14 @@ function isOrGroup(x) {
619
950
  return isGroup(x) && "or" in x;
620
951
  }
621
952
  function isAndGroup(x) {
622
- return isGroup(x) && "and" in x;
953
+ return "and" in x;
623
954
  }
624
955
  function isQuantity(x) {
625
956
  return x && typeof x === "object" && "quantity" in x;
626
957
  }
958
+ function isSimpleGroup(entry) {
959
+ return "quantity" in entry;
960
+ }
627
961
  function isNumericValueIntegerLike(v) {
628
962
  if (v.type === "decimal") return Number.isInteger(v.decimal);
629
963
  return v.num % v.den === 0;
@@ -635,23 +969,11 @@ function isValueIntegerLike(q) {
635
969
  }
636
970
  return isNumericValueIntegerLike(q.min) && isNumericValueIntegerLike(q.max);
637
971
  }
972
+ function hasAlternatives(entry) {
973
+ return "alternatives" in entry && Array.isArray(entry.alternatives) && entry.alternatives.length > 0;
974
+ }
638
975
 
639
976
  // src/quantities/mutations.ts
640
- function extendAllUnits(q) {
641
- if (isAndGroup(q)) {
642
- return { and: q.and.map(extendAllUnits) };
643
- } else if (isOrGroup(q)) {
644
- return { or: q.or.map(extendAllUnits) };
645
- } else {
646
- const newQ = {
647
- quantity: q.quantity
648
- };
649
- if (q.unit) {
650
- newQ.unit = { name: q.unit };
651
- }
652
- return newQ;
653
- }
654
- }
655
977
  function normalizeAllUnits(q) {
656
978
  if (isAndGroup(q)) {
657
979
  return { and: q.and.map(normalizeAllUnits) };
@@ -673,11 +995,6 @@ function normalizeAllUnits(q) {
673
995
  return newQ;
674
996
  }
675
997
  }
676
- var convertQuantityValue = (value, def, targetDef) => {
677
- if (def.name === targetDef.name) return value;
678
- const factor = def.toBase / targetDef.toBase;
679
- return multiplyQuantityValue(value, factor);
680
- };
681
998
  function getDefaultQuantityValue() {
682
999
  return { type: "fixed", value: { type: "decimal", decimal: 0 } };
683
1000
  }
@@ -704,7 +1021,7 @@ function addQuantityValues(v1, v2) {
704
1021
  );
705
1022
  return { type: "range", min: newMin, max: newMax };
706
1023
  }
707
- function addQuantities(q1, q2) {
1024
+ function addQuantities(q1, q2, system) {
708
1025
  const v1 = q1.quantity;
709
1026
  const v2 = q2.quantity;
710
1027
  if (v1.type === "fixed" && v1.value.type === "text" || v2.type === "fixed" && v2.value.type === "text") {
@@ -722,35 +1039,129 @@ function addQuantities(q1, q2) {
722
1039
  if ((q2.unit?.name === "" || q2.unit === void 0) && q1.unit !== void 0) {
723
1040
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
724
1041
  }
725
- if (!q1.unit && !q2.unit || q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
1042
+ if (!q1.unit && !q2.unit) {
1043
+ return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
1044
+ }
1045
+ if (q1.unit && q2.unit && q1.unit.name.toLowerCase() === q2.unit.name.toLowerCase()) {
1046
+ if (unit1Def) {
1047
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unit1Def.system) ? unit1Def.system : "US");
1048
+ return addAndFindBestUnit(v1, v2, unit1Def, unit1Def, effectiveSystem, [
1049
+ unit1Def
1050
+ ]);
1051
+ }
726
1052
  return addQuantityValuesAndSetUnit(v1, v2, q1.unit);
727
1053
  }
728
1054
  if (unit1Def && unit2Def) {
729
- if (unit1Def.type !== unit2Def.type) {
1055
+ if (!areUnitsConvertible(unit1Def, unit2Def)) {
730
1056
  throw new IncompatibleUnitsError(
731
1057
  `${unit1Def.type} (${q1.unit?.name})`,
732
1058
  `${unit2Def.type} (${q2.unit?.name})`
733
1059
  );
734
1060
  }
735
- let targetUnitDef;
736
- if (unit1Def.system !== unit2Def.system) {
737
- const metricUnitDef = unit1Def.system === "metric" ? unit1Def : unit2Def;
738
- targetUnitDef = units.filter((u) => u.type === metricUnitDef.type && u.system === "metric").reduce(
739
- (prev, current) => prev.toBase > current.toBase ? prev : current
740
- );
741
- } else {
742
- targetUnitDef = unit1Def.toBase >= unit2Def.toBase ? unit1Def : unit2Def;
1061
+ let effectiveSystem = system;
1062
+ if (!effectiveSystem) {
1063
+ if (unit1Def.system === "metric" || unit2Def.system === "metric") {
1064
+ effectiveSystem = "metric";
1065
+ } else {
1066
+ if (unit1Def.system === "JP" && unit2Def.system === "JP") {
1067
+ effectiveSystem = "JP";
1068
+ } else {
1069
+ const unit1SupportsUS = unit1Def.system === "US" || unit1Def.system === "ambiguous" && unit1Def.toBaseBySystem && "US" in unit1Def.toBaseBySystem;
1070
+ const unit2SupportsUS = unit2Def.system === "US" || unit2Def.system === "ambiguous" && unit2Def.toBaseBySystem && "US" in unit2Def.toBaseBySystem;
1071
+ effectiveSystem = unit1SupportsUS && unit2SupportsUS ? "US" : "metric";
1072
+ }
1073
+ }
743
1074
  }
744
- const convertedV1 = convertQuantityValue(v1, unit1Def, targetUnitDef);
745
- const convertedV2 = convertQuantityValue(v2, unit2Def, targetUnitDef);
746
- const targetUnit = { name: targetUnitDef.name };
747
- return addQuantityValuesAndSetUnit(convertedV1, convertedV2, targetUnit);
1075
+ return addAndFindBestUnit(v1, v2, unit1Def, unit2Def, effectiveSystem, [
1076
+ unit1Def,
1077
+ unit2Def
1078
+ ]);
748
1079
  }
749
1080
  throw new IncompatibleUnitsError(
750
1081
  q1.unit?.name,
751
1082
  q2.unit?.name
752
1083
  );
753
1084
  }
1085
+ function addAndFindBestUnit(v1, v2, unit1Def, unit2Def, system, inputUnits) {
1086
+ const toBase1 = getToBase(unit1Def, system);
1087
+ const toBase2 = getToBase(unit2Def, system);
1088
+ let sumInBase;
1089
+ if (v1.type === "fixed" && v2.type === "fixed") {
1090
+ const val1 = getNumericValue(v1.value);
1091
+ const val2 = getNumericValue(v2.value);
1092
+ sumInBase = val1 * toBase1 + val2 * toBase2;
1093
+ } else {
1094
+ const avg1 = getAverageValue(v1);
1095
+ const avg2 = getAverageValue(v2);
1096
+ sumInBase = avg1 * toBase1 + avg2 * toBase2;
1097
+ }
1098
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1099
+ sumInBase,
1100
+ unit1Def.type,
1101
+ system,
1102
+ inputUnits
1103
+ );
1104
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1105
+ if (v1.type === "range" || v2.type === "range") {
1106
+ const r1 = v1.type === "range" ? v1 : { type: "range", min: v1.value, max: v1.value };
1107
+ const r2 = v2.type === "range" ? v2 : { type: "range", min: v2.value, max: v2.value };
1108
+ const minInBase = getNumericValue(r1.min) * toBase1 + getNumericValue(r2.min) * toBase2;
1109
+ const maxInBase = getNumericValue(r1.max) * toBase1 + getNumericValue(r2.max) * toBase2;
1110
+ const bestToBase = getToBase(bestUnit, system);
1111
+ const minValue = minInBase / bestToBase;
1112
+ const maxValue = maxInBase / bestToBase;
1113
+ return {
1114
+ quantity: {
1115
+ type: "range",
1116
+ min: formatOutputValue(minValue, bestUnit),
1117
+ max: formatOutputValue(maxValue, bestUnit)
1118
+ },
1119
+ unit: { name: bestUnit.name }
1120
+ };
1121
+ }
1122
+ return {
1123
+ quantity: { type: "fixed", value: formattedValue },
1124
+ unit: { name: bestUnit.name }
1125
+ };
1126
+ }
1127
+ function convertQuantityToSystem(quantity, system) {
1128
+ const unitDef = resolveUnit(
1129
+ typeof quantity.unit === "string" ? quantity.unit : quantity.unit?.name
1130
+ );
1131
+ if (unitDef.type === "other" || !("toBase" in unitDef)) {
1132
+ return void 0;
1133
+ }
1134
+ const avgValue = getAverageValue(quantity.quantity);
1135
+ if (typeof avgValue !== "number") {
1136
+ return void 0;
1137
+ }
1138
+ const toBase = getToBase(unitDef, system);
1139
+ const valueInBase = avgValue * toBase;
1140
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1141
+ valueInBase,
1142
+ unitDef.type,
1143
+ system,
1144
+ [unitDef]
1145
+ );
1146
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1147
+ if (quantity.quantity.type === "range") {
1148
+ const bestToBase = getToBase(bestUnit, system);
1149
+ const minValue = getNumericValue(quantity.quantity.min) * toBase / bestToBase;
1150
+ const maxValue = getNumericValue(quantity.quantity.max) * toBase / bestToBase;
1151
+ return {
1152
+ quantity: {
1153
+ type: "range",
1154
+ min: formatOutputValue(minValue, bestUnit),
1155
+ max: formatOutputValue(maxValue, bestUnit)
1156
+ },
1157
+ unit: { name: bestUnit.name }
1158
+ };
1159
+ }
1160
+ return {
1161
+ quantity: { type: "fixed", value: formattedValue },
1162
+ unit: { name: bestUnit.name }
1163
+ };
1164
+ }
754
1165
  function toPlainUnit(quantity) {
755
1166
  if (isQuantity(quantity))
756
1167
  return quantity.unit ? { ...quantity, unit: quantity.unit.name } : quantity;
@@ -827,36 +1238,126 @@ var flattenPlainUnitGroup = (summed) => {
827
1238
  }
828
1239
  } else if (isAndGroup(summed)) {
829
1240
  const andEntries = [];
1241
+ const standaloneEntries = [];
830
1242
  const equivalentsList = [];
831
1243
  for (const entry of summed.and) {
832
1244
  if (isOrGroup(entry)) {
833
1245
  const orEntries = entry.or;
834
- andEntries.push({
835
- quantity: orEntries[0].quantity,
836
- ...orEntries[0].unit && { unit: orEntries[0].unit }
837
- });
838
- equivalentsList.push(...orEntries.slice(1));
839
- } else if (isQuantity(entry)) {
840
- andEntries.push({
841
- quantity: entry.quantity,
842
- ...entry.unit && { unit: entry.unit }
1246
+ const firstEntry = orEntries[0];
1247
+ if (isAndGroup(firstEntry)) {
1248
+ for (const nestedEntry of firstEntry.and) {
1249
+ andEntries.push({
1250
+ quantity: nestedEntry.quantity,
1251
+ ...nestedEntry.unit && { unit: nestedEntry.unit }
1252
+ });
1253
+ }
1254
+ } else {
1255
+ const primary = firstEntry;
1256
+ andEntries.push({
1257
+ quantity: primary.quantity,
1258
+ ...primary.unit && { unit: primary.unit }
1259
+ });
1260
+ }
1261
+ const equivEntries = orEntries.slice(1).filter((e2) => isQuantity(e2));
1262
+ equivalentsList.push(
1263
+ ...equivEntries.map((e2) => ({
1264
+ quantity: e2.quantity,
1265
+ ...e2.unit && { unit: e2.unit }
1266
+ }))
1267
+ );
1268
+ } else {
1269
+ const simpleQuantityEntry = entry;
1270
+ standaloneEntries.push({
1271
+ quantity: simpleQuantityEntry.quantity,
1272
+ ...simpleQuantityEntry.unit && { unit: simpleQuantityEntry.unit }
843
1273
  });
844
1274
  }
845
1275
  }
846
1276
  if (equivalentsList.length === 0) {
847
- return andEntries;
1277
+ return [...andEntries, ...standaloneEntries];
848
1278
  }
849
- const result = {
1279
+ const result = [];
1280
+ result.push({
850
1281
  and: andEntries,
851
1282
  equivalents: equivalentsList
852
- };
853
- return [result];
1283
+ });
1284
+ result.push(...standaloneEntries);
1285
+ return result;
854
1286
  } else {
855
1287
  return [
856
1288
  { quantity: summed.quantity, ...summed.unit && { unit: summed.unit } }
857
1289
  ];
858
1290
  }
859
1291
  };
1292
+ function applyBestUnit(q, system) {
1293
+ const extended = { quantity: q.quantity };
1294
+ if (q.unit) {
1295
+ extended.unit = typeof q.unit === "string" ? { name: q.unit } : q.unit;
1296
+ }
1297
+ if (!extended.unit?.name) {
1298
+ return q;
1299
+ }
1300
+ const unitDef = resolveUnit(extended.unit.name);
1301
+ if (unitDef.type === "other") {
1302
+ return q;
1303
+ }
1304
+ if (extended.quantity.type === "fixed" && extended.quantity.value.type === "text") {
1305
+ return q;
1306
+ }
1307
+ const avgValue = getAverageValue(extended.quantity);
1308
+ const effectiveSystem = system ?? (["metric", "JP"].includes(unitDef.system) ? unitDef.system : "US");
1309
+ const toBase = getToBase(unitDef, effectiveSystem);
1310
+ const valueInBase = avgValue * toBase;
1311
+ const { unit: bestUnit, value: bestValue } = findBestUnit(
1312
+ valueInBase,
1313
+ unitDef.type,
1314
+ effectiveSystem,
1315
+ [unitDef]
1316
+ );
1317
+ const originalCanonicalName = normalizeUnit(extended.unit.name)?.name;
1318
+ if (bestUnit.name === originalCanonicalName) {
1319
+ return q;
1320
+ }
1321
+ const formattedValue = formatOutputValue(bestValue, bestUnit);
1322
+ if (extended.quantity.type === "range") {
1323
+ const bestToBase = getToBase(bestUnit, effectiveSystem);
1324
+ const minValue = getNumericValue(extended.quantity.min) * toBase / bestToBase;
1325
+ const maxValue = getNumericValue(extended.quantity.max) * toBase / bestToBase;
1326
+ return {
1327
+ quantity: {
1328
+ type: "range",
1329
+ min: formatOutputValue(minValue, bestUnit),
1330
+ max: formatOutputValue(maxValue, bestUnit)
1331
+ },
1332
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1333
+ };
1334
+ }
1335
+ return {
1336
+ quantity: {
1337
+ type: "fixed",
1338
+ value: formattedValue
1339
+ },
1340
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1341
+ };
1342
+ }
1343
+ function subtractQuantities(q1, q2, options = {}) {
1344
+ const { clampToZero = true, system } = options;
1345
+ const negatedQ2 = {
1346
+ ...q2,
1347
+ quantity: multiplyQuantityValue(q2.quantity, -1)
1348
+ };
1349
+ const result = addQuantities(q1, negatedQ2, system);
1350
+ if (clampToZero) {
1351
+ const avg = getAverageValue(result.quantity);
1352
+ if (typeof avg === "number" && avg < 0) {
1353
+ return {
1354
+ quantity: { type: "fixed", value: { type: "decimal", decimal: 0 } },
1355
+ unit: result.unit
1356
+ };
1357
+ }
1358
+ }
1359
+ return result;
1360
+ }
860
1361
 
861
1362
  // src/utils/parser_helpers.ts
862
1363
  function flushPendingNote(section, noteItems) {
@@ -866,9 +1367,12 @@ function flushPendingNote(section, noteItems) {
866
1367
  }
867
1368
  return noteItems;
868
1369
  }
869
- function flushPendingItems(section, items) {
1370
+ function flushPendingItems(section, items, stepVariants, stepOptional) {
870
1371
  if (items.length > 0) {
871
- section.content.push({ type: "step", items: [...items] });
1372
+ const step = { type: "step", items: [...items] };
1373
+ if (stepVariants) step.variants = stepVariants;
1374
+ if (stepOptional) step.optional = true;
1375
+ section.content.push(step);
872
1376
  items.length = 0;
873
1377
  return true;
874
1378
  }
@@ -987,7 +1491,7 @@ function stringifyFixedValue(quantity) {
987
1491
  return String(quantity.value.decimal);
988
1492
  else return quantity.value.text;
989
1493
  }
990
- function parseQuantityInput(input_str) {
1494
+ function parseQuantityValue(input_str) {
991
1495
  const clean_str = String(input_str).trim();
992
1496
  if (rangeRegex.test(clean_str)) {
993
1497
  const range_parts = clean_str.split("-");
@@ -997,19 +1501,253 @@ function parseQuantityInput(input_str) {
997
1501
  }
998
1502
  return { type: "fixed", value: parseFixedValue(clean_str) };
999
1503
  }
1504
+ function parseQuantityWithUnit(input) {
1505
+ const trimmed = input.trim();
1506
+ const separatorIndex = trimmed.indexOf("%");
1507
+ if (separatorIndex === -1) {
1508
+ return { value: parseQuantityValue(trimmed) };
1509
+ }
1510
+ const valuePart = trimmed.slice(0, separatorIndex).trim();
1511
+ const unitPart = trimmed.slice(separatorIndex + 1).trim();
1512
+ return {
1513
+ value: parseQuantityValue(valuePart),
1514
+ unit: unitPart || void 0
1515
+ };
1516
+ }
1517
+ function parseDateFromFormat(input, format) {
1518
+ const delimiterMatch = format.match(/[^A-Za-z]/);
1519
+ if (!delimiterMatch) {
1520
+ throw new Error(`Invalid date format: ${format}. No delimiter found.`);
1521
+ }
1522
+ const delimiter = delimiterMatch[0];
1523
+ const formatParts = format.split(delimiter);
1524
+ const inputParts = input.trim().split(delimiter);
1525
+ if (formatParts.length !== 3 || inputParts.length !== 3) {
1526
+ throw new Error(
1527
+ `Invalid date input "${input}" for format "${format}". Expected 3 parts.`
1528
+ );
1529
+ }
1530
+ let day = 0, month = 0, year = 0;
1531
+ for (let i2 = 0; i2 < 3; i2++) {
1532
+ const token = formatParts[i2].toUpperCase();
1533
+ const value = parseInt(inputParts[i2], 10);
1534
+ if (isNaN(value)) {
1535
+ throw new Error(
1536
+ `Invalid date input "${input}": non-numeric part "${inputParts[i2]}".`
1537
+ );
1538
+ }
1539
+ if (token === "DD") day = value;
1540
+ else if (token === "MM") month = value;
1541
+ else if (token === "YYYY") year = value;
1542
+ else
1543
+ throw new Error(
1544
+ `Unknown token "${formatParts[i2]}" in format "${format}"`
1545
+ );
1546
+ }
1547
+ const date = new Date(year, month - 1, day);
1548
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1549
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1550
+ }
1551
+ return date;
1552
+ }
1553
+ function disambiguateDayMonth(first, second, year) {
1554
+ if (second > 12 && first <= 12) {
1555
+ return [second, first, year];
1556
+ }
1557
+ return [first, second, year];
1558
+ }
1559
+ function parseFuzzyDate(input) {
1560
+ const trimmed = input.trim();
1561
+ const delimiterMatch = trimmed.match(/[./-]/);
1562
+ if (!delimiterMatch) {
1563
+ throw new Error(`Cannot parse date "${input}": no delimiter found.`);
1564
+ }
1565
+ const delimiter = delimiterMatch[0];
1566
+ const parts = trimmed.split(delimiter);
1567
+ if (parts.length !== 3) {
1568
+ throw new Error(
1569
+ `Cannot parse date "${input}": expected 3 parts, got ${parts.length}.`
1570
+ );
1571
+ }
1572
+ const nums = parts.map((p) => parseInt(p, 10));
1573
+ if (nums.some((n2) => isNaN(n2))) {
1574
+ throw new Error(`Cannot parse date "${input}": non-numeric parts found.`);
1575
+ }
1576
+ let day, month, year;
1577
+ if (nums[0] >= 1e3) {
1578
+ year = nums[0];
1579
+ month = nums[1];
1580
+ day = nums[2];
1581
+ } else if (nums[2] >= 1e3) {
1582
+ [day, month, year] = disambiguateDayMonth(nums[0], nums[1], nums[2]);
1583
+ } else {
1584
+ if (nums[2] >= 100)
1585
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1586
+ [day, month] = disambiguateDayMonth(nums[0], nums[1], 0);
1587
+ year = 2e3 + nums[2];
1588
+ }
1589
+ const date = new Date(year, month - 1, day);
1590
+ if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
1591
+ throw new Error(`Invalid date: "${input}" does not form a valid date.`);
1592
+ }
1593
+ return date;
1594
+ }
1595
+ function parseMarkdownSegments(text) {
1596
+ const items = [];
1597
+ let cursor = 0;
1598
+ for (const match of text.matchAll(markdownRegex)) {
1599
+ const idx = match.index;
1600
+ if (idx > cursor) {
1601
+ items.push({ type: "text", value: text.slice(cursor, idx) });
1602
+ }
1603
+ const [
1604
+ ,
1605
+ escaped,
1606
+ // group 1: escaped character
1607
+ code,
1608
+ // group 2: inline code
1609
+ linkText,
1610
+ // group 3: link text
1611
+ linkUrl,
1612
+ // group 4: link url
1613
+ tripleAst,
1614
+ // group 5: ***bold+italic***
1615
+ tripleUnd,
1616
+ // group 6: ___bold+italic___
1617
+ astUnd,
1618
+ // group 7: **_bold+italic_**
1619
+ undAst,
1620
+ // group 8: __*bold+italic*__
1621
+ astUndUnd,
1622
+ // group 9: *__bold+italic__*
1623
+ undAstAst,
1624
+ // group 10: _**bold+italic**_
1625
+ boldAst,
1626
+ // group 11: **bold**
1627
+ boldUnd,
1628
+ // group 12: __bold__
1629
+ italicAst,
1630
+ // group 13: *italic*
1631
+ italicUnd
1632
+ // group 14: _italic_
1633
+ ] = match;
1634
+ let value;
1635
+ let attribute;
1636
+ let href;
1637
+ if (escaped !== void 0) {
1638
+ items.push({ type: "text", value: escaped });
1639
+ cursor = idx + match[0].length;
1640
+ continue;
1641
+ } else if (code !== void 0) {
1642
+ value = code;
1643
+ attribute = "code";
1644
+ } else if (linkText !== void 0) {
1645
+ value = linkText;
1646
+ attribute = "link";
1647
+ href = linkUrl;
1648
+ } else if (tripleAst !== void 0 || tripleUnd !== void 0 || astUnd !== void 0 || undAst !== void 0 || astUndUnd !== void 0 || undAstAst !== void 0) {
1649
+ value = tripleAst ?? tripleUnd ?? astUnd ?? undAst ?? astUndUnd ?? undAstAst;
1650
+ attribute = "bold+italic";
1651
+ } else if (boldAst !== void 0 || boldUnd !== void 0) {
1652
+ value = boldAst ?? boldUnd;
1653
+ attribute = "bold";
1654
+ } else {
1655
+ value = italicAst ?? italicUnd;
1656
+ attribute = "italic";
1657
+ }
1658
+ const item = { type: "text", value };
1659
+ if (attribute) item.attribute = attribute;
1660
+ if (href) item.href = href;
1661
+ items.push(item);
1662
+ cursor = idx + match[0].length;
1663
+ }
1664
+ if (cursor < text.length) {
1665
+ items.push({ type: "text", value: text.slice(cursor) });
1666
+ }
1667
+ return items;
1668
+ }
1000
1669
  function parseSimpleMetaVar(content, varName) {
1001
1670
  const varMatch = content.match(
1002
1671
  new RegExp(`^${varName}:\\s*(.*(?:\\r?\\n\\s+.*)*)+`, "m")
1003
1672
  );
1004
1673
  return varMatch ? varMatch[1]?.trim().replace(/\s*\r?\n\s+/g, " ") : void 0;
1005
1674
  }
1006
- function parseScalingMetaVar(content, varName) {
1007
- const varMatch = content.match(scalingMetaValueRegex(varName));
1008
- if (!varMatch) return void 0;
1009
- if (isNaN(Number(varMatch[2]?.trim()))) {
1010
- throw new Error("Scaling variables should be numbers");
1675
+ function parseBlockScalarMetaVar(content, varName) {
1676
+ const match = content.match(
1677
+ new RegExp(
1678
+ `^${varName}:\\s*([|>])\\s*\\r?\\n((?:(?:[ ]+.*|\\s*)(?:\\r?\\n|$))+)`,
1679
+ "m"
1680
+ )
1681
+ );
1682
+ if (!match) return void 0;
1683
+ const style = match[1];
1684
+ const rawBlock = match[2];
1685
+ const lines = rawBlock.split(/\r?\n/);
1686
+ const firstNonEmpty = lines.find((l) => l.trim() !== "");
1687
+ if (!firstNonEmpty) return void 0;
1688
+ const baseIndent = firstNonEmpty.match(/^([ ]*)/)[1].length;
1689
+ const stripped = lines.map((line) => line.trim() === "" ? "" : line.slice(baseIndent)).join("\n").replace(/\n+$/, "");
1690
+ if (style === "|") {
1691
+ return stripped;
1692
+ }
1693
+ return stripped.replace(/\n\n/g, "\0").replace(/\n/g, " ").replace(/\0/g, "\n");
1694
+ }
1695
+ function parseArbitraryQuantity(raw) {
1696
+ const quantityMatch = raw.trim().match(quantityAlternativeRegex);
1697
+ if (!quantityMatch?.groups) {
1698
+ throw new InvalidQuantityFormat(
1699
+ raw,
1700
+ "Arbitrary quantities must have a numerical value"
1701
+ );
1702
+ }
1703
+ const value = parseQuantityValue(quantityMatch.groups.quantity);
1704
+ const unit = quantityMatch.groups.unit;
1705
+ if (!value || value.type === "fixed" && value.value.type === "text") {
1706
+ throw new InvalidQuantityFormat(
1707
+ raw,
1708
+ "Arbitrary quantities must have a numerical value"
1709
+ );
1710
+ }
1711
+ const arbitrary = {
1712
+ quantity: value
1713
+ };
1714
+ if (unit) arbitrary.unit = unit;
1715
+ return arbitrary;
1716
+ }
1717
+ function parseServingsMetaVar(content, varName) {
1718
+ const raw = parseSimpleMetaVar(content, varName);
1719
+ if (raw === void 0) return void 0;
1720
+ const num = Number(raw);
1721
+ if (isNaN(num)) {
1722
+ return { numericValue: 1, rawValue: raw };
1723
+ }
1724
+ return { numericValue: num, rawValue: num };
1725
+ }
1726
+ function parseYieldMetaVar(content) {
1727
+ const match = content.match(yieldMetaValueRegex);
1728
+ if (!match) return void 0;
1729
+ if (match.groups?.arbitraryQuantity) {
1730
+ const parsed = parseArbitraryQuantity(match.groups.arbitraryQuantity);
1731
+ const result = {
1732
+ quantity: parsed.quantity
1733
+ };
1734
+ if (parsed.unit) result.unit = parsed.unit;
1735
+ if (match.groups.servingsPrefix) {
1736
+ result.textBefore = match.groups.servingsPrefix;
1737
+ }
1738
+ if (match.groups.servingsSuffix) {
1739
+ result.textAfter = match.groups.servingsSuffix;
1740
+ }
1741
+ return result;
1742
+ }
1743
+ if (match.groups?.quantity) {
1744
+ const result = {
1745
+ quantity: parseQuantityValue(match.groups.quantity)
1746
+ };
1747
+ if (match.groups.unit) result.unit = match.groups.unit;
1748
+ return result;
1011
1749
  }
1012
- return [Number(varMatch[2]?.trim()), varMatch[1].trim()];
1750
+ return void 0;
1013
1751
  }
1014
1752
  function parseListMetaVar(content, varName) {
1015
1753
  const listMatch = content.match(
@@ -1025,6 +1763,115 @@ function parseListMetaVar(content, varName) {
1025
1763
  return listMatch[2].split("\n").filter((line) => line.trim() !== "").map((line) => line.replace(/^\s*-\s*/, "").trim());
1026
1764
  }
1027
1765
  }
1766
+ function extractAllMetadataKeys(content) {
1767
+ const keys = [];
1768
+ for (const match of content.matchAll(metadataKeyRegex)) {
1769
+ keys.push(match[1].trim());
1770
+ }
1771
+ return [...new Set(keys)];
1772
+ }
1773
+ function parseNestedMetaVar(content, varName) {
1774
+ const match = content.match(nestedMetaVarRegex(varName));
1775
+ if (!match) return void 0;
1776
+ const nestedContent = match[1];
1777
+ return parseNestedBlock(nestedContent);
1778
+ }
1779
+ function parseNestedBlock(content) {
1780
+ const lines = content.split(/\r?\n/).filter((line) => line.trim() !== "");
1781
+ if (lines.length === 0) return void 0;
1782
+ const baseIndentMatch = lines[0].match(/^(\s*)/);
1783
+ if (baseIndentMatch?.[0]?.includes(" ")) {
1784
+ throw new NoTabAsIndentError();
1785
+ }
1786
+ const baseIndent = baseIndentMatch?.[1]?.length;
1787
+ if (lines[0].trim().startsWith("- ")) return void 0;
1788
+ const result = {};
1789
+ let i2 = 0;
1790
+ while (i2 < lines.length) {
1791
+ const line = lines[i2];
1792
+ const leadingWhitespace = line.match(/^(\s*)/)?.[1];
1793
+ if (leadingWhitespace && leadingWhitespace.includes(" ")) {
1794
+ throw new NoTabAsIndentError();
1795
+ }
1796
+ const currentIndent = leadingWhitespace.length;
1797
+ if (currentIndent < baseIndent) {
1798
+ break;
1799
+ }
1800
+ if (currentIndent !== baseIndent) {
1801
+ throw new BadIndentationError();
1802
+ }
1803
+ const keyValueMatch = line.match(/^[ ]*([^:\n]+?):\s*(.*)$/);
1804
+ if (!keyValueMatch) {
1805
+ i2++;
1806
+ continue;
1807
+ }
1808
+ const key = keyValueMatch[1].trim();
1809
+ const rawValue = keyValueMatch[2].trim();
1810
+ if (rawValue === "") {
1811
+ const childLines = [];
1812
+ let j = i2 + 1;
1813
+ while (j < lines.length) {
1814
+ const childLine = lines[j];
1815
+ const childIndent = childLine.match(/^([ ]*)/)?.[1]?.length;
1816
+ if (childIndent && childIndent > baseIndent) {
1817
+ childLines.push(childLine);
1818
+ j++;
1819
+ } else {
1820
+ break;
1821
+ }
1822
+ }
1823
+ if (childLines.length > 0) {
1824
+ const firstChildTrimmed = childLines[0].trim();
1825
+ if (firstChildTrimmed.startsWith("- ")) {
1826
+ const reconstructedContent = `${key}:
1827
+ ${childLines.join("\n")}`;
1828
+ const listResult = parseListMetaVar(reconstructedContent, key);
1829
+ if (listResult) {
1830
+ result[key] = listResult.map(
1831
+ (item) => parseMetadataValue(item)
1832
+ );
1833
+ }
1834
+ } else {
1835
+ const childContent = childLines.join("\n");
1836
+ const nested = parseNestedBlock(childContent);
1837
+ if (nested) {
1838
+ result[key] = nested;
1839
+ }
1840
+ }
1841
+ }
1842
+ i2 = j;
1843
+ } else {
1844
+ result[key] = parseMetadataValue(rawValue);
1845
+ i2++;
1846
+ }
1847
+ }
1848
+ return result;
1849
+ }
1850
+ function parseMetadataValue(rawValue) {
1851
+ if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
1852
+ return rawValue.slice(1, -1).split(",").map((item) => item.trim());
1853
+ }
1854
+ if (numericValueRegex.test(rawValue)) {
1855
+ return Number(rawValue);
1856
+ }
1857
+ return rawValue;
1858
+ }
1859
+ function parseAnyMetaVar(content, varName) {
1860
+ const nested = parseNestedMetaVar(content, varName);
1861
+ if (nested) return nested;
1862
+ const list = parseListMetaVar(content, varName);
1863
+ if (list) return list;
1864
+ const simple = parseSimpleMetaVar(content, varName);
1865
+ if (simple) return parseMetadataValue(simple);
1866
+ return void 0;
1867
+ }
1868
+ function getNumericValueFromYield(v) {
1869
+ if (v.quantity.type === "fixed" && v.quantity.value.type !== "text") {
1870
+ return getNumericValue(v.quantity.value);
1871
+ }
1872
+ if (v.quantity.type === "range") return getNumericValue(v.quantity.min);
1873
+ return 1;
1874
+ }
1028
1875
  function extractMetadata(content) {
1029
1876
  const metadata = {};
1030
1877
  let servings = void 0;
@@ -1032,20 +1879,49 @@ function extractMetadata(content) {
1032
1879
  if (!metadataContent) {
1033
1880
  return { metadata };
1034
1881
  }
1035
- for (const metaVar of [
1882
+ const handledKeys = /* @__PURE__ */ new Set([
1883
+ // Simple string fields
1036
1884
  "title",
1037
- "source",
1038
- "source.name",
1039
- "source.url",
1040
1885
  "author",
1041
- "source.author",
1042
- "prep time",
1043
- "time.prep",
1886
+ "locale",
1887
+ "introduction",
1888
+ "description",
1889
+ "course",
1890
+ "category",
1891
+ "diet",
1892
+ "cuisine",
1893
+ "difficulty",
1894
+ // Source fields
1895
+ "source",
1896
+ "source.name",
1897
+ "source.url",
1898
+ "source.author",
1899
+ // Time fields
1900
+ "prep time",
1901
+ "time.prep",
1044
1902
  "cook time",
1045
1903
  "time.cook",
1046
1904
  "time required",
1047
1905
  "time",
1048
1906
  "duration",
1907
+ // Image fields
1908
+ "image",
1909
+ "picture",
1910
+ "images",
1911
+ "pictures",
1912
+ // Unit system
1913
+ "unit system",
1914
+ // Scaling fields
1915
+ "servings",
1916
+ "yield",
1917
+ "serves",
1918
+ // List fields
1919
+ "tags",
1920
+ "variants"
1921
+ ]);
1922
+ for (const metaVar of [
1923
+ "title",
1924
+ "author",
1049
1925
  "locale",
1050
1926
  "introduction",
1051
1927
  "description",
@@ -1053,25 +1929,97 @@ function extractMetadata(content) {
1053
1929
  "category",
1054
1930
  "diet",
1055
1931
  "cuisine",
1056
- "difficulty",
1057
- "image",
1058
- "picture"
1932
+ "difficulty"
1059
1933
  ]) {
1934
+ if (metaVar === "description" || metaVar === "introduction") {
1935
+ const blockValue = parseBlockScalarMetaVar(metadataContent, metaVar);
1936
+ if (blockValue) {
1937
+ metadata[metaVar] = blockValue;
1938
+ continue;
1939
+ }
1940
+ }
1060
1941
  const stringMetaValue = parseSimpleMetaVar(metadataContent, metaVar);
1061
1942
  if (stringMetaValue) metadata[metaVar] = stringMetaValue;
1062
1943
  }
1063
- for (const metaVar of ["serves", "yield", "servings"]) {
1064
- const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1065
- if (scalingMetaValue && scalingMetaValue[1]) {
1066
- metadata[metaVar] = scalingMetaValue[1];
1067
- servings = scalingMetaValue[0];
1944
+ const sourceNested = parseNestedMetaVar(metadataContent, "source");
1945
+ const sourceTxt = parseSimpleMetaVar(metadataContent, "source");
1946
+ const sourceName = parseSimpleMetaVar(metadataContent, "source.name");
1947
+ const sourceUrl = parseSimpleMetaVar(metadataContent, "source.url");
1948
+ const sourceAuthor = parseSimpleMetaVar(metadataContent, "source.author");
1949
+ if (sourceNested) {
1950
+ const source = {};
1951
+ if (typeof sourceNested.name === "string") source.name = sourceNested.name;
1952
+ if (typeof sourceNested.url === "string") source.url = sourceNested.url;
1953
+ if (typeof sourceNested.author === "string")
1954
+ source.author = sourceNested.author;
1955
+ if (Object.keys(source).length > 0) metadata.source = source;
1956
+ } else if (sourceName || sourceAuthor || sourceUrl) {
1957
+ const source = {};
1958
+ if (sourceName) source.name = sourceName;
1959
+ if (sourceUrl) source.url = sourceUrl;
1960
+ if (sourceAuthor) source.author = sourceAuthor;
1961
+ metadata.source = source;
1962
+ } else if (sourceTxt) {
1963
+ metadata.source = sourceTxt;
1964
+ }
1965
+ const timeNested = parseNestedMetaVar(metadataContent, "time");
1966
+ const prepTime = parseSimpleMetaVar(metadataContent, "prep time") ?? parseSimpleMetaVar(metadataContent, "time.prep");
1967
+ const cookTime = parseSimpleMetaVar(metadataContent, "cook time") ?? parseSimpleMetaVar(metadataContent, "time.cook");
1968
+ const totalTime = parseSimpleMetaVar(metadataContent, "time required") ?? parseSimpleMetaVar(metadataContent, "time") ?? parseSimpleMetaVar(metadataContent, "duration");
1969
+ if (timeNested) {
1970
+ const time = {};
1971
+ if (typeof timeNested.prep === "string") time.prep = timeNested.prep;
1972
+ if (typeof timeNested.cook === "string") time.cook = timeNested.cook;
1973
+ if (typeof timeNested.total === "string") time.total = timeNested.total;
1974
+ if (Object.keys(time).length > 0) metadata.time = time;
1975
+ } else if (prepTime || cookTime || totalTime) {
1976
+ const time = {};
1977
+ if (prepTime) time.prep = prepTime;
1978
+ if (cookTime) time.cook = cookTime;
1979
+ if (totalTime) time.total = totalTime;
1980
+ metadata.time = time;
1981
+ }
1982
+ const image = parseSimpleMetaVar(metadataContent, "image") ?? parseSimpleMetaVar(metadataContent, "picture");
1983
+ if (image) metadata.image = image;
1984
+ const images = parseListMetaVar(metadataContent, "images") ?? parseListMetaVar(metadataContent, "pictures");
1985
+ if (images) metadata.images = images;
1986
+ let unitSystem;
1987
+ const unitSystemRaw = parseSimpleMetaVar(metadataContent, "unit system");
1988
+ if (unitSystemRaw) {
1989
+ metadata.unitSystem = unitSystemRaw;
1990
+ const unitSystemMap = {
1991
+ metric: "metric",
1992
+ us: "US",
1993
+ uk: "UK",
1994
+ jp: "JP"
1995
+ };
1996
+ unitSystem = unitSystemMap[unitSystemRaw.toLowerCase()];
1997
+ }
1998
+ const yieldValue = parseYieldMetaVar(metadataContent);
1999
+ if (yieldValue) {
2000
+ metadata.yield = yieldValue;
2001
+ servings = getNumericValueFromYield(yieldValue);
2002
+ }
2003
+ for (const metaVar of ["serves", "servings"]) {
2004
+ const result = parseServingsMetaVar(metadataContent, metaVar);
2005
+ if (result !== void 0) {
2006
+ metadata[metaVar] = result.rawValue;
2007
+ servings = result.numericValue;
1068
2008
  }
1069
2009
  }
1070
- for (const metaVar of ["tags", "images", "pictures"]) {
1071
- const listMetaValue = parseListMetaVar(metadataContent, metaVar);
1072
- if (listMetaValue) metadata[metaVar] = listMetaValue;
2010
+ const tags = parseListMetaVar(metadataContent, "tags");
2011
+ if (tags) metadata.tags = tags;
2012
+ const variants = parseListMetaVar(metadataContent, "variants");
2013
+ if (variants) metadata.variants = variants;
2014
+ const allKeys = extractAllMetadataKeys(metadataContent);
2015
+ for (const key of allKeys) {
2016
+ if (handledKeys.has(key)) continue;
2017
+ const value = parseAnyMetaVar(metadataContent, key);
2018
+ if (value !== void 0) {
2019
+ metadata[key] = value;
2020
+ }
1073
2021
  }
1074
- return { metadata, servings };
2022
+ return { metadata, servings, unitSystem };
1075
2023
  }
1076
2024
  function isPositiveIntegerString(str) {
1077
2025
  return /^\d+$/.test(str);
@@ -1085,10 +2033,230 @@ function unionOfSets(s1, s2) {
1085
2033
  }
1086
2034
  function getAlternativeSignature(alternatives) {
1087
2035
  if (!alternatives || alternatives.length === 0) return null;
1088
- return alternatives.map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
2036
+ return alternatives.flat().map((a2) => a2.index).sort((a2, b) => a2 - b).join(",");
1089
2037
  }
1090
2038
 
2039
+ // src/classes/pantry.ts
2040
+ var Pantry = class {
2041
+ /**
2042
+ * Creates a new Pantry instance.
2043
+ * @param tomlContent - Optional TOML content to parse.
2044
+ * @param options - Optional configuration options.
2045
+ */
2046
+ constructor(tomlContent, options = {}) {
2047
+ /**
2048
+ * The parsed pantry items.
2049
+ */
2050
+ __publicField(this, "items", []);
2051
+ /**
2052
+ * Options for date parsing and other configuration.
2053
+ */
2054
+ __publicField(this, "options");
2055
+ /**
2056
+ * Optional category configuration for alias-based lookups.
2057
+ */
2058
+ __publicField(this, "categoryConfig");
2059
+ this.options = options;
2060
+ if (tomlContent) {
2061
+ this.parse(tomlContent);
2062
+ }
2063
+ }
2064
+ /**
2065
+ * Parses a TOML string into pantry items.
2066
+ * @param tomlContent - The TOML string to parse.
2067
+ * @returns The parsed list of pantry items.
2068
+ */
2069
+ parse(tomlContent) {
2070
+ const raw = TOML.parse(tomlContent);
2071
+ this.items = [];
2072
+ for (const [location, locationData] of Object.entries(raw)) {
2073
+ const locationTable = locationData;
2074
+ for (const [itemName, itemData] of Object.entries(locationTable)) {
2075
+ const item = this.parseItem(
2076
+ itemName,
2077
+ location,
2078
+ itemData
2079
+ );
2080
+ this.items.push(item);
2081
+ }
2082
+ }
2083
+ return this.items;
2084
+ }
2085
+ /**
2086
+ * Parses a single pantry item from its TOML representation.
2087
+ */
2088
+ parseItem(name, location, data) {
2089
+ const item = { name, location };
2090
+ if (typeof data === "string") {
2091
+ const parsed = parseQuantityWithUnit(data);
2092
+ item.quantity = parsed.value;
2093
+ if (parsed.unit) item.unit = parsed.unit;
2094
+ } else {
2095
+ if (data.quantity) {
2096
+ const parsed = parseQuantityWithUnit(data.quantity);
2097
+ item.quantity = parsed.value;
2098
+ if (parsed.unit) item.unit = parsed.unit;
2099
+ }
2100
+ if (data.low) {
2101
+ const parsed = parseQuantityWithUnit(data.low);
2102
+ item.low = parsed.value;
2103
+ if (parsed.unit) item.lowUnit = parsed.unit;
2104
+ }
2105
+ if (data.bought) {
2106
+ item.bought = this.parseDate(data.bought);
2107
+ }
2108
+ if (data.expire) {
2109
+ item.expire = this.parseDate(data.expire);
2110
+ }
2111
+ }
2112
+ return item;
2113
+ }
2114
+ /**
2115
+ * Parses a date string using the configured format or fuzzy detection.
2116
+ */
2117
+ parseDate(input) {
2118
+ if (this.options.dateFormat) {
2119
+ return parseDateFromFormat(input, this.options.dateFormat);
2120
+ }
2121
+ return parseFuzzyDate(input);
2122
+ }
2123
+ /**
2124
+ * Sets a category configuration for alias-based item lookups.
2125
+ * @param config - The category configuration to use.
2126
+ */
2127
+ setCategoryConfig(config) {
2128
+ this.categoryConfig = config;
2129
+ }
2130
+ /**
2131
+ * Finds a pantry item by name, using exact match first, then alias lookup
2132
+ * via the stored CategoryConfig.
2133
+ * @param name - The name to search for.
2134
+ * @returns The matching pantry item, or undefined if not found.
2135
+ */
2136
+ findItem(name) {
2137
+ const lowerName = name.toLowerCase();
2138
+ const exact = this.items.find(
2139
+ (item) => item.name.toLowerCase() === lowerName
2140
+ );
2141
+ if (exact) return exact;
2142
+ if (this.categoryConfig) {
2143
+ for (const category of this.categoryConfig.categories) {
2144
+ for (const catIngredient of category.ingredients) {
2145
+ if (catIngredient.aliases.some(
2146
+ (alias) => alias.toLowerCase() === lowerName
2147
+ )) {
2148
+ const canonicalName = catIngredient.name.toLowerCase();
2149
+ const byCanonical = this.items.find(
2150
+ (item) => item.name.toLowerCase() === canonicalName
2151
+ );
2152
+ if (byCanonical) return byCanonical;
2153
+ for (const alias of catIngredient.aliases) {
2154
+ const byAlias = this.items.find(
2155
+ (item) => item.name.toLowerCase() === alias.toLowerCase()
2156
+ );
2157
+ if (byAlias) return byAlias;
2158
+ }
2159
+ }
2160
+ }
2161
+ }
2162
+ }
2163
+ return void 0;
2164
+ }
2165
+ /**
2166
+ * Gets the numeric value of a pantry item's quantity, optionally converted to base units.
2167
+ * Returns undefined if the quantity has a text value or is not set.
2168
+ */
2169
+ getItemNumericValue(quantity, unit) {
2170
+ if (!quantity) return void 0;
2171
+ let numericValue;
2172
+ if (quantity.type === "fixed") {
2173
+ if (quantity.value.type === "text") return void 0;
2174
+ numericValue = getNumericValue(quantity.value);
2175
+ } else {
2176
+ numericValue = (getNumericValue(quantity.min) + getNumericValue(quantity.max)) / 2;
2177
+ }
2178
+ if (unit) {
2179
+ const unitDef = normalizeUnit(unit);
2180
+ if (unitDef) {
2181
+ const toBase = getToBase(unitDef);
2182
+ numericValue *= toBase;
2183
+ }
2184
+ }
2185
+ return numericValue;
2186
+ }
2187
+ /**
2188
+ * Returns all items that are depleted (quantity = 0) or below their low threshold.
2189
+ * @returns An array of depleted pantry items.
2190
+ */
2191
+ getDepletedItems() {
2192
+ return this.items.filter((item) => this.isItemLow(item));
2193
+ }
2194
+ /**
2195
+ * Returns all items whose expiration date is within `nbDays` days from today
2196
+ * (or already passed).
2197
+ * @param nbDays - Number of days ahead to check. Defaults to 0 (already expired).
2198
+ * @returns An array of expired pantry items.
2199
+ */
2200
+ getExpiredItems(nbDays = 0) {
2201
+ return this.items.filter((item) => this.isItemExpired(item, nbDays));
2202
+ }
2203
+ /**
2204
+ * Checks if a specific item is low (quantity = 0 or below `low` threshold).
2205
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2206
+ * @returns true if the item is low, false otherwise. Returns false if item not found.
2207
+ */
2208
+ isLow(itemName) {
2209
+ const item = this.findItem(itemName);
2210
+ if (!item) return false;
2211
+ return this.isItemLow(item);
2212
+ }
2213
+ /**
2214
+ * Checks if a specific item is expired or expires within `nbDays` days.
2215
+ * @param itemName - The name of the item to check (supports aliases if CategoryConfig is set).
2216
+ * @param nbDays - Number of days ahead to check. Defaults to 0.
2217
+ * @returns true if the item is expired, false otherwise. Returns false if item not found.
2218
+ */
2219
+ isExpired(itemName, nbDays = 0) {
2220
+ const item = this.findItem(itemName);
2221
+ if (!item) return false;
2222
+ return this.isItemExpired(item, nbDays);
2223
+ }
2224
+ /**
2225
+ * Internal: checks if a pantry item is low.
2226
+ */
2227
+ isItemLow(item) {
2228
+ if (!item.quantity) return false;
2229
+ const qtyValue = this.getItemNumericValue(item.quantity, item.unit);
2230
+ if (qtyValue === void 0) return false;
2231
+ if (qtyValue === 0) return true;
2232
+ if (item.low) {
2233
+ const lowValue = this.getItemNumericValue(item.low, item.lowUnit);
2234
+ if (lowValue !== void 0 && qtyValue <= lowValue) return true;
2235
+ }
2236
+ return false;
2237
+ }
2238
+ /**
2239
+ * Internal: checks if a pantry item is expired.
2240
+ */
2241
+ isItemExpired(item, nbDays) {
2242
+ if (!item.expire) return false;
2243
+ const now = /* @__PURE__ */ new Date();
2244
+ const cutoff = new Date(
2245
+ now.getFullYear(),
2246
+ now.getMonth(),
2247
+ now.getDate() + nbDays
2248
+ );
2249
+ const expireDay = new Date(
2250
+ item.expire.getFullYear(),
2251
+ item.expire.getMonth(),
2252
+ item.expire.getDate()
2253
+ );
2254
+ return expireDay <= cutoff;
2255
+ }
2256
+ };
2257
+
1091
2258
  // src/classes/product_catalog.ts
2259
+ import TOML2 from "smol-toml";
1092
2260
  var ProductCatalog = class {
1093
2261
  constructor(tomlContent) {
1094
2262
  __publicField(this, "products", []);
@@ -1100,7 +2268,7 @@ var ProductCatalog = class {
1100
2268
  * @returns A parsed list of `ProductOption`.
1101
2269
  */
1102
2270
  parse(tomlContent) {
1103
- const catalogRaw = TOML.parse(tomlContent);
2271
+ const catalogRaw = TOML2.parse(tomlContent);
1104
2272
  this.products = [];
1105
2273
  if (!this.isValidTomlContent(catalogRaw)) {
1106
2274
  throw new InvalidProductCatalogFormat();
@@ -1117,7 +2285,7 @@ var ProductCatalog = class {
1117
2285
  const sizeStrings = Array.isArray(size) ? size : [size];
1118
2286
  const sizes = sizeStrings.map((sizeStr) => {
1119
2287
  const sizeAndUnitRaw = sizeStr.split("%");
1120
- const sizeParsed = parseQuantityInput(
2288
+ const sizeParsed = parseQuantityValue(
1121
2289
  sizeAndUnitRaw[0]
1122
2290
  );
1123
2291
  const productSize = { size: sizeParsed };
@@ -1173,7 +2341,7 @@ var ProductCatalog = class {
1173
2341
  size: sizeStrings.length === 1 ? sizeStrings[0] : sizeStrings
1174
2342
  };
1175
2343
  }
1176
- return TOML.stringify(grouped);
2344
+ return TOML2.stringify(grouped);
1177
2345
  }
1178
2346
  /**
1179
2347
  * Adds a product to the catalog.
@@ -1230,8 +2398,10 @@ var Section = class {
1230
2398
  /**
1231
2399
  * Creates an instance of Section.
1232
2400
  * @param name - The name of the section. Defaults to an empty string.
2401
+ * @param variants - Optional variant names for this section.
2402
+ * @param optional - Whether the section is optional.
1233
2403
  */
1234
- constructor(name = "") {
2404
+ constructor(name = "", variants, optional) {
1235
2405
  /**
1236
2406
  * The name of the section. Can be an empty string for the default (first) section.
1237
2407
  * @defaultValue `""`
@@ -1239,7 +2409,13 @@ var Section = class {
1239
2409
  __publicField(this, "name");
1240
2410
  /** An array of steps and notes that make up the content of the section. */
1241
2411
  __publicField(this, "content", []);
2412
+ /** Optional list of variant names this section belongs to. */
2413
+ __publicField(this, "variants");
2414
+ /** Whether the section has been marked as optional ([?]) */
2415
+ __publicField(this, "optional");
1242
2416
  this.name = name;
2417
+ if (variants) this.variants = variants;
2418
+ if (optional) this.optional = true;
1243
2419
  }
1244
2420
  /**
1245
2421
  * Checks if the section is blank (has no name and no content).
@@ -1252,46 +2428,16 @@ var Section = class {
1252
2428
  };
1253
2429
 
1254
2430
  // src/quantities/alternatives.ts
1255
- import Big3 from "big.js";
1256
-
1257
- // src/units/conversion.ts
1258
- import Big2 from "big.js";
1259
- function getUnitRatio(q1, q2) {
1260
- const q1Value = getAverageValue(q1.quantity);
1261
- const q2Value = getAverageValue(q2.quantity);
1262
- const factor = "toBase" in q1.unit && "toBase" in q2.unit ? q1.unit.toBase / q2.unit.toBase : 1;
1263
- if (typeof q1Value !== "number" || typeof q2Value !== "number") {
1264
- throw Error(
1265
- "One of both values is not a number, so a ratio cannot be computed"
1266
- );
1267
- }
1268
- return Big2(q1Value).times(factor).div(q2Value);
1269
- }
1270
- function getBaseUnitRatio(q, qRef) {
1271
- if ("toBase" in q.unit && "toBase" in qRef.unit) {
1272
- return q.unit.toBase / qRef.unit.toBase;
1273
- } else {
1274
- return 1;
1275
- }
1276
- }
2431
+ import Big4 from "big.js";
1277
2432
 
1278
2433
  // src/units/lookup.ts
1279
- function areUnitsCompatible(u1, u2) {
1280
- if (u1.name === u2.name) {
1281
- return true;
1282
- }
1283
- if (u1.type !== "other" && u1.type === u2.type && u1.system === u2.system) {
1284
- return true;
1285
- }
1286
- return false;
1287
- }
1288
2434
  function findListWithCompatibleQuantity(list, quantity) {
1289
2435
  const quantityWithUnitDef = {
1290
2436
  ...quantity,
1291
2437
  unit: resolveUnit(quantity.unit?.name)
1292
2438
  };
1293
2439
  return list.find(
1294
- (l) => l.some((lq) => areUnitsCompatible(lq.unit, quantityWithUnitDef.unit))
2440
+ (l) => l.some((lq) => areUnitsGroupable(lq.unit, quantityWithUnitDef.unit))
1295
2441
  );
1296
2442
  }
1297
2443
  function findCompatibleQuantityWithinList(list, quantity) {
@@ -1305,10 +2451,14 @@ function findCompatibleQuantityWithinList(list, quantity) {
1305
2451
  }
1306
2452
 
1307
2453
  // src/utils/general.ts
2454
+ import Big3 from "big.js";
1308
2455
  var legacyDeepClone = (v) => {
1309
2456
  if (v === null || typeof v !== "object") {
1310
2457
  return v;
1311
2458
  }
2459
+ if (v instanceof Big3) {
2460
+ return new Big3(v);
2461
+ }
1312
2462
  if (v instanceof Map) {
1313
2463
  return new Map(
1314
2464
  Array.from(v.entries()).map(([k, val]) => [
@@ -1318,7 +2468,9 @@ var legacyDeepClone = (v) => {
1318
2468
  );
1319
2469
  }
1320
2470
  if (v instanceof Set) {
1321
- return new Set(Array.from(v).map((val) => legacyDeepClone(val)));
2471
+ return new Set(
2472
+ Array.from(v).map((val) => legacyDeepClone(val))
2473
+ );
1322
2474
  }
1323
2475
  if (v instanceof Date) {
1324
2476
  return new Date(v.getTime());
@@ -1332,7 +2484,7 @@ var legacyDeepClone = (v) => {
1332
2484
  }
1333
2485
  return cloned;
1334
2486
  };
1335
- var deepClone = (v) => typeof structuredClone === "function" ? structuredClone(v) : legacyDeepClone(v);
2487
+ var deepClone = (v) => legacyDeepClone(v);
1336
2488
 
1337
2489
  // src/quantities/alternatives.ts
1338
2490
  function getEquivalentUnitsLists(...quantities) {
@@ -1340,7 +2492,6 @@ function getEquivalentUnitsLists(...quantities) {
1340
2492
  const OrGroups = quantitiesCopy.filter(isOrGroup).filter((q) => q.or.length > 1);
1341
2493
  const unitLists = [];
1342
2494
  const normalizeOrGroup = (og) => ({
1343
- ...og,
1344
2495
  or: og.or.map((q) => ({
1345
2496
  ...q,
1346
2497
  unit: resolveUnit(q.unit?.name, q.unit?.integerProtected)
@@ -1348,11 +2499,9 @@ function getEquivalentUnitsLists(...quantities) {
1348
2499
  });
1349
2500
  function findLinkIndexForUnits(lists, unitsToCheck) {
1350
2501
  return lists.findIndex((l) => {
1351
- const listItem = l.map((q) => resolveUnit(q.unit?.name));
2502
+ const listItems = l.map((q) => resolveUnit(q.unit?.name));
1352
2503
  return unitsToCheck.some(
1353
- (u) => listItem.some(
1354
- (lu) => lu.name === u?.name || lu.system === u?.system && lu.type === u?.type && lu.type !== "other"
1355
- )
2504
+ (u) => u && listItems.some((lu) => areUnitsGroupable(lu, u))
1356
2505
  );
1357
2506
  });
1358
2507
  }
@@ -1364,16 +2513,18 @@ function getEquivalentUnitsLists(...quantities) {
1364
2513
  unit: resolveUnit(v.unit?.name, v.unit?.integerProtected)
1365
2514
  };
1366
2515
  const commonQuantity = og.or.find(
1367
- (q) => isQuantity(q) && areUnitsCompatible(q.unit, normalizedV.unit)
2516
+ (q) => isQuantity(q) && areUnitsGroupable(q.unit, normalizedV.unit)
1368
2517
  );
1369
2518
  if (commonQuantity) {
1370
2519
  acc.push(normalizedV);
1371
- unitRatio = getUnitRatio(normalizedV, commonQuantity);
2520
+ if (!unitRatio) {
2521
+ unitRatio = getUnitRatio(normalizedV, commonQuantity);
2522
+ }
1372
2523
  }
1373
2524
  return acc;
1374
2525
  }, []);
1375
2526
  for (const newQ of og.or) {
1376
- if (commonUnitList.some((q) => areUnitsCompatible(q.unit, newQ.unit))) {
2527
+ if (commonUnitList.some((q) => areUnitsGroupable(q.unit, newQ.unit))) {
1377
2528
  continue;
1378
2529
  } else {
1379
2530
  const scaledQuantity = multiplyQuantityValue(newQ.quantity, unitRatio);
@@ -1490,7 +2641,7 @@ function reduceOrsToFirstEquivalent(unitList, quantities) {
1490
2641
  return reduceToQuantity(qListModified[0]);
1491
2642
  });
1492
2643
  }
1493
- function addQuantitiesOrGroups(...quantities) {
2644
+ function addQuantitiesOrGroups(quantities, system) {
1494
2645
  if (quantities.length === 0)
1495
2646
  return {
1496
2647
  sum: {
@@ -1520,7 +2671,7 @@ function addQuantitiesOrGroups(...quantities) {
1520
2671
  unit: resolveUnit(nextQ.unit?.name)
1521
2672
  });
1522
2673
  } else {
1523
- const sumQ = addQuantities(existingQ, nextQ);
2674
+ const sumQ = addQuantities(existingQ, nextQ, system);
1524
2675
  existingQ.quantity = sumQ.quantity;
1525
2676
  existingQ.unit = resolveUnit(sumQ.unit?.name);
1526
2677
  }
@@ -1530,7 +2681,7 @@ function addQuantitiesOrGroups(...quantities) {
1530
2681
  }
1531
2682
  return { sum: { and: sum }, unitsLists };
1532
2683
  }
1533
- function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
2684
+ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists, system) {
1534
2685
  const sumQuantities = isAndGroup(sum) ? sum.and : [sum];
1535
2686
  const result = [];
1536
2687
  const processedQuantities = /* @__PURE__ */ new Set();
@@ -1558,10 +2709,20 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1558
2709
  }
1559
2710
  return main.reduce((acc, v) => {
1560
2711
  const mainInList = findCompatibleQuantityWithinList(list, v);
2712
+ const conversionRatio = getBaseUnitRatio(v, mainInList);
2713
+ const valueInOriginalUnit = Big4(getAverageValue(v.quantity)).times(
2714
+ conversionRatio
2715
+ );
1561
2716
  const newValue = {
1562
2717
  quantity: multiplyQuantityValue(
1563
- v.quantity,
1564
- Big3(getAverageValue(equiv.quantity)).div(
2718
+ {
2719
+ type: "fixed",
2720
+ value: {
2721
+ type: "decimal",
2722
+ decimal: valueInOriginalUnit.toNumber()
2723
+ }
2724
+ },
2725
+ Big4(getAverageValue(equiv.quantity)).div(
1565
2726
  getAverageValue(mainInList.quantity)
1566
2727
  )
1567
2728
  )
@@ -1569,7 +2730,7 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1569
2730
  if (equiv.unit && !isNoUnit(equiv.unit)) {
1570
2731
  newValue.unit = { name: equiv.unit.name };
1571
2732
  }
1572
- return addQuantities(acc, newValue);
2733
+ return addQuantities(acc, newValue, system);
1573
2734
  }, initialValue);
1574
2735
  });
1575
2736
  if (main.length + equivalents.length > 1) {
@@ -1586,21 +2747,66 @@ function regroupQuantitiesAndExpandEquivalents(sum, unitsLists) {
1586
2747
  sumQuantities.filter((q) => !processedQuantities.has(q)).forEach((q) => result.push(deNormalizeQuantity(q)));
1587
2748
  return result;
1588
2749
  }
1589
- function addEquivalentsAndSimplify(...quantities) {
2750
+ function addEquivalentsAndSimplify(quantities, system) {
1590
2751
  if (quantities.length === 1) {
1591
2752
  return toPlainUnit(quantities[0]);
1592
2753
  }
1593
- const { sum, unitsLists } = addQuantitiesOrGroups(...quantities);
1594
- const regrouped = regroupQuantitiesAndExpandEquivalents(sum, unitsLists);
2754
+ const { sum, unitsLists } = addQuantitiesOrGroups(quantities, system);
2755
+ const regrouped = regroupQuantitiesAndExpandEquivalents(
2756
+ sum,
2757
+ unitsLists,
2758
+ system
2759
+ );
1595
2760
  if (regrouped.length === 1) {
1596
2761
  return toPlainUnit(regrouped[0]);
1597
2762
  } else {
1598
2763
  return { and: regrouped.map(toPlainUnit) };
1599
2764
  }
1600
2765
  }
2766
+ function buildEquivalenceRatioMap(unitsLists) {
2767
+ const ratioMap = {};
2768
+ for (const list of unitsLists) {
2769
+ for (const equiv of list) {
2770
+ const equivValue = getAverageValue(equiv.quantity);
2771
+ for (const primary of list) {
2772
+ if (primary === equiv) continue;
2773
+ const primaryValue = getAverageValue(primary.quantity);
2774
+ const equivUnit = normalizeUnit(equiv.unit.name)?.name ?? equiv.unit.name;
2775
+ const primaryUnit = normalizeUnit(primary.unit.name)?.name ?? primary.unit.name;
2776
+ ratioMap[equivUnit] ?? (ratioMap[equivUnit] = {});
2777
+ ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue;
2778
+ }
2779
+ }
2780
+ }
2781
+ return ratioMap;
2782
+ }
2783
+ function recomputeEquivalents(primaries, ratioMap, equivUnits) {
2784
+ const equivalents = [];
2785
+ for (const equivUnit of equivUnits) {
2786
+ const ratios = ratioMap[normalizeUnit(equivUnit)?.name ?? equivUnit];
2787
+ let total = 0;
2788
+ for (const primary of primaries) {
2789
+ const pUnit = normalizeUnit(primary.unit ?? NO_UNIT)?.name ?? primary.unit ?? NO_UNIT;
2790
+ const ratio = ratios[pUnit];
2791
+ if (ratio === void 0) continue;
2792
+ const pValue = getAverageValue(primary.quantity);
2793
+ total += pValue * ratio;
2794
+ }
2795
+ if (total > 0) {
2796
+ equivalents.push({
2797
+ quantity: {
2798
+ type: "fixed",
2799
+ value: { type: "decimal", decimal: total }
2800
+ },
2801
+ ...equivUnit !== "" && { unit: equivUnit }
2802
+ });
2803
+ }
2804
+ }
2805
+ return equivalents.length > 0 ? equivalents : void 0;
2806
+ }
1601
2807
 
1602
2808
  // src/classes/recipe.ts
1603
- import Big4 from "big.js";
2809
+ import Big5 from "big.js";
1604
2810
  var _Recipe = class _Recipe {
1605
2811
  /**
1606
2812
  * Creates a new Recipe instance.
@@ -1616,7 +2822,8 @@ var _Recipe = class _Recipe {
1616
2822
  */
1617
2823
  __publicField(this, "choices", {
1618
2824
  ingredientItems: /* @__PURE__ */ new Map(),
1619
- ingredientGroups: /* @__PURE__ */ new Map()
2825
+ ingredientGroups: /* @__PURE__ */ new Map(),
2826
+ variants: []
1620
2827
  });
1621
2828
  /**
1622
2829
  * The parsed recipe ingredients.
@@ -1647,10 +2854,20 @@ var _Recipe = class _Recipe {
1647
2854
  */
1648
2855
  __publicField(this, "servings");
1649
2856
  _Recipe.itemCounts.set(this, 0);
2857
+ _Recipe.subgroupIndices.set(this, /* @__PURE__ */ new Map());
1650
2858
  if (content) {
1651
2859
  this.parse(content);
1652
2860
  }
1653
2861
  }
2862
+ /**
2863
+ * Gets the unit system specified in the recipe metadata.
2864
+ * Used for resolving ambiguous units like tsp, tbsp, cup, etc.
2865
+ *
2866
+ * @returns The unit system if specified, or undefined to use defaults
2867
+ */
2868
+ get unitSystem() {
2869
+ return _Recipe.unitSystems.get(this);
2870
+ }
1654
2871
  /**
1655
2872
  * Gets the current item count for this recipe.
1656
2873
  */
@@ -1673,27 +2890,17 @@ var _Recipe = class _Recipe {
1673
2890
  */
1674
2891
  _parseArbitraryScalable(regexMatchGroups, intoArray) {
1675
2892
  if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
1676
- const quantityMatch = regexMatchGroups.arbitraryQuantity?.trim().match(quantityAlternativeRegex);
1677
- if (quantityMatch?.groups) {
1678
- const value = parseQuantityInput(quantityMatch.groups.quantity);
1679
- const unit = quantityMatch.groups.unit;
1680
- const name = regexMatchGroups.arbitraryName || void 0;
1681
- if (!value || value.type === "fixed" && value.value.type === "text") {
1682
- throw new InvalidQuantityFormat(
1683
- regexMatchGroups.arbitraryQuantity?.trim(),
1684
- "Arbitrary quantities must have a numerical value"
1685
- );
1686
- }
1687
- const arbitrary = {
1688
- quantity: value
1689
- };
1690
- if (name) arbitrary.name = name;
1691
- if (unit) arbitrary.unit = unit;
1692
- intoArray.push({
1693
- type: "arbitrary",
1694
- index: this.arbitraries.push(arbitrary) - 1
1695
- });
1696
- }
2893
+ const parsed = parseArbitraryQuantity(regexMatchGroups.arbitraryQuantity);
2894
+ const name = regexMatchGroups.arbitraryName || void 0;
2895
+ const arbitrary = {
2896
+ quantity: parsed.quantity
2897
+ };
2898
+ if (name) arbitrary.name = name;
2899
+ if (parsed.unit) arbitrary.unit = parsed.unit;
2900
+ intoArray.push({
2901
+ type: "arbitrary",
2902
+ index: this.arbitraries.push(arbitrary) - 1
2903
+ });
1697
2904
  }
1698
2905
  /**
1699
2906
  * Parses text for arbitrary scalables and returns NoteItem array.
@@ -1707,13 +2914,13 @@ var _Recipe = class _Recipe {
1707
2914
  for (const match of text.matchAll(globalRegex)) {
1708
2915
  const idx = match.index;
1709
2916
  if (idx > cursor) {
1710
- noteItems.push({ type: "text", value: text.slice(cursor, idx) });
2917
+ noteItems.push(...parseMarkdownSegments(text.slice(cursor, idx)));
1711
2918
  }
1712
2919
  this._parseArbitraryScalable(match.groups, noteItems);
1713
2920
  cursor = idx + match[0].length;
1714
2921
  }
1715
2922
  if (cursor < text.length) {
1716
- noteItems.push({ type: "text", value: text.slice(cursor) });
2923
+ noteItems.push(...parseMarkdownSegments(text.slice(cursor)));
1717
2924
  }
1718
2925
  return noteItems;
1719
2926
  }
@@ -1721,7 +2928,7 @@ var _Recipe = class _Recipe {
1721
2928
  let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
1722
2929
  const quantities = [];
1723
2930
  while (quantityMatch?.groups) {
1724
- const value = quantityMatch.groups.quantity ? parseQuantityInput(quantityMatch.groups.quantity) : void 0;
2931
+ const value = quantityMatch.groups.quantity ? parseQuantityValue(quantityMatch.groups.quantity) : void 0;
1725
2932
  const unit = quantityMatch.groups.unit;
1726
2933
  if (value) {
1727
2934
  const newQuantity = { quantity: value };
@@ -1822,7 +3029,7 @@ var _Recipe = class _Recipe {
1822
3029
  alternative.note = note;
1823
3030
  }
1824
3031
  if (itemQuantity) {
1825
- alternative.itemQuantity = itemQuantity;
3032
+ Object.assign(alternative, itemQuantity);
1826
3033
  }
1827
3034
  alternatives.push(alternative);
1828
3035
  testString = groups.ingredientAlternative || "";
@@ -1865,6 +3072,7 @@ var _Recipe = class _Recipe {
1865
3072
  if (!match?.groups) return;
1866
3073
  const groups = match.groups;
1867
3074
  const groupKey = groups.gIngredientGroupKey;
3075
+ const subgroupKey = groups.gIngredientSubgroupKey;
1868
3076
  let name = groups.gmIngredientName || groups.gsIngredientName;
1869
3077
  const preparation = groups.gIngredientPreparation;
1870
3078
  const modifiers = groups.gIngredientModifiers;
@@ -1930,9 +3138,14 @@ var _Recipe = class _Recipe {
1930
3138
  displayName
1931
3139
  };
1932
3140
  if (itemQuantity) {
1933
- alternative.itemQuantity = itemQuantity;
3141
+ Object.assign(alternative, itemQuantity);
3142
+ }
3143
+ const note = groups.gIngredientNote?.trim();
3144
+ if (note) {
3145
+ alternative.note = note;
1934
3146
  }
1935
- const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
3147
+ const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3148
+ const existingAlternativesFlat = existingSubgroups?.flat();
1936
3149
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
1937
3150
  const ingredient = ingredients[ingredientIdx];
1938
3151
  if (ingredient) {
@@ -1943,8 +3156,8 @@ var _Recipe = class _Recipe {
1943
3156
  }
1944
3157
  }
1945
3158
  }
1946
- if (existingAlternatives) {
1947
- for (const alt of existingAlternatives) {
3159
+ if (existingAlternativesFlat) {
3160
+ for (const alt of existingAlternativesFlat) {
1948
3161
  upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
1949
3162
  upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
1950
3163
  }
@@ -1956,14 +3169,35 @@ var _Recipe = class _Recipe {
1956
3169
  group: groupKey,
1957
3170
  alternatives: [alternative]
1958
3171
  };
3172
+ if (subgroupKey !== void 0) {
3173
+ newItem.subgroup = subgroupKey;
3174
+ }
1959
3175
  items.push(newItem);
1960
3176
  const choiceAlternative = deepClone(alternative);
1961
3177
  choiceAlternative.itemId = id;
1962
3178
  const existingChoice = this.choices.ingredientGroups.get(groupKey);
3179
+ const sgMap = _Recipe.subgroupIndices.get(this);
1963
3180
  if (!existingChoice) {
1964
- this.choices.ingredientGroups.set(groupKey, [choiceAlternative]);
3181
+ this.choices.ingredientGroups.set(groupKey, [[choiceAlternative]]);
3182
+ if (subgroupKey !== void 0) {
3183
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, 0]]));
3184
+ }
3185
+ } else if (subgroupKey !== void 0) {
3186
+ const groupSgMap = sgMap.get(groupKey);
3187
+ const existingIdx = groupSgMap?.get(subgroupKey);
3188
+ if (existingIdx !== void 0) {
3189
+ existingChoice[existingIdx].push(choiceAlternative);
3190
+ } else {
3191
+ const newIdx = existingChoice.length;
3192
+ existingChoice.push([choiceAlternative]);
3193
+ if (!groupSgMap) {
3194
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, newIdx]]));
3195
+ } else {
3196
+ groupSgMap.set(subgroupKey, newIdx);
3197
+ }
3198
+ }
1965
3199
  } else {
1966
- existingChoice.push(choiceAlternative);
3200
+ existingChoice.push([choiceAlternative]);
1967
3201
  }
1968
3202
  }
1969
3203
  /**
@@ -1977,7 +3211,7 @@ var _Recipe = class _Recipe {
1977
3211
  * Quantities are grouped by their alternative signature and summed using addEquivalentsAndSimplify.
1978
3212
  * @internal
1979
3213
  */
1980
- _populate_ingredient_quantities() {
3214
+ _populateIngredientQuantities() {
1981
3215
  for (const ing of this.ingredients) {
1982
3216
  delete ing.quantities;
1983
3217
  delete ing.usedAsPrimary;
@@ -1998,35 +3232,13 @@ var _Recipe = class _Recipe {
1998
3232
  }
1999
3233
  }
2000
3234
  }
2001
- /**
2002
- * Gets ingredients with their quantities populated, optionally filtered by section/step
2003
- * and respecting user choices for alternatives.
2004
- *
2005
- * When no options are provided, returns all recipe ingredients with quantities
2006
- * calculated using primary alternatives (same as after parsing).
2007
- *
2008
- * @param options - Options for filtering and choice selection:
2009
- * - `section`: Filter to a specific section (Section object or 0-based index)
2010
- * - `step`: Filter to a specific step (Step object or 0-based index)
2011
- * - `choices`: Choices for alternative ingredients (defaults to primary)
2012
- * @returns Array of Ingredient objects with quantities populated
2013
- *
2014
- * @example
2015
- * ```typescript
2016
- * // Get all ingredients with primary alternatives
2017
- * const ingredients = recipe.getIngredientQuantities();
2018
- *
2019
- * // Get ingredients for a specific section
2020
- * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
2021
- *
2022
- * // Get ingredients with specific choices applied
2023
- * const withChoices = recipe.getIngredientQuantities({
2024
- * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
2025
- * });
2026
- * ```
2027
- */
2028
- getIngredientQuantities(options) {
3235
+ // Type for accumulated quantities (used internally by collectQuantityGroups)
3236
+ // Defined as a static type alias for the private method's return type
3237
+ /** @internal */
3238
+ collectQuantityGroups(options) {
2029
3239
  const { section, step, choices } = options || {};
3240
+ const activeVariant = choices?.variant;
3241
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
2030
3242
  const sectionsToProcess = section !== void 0 ? (() => {
2031
3243
  const idx = typeof section === "number" ? section : this.sections.indexOf(section);
2032
3244
  return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
@@ -2034,80 +3246,155 @@ var _Recipe = class _Recipe {
2034
3246
  const ingredientGroups = /* @__PURE__ */ new Map();
2035
3247
  const selectedIndices = /* @__PURE__ */ new Set();
2036
3248
  const referencedIndices = /* @__PURE__ */ new Set();
3249
+ const dynamicOptionalIndices = /* @__PURE__ */ new Set();
2037
3250
  for (const currentSection of sectionsToProcess) {
3251
+ if (currentSection.variants) {
3252
+ if (isDefaultVariant) {
3253
+ if (!currentSection.variants.includes("*")) continue;
3254
+ } else {
3255
+ if (!currentSection.variants.includes(activeVariant)) continue;
3256
+ }
3257
+ }
2038
3258
  const allSteps = currentSection.content.filter(
2039
3259
  (item) => item.type === "step"
2040
3260
  );
3261
+ const isOptionalSection = currentSection.optional === true;
2041
3262
  const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
2042
3263
  for (const currentStep of stepsToProcess) {
3264
+ if (currentStep.variants) {
3265
+ if (isDefaultVariant) {
3266
+ if (!currentStep.variants.includes("*")) continue;
3267
+ } else {
3268
+ if (!currentStep.variants.includes(activeVariant)) continue;
3269
+ }
3270
+ }
3271
+ const isOptionalStep = currentStep.optional === true || isOptionalSection;
2043
3272
  for (const item of currentStep.items.filter(
2044
3273
  (item2) => item2.type === "ingredient"
2045
3274
  )) {
2046
3275
  const isGrouped = "group" in item && item.group !== void 0;
2047
- const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3276
+ const groupSubgroups = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
2048
3277
  let selectedAltIndex = 0;
2049
- let isSelected = false;
2050
- let hasExplicitChoice = false;
3278
+ let isSelected;
3279
+ let hasExplicitChoice;
2051
3280
  if (isGrouped) {
2052
3281
  const groupChoice = choices?.ingredientGroups?.get(item.group);
2053
3282
  hasExplicitChoice = groupChoice !== void 0;
2054
- const targetIndex = groupChoice ?? 0;
2055
- isSelected = groupAlternatives?.[targetIndex]?.itemId === item.id;
3283
+ if (!hasExplicitChoice && !isDefaultVariant) {
3284
+ const matchingSubgroupIdx = groupSubgroups?.findIndex(
3285
+ (sg) => sg.some(
3286
+ (alt) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3287
+ )
3288
+ );
3289
+ if (matchingSubgroupIdx !== void 0 && matchingSubgroupIdx >= 0) {
3290
+ const matchedSubgroup = groupSubgroups[matchingSubgroupIdx];
3291
+ isSelected = matchedSubgroup.some(
3292
+ (alt) => alt.itemId === item.id
3293
+ );
3294
+ hasExplicitChoice = true;
3295
+ selectedAltIndex = 0;
3296
+ } else {
3297
+ const targetSubgroupIndex = 0;
3298
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3299
+ isSelected = selectedSubgroup.some(
3300
+ (alt) => alt.itemId === item.id
3301
+ );
3302
+ }
3303
+ } else {
3304
+ const targetSubgroupIndex = groupChoice ?? 0;
3305
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3306
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3307
+ }
2056
3308
  } else {
2057
3309
  const itemChoice = choices?.ingredientItems?.get(item.id);
2058
3310
  hasExplicitChoice = itemChoice !== void 0;
2059
- selectedAltIndex = itemChoice ?? 0;
3311
+ if (!hasExplicitChoice && !isDefaultVariant) {
3312
+ const matchingIndices = item.alternatives.map((alt, idx) => ({ alt, idx })).filter(
3313
+ ({ alt }) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3314
+ ).map(({ idx }) => idx);
3315
+ if (matchingIndices.length > 0) {
3316
+ selectedAltIndex = matchingIndices[0];
3317
+ hasExplicitChoice = true;
3318
+ } else {
3319
+ selectedAltIndex = itemChoice ?? 0;
3320
+ }
3321
+ } else {
3322
+ selectedAltIndex = itemChoice ?? 0;
3323
+ }
2060
3324
  isSelected = true;
2061
3325
  }
2062
3326
  const alternative = item.alternatives[selectedAltIndex];
2063
3327
  if (!alternative || !isSelected) continue;
2064
3328
  selectedIndices.add(alternative.index);
2065
- const allAlts = isGrouped ? groupAlternatives : item.alternatives;
2066
- for (const alt of allAlts) {
3329
+ if (isOptionalStep) {
3330
+ dynamicOptionalIndices.add(alternative.index);
3331
+ }
3332
+ const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3333
+ for (const alt of allAltsFlat) {
2067
3334
  referencedIndices.add(alt.index);
2068
3335
  }
2069
- if (!alternative.itemQuantity) continue;
3336
+ if (!alternative.quantity) continue;
2070
3337
  const baseQty = {
2071
- quantity: alternative.itemQuantity.quantity,
2072
- ...alternative.itemQuantity.unit && {
2073
- unit: alternative.itemQuantity.unit
3338
+ quantity: alternative.quantity,
3339
+ ...alternative.unit && {
3340
+ unit: alternative.unit
2074
3341
  }
2075
3342
  };
2076
- const quantityEntry = alternative.itemQuantity.equivalents?.length ? { or: [baseQty, ...alternative.itemQuantity.equivalents] } : baseQty;
3343
+ const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
2077
3344
  let alternativeRefs;
2078
- if (!hasExplicitChoice && allAlts.length > 1) {
2079
- alternativeRefs = allAlts.filter(
2080
- (alt) => isGrouped ? alt.itemId !== item.id : alt.index !== alternative.index
2081
- ).map((otherAlt) => {
3345
+ if (!hasExplicitChoice && groupSubgroups && groupSubgroups.length > 1) {
3346
+ const currentSubgroupIdx = groupSubgroups.findIndex(
3347
+ (sg) => sg.some((alt) => alt.itemId === item.id)
3348
+ );
3349
+ alternativeRefs = groupSubgroups.filter((_, idx) => idx !== currentSubgroupIdx).map(
3350
+ (subgroup) => subgroup.map((otherAlt) => {
3351
+ const ref = {
3352
+ index: otherAlt.index
3353
+ };
3354
+ if (otherAlt.quantity) {
3355
+ const altQty = {
3356
+ quantity: otherAlt.quantity,
3357
+ ...otherAlt.unit && {
3358
+ unit: otherAlt.unit.name
3359
+ },
3360
+ ...otherAlt.equivalents && {
3361
+ equivalents: otherAlt.equivalents.map(
3362
+ (eq) => toPlainUnit(eq)
3363
+ )
3364
+ }
3365
+ };
3366
+ ref.quantities = [altQty];
3367
+ }
3368
+ return ref;
3369
+ })
3370
+ );
3371
+ } else if (!hasExplicitChoice && !isGrouped && allAltsFlat.length > 1) {
3372
+ alternativeRefs = allAltsFlat.filter((alt) => alt.index !== alternative.index).map((otherAlt) => {
2082
3373
  const ref = { index: otherAlt.index };
2083
- if (otherAlt.itemQuantity) {
3374
+ if (otherAlt.quantity) {
2084
3375
  const altQty = {
2085
- quantity: otherAlt.itemQuantity.quantity,
2086
- ...otherAlt.itemQuantity.unit && {
2087
- unit: otherAlt.itemQuantity.unit.name
3376
+ quantity: otherAlt.quantity,
3377
+ ...otherAlt.unit && {
3378
+ unit: otherAlt.unit.name
2088
3379
  },
2089
- ...otherAlt.itemQuantity.equivalents && {
2090
- equivalents: otherAlt.itemQuantity.equivalents.map(
3380
+ ...otherAlt.equivalents && {
3381
+ equivalents: otherAlt.equivalents.map(
2091
3382
  (eq) => toPlainUnit(eq)
2092
3383
  )
2093
3384
  }
2094
3385
  };
2095
3386
  ref.quantities = [altQty];
2096
3387
  }
2097
- return ref;
3388
+ return [ref];
2098
3389
  });
2099
3390
  }
2100
3391
  const altIndices = getAlternativeSignature(alternativeRefs) ?? "";
2101
3392
  let signature;
2102
3393
  if (isGrouped) {
2103
- const resolvedUnit = resolveUnit(
2104
- alternative.itemQuantity.unit?.name
2105
- );
3394
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
2106
3395
  signature = `group:${item.group}|${altIndices}|${resolvedUnit.type}`;
2107
3396
  } else if (altIndices) {
2108
- const resolvedUnit = resolveUnit(
2109
- alternative.itemQuantity.unit?.name
2110
- );
3397
+ const resolvedUnit = resolveUnit(alternative.unit?.name);
2111
3398
  signature = `${altIndices}|${resolvedUnit.type}}`;
2112
3399
  } else {
2113
3400
  signature = null;
@@ -2119,42 +3406,145 @@ var _Recipe = class _Recipe {
2119
3406
  if (!groupsForIng.has(signature)) {
2120
3407
  groupsForIng.set(signature, {
2121
3408
  quantities: [],
2122
- alternativeQuantities: /* @__PURE__ */ new Map()
3409
+ alternativeQuantities: /* @__PURE__ */ new Map(),
3410
+ alternativeSubgroups: []
2123
3411
  });
2124
3412
  }
2125
3413
  const group = groupsForIng.get(signature);
2126
3414
  group.quantities.push(quantityEntry);
2127
- for (const ref of alternativeRefs ?? []) {
2128
- if (!group.alternativeQuantities.has(ref.index)) {
2129
- group.alternativeQuantities.set(ref.index, []);
2130
- }
2131
- for (const altQty of ref.quantities ?? []) {
2132
- const extended = toExtendedUnit({
2133
- quantity: altQty.quantity,
2134
- unit: altQty.unit
2135
- });
2136
- if (altQty.equivalents?.length) {
2137
- const eqEntries = [
2138
- extended,
2139
- ...altQty.equivalents.map((eq) => toExtendedUnit(eq))
2140
- ];
2141
- group.alternativeQuantities.get(ref.index).push({ or: eqEntries });
2142
- } else {
2143
- group.alternativeQuantities.get(ref.index).push(extended);
3415
+ if (alternativeRefs && alternativeRefs.length > 0 && group.alternativeSubgroups.length === 0) {
3416
+ group.alternativeSubgroups = alternativeRefs.map(
3417
+ (subgroup) => subgroup.map((ref) => ref.index)
3418
+ );
3419
+ }
3420
+ for (const subgroup of alternativeRefs ?? []) {
3421
+ for (const ref of subgroup) {
3422
+ if (!group.alternativeQuantities.has(ref.index)) {
3423
+ group.alternativeQuantities.set(ref.index, []);
3424
+ }
3425
+ for (const altQty of ref.quantities ?? []) {
3426
+ const extended = toExtendedUnit({
3427
+ quantity: altQty.quantity,
3428
+ unit: altQty.unit
3429
+ });
3430
+ if (altQty.equivalents?.length) {
3431
+ const eqEntries = [
3432
+ extended,
3433
+ ...altQty.equivalents.map((eq) => toExtendedUnit(eq))
3434
+ ];
3435
+ group.alternativeQuantities.get(ref.index).push({ or: eqEntries });
3436
+ } else {
3437
+ group.alternativeQuantities.get(ref.index).push(extended);
3438
+ }
2144
3439
  }
2145
3440
  }
2146
3441
  }
2147
3442
  }
2148
3443
  }
2149
3444
  }
3445
+ return {
3446
+ ingredientGroups,
3447
+ selectedIndices,
3448
+ referencedIndices,
3449
+ dynamicOptionalIndices
3450
+ };
3451
+ }
3452
+ /**
3453
+ * Gets the raw (unprocessed) quantity groups for each ingredient, before
3454
+ * any summation or equivalents simplification. This is useful for cross-recipe
3455
+ * aggregation (e.g., in {@link ShoppingList}), where quantities from multiple
3456
+ * recipes should be combined before processing.
3457
+ *
3458
+ * @param options - Options for filtering and choice selection (same as {@link getIngredientQuantities}).
3459
+ * @returns Array of {@link RawQuantityGroup} objects, one per ingredient with quantities.
3460
+ *
3461
+ * @example
3462
+ * ```typescript
3463
+ * const rawGroups = recipe.getRawQuantityGroups();
3464
+ * // Each group has: name, usedAsPrimary, flags, quantities[]
3465
+ * // quantities are the raw QuantityWithExtendedUnit or FlatOrGroup entries
3466
+ * ```
3467
+ */
3468
+ getRawQuantityGroups(options) {
3469
+ const {
3470
+ ingredientGroups,
3471
+ selectedIndices,
3472
+ referencedIndices,
3473
+ dynamicOptionalIndices
3474
+ } = this.collectQuantityGroups(options);
2150
3475
  const result = [];
2151
3476
  for (let index = 0; index < this.ingredients.length; index++) {
2152
3477
  if (!referencedIndices.has(index)) continue;
2153
3478
  const orig = this.ingredients[index];
3479
+ const usedAsPrimary = selectedIndices.has(index);
3480
+ let flags = orig.flags;
3481
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3482
+ flags = [...flags ?? [], "optional"];
3483
+ }
3484
+ const quantities = [];
3485
+ if (usedAsPrimary) {
3486
+ const groupsForIng = ingredientGroups.get(index);
3487
+ if (groupsForIng) {
3488
+ for (const [, group] of groupsForIng) {
3489
+ quantities.push(...group.quantities);
3490
+ }
3491
+ }
3492
+ }
3493
+ result.push({
3494
+ name: orig.name,
3495
+ ...usedAsPrimary && { usedAsPrimary: true },
3496
+ ...flags && { flags },
3497
+ quantities
3498
+ });
3499
+ }
3500
+ return result;
3501
+ }
3502
+ /**
3503
+ * Gets ingredients with their quantities populated, optionally filtered by section/step
3504
+ * and respecting user choices for alternatives.
3505
+ *
3506
+ * When no options are provided, returns all recipe ingredients with quantities
3507
+ * calculated using primary alternatives (same as after parsing).
3508
+ *
3509
+ * @param options - Options for filtering and choice selection:
3510
+ * - `section`: Filter to a specific section (Section object or 0-based index)
3511
+ * - `step`: Filter to a specific step (Step object or 0-based index)
3512
+ * - `choices`: Choices for alternative ingredients (defaults to primary)
3513
+ * @returns Array of Ingredient objects with quantities populated
3514
+ *
3515
+ * @example
3516
+ * ```typescript
3517
+ * // Get all ingredients with primary alternatives
3518
+ * const ingredients = recipe.getIngredientQuantities();
3519
+ *
3520
+ * // Get ingredients for a specific section
3521
+ * const sectionIngredients = recipe.getIngredientQuantities({ section: 0 });
3522
+ *
3523
+ * // Get ingredients with specific choices applied
3524
+ * const withChoices = recipe.getIngredientQuantities({
3525
+ * choices: { ingredientItems: new Map([['ingredient-item-2', 1]]) }
3526
+ * });
3527
+ * ```
3528
+ */
3529
+ getIngredientQuantities(options) {
3530
+ const {
3531
+ ingredientGroups,
3532
+ selectedIndices,
3533
+ referencedIndices,
3534
+ dynamicOptionalIndices
3535
+ } = this.collectQuantityGroups(options);
3536
+ const result = [];
3537
+ for (let index = 0; index < this.ingredients.length; index++) {
3538
+ if (!referencedIndices.has(index)) continue;
3539
+ const orig = this.ingredients[index];
3540
+ let flags = orig.flags;
3541
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3542
+ flags = [...flags ?? [], "optional"];
3543
+ }
2154
3544
  const ing = {
2155
3545
  name: orig.name,
2156
3546
  ...orig.preparation && { preparation: orig.preparation },
2157
- ...orig.flags && { flags: orig.flags },
3547
+ ...flags && { flags },
2158
3548
  ...orig.extras && { extras: orig.extras }
2159
3549
  };
2160
3550
  if (selectedIndices.has(index)) {
@@ -2163,19 +3553,30 @@ var _Recipe = class _Recipe {
2163
3553
  if (groupsForIng) {
2164
3554
  const quantityGroups = [];
2165
3555
  for (const [, group] of groupsForIng) {
2166
- const summed = addEquivalentsAndSimplify(...group.quantities);
3556
+ const summed = addEquivalentsAndSimplify(
3557
+ group.quantities,
3558
+ this.unitSystem
3559
+ );
2167
3560
  const flattened = flattenPlainUnitGroup(summed);
2168
- const alternatives = group.alternativeQuantities.size > 0 ? [...group.alternativeQuantities].map(([altIdx, altQtys]) => ({
2169
- index: altIdx,
2170
- ...altQtys.length > 0 && {
2171
- quantities: flattenPlainUnitGroup(
2172
- addEquivalentsAndSimplify(...altQtys)
2173
- ).flatMap(
2174
- /* v8 ignore next -- item.and branch requires complex nested AND-with-equivalents structure */
2175
- (item) => "quantity" in item ? [item] : item.and
2176
- )
2177
- }
2178
- })) : void 0;
3561
+ let alternatives;
3562
+ if (group.alternativeSubgroups.length > 0) {
3563
+ alternatives = group.alternativeSubgroups.map(
3564
+ (subgroupIndices) => subgroupIndices.map((altIdx) => {
3565
+ const altQtys = group.alternativeQuantities.get(altIdx);
3566
+ return {
3567
+ index: altIdx,
3568
+ ...altQtys.length > 0 && {
3569
+ quantities: flattenPlainUnitGroup(
3570
+ addEquivalentsAndSimplify(altQtys, this.unitSystem)
3571
+ ).flatMap(
3572
+ /* v8 ignore next -- item.and branch requires complex nested AND-with-equivalents structure */
3573
+ (item) => "quantity" in item ? [item] : item.and
3574
+ )
3575
+ }
3576
+ };
3577
+ })
3578
+ );
3579
+ }
2179
3580
  for (const gq of flattened) {
2180
3581
  if ("and" in gq) {
2181
3582
  quantityGroups.push({
@@ -2202,23 +3603,82 @@ var _Recipe = class _Recipe {
2202
3603
  }
2203
3604
  return result;
2204
3605
  }
3606
+ /**
3607
+ * Returns the list of cookware items that are used in the active variant.
3608
+ * Cookware in steps/sections not matching the active variant are excluded.
3609
+ * Hidden cookware is always excluded.
3610
+ *
3611
+ * @param options - Options for filtering:
3612
+ * - `choices`: The choices to apply (only `variant` is used)
3613
+ * @returns Array of Cookware objects referenced by active steps
3614
+ *
3615
+ * @example
3616
+ * ```typescript
3617
+ * // Get all cookware for the default variant
3618
+ * const cookware = recipe.getCookwareForVariant();
3619
+ *
3620
+ * // Get cookware for a specific variant
3621
+ * const veganCookware = recipe.getCookwareForVariant({ choices: { variant: 'vegan' } });
3622
+ * ```
3623
+ */
3624
+ getCookwareForVariant(options) {
3625
+ const { choices } = options || {};
3626
+ const activeVariant = choices?.variant;
3627
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3628
+ const cookwareIndices = /* @__PURE__ */ new Set();
3629
+ for (const currentSection of this.sections) {
3630
+ if (currentSection.variants) {
3631
+ if (isDefaultVariant) {
3632
+ if (!currentSection.variants.includes("*")) continue;
3633
+ } else {
3634
+ if (!currentSection.variants.includes(activeVariant)) continue;
3635
+ }
3636
+ }
3637
+ const allSteps = currentSection.content.filter(
3638
+ (item) => item.type === "step"
3639
+ );
3640
+ for (const currentStep of allSteps) {
3641
+ if (currentStep.variants) {
3642
+ if (isDefaultVariant) {
3643
+ if (!currentStep.variants.includes("*")) continue;
3644
+ } else {
3645
+ if (!currentStep.variants.includes(activeVariant)) continue;
3646
+ }
3647
+ }
3648
+ for (const item of currentStep.items) {
3649
+ if (item.type === "cookware") {
3650
+ cookwareIndices.add(item.index);
3651
+ }
3652
+ }
3653
+ }
3654
+ }
3655
+ return this.cookware.filter(
3656
+ (cw, idx) => cookwareIndices.has(idx) && !cw.flags?.includes("hidden")
3657
+ );
3658
+ }
2205
3659
  /**
2206
3660
  * Parses a recipe from a string.
2207
3661
  * @param content - The recipe content to parse.
2208
3662
  */
2209
3663
  parse(content) {
2210
3664
  const cleanContent = content.replace(metadataRegex, "").replace(commentRegex, "").replace(blockCommentRegex, "").trim().split(/\r\n?|\n/);
2211
- const { metadata, servings } = extractMetadata(content);
3665
+ const { metadata, servings, unitSystem } = extractMetadata(content);
2212
3666
  this.metadata = metadata;
2213
3667
  this.servings = servings;
3668
+ if (unitSystem) _Recipe.unitSystems.set(this, unitSystem);
2214
3669
  let blankLineBefore = true;
2215
3670
  let section = new Section();
2216
3671
  const items = [];
2217
3672
  let noteText = "";
2218
3673
  let inNote = false;
3674
+ let stepVariants;
3675
+ let stepOptional;
3676
+ const discoveredVariants = /* @__PURE__ */ new Set();
2219
3677
  for (const line of cleanContent) {
2220
3678
  if (line.trim().length === 0) {
2221
- flushPendingItems(section, items);
3679
+ flushPendingItems(section, items, stepVariants, stepOptional);
3680
+ stepVariants = void 0;
3681
+ stepOptional = void 0;
2222
3682
  flushPendingNote(
2223
3683
  section,
2224
3684
  noteText ? this._parseNoteText(noteText) : []
@@ -2229,26 +3689,42 @@ var _Recipe = class _Recipe {
2229
3689
  continue;
2230
3690
  }
2231
3691
  if (line.startsWith("=")) {
2232
- flushPendingItems(section, items);
3692
+ flushPendingItems(section, items, stepVariants, stepOptional);
3693
+ stepVariants = void 0;
3694
+ stepOptional = void 0;
2233
3695
  flushPendingNote(
2234
3696
  section,
2235
3697
  noteText ? this._parseNoteText(noteText) : []
2236
3698
  );
2237
3699
  noteText = "";
2238
- if (this.sections.length === 0 && section.isBlank()) {
2239
- section.name = line.replace(/^=+|=+$/g, "").trim();
2240
- } else {
2241
- if (!section.isBlank()) {
2242
- this.sections.push(section);
3700
+ let sectionName = line.replace(/^=+|=+$/g, "").trim();
3701
+ let sectionVariants;
3702
+ let sectionOptional;
3703
+ const sectionVarMatch = sectionName.match(variantTagRegex);
3704
+ if (sectionVarMatch?.groups) {
3705
+ const isOptionalPrefix = sectionVarMatch.groups.variantOptionalPrefix === "?";
3706
+ const names = (sectionVarMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3707
+ if (names.length > 0) {
3708
+ sectionVariants = names;
3709
+ for (const v of names) discoveredVariants.add(v);
3710
+ }
3711
+ if (isOptionalPrefix) {
3712
+ sectionOptional = true;
2243
3713
  }
2244
- section = new Section(line.replace(/^=+|=+$/g, "").trim());
3714
+ sectionName = sectionName.slice(sectionVarMatch[0].length).trim();
2245
3715
  }
3716
+ if (!section.isBlank()) {
3717
+ this.sections.push(section);
3718
+ }
3719
+ section = new Section(sectionName, sectionVariants, sectionOptional);
2246
3720
  blankLineBefore = true;
2247
3721
  inNote = false;
2248
3722
  continue;
2249
3723
  }
2250
3724
  if (blankLineBefore && line.startsWith(">")) {
2251
- flushPendingItems(section, items);
3725
+ flushPendingItems(section, items, stepVariants, stepOptional);
3726
+ stepVariants = void 0;
3727
+ stepOptional = void 0;
2252
3728
  noteText = line.substring(1).trim();
2253
3729
  inNote = true;
2254
3730
  blankLineBefore = false;
@@ -2263,11 +3739,31 @@ var _Recipe = class _Recipe {
2263
3739
  blankLineBefore = false;
2264
3740
  continue;
2265
3741
  }
3742
+ let currentLine = line;
3743
+ if (items.length === 0) {
3744
+ const varMatch = currentLine.match(variantTagRegex);
3745
+ if (varMatch?.groups) {
3746
+ const isOptionalPrefix = varMatch.groups.variantOptionalPrefix === "?";
3747
+ const names = (varMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3748
+ if (names.length > 0) {
3749
+ stepVariants = names;
3750
+ for (const v of names) discoveredVariants.add(v);
3751
+ }
3752
+ if (isOptionalPrefix) {
3753
+ stepOptional = true;
3754
+ }
3755
+ currentLine = currentLine.slice(varMatch[0].length);
3756
+ if (currentLine.trim().length === 0) {
3757
+ blankLineBefore = false;
3758
+ continue;
3759
+ }
3760
+ }
3761
+ }
2266
3762
  let cursor = 0;
2267
- for (const match of line.matchAll(tokensRegex)) {
3763
+ for (const match of currentLine.matchAll(tokensRegex)) {
2268
3764
  const idx = match.index;
2269
3765
  if (idx > cursor) {
2270
- items.push({ type: "text", value: line.slice(cursor, idx) });
3766
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor, idx)));
2271
3767
  }
2272
3768
  const groups = match.groups;
2273
3769
  if (groups.mIngredientName || groups.sIngredientName) {
@@ -2286,7 +3782,7 @@ var _Recipe = class _Recipe {
2286
3782
  if (modifiers !== void 0 && modifiers.includes("-")) {
2287
3783
  flags.push("hidden");
2288
3784
  }
2289
- const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
3785
+ const quantity = quantityRaw ? parseQuantityValue(quantityRaw) : void 0;
2290
3786
  const newCookware = {
2291
3787
  name
2292
3788
  };
@@ -2318,7 +3814,7 @@ var _Recipe = class _Recipe {
2318
3814
  throw new Error("Timer missing unit");
2319
3815
  }
2320
3816
  const name = groups.timerName || void 0;
2321
- const duration = parseQuantityInput(durationStr);
3817
+ const duration = parseQuantityValue(durationStr);
2322
3818
  const timerObj = {
2323
3819
  name,
2324
3820
  duration,
@@ -2328,17 +3824,22 @@ var _Recipe = class _Recipe {
2328
3824
  }
2329
3825
  cursor = idx + match[0].length;
2330
3826
  }
2331
- if (cursor < line.length) {
2332
- items.push({ type: "text", value: line.slice(cursor) });
3827
+ if (cursor < currentLine.length) {
3828
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor)));
2333
3829
  }
2334
3830
  blankLineBefore = false;
2335
3831
  }
2336
- flushPendingItems(section, items);
3832
+ flushPendingItems(section, items, stepVariants, stepOptional);
2337
3833
  flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
2338
3834
  if (!section.isBlank()) {
2339
3835
  this.sections.push(section);
2340
3836
  }
2341
- this._populate_ingredient_quantities();
3837
+ const metaVariants = this.metadata.variants ?? [];
3838
+ const allVariants = /* @__PURE__ */ new Set([...metaVariants, ...discoveredVariants]);
3839
+ if (allVariants.size > 0) {
3840
+ this.choices.variants = [...allVariants];
3841
+ }
3842
+ this._populateIngredientQuantities();
2342
3843
  }
2343
3844
  /**
2344
3845
  * Scales the recipe to a new number of servings. In practice, it calls
@@ -2353,7 +3854,7 @@ var _Recipe = class _Recipe {
2353
3854
  if (originalServings === void 0 || originalServings === 0) {
2354
3855
  originalServings = 1;
2355
3856
  }
2356
- const factor = Big4(newServings).div(originalServings);
3857
+ const factor = Big5(newServings).div(originalServings);
2357
3858
  return this.scaleBy(factor);
2358
3859
  }
2359
3860
  /**
@@ -2368,18 +3869,19 @@ var _Recipe = class _Recipe {
2368
3869
  if (originalServings === void 0 || originalServings === 0) {
2369
3870
  originalServings = 1;
2370
3871
  }
3872
+ const unitSystem = this.unitSystem;
2371
3873
  function scaleAlternativesBy(alternatives, factor2) {
2372
3874
  for (const alternative of alternatives) {
2373
- if (alternative.itemQuantity) {
2374
- const scaleFactor = alternative.itemQuantity.scalable ? Big4(factor2) : 1;
2375
- if (alternative.itemQuantity.quantity.type !== "fixed" || alternative.itemQuantity.quantity.value.type !== "text") {
2376
- alternative.itemQuantity.quantity = multiplyQuantityValue(
2377
- alternative.itemQuantity.quantity,
3875
+ if (alternative.quantity) {
3876
+ const scaleFactor = alternative.scalable ? Big5(factor2) : 1;
3877
+ if (alternative.quantity.type !== "fixed" || alternative.quantity.value.type !== "text") {
3878
+ alternative.quantity = multiplyQuantityValue(
3879
+ alternative.quantity,
2378
3880
  scaleFactor
2379
3881
  );
2380
3882
  }
2381
- if (alternative.itemQuantity.equivalents) {
2382
- alternative.itemQuantity.equivalents = alternative.itemQuantity.equivalents.map(
3883
+ if (alternative.equivalents) {
3884
+ alternative.equivalents = alternative.equivalents.map(
2383
3885
  (altQuantity) => {
2384
3886
  if (altQuantity.quantity.type === "fixed" && altQuantity.quantity.value.type === "text") {
2385
3887
  return altQuantity;
@@ -2395,6 +3897,20 @@ var _Recipe = class _Recipe {
2395
3897
  }
2396
3898
  );
2397
3899
  }
3900
+ const optimizedPrimary = applyBestUnit(
3901
+ {
3902
+ quantity: alternative.quantity,
3903
+ unit: alternative.unit
3904
+ },
3905
+ unitSystem
3906
+ );
3907
+ alternative.quantity = optimizedPrimary.quantity;
3908
+ alternative.unit = optimizedPrimary.unit;
3909
+ if (alternative.equivalents) {
3910
+ alternative.equivalents = alternative.equivalents.map(
3911
+ (eq) => applyBestUnit(eq, unitSystem)
3912
+ );
3913
+ }
2398
3914
  }
2399
3915
  }
2400
3916
  }
@@ -2409,8 +3925,10 @@ var _Recipe = class _Recipe {
2409
3925
  }
2410
3926
  }
2411
3927
  }
2412
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
2413
- scaleAlternativesBy(alternatives, factor);
3928
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3929
+ for (const subgroup of subgroups) {
3930
+ scaleAlternativesBy(subgroup, factor);
3931
+ }
2414
3932
  }
2415
3933
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
2416
3934
  scaleAlternativesBy(alternatives, factor);
@@ -2420,39 +3938,198 @@ var _Recipe = class _Recipe {
2420
3938
  arbitrary.quantity,
2421
3939
  factor
2422
3940
  );
3941
+ const optimized = applyBestUnit(
3942
+ { quantity: arbitrary.quantity, unit: arbitrary.unit },
3943
+ unitSystem
3944
+ );
3945
+ arbitrary.quantity = optimized.quantity;
3946
+ arbitrary.unit = optimized.unit;
2423
3947
  }
2424
- newRecipe._populate_ingredient_quantities();
2425
- newRecipe.servings = Big4(originalServings).times(factor).toNumber();
2426
- if (newRecipe.metadata.servings && this.metadata.servings) {
2427
- if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
2428
- const servingsValue = parseFloat(
2429
- String(this.metadata.servings).replace(",", ".")
2430
- );
2431
- newRecipe.metadata.servings = String(
2432
- Big4(servingsValue).times(factor).toNumber()
2433
- );
3948
+ newRecipe._populateIngredientQuantities();
3949
+ newRecipe.servings = Big5(originalServings).times(factor).toNumber();
3950
+ for (const metaVar of ["servings", "serves"]) {
3951
+ if (typeof newRecipe.metadata[metaVar] === "number") {
3952
+ newRecipe.metadata[metaVar] = Big5(newRecipe.metadata[metaVar]).times(factor).toNumber();
2434
3953
  }
2435
3954
  }
2436
3955
  if (newRecipe.metadata.yield && this.metadata.yield) {
2437
- if (floatRegex.test(String(this.metadata.yield).replace(",", ".").trim())) {
2438
- const yieldValue = parseFloat(
2439
- String(this.metadata.yield).replace(",", ".")
3956
+ const original = this.metadata.yield;
3957
+ if (original.quantity.type === "fixed" && original.quantity.value.type === "text") {
3958
+ } else {
3959
+ const scaledQuantity = multiplyQuantityValue(
3960
+ original.quantity,
3961
+ factor
2440
3962
  );
2441
- newRecipe.metadata.yield = String(
2442
- Big4(yieldValue).times(factor).toNumber()
3963
+ const optimized = applyBestUnit(
3964
+ { quantity: scaledQuantity, unit: original.unit },
3965
+ unitSystem
2443
3966
  );
3967
+ const scaled = {
3968
+ quantity: optimized.quantity
3969
+ };
3970
+ if (optimized.unit) scaled.unit = optimized.unit;
3971
+ if (original.textBefore) scaled.textBefore = original.textBefore;
3972
+ if (original.textAfter) scaled.textAfter = original.textAfter;
3973
+ newRecipe.metadata.yield = scaled;
2444
3974
  }
2445
3975
  }
2446
- if (newRecipe.metadata.serves && this.metadata.serves) {
2447
- if (floatRegex.test(String(this.metadata.serves).replace(",", ".").trim())) {
2448
- const servesValue = parseFloat(
2449
- String(this.metadata.serves).replace(",", ".")
3976
+ return newRecipe;
3977
+ }
3978
+ /**
3979
+ * Converts all ingredient quantities in the recipe to a target unit system.
3980
+ *
3981
+ * @param system - The target unit system to convert to (metric, US, UK, JP)
3982
+ * @param method - How to handle existing quantities:
3983
+ * - "keep": Keep all existing equivalents (swap if needed, or add converted)
3984
+ * - "replace": Replace primary with target system quantity, discard equivalent used for conversion
3985
+ * - "remove": Only keep target system quantity, delete all equivalents
3986
+ * @returns A new Recipe instance with converted quantities
3987
+ *
3988
+ * @example
3989
+ * ```typescript
3990
+ * // Convert a recipe to metric, keeping original units as equivalents
3991
+ * const metricRecipe = recipe.convertTo("metric", "keep");
3992
+ *
3993
+ * // Convert to US units, removing all other equivalents
3994
+ * const usRecipe = recipe.convertTo("US", "remove");
3995
+ * ```
3996
+ */
3997
+ convertTo(system, method) {
3998
+ const newRecipe = this.clone();
3999
+ function buildNewPrimary(convertedQty, oldPrimary, remainingEquivalents, scalable, integerProtected, source) {
4000
+ const newUnit = integerProtected && convertedQty.unit ? { name: convertedQty.unit.name, integerProtected: true } : convertedQty.unit;
4001
+ const newPrimary = {
4002
+ quantity: convertedQty.quantity,
4003
+ unit: newUnit,
4004
+ scalable
4005
+ };
4006
+ if (method === "remove") {
4007
+ return newPrimary;
4008
+ } else if (method === "replace") {
4009
+ if (source === "converted") remainingEquivalents.push(oldPrimary);
4010
+ if (remainingEquivalents.length > 0) {
4011
+ newPrimary.equivalents = remainingEquivalents;
4012
+ }
4013
+ } else {
4014
+ newPrimary.equivalents = [oldPrimary, ...remainingEquivalents];
4015
+ }
4016
+ return newPrimary;
4017
+ }
4018
+ function convertAlternativeQuantity(alternative) {
4019
+ const primaryUnit = resolveUnit(alternative.unit?.name);
4020
+ const equivalents = alternative.equivalents ?? [];
4021
+ const oldPrimary = {
4022
+ quantity: alternative.quantity,
4023
+ unit: alternative.unit
4024
+ };
4025
+ if (primaryUnit.type !== "other" && isUnitCompatibleWithSystem(primaryUnit, system)) {
4026
+ if (method === "remove") {
4027
+ return {
4028
+ quantity: alternative.quantity,
4029
+ unit: alternative.unit,
4030
+ scalable: alternative.scalable
4031
+ };
4032
+ }
4033
+ return {
4034
+ quantity: alternative.quantity,
4035
+ unit: alternative.unit,
4036
+ scalable: alternative.scalable,
4037
+ equivalents
4038
+ };
4039
+ }
4040
+ const targetEquivIndex = equivalents.findIndex((eq) => {
4041
+ const eqUnit = resolveUnit(eq.unit?.name);
4042
+ return eqUnit.type !== "other" && isUnitCompatibleWithSystem(eqUnit, system);
4043
+ });
4044
+ if (targetEquivIndex !== -1) {
4045
+ const targetEquiv = equivalents[targetEquivIndex];
4046
+ const remainingEquivalents = equivalents.filter(
4047
+ (_, i2) => i2 !== targetEquivIndex
4048
+ );
4049
+ return buildNewPrimary(
4050
+ targetEquiv,
4051
+ oldPrimary,
4052
+ remainingEquivalents,
4053
+ alternative.scalable,
4054
+ targetEquiv.unit?.integerProtected,
4055
+ "swapped"
2450
4056
  );
2451
- newRecipe.metadata.serves = String(
2452
- Big4(servesValue).times(factor).toNumber()
4057
+ }
4058
+ const converted = convertQuantityToSystem(oldPrimary, system);
4059
+ if (converted && converted.unit) {
4060
+ return buildNewPrimary(
4061
+ converted,
4062
+ oldPrimary,
4063
+ equivalents,
4064
+ alternative.scalable,
4065
+ alternative.unit?.integerProtected,
4066
+ "swapped"
2453
4067
  );
2454
4068
  }
4069
+ for (let i2 = 0; i2 < equivalents.length; i2++) {
4070
+ const equiv = equivalents[i2];
4071
+ const convertedEquiv = convertQuantityToSystem(equiv, system);
4072
+ if (convertedEquiv && convertedEquiv.unit) {
4073
+ const remainingEquivalents = method === "keep" ? equivalents : equivalents.filter((_, idx) => idx !== i2);
4074
+ return buildNewPrimary(
4075
+ convertedEquiv,
4076
+ oldPrimary,
4077
+ remainingEquivalents,
4078
+ alternative.scalable,
4079
+ equiv.unit?.integerProtected,
4080
+ "converted"
4081
+ );
4082
+ }
4083
+ }
4084
+ if (method === "remove") {
4085
+ return {
4086
+ quantity: alternative.quantity,
4087
+ unit: alternative.unit,
4088
+ scalable: alternative.scalable
4089
+ };
4090
+ } else {
4091
+ return {
4092
+ quantity: alternative.quantity,
4093
+ unit: alternative.unit,
4094
+ scalable: alternative.scalable,
4095
+ equivalents
4096
+ };
4097
+ }
4098
+ }
4099
+ function convertAlternatives(alternatives) {
4100
+ for (const alternative of alternatives) {
4101
+ if (alternative.quantity) {
4102
+ const converted = convertAlternativeQuantity(
4103
+ alternative
4104
+ );
4105
+ alternative.quantity = converted.quantity;
4106
+ alternative.unit = converted.unit;
4107
+ alternative.scalable = converted.scalable;
4108
+ alternative.equivalents = converted.equivalents;
4109
+ }
4110
+ }
4111
+ }
4112
+ for (const section of newRecipe.sections) {
4113
+ for (const step of section.content.filter(
4114
+ (item) => item.type === "step"
4115
+ )) {
4116
+ for (const item of step.items.filter(
4117
+ (item2) => item2.type === "ingredient"
4118
+ )) {
4119
+ convertAlternatives(item.alternatives);
4120
+ }
4121
+ }
4122
+ }
4123
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
4124
+ for (const subgroup of subgroups) {
4125
+ convertAlternatives(subgroup);
4126
+ }
2455
4127
  }
4128
+ for (const alternatives of newRecipe.choices.ingredientItems.values()) {
4129
+ convertAlternatives(alternatives);
4130
+ }
4131
+ newRecipe._populateIngredientQuantities();
4132
+ if (method !== "keep") _Recipe.unitSystems.set(newRecipe, system);
2456
4133
  return newRecipe;
2457
4134
  }
2458
4135
  /**
@@ -2477,7 +4154,11 @@ var _Recipe = class _Recipe {
2477
4154
  newRecipe.metadata = deepClone(this.metadata);
2478
4155
  newRecipe.ingredients = deepClone(this.ingredients);
2479
4156
  newRecipe.sections = this.sections.map((section) => {
2480
- const newSection = new Section(section.name);
4157
+ const newSection = new Section(
4158
+ section.name,
4159
+ section.variants,
4160
+ section.optional
4161
+ );
2481
4162
  newSection.content = deepClone(section.content);
2482
4163
  return newSection;
2483
4164
  });
@@ -2488,21 +4169,30 @@ var _Recipe = class _Recipe {
2488
4169
  return newRecipe;
2489
4170
  }
2490
4171
  };
4172
+ /**
4173
+ * External storage for unit system (not a property on instances).
4174
+ * Used for resolving ambiguous units during quantity addition.
4175
+ */
4176
+ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
2491
4177
  /**
2492
4178
  * External storage for item count (not a property on instances).
2493
4179
  * Used for giving ID numbers to items during parsing.
2494
4180
  */
2495
4181
  __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
4182
+ /**
4183
+ * External storage for subgroup index tracking during parsing.
4184
+ * Maps groupKey → subgroupKey → index within the subgroups array.
4185
+ */
4186
+ __publicField(_Recipe, "subgroupIndices", /* @__PURE__ */ new WeakMap());
2496
4187
  var Recipe = _Recipe;
2497
4188
 
2498
4189
  // src/classes/shopping_list.ts
2499
4190
  var ShoppingList = class {
2500
4191
  /**
2501
4192
  * Creates a new ShoppingList instance
2502
- * @param category_config_str - The category configuration to parse.
4193
+ * @param categoryConfigStr - The category configuration to parse.
2503
4194
  */
2504
- constructor(category_config_str) {
2505
- // TODO: backport type change
4195
+ constructor(categoryConfigStr) {
2506
4196
  /**
2507
4197
  * The ingredients in the shopping list.
2508
4198
  */
@@ -2514,43 +4204,43 @@ var ShoppingList = class {
2514
4204
  /**
2515
4205
  * The category configuration for the shopping list.
2516
4206
  */
2517
- __publicField(this, "category_config");
4207
+ __publicField(this, "categoryConfig");
2518
4208
  /**
2519
4209
  * The categorized ingredients in the shopping list.
2520
4210
  */
2521
4211
  __publicField(this, "categories");
2522
- if (category_config_str) {
2523
- this.set_category_config(category_config_str);
4212
+ /**
4213
+ * The unit system to use for quantity simplification.
4214
+ * When set, overrides per-recipe unit systems.
4215
+ */
4216
+ __publicField(this, "unitSystem");
4217
+ /**
4218
+ * Per-ingredient equivalence ratio maps for recomputing equivalents
4219
+ * after pantry subtraction. Keyed by ingredient name.
4220
+ * @internal
4221
+ */
4222
+ __publicField(this, "equivalenceRatios", /* @__PURE__ */ new Map());
4223
+ /**
4224
+ * The original pantry (never mutated by recipe calculations).
4225
+ */
4226
+ __publicField(this, "pantry");
4227
+ /**
4228
+ * The pantry with quantities updated after subtracting recipe needs.
4229
+ * Recomputed on every {@link ShoppingList.calculateIngredients | calculateIngredients()} call.
4230
+ */
4231
+ __publicField(this, "resultingPantry");
4232
+ if (categoryConfigStr) {
4233
+ this.setCategoryConfig(categoryConfigStr);
2524
4234
  }
2525
4235
  }
2526
- calculate_ingredients() {
4236
+ calculateIngredients() {
2527
4237
  this.ingredients = [];
2528
- const addIngredientQuantity = (name, quantityTotal) => {
2529
- const quantityTotalExtended = extendAllUnits(quantityTotal);
2530
- const newQuantities = isAndGroup(quantityTotalExtended) ? quantityTotalExtended.and : [quantityTotalExtended];
2531
- const existing = this.ingredients.find((i2) => i2.name === name);
2532
- if (existing) {
2533
- if (!existing.quantityTotal) {
2534
- existing.quantityTotal = quantityTotal;
2535
- return;
2536
- }
2537
- try {
2538
- const existingQuantityTotalExtended = extendAllUnits(
2539
- existing.quantityTotal
2540
- );
2541
- const existingQuantities = isAndGroup(existingQuantityTotalExtended) ? existingQuantityTotalExtended.and : [existingQuantityTotalExtended];
2542
- existing.quantityTotal = addEquivalentsAndSimplify(
2543
- ...existingQuantities,
2544
- ...newQuantities
2545
- );
2546
- return;
2547
- } catch {
2548
- }
4238
+ const rawQuantitiesMap = /* @__PURE__ */ new Map();
4239
+ const nameOrder = [];
4240
+ const trackName = (name) => {
4241
+ if (!nameOrder.includes(name)) {
4242
+ nameOrder.push(name);
2549
4243
  }
2550
- this.ingredients.push({
2551
- name,
2552
- quantityTotal
2553
- });
2554
4244
  };
2555
4245
  for (const addedRecipe of this.recipes) {
2556
4246
  let scaledRecipe;
@@ -2560,48 +4250,245 @@ var ShoppingList = class {
2560
4250
  } else {
2561
4251
  scaledRecipe = addedRecipe.recipe.scaleTo(addedRecipe.servings);
2562
4252
  }
2563
- const ingredients = scaledRecipe.getIngredientQuantities({
4253
+ const rawGroups = scaledRecipe.getRawQuantityGroups({
2564
4254
  choices: addedRecipe.choices
2565
4255
  });
2566
- for (const ingredient of ingredients) {
2567
- if (ingredient.flags && ingredient.flags.includes("hidden")) {
4256
+ for (const group of rawGroups) {
4257
+ if (group.flags?.includes("hidden") || !group.usedAsPrimary) {
2568
4258
  continue;
2569
4259
  }
2570
- if (!ingredient.usedAsPrimary) {
2571
- continue;
4260
+ trackName(group.name);
4261
+ if (group.quantities.length > 0) {
4262
+ const existing = rawQuantitiesMap.get(group.name) ?? [];
4263
+ existing.push(...group.quantities);
4264
+ rawQuantitiesMap.set(group.name, existing);
4265
+ }
4266
+ }
4267
+ }
4268
+ this.equivalenceRatios.clear();
4269
+ for (const name of nameOrder) {
4270
+ const rawQuantities = rawQuantitiesMap.get(name);
4271
+ if (!rawQuantities || rawQuantities.length === 0) {
4272
+ this.ingredients.push({ name });
4273
+ continue;
4274
+ }
4275
+ const textEntries = [];
4276
+ const numericEntries = [];
4277
+ for (const q of rawQuantities) {
4278
+ if ("quantity" in q && q.quantity.type === "fixed" && q.quantity.value.type === "text") {
4279
+ textEntries.push(q);
4280
+ } else {
4281
+ numericEntries.push(q);
4282
+ }
4283
+ }
4284
+ if (numericEntries.length > 1) {
4285
+ const ratioMap = buildEquivalenceRatioMap(
4286
+ getEquivalentUnitsLists(...numericEntries)
4287
+ );
4288
+ if (Object.keys(ratioMap).length > 0) {
4289
+ this.equivalenceRatios.set(name, ratioMap);
2572
4290
  }
2573
- if (ingredient.quantities && ingredient.quantities.length > 0) {
2574
- const allQuantities = [];
2575
- for (const qGroup of ingredient.quantities) {
2576
- if ("and" in qGroup) {
2577
- for (const qty of qGroup.and) {
2578
- allQuantities.push(qty);
4291
+ }
4292
+ const resultQuantities = [];
4293
+ for (const t2 of textEntries) {
4294
+ resultQuantities.push(toPlainUnit(t2));
4295
+ }
4296
+ if (numericEntries.length > 0) {
4297
+ resultQuantities.push(
4298
+ ...flattenPlainUnitGroup(
4299
+ addEquivalentsAndSimplify(numericEntries, this.unitSystem)
4300
+ )
4301
+ );
4302
+ }
4303
+ this.ingredients.push({
4304
+ name,
4305
+ quantities: resultQuantities
4306
+ });
4307
+ }
4308
+ this.applyPantrySubtraction();
4309
+ }
4310
+ /**
4311
+ * Subtracts pantry item quantities from calculated ingredient quantities
4312
+ * and updates the resultingPantry to reflect consumed stock.
4313
+ */
4314
+ applyPantrySubtraction() {
4315
+ if (!this.pantry) {
4316
+ this.resultingPantry = void 0;
4317
+ return;
4318
+ }
4319
+ const clonedPantry = new Pantry();
4320
+ clonedPantry.items = deepClone(this.pantry.items);
4321
+ if (this.categoryConfig) {
4322
+ clonedPantry.setCategoryConfig(this.categoryConfig);
4323
+ }
4324
+ for (const ingredient of this.ingredients) {
4325
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
4326
+ continue;
4327
+ const pantryItem = clonedPantry.findItem(ingredient.name);
4328
+ if (!pantryItem || !pantryItem.quantity) continue;
4329
+ let pantryExtended = {
4330
+ quantity: pantryItem.quantity,
4331
+ ...pantryItem.unit && { unit: { name: pantryItem.unit } }
4332
+ };
4333
+ for (let i2 = 0; i2 < ingredient.quantities.length; i2++) {
4334
+ const entry = ingredient.quantities[i2];
4335
+ const leaves = "and" in entry ? entry.and : [entry];
4336
+ for (const leaf of leaves) {
4337
+ const ingredientExtended = toExtendedUnit(leaf);
4338
+ const leafHasUnit = leaf.unit !== void 0 && leaf.unit !== "";
4339
+ const pantryHasUnit = pantryExtended.unit !== void 0 && pantryExtended.unit.name !== "";
4340
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4341
+ const unitMismatch = leafHasUnit !== pantryHasUnit && ratioMap !== void 0;
4342
+ const leafDef = normalizeUnit(leaf.unit);
4343
+ const pantryDef = normalizeUnit(pantryExtended.unit?.name);
4344
+ if (unitMismatch) {
4345
+ const leafUnit = leaf.unit ?? NO_UNIT;
4346
+ const pantryUnit = pantryExtended.unit?.name ?? NO_UNIT;
4347
+ const ratioFromPantry = ratioMap[normalizeUnit(leafUnit)?.name ?? leafUnit]?.[normalizeUnit(pantryUnit)?.name ?? pantryUnit];
4348
+ if (ratioFromPantry !== void 0) {
4349
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4350
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4351
+ if (typeof pantryValue === "number" && typeof leafValue === "number") {
4352
+ const pantryInLeafUnits = pantryValue * ratioFromPantry;
4353
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4354
+ const remainingLeafValue = Math.max(
4355
+ leafValue - pantryInLeafUnits,
4356
+ 0
4357
+ );
4358
+ leaf.quantity = {
4359
+ type: "fixed",
4360
+ value: { type: "decimal", decimal: remainingLeafValue }
4361
+ };
4362
+ const consumedInPantryUnits = subtracted / ratioFromPantry;
4363
+ const remainingPantryValue = Math.max(
4364
+ pantryValue - consumedInPantryUnits,
4365
+ 0
4366
+ );
4367
+ pantryExtended = {
4368
+ quantity: {
4369
+ type: "fixed",
4370
+ value: {
4371
+ type: "decimal",
4372
+ decimal: remainingPantryValue
4373
+ }
4374
+ },
4375
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4376
+ };
4377
+ continue;
2579
4378
  }
2580
4379
  } else {
2581
- const plainQty = {
2582
- quantity: qGroup.quantity
2583
- };
2584
- if (qGroup.unit) plainQty.unit = qGroup.unit;
2585
- if (qGroup.equivalents) plainQty.equivalents = qGroup.equivalents;
2586
- allQuantities.push(plainQty);
4380
+ continue;
4381
+ }
4382
+ } else if (leafDef && pantryDef && areUnitsConvertible(leafDef, pantryDef) || (leaf.unit ?? "").toLowerCase() === (pantryExtended.unit?.name ?? "").toLowerCase()) {
4383
+ const remaining = subtractQuantities(
4384
+ ingredientExtended,
4385
+ pantryExtended,
4386
+ { clampToZero: true }
4387
+ );
4388
+ const consumed = subtractQuantities(
4389
+ pantryExtended,
4390
+ ingredientExtended,
4391
+ { clampToZero: true }
4392
+ );
4393
+ pantryExtended = consumed;
4394
+ const updated = toPlainUnit(remaining);
4395
+ leaf.quantity = updated.quantity;
4396
+ leaf.unit = updated.unit;
4397
+ } else if (ratioMap) {
4398
+ const canonicalLeaf = normalizeUnit(leaf.unit)?.name ?? leaf.unit;
4399
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4400
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4401
+ if (typeof leafValue === "number" && typeof pantryValue === "number" && pantryDef) {
4402
+ for (const [equivUnit, ratios] of Object.entries(ratioMap)) {
4403
+ const ratio = ratios[canonicalLeaf];
4404
+ if (ratio === void 0) continue;
4405
+ const equivDef = normalizeUnit(equivUnit);
4406
+ if (!equivDef || !areUnitsConvertible(equivDef, pantryDef))
4407
+ continue;
4408
+ const pantryInEquiv = pantryValue * getToBase(pantryDef) / getToBase(equivDef);
4409
+ const pantryInLeafUnits = pantryInEquiv / ratio;
4410
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4411
+ const remainingLeafValue = Math.max(
4412
+ leafValue - pantryInLeafUnits,
4413
+ 0
4414
+ );
4415
+ leaf.quantity = {
4416
+ type: "fixed",
4417
+ value: { type: "decimal", decimal: remainingLeafValue }
4418
+ };
4419
+ const consumedInEquiv = subtracted * ratio;
4420
+ const consumedInPantryUnits = consumedInEquiv * getToBase(equivDef) / getToBase(pantryDef);
4421
+ const remainingPantryValue = Math.max(
4422
+ pantryValue - consumedInPantryUnits,
4423
+ 0
4424
+ );
4425
+ pantryExtended = {
4426
+ quantity: {
4427
+ type: "fixed",
4428
+ value: {
4429
+ type: "decimal",
4430
+ decimal: remainingPantryValue
4431
+ }
4432
+ },
4433
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4434
+ };
4435
+ break;
4436
+ }
2587
4437
  }
2588
4438
  }
2589
- if (allQuantities.length === 1) {
2590
- addIngredientQuantity(ingredient.name, allQuantities[0]);
2591
- } else {
2592
- const extendedQuantities = allQuantities.map(
2593
- (q) => extendAllUnits(q)
4439
+ }
4440
+ if ("and" in entry) {
4441
+ const nonZero = entry.and.filter(
4442
+ (leaf) => leaf.quantity.type !== "fixed" || leaf.quantity.value.type !== "decimal" || leaf.quantity.value.decimal !== 0
4443
+ );
4444
+ entry.and.length = 0;
4445
+ entry.and.push(...nonZero);
4446
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4447
+ if (entry.equivalents && ratioMap) {
4448
+ const equivUnits = entry.equivalents.map((e2) => e2.unit);
4449
+ entry.equivalents = recomputeEquivalents(
4450
+ entry.and,
4451
+ ratioMap,
4452
+ equivUnits
4453
+ );
4454
+ }
4455
+ if (entry.and.length === 1) {
4456
+ const single = entry.and[0];
4457
+ ingredient.quantities[i2] = {
4458
+ quantity: single.quantity,
4459
+ ...single.unit && { unit: single.unit },
4460
+ ...entry.equivalents && { equivalents: entry.equivalents }
4461
+ };
4462
+ }
4463
+ } else if ("equivalents" in entry && entry.equivalents) {
4464
+ const ratioMap = this.equivalenceRatios.get(ingredient.name);
4465
+ if (ratioMap) {
4466
+ const equivUnits = entry.equivalents.map(
4467
+ (e2) => e2.unit
4468
+ // equivalents always have units
2594
4469
  );
2595
- const totalQuantity = addEquivalentsAndSimplify(
2596
- ...extendedQuantities
4470
+ const recomputed = recomputeEquivalents(
4471
+ [entry],
4472
+ ratioMap,
4473
+ equivUnits
2597
4474
  );
2598
- addIngredientQuantity(ingredient.name, totalQuantity);
4475
+ entry.equivalents = recomputed;
2599
4476
  }
2600
- } else if (!this.ingredients.some((i2) => i2.name === ingredient.name)) {
2601
- this.ingredients.push({ name: ingredient.name });
2602
4477
  }
2603
4478
  }
4479
+ ingredient.quantities = ingredient.quantities.filter((entry) => {
4480
+ if ("and" in entry) return entry.and.length > 0;
4481
+ return !(entry.quantity.type === "fixed" && entry.quantity.value.type === "decimal" && entry.quantity.value.decimal === 0);
4482
+ });
4483
+ if (ingredient.quantities.length === 0) {
4484
+ ingredient.quantities = void 0;
4485
+ }
4486
+ pantryItem.quantity = pantryExtended.quantity;
4487
+ if (pantryExtended.unit) {
4488
+ pantryItem.unit = pantryExtended.unit.name;
4489
+ }
2604
4490
  }
4491
+ this.resultingPantry = clonedPantry;
2605
4492
  }
2606
4493
  /**
2607
4494
  * Adds a recipe to the shopping list, then automatically
@@ -2610,7 +4497,7 @@ var ShoppingList = class {
2610
4497
  * @param options - Options for adding the recipe.
2611
4498
  * @throws Error if the recipe has alternatives without corresponding choices.
2612
4499
  */
2613
- add_recipe(recipe, options = {}) {
4500
+ addRecipe(recipe, options = {}) {
2614
4501
  const errorMessage = this.getUnresolvedAlternativesError(
2615
4502
  recipe,
2616
4503
  options.choices
@@ -2639,7 +4526,7 @@ var ShoppingList = class {
2639
4526
  });
2640
4527
  }
2641
4528
  }
2642
- this.calculate_ingredients();
4529
+ this.calculateIngredients();
2643
4530
  this.categorize();
2644
4531
  }
2645
4532
  /**
@@ -2679,27 +4566,62 @@ var ShoppingList = class {
2679
4566
  }
2680
4567
  /**
2681
4568
  * Removes a recipe from the shopping list, then automatically
2682
- * recalculates the quantities and recategorize the ingredients.s
4569
+ * recalculates the quantities and recategorize the ingredients.
2683
4570
  * @param index - The index of the recipe to remove.
2684
4571
  */
2685
- remove_recipe(index) {
4572
+ removeRecipe(index) {
2686
4573
  if (index < 0 || index >= this.recipes.length) {
2687
4574
  throw new Error("Index out of bounds");
2688
4575
  }
2689
4576
  this.recipes.splice(index, 1);
2690
- this.calculate_ingredients();
4577
+ this.calculateIngredients();
4578
+ this.categorize();
4579
+ }
4580
+ /**
4581
+ * Adds a pantry to the shopping list. On-hand pantry quantities will be
4582
+ * subtracted from recipe ingredient needs on each recalculation.
4583
+ * @param pantry - A Pantry instance or a TOML string to parse.
4584
+ * @param options - Options for pantry parsing (only used when providing a TOML string).
4585
+ */
4586
+ addPantry(pantry, options) {
4587
+ if (typeof pantry === "string") {
4588
+ this.pantry = new Pantry(pantry, options);
4589
+ } else if (pantry instanceof Pantry) {
4590
+ this.pantry = pantry;
4591
+ } else {
4592
+ throw new Error(
4593
+ "Invalid pantry: expected a Pantry instance or TOML string"
4594
+ );
4595
+ }
4596
+ if (this.categoryConfig) {
4597
+ this.pantry.setCategoryConfig(this.categoryConfig);
4598
+ }
4599
+ this.calculateIngredients();
2691
4600
  this.categorize();
2692
4601
  }
4602
+ /**
4603
+ * Returns the resulting pantry with quantities updated to reflect
4604
+ * what was consumed by the shopping list's recipes.
4605
+ * Returns undefined if no pantry was added.
4606
+ * @returns The resulting Pantry, or undefined.
4607
+ */
4608
+ getPantry() {
4609
+ return this.resultingPantry;
4610
+ }
2693
4611
  /**
2694
4612
  * Sets the category configuration for the shopping list
2695
4613
  * and automatically categorize current ingredients from the list.
4614
+ * Also propagates the configuration to the pantry if one is set.
2696
4615
  * @param config - The category configuration to parse.
2697
4616
  */
2698
- set_category_config(config) {
4617
+ setCategoryConfig(config) {
2699
4618
  if (typeof config === "string")
2700
- this.category_config = new CategoryConfig(config);
2701
- else if (config instanceof CategoryConfig) this.category_config = config;
4619
+ this.categoryConfig = new CategoryConfig(config);
4620
+ else if (config instanceof CategoryConfig) this.categoryConfig = config;
2702
4621
  else throw new Error("Invalid category configuration");
4622
+ if (this.pantry) {
4623
+ this.pantry.setCategoryConfig(this.categoryConfig);
4624
+ }
2703
4625
  this.categorize();
2704
4626
  }
2705
4627
  /**
@@ -2707,17 +4629,17 @@ var ShoppingList = class {
2707
4629
  * Will use the category config if any, otherwise all ingredients will be placed in the "other" category
2708
4630
  */
2709
4631
  categorize() {
2710
- if (!this.category_config) {
4632
+ if (!this.categoryConfig) {
2711
4633
  this.categories = { other: this.ingredients };
2712
4634
  return;
2713
4635
  }
2714
4636
  const categories = { other: [] };
2715
- for (const category of this.category_config.categories) {
4637
+ for (const category of this.categoryConfig.categories) {
2716
4638
  categories[category.name] = [];
2717
4639
  }
2718
4640
  for (const ingredient of this.ingredients) {
2719
4641
  let found = false;
2720
- for (const category of this.category_config.categories) {
4642
+ for (const category of this.categoryConfig.categories) {
2721
4643
  for (const categoryIngredient of category.ingredients) {
2722
4644
  if (categoryIngredient.aliases.includes(ingredient.name)) {
2723
4645
  categories[category.name].push(ingredient);
@@ -2781,7 +4703,6 @@ var ShoppingCart = class {
2781
4703
  setProductCatalog(catalog) {
2782
4704
  this.productCatalog = catalog;
2783
4705
  }
2784
- // TODO: harmonize recipe name to use underscores
2785
4706
  /**
2786
4707
  * Sets the shopping list to build the cart from.
2787
4708
  * To use if a shopping list was not provided at the creation of the instance
@@ -2845,8 +4766,27 @@ var ShoppingCart = class {
2845
4766
  getOptimumMatch(ingredient, options) {
2846
4767
  if (options.length === 0)
2847
4768
  throw new NoProductMatchError(ingredient.name, "noProduct");
2848
- if (!ingredient.quantityTotal)
4769
+ if (!ingredient.quantities || ingredient.quantities.length === 0)
2849
4770
  throw new NoProductMatchError(ingredient.name, "noQuantity");
4771
+ const allPlainEntries = [];
4772
+ for (const q of ingredient.quantities) {
4773
+ if ("and" in q) {
4774
+ allPlainEntries.push({ and: q.and });
4775
+ } else {
4776
+ const entry = {
4777
+ quantity: q.quantity,
4778
+ ...q.unit && { unit: q.unit },
4779
+ ...q.equivalents && { equivalents: q.equivalents }
4780
+ };
4781
+ allPlainEntries.push(entry);
4782
+ }
4783
+ }
4784
+ let quantityTotal;
4785
+ if (allPlainEntries.length === 1) {
4786
+ quantityTotal = allPlainEntries[0];
4787
+ } else {
4788
+ quantityTotal = { and: allPlainEntries };
4789
+ }
2850
4790
  const normalizedOptions = options.map(
2851
4791
  (option) => ({
2852
4792
  ...option,
@@ -2862,7 +4802,7 @@ var ShoppingCart = class {
2862
4802
  })
2863
4803
  })
2864
4804
  );
2865
- const normalizedQuantityTotal = normalizeAllUnits(ingredient.quantityTotal);
4805
+ const normalizedQuantityTotal = normalizeAllUnits(quantityTotal);
2866
4806
  function getOptimumMatchForQuantityParts(normalizedQuantities, normalizedOptions2, selection = []) {
2867
4807
  if (isAndGroup(normalizedQuantities)) {
2868
4808
  for (const q of normalizedQuantities.and) {
@@ -2889,12 +4829,12 @@ var ShoppingCart = class {
2889
4829
  alternative.quantity = scaledQuantity;
2890
4830
  const matchOptions = normalizedOptions2.filter(
2891
4831
  (option) => option.sizes.some(
2892
- (s) => areUnitsCompatible(alternative.unit, s.unit)
4832
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2893
4833
  )
2894
4834
  );
2895
4835
  if (matchOptions.length > 0) {
2896
4836
  const findCompatibleSize = (option) => option.sizes.find(
2897
- (s) => areUnitsCompatible(alternative.unit, s.unit)
4837
+ (s) => areUnitsGroupable(alternative.unit, s.unit)
2898
4838
  );
2899
4839
  if (matchOptions.length == 1) {
2900
4840
  const matchedOption = matchOptions[0];
@@ -2996,33 +4936,176 @@ var ShoppingCart = class {
2996
4936
  };
2997
4937
 
2998
4938
  // src/utils/render_helpers.ts
4939
+ var VULGAR_FRACTIONS = {
4940
+ "1/2": "\xBD",
4941
+ "1/3": "\u2153",
4942
+ "2/3": "\u2154",
4943
+ "1/4": "\xBC",
4944
+ "3/4": "\xBE",
4945
+ "1/8": "\u215B",
4946
+ "3/8": "\u215C",
4947
+ "5/8": "\u215D",
4948
+ "7/8": "\u215E"
4949
+ };
4950
+ function renderFractionAsVulgar(num, den) {
4951
+ const wholePart = Math.floor(num / den);
4952
+ const remainder = num % den;
4953
+ if (remainder === 0) {
4954
+ return String(wholePart);
4955
+ }
4956
+ const fractionKey = `${remainder}/${den}`;
4957
+ const vulgar = VULGAR_FRACTIONS[fractionKey];
4958
+ if (wholePart > 0) {
4959
+ return vulgar ? `${wholePart}${vulgar}` : `${wholePart} ${remainder}/${den}`;
4960
+ }
4961
+ return vulgar ?? `${num}/${den}`;
4962
+ }
4963
+ function formatNumericValue(value, useVulgar = true) {
4964
+ if (value.type === "decimal") {
4965
+ return String(value.decimal);
4966
+ }
4967
+ if (useVulgar) {
4968
+ return renderFractionAsVulgar(value.num, value.den);
4969
+ }
4970
+ return `${value.num}/${value.den}`;
4971
+ }
4972
+ function formatSingleValue(value) {
4973
+ if (value.type === "text") {
4974
+ return value.text;
4975
+ }
4976
+ return formatNumericValue(value);
4977
+ }
4978
+ function formatQuantity(quantity) {
4979
+ if (quantity.type === "fixed") {
4980
+ return formatSingleValue(quantity.value);
4981
+ }
4982
+ const minStr = formatNumericValue(quantity.min);
4983
+ const maxStr = formatNumericValue(quantity.max);
4984
+ return `${minStr}-${maxStr}`;
4985
+ }
4986
+ function formatUnit(unit) {
4987
+ if (!unit) return "";
4988
+ if (typeof unit === "string") return unit;
4989
+ return unit.name;
4990
+ }
4991
+ function formatQuantityWithUnit(quantity, unit) {
4992
+ if (!quantity) return "";
4993
+ const qty = formatQuantity(quantity);
4994
+ const unitStr = formatUnit(unit);
4995
+ return unitStr ? `${qty} ${unitStr}` : qty;
4996
+ }
4997
+ function formatExtendedQuantity(item) {
4998
+ return formatQuantityWithUnit(item.quantity, item.unit);
4999
+ }
5000
+ function formatItemQuantity(itemQuantity, separator = " | ") {
5001
+ const parts = [];
5002
+ parts.push(formatExtendedQuantity(itemQuantity));
5003
+ if (itemQuantity.equivalents) {
5004
+ for (const eq of itemQuantity.equivalents) {
5005
+ parts.push(formatExtendedQuantity(eq));
5006
+ }
5007
+ }
5008
+ return parts.join(separator);
5009
+ }
5010
+ function isGroupedItem(item) {
5011
+ return item.group !== void 0;
5012
+ }
2999
5013
  function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
3000
5014
  if (item.group) {
3001
5015
  const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
3002
- const groupAlternatives = recipe.choices.ingredientGroups.get(item.group);
3003
- if (groupAlternatives && selectedIndex2 && selectedIndex2 < groupAlternatives.length) {
3004
- const selectedItemId = groupAlternatives[selectedIndex2]?.itemId;
3005
- return selectedItemId === item.id;
5016
+ const groupSubgroups = recipe.choices.ingredientGroups.get(item.group);
5017
+ if (groupSubgroups && selectedIndex2 !== void 0 && selectedIndex2 < groupSubgroups.length) {
5018
+ const selectedSubgroup = groupSubgroups[selectedIndex2];
5019
+ return selectedSubgroup?.some((alt) => alt.itemId === item.id);
3006
5020
  }
3007
5021
  return false;
3008
5022
  }
3009
5023
  const selectedIndex = choices?.ingredientItems?.get(item.id);
3010
5024
  return alternativeIndex === selectedIndex;
3011
5025
  }
5026
+ function isSectionActive(section, variant) {
5027
+ if (!section.variants) return true;
5028
+ const isDefault = variant === void 0 || variant === "*";
5029
+ if (isDefault) {
5030
+ return section.variants.includes("*");
5031
+ }
5032
+ return section.variants.includes(variant);
5033
+ }
5034
+ function isStepActive(step, variant) {
5035
+ if (!step.variants) return true;
5036
+ const isDefault = variant === void 0 || variant === "*";
5037
+ if (isDefault) {
5038
+ return step.variants.includes("*");
5039
+ }
5040
+ return step.variants.includes(variant);
5041
+ }
5042
+ function getEffectiveChoices(recipe, variant) {
5043
+ const choices = { variant };
5044
+ if (variant === void 0 || variant === "*") return choices;
5045
+ const variantLower = variant.toLowerCase();
5046
+ for (const [itemId, alternatives] of recipe.choices.ingredientItems) {
5047
+ const matchIdx = alternatives.findIndex(
5048
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5049
+ );
5050
+ if (matchIdx >= 0) {
5051
+ if (!choices.ingredientItems) choices.ingredientItems = /* @__PURE__ */ new Map();
5052
+ choices.ingredientItems.set(itemId, matchIdx);
5053
+ }
5054
+ }
5055
+ for (const [groupId, subgroups] of recipe.choices.ingredientGroups) {
5056
+ const matchIdx = subgroups.findIndex(
5057
+ (sg) => sg.some(
5058
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5059
+ )
5060
+ );
5061
+ if (matchIdx >= 0) {
5062
+ if (!choices.ingredientGroups) choices.ingredientGroups = /* @__PURE__ */ new Map();
5063
+ choices.ingredientGroups.set(groupId, matchIdx);
5064
+ }
5065
+ }
5066
+ return choices;
5067
+ }
3012
5068
  export {
3013
5069
  CategoryConfig,
3014
5070
  NoProductCatalogForCartError,
3015
5071
  NoShoppingListForCartError,
5072
+ Pantry,
3016
5073
  ProductCatalog,
3017
5074
  Recipe,
3018
5075
  Section,
3019
5076
  ShoppingCart,
3020
5077
  ShoppingList,
3021
- isAlternativeSelected
5078
+ convertQuantityToSystem,
5079
+ formatExtendedQuantity,
5080
+ formatItemQuantity,
5081
+ formatNumericValue,
5082
+ formatQuantity,
5083
+ formatQuantityWithUnit,
5084
+ formatSingleValue,
5085
+ formatUnit,
5086
+ getEffectiveChoices,
5087
+ hasAlternatives,
5088
+ isAlternativeSelected,
5089
+ isAndGroup,
5090
+ isGroupedItem,
5091
+ isSectionActive,
5092
+ isSimpleGroup,
5093
+ isStepActive,
5094
+ renderFractionAsVulgar
3022
5095
  };
3023
5096
  /* v8 ignore else -- @preserve */
5097
+ /* v8 ignore next -- @preserve: defensive fallback for ambiguous units without toBaseBySystem */
5098
+ /* v8 ignore start -- @preserve: defensive fallback that shouldn't happen with valid inputs */
3024
5099
  // v8 ignore else -- @preserve
3025
- /* v8 ignore else -- expliciting error type -- @preserve */
3026
5100
  // v8 ignore if -- @preserve
5101
+ /* v8 ignore else -- expliciting error type -- @preserve */
5102
+ /* v8 ignore next 4 -- @preserve: defensive guard; regex always matches */
5103
+ // v8 ignore if -- @preserve: defensive type guard
3027
5104
  /* v8 ignore if -- @preserve */
5105
+ // v8 ignore next -- @preserve
5106
+ // v8 ignore else -- @preserve: text quantities never reach the equivalence path
5107
+ // v8 ignore else --@preserve: defensive type guard
5108
+ // v8 ignore else -- @preserve: detection if
5109
+ /* v8 ignore else -- @preserve: only act when there are matches */
5110
+ /* v8 ignore else -- @preserve: initialization pattern */
3028
5111
  //# sourceMappingURL=index.js.map