@tmlmt/cooklang-parser 3.0.0-alpha.17 → 3.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -32,11 +32,9 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
32
32
  // src/index.ts
33
33
  var index_exports = {};
34
34
  __export(index_exports, {
35
- BadIndentationError: () => BadIndentationError,
36
35
  CategoryConfig: () => CategoryConfig,
37
36
  NoProductCatalogForCartError: () => NoProductCatalogForCartError,
38
37
  NoShoppingListForCartError: () => NoShoppingListForCartError,
39
- NoTabAsIndentError: () => NoTabAsIndentError,
40
38
  Pantry: () => Pantry,
41
39
  ProductCatalog: () => ProductCatalog,
42
40
  Recipe: () => Recipe,
@@ -51,11 +49,14 @@ __export(index_exports, {
51
49
  formatQuantityWithUnit: () => formatQuantityWithUnit,
52
50
  formatSingleValue: () => formatSingleValue,
53
51
  formatUnit: () => formatUnit,
52
+ getEffectiveChoices: () => getEffectiveChoices,
54
53
  hasAlternatives: () => hasAlternatives,
55
54
  isAlternativeSelected: () => isAlternativeSelected,
56
55
  isAndGroup: () => isAndGroup,
57
56
  isGroupedItem: () => isGroupedItem,
57
+ isSectionActive: () => isSectionActive,
58
58
  isSimpleGroup: () => isSimpleGroup,
59
+ isStepActive: () => isStepActive,
59
60
  renderFractionAsVulgar: () => renderFractionAsVulgar
60
61
  });
61
62
  module.exports = __toCommonJS(index_exports);
@@ -334,8 +335,8 @@ var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
334
335
  var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
335
336
  var ingredientWithAlternativeRegex = d().literal("@").startNamedGroup("ingredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("ingredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("mIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("sIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("ingredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("ingredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("ingredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().startGroup().literal("[").startNamedGroup("ingredientNote").notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().startNamedGroup("ingredientAlternative").startGroup().literal("|").startGroup().anyOf("@\\-&?").zeroOrMore().endGroup().optional().startGroup().literal("./").endGroup().optional().startGroup().startGroup().startGroup().notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startGroup().notAnyOf(nonWordChar).oneOrMore().endGroup().endGroup().startGroup().literal("{").startGroup().literal("=").exactly(1).endGroup().optional().startGroup().notAnyOf("}%").oneOrMore().endGroup().optional().startGroup().literal("%").startGroup().notAnyOf("}").oneOrMore().lazy().endGroup().endGroup().optional().literal("}").endGroup().optional().startGroup().literal("(").startGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().startGroup().literal("[").startGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().endGroup().zeroOrMore().endGroup().toRegExp();
336
337
  var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
337
- 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();
338
- 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().toRegExp();
338
+ var quantityAlternativeRegex = d().startNamedGroup("quantity").notAnyOf("{}|%").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("unit").notAnyOf("|}").oneOrMore().endGroup().endGroup().optional().startGroup().literal("|").startNamedGroup("alternative").startGroup().notAnyOf("}").oneOrMore().endGroup().zeroOrMore().endGroup().endGroup().optional().toRegExp();
339
+ var ingredientWithGroupKeyRegex = d().literal("@|").startNamedGroup("gIngredientGroupKey").notAnyOf(nonWordCharStrict + "/").oneOrMore().endGroup().startGroup().literal("/").startNamedGroup("gIngredientSubgroupKey").notAnyOf(nonWordCharStrict).oneOrMore().endGroup().endGroup().optional().literal("|").startNamedGroup("gIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("gIngredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("gmIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("gsIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("gIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("gIngredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("gIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().startGroup().literal("[").startNamedGroup("gIngredientNote").notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("]").endGroup().optional().toRegExp();
339
340
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
340
341
  var cookwareRegex = d().literal("#").startNamedGroup("cookwareModifiers").anyOf("\\-&?").zeroOrMore().endGroup().startGroup().startGroup().startNamedGroup("mCookwareName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\})").endGroup().or().startNamedGroup("sCookwareName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("cookwareQuantity").anyCharacter().zeroOrMore().lazy().endGroup().literal("}").endGroup().optional().toRegExp();
341
342
  var timerRegex = d().literal("~").startNamedGroup("timerName").anyCharacter().zeroOrMore().lazy().endGroup().literal("{").startNamedGroup("timerQuantity").anyCharacter().oneOrMore().lazy().endGroup().startGroup().literal("%").startNamedGroup("timerUnit").anyCharacter().oneOrMore().lazy().endGroup().endGroup().optional().literal("}").toRegExp();
@@ -350,11 +351,18 @@ var tokensRegex = new RegExp(
350
351
  ].map((r2) => r2.source).join("|"),
351
352
  "gu"
352
353
  );
353
- var servingsPrefixPart = (varName) => d().startAnchor().literal(varName).literal(":").anyOf(" ").zeroOrMore().startNamedGroup("servingsPrefix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().lazy().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().toRegExp();
354
- var servingsSuffixPart = d().anyOf(" ").zeroOrMore().startNamedGroup("servingsSuffix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
355
- var scalingSimpleMetaValueRegex = (varName) => d().startAnchor().literal(varName).literal(":").anyOf("\\t ").zeroOrMore().startCaptureGroup().startCaptureGroup().notAnyOf(",\\n").oneOrMore().endGroup().startGroup().literal(",").anyOf("\\t ").zeroOrMore().startCaptureGroup().anyCharacter().oneOrMore().endGroup().optional().endGroup().optional().endGroup().endAnchor().multiline().toRegExp();
356
- var scalingMetaValueWithUnitRegex = (varName) => new RegExp(
357
- servingsPrefixPart(varName).source + arbitraryScalableRegex.source + servingsSuffixPart.source,
354
+ var yieldPrefixPart = d().startAnchor().literal("yield").literal(":").anyOf(" ").zeroOrMore().startNamedGroup("servingsPrefix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().lazy().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().toRegExp();
355
+ var yieldSuffixPart = d().anyOf(" ").zeroOrMore().startNamedGroup("servingsSuffix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
356
+ var yieldMetaValueWithUnitRegex = new RegExp(
357
+ yieldPrefixPart.source + arbitraryScalableRegex.source + yieldSuffixPart.source,
358
+ "m"
359
+ );
360
+ var yieldMetaValueAsQuantityRegex = d().startAnchor().literal("yield:").anyOf(" ").zeroOrMore().startNamedGroup("quantity").notAnyOf("{}|%\\n\\r").oneOrMore().endGroup().optional().startGroup().literal("%").startNamedGroup("unit").notAnyOf("\\n\\r|}").oneOrMore().endGroup().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
361
+ var yieldMetaValueRegex = new RegExp(
362
+ [
363
+ yieldMetaValueWithUnitRegex.source,
364
+ yieldMetaValueAsQuantityRegex.source
365
+ ].join("|"),
358
366
  "m"
359
367
  );
360
368
  var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
@@ -363,6 +371,7 @@ var shoppingListRegex = d().literal("[").startNamedGroup("name").anyCharacter().
363
371
  var rangeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().literal("-").digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
364
372
  var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
365
373
  var floatRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
374
+ var variantTagRegex = d().startAnchor().literal("[").startNamedGroup("variantOptionalPrefix").literal("?").endGroup().optional().startNamedGroup("variantNames").notAnyOf("\\]").oneOrMore().endGroup().optional().literal("]").whitespace().zeroOrMore().toRegExp();
366
375
  var mdEscaped = d().literal("\\").startCaptureGroup().anyOf("*_`").endGroup();
367
376
  var mdInlineCode = d().literal("`").startCaptureGroup().notAnyOf("`").oneOrMore().lazy().endGroup().literal("`");
368
377
  var mdLink = d().literal("[").startCaptureGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("](").startCaptureGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")");
@@ -938,7 +947,7 @@ var NoProductMatchError = class extends Error {
938
947
  constructor(item_name, code) {
939
948
  const messageMap = {
940
949
  incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`,
941
- noProduct: "No product was found linked to ingredient name ${item_name} in the shopping list",
950
+ noProduct: `No product was found linked to ingredient name ${item_name} in the shopping list`,
942
951
  textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`,
943
952
  noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`,
944
953
  textValue_incompatibleUnits: `Multiple alternative quantities were provided for ingredient ${item_name} in the shopping list but they were either text values or no product in catalog were found to have compatible units`
@@ -1417,9 +1426,12 @@ function flushPendingNote(section, noteItems) {
1417
1426
  }
1418
1427
  return noteItems;
1419
1428
  }
1420
- function flushPendingItems(section, items) {
1429
+ function flushPendingItems(section, items, stepVariants, stepOptional) {
1421
1430
  if (items.length > 0) {
1422
- section.content.push({ type: "step", items: [...items] });
1431
+ const step = { type: "step", items: [...items] };
1432
+ if (stepVariants) step.variants = stepVariants;
1433
+ if (stepOptional) step.optional = true;
1434
+ section.content.push(step);
1423
1435
  items.length = 0;
1424
1436
  return true;
1425
1437
  }
@@ -1538,7 +1550,7 @@ function stringifyFixedValue(quantity) {
1538
1550
  return String(quantity.value.decimal);
1539
1551
  else return quantity.value.text;
1540
1552
  }
1541
- function parseQuantityInput(input_str) {
1553
+ function parseQuantityValue(input_str) {
1542
1554
  const clean_str = String(input_str).trim();
1543
1555
  if (rangeRegex.test(clean_str)) {
1544
1556
  const range_parts = clean_str.split("-");
@@ -1552,12 +1564,12 @@ function parseQuantityWithUnit(input) {
1552
1564
  const trimmed = input.trim();
1553
1565
  const separatorIndex = trimmed.indexOf("%");
1554
1566
  if (separatorIndex === -1) {
1555
- return { value: parseQuantityInput(trimmed) };
1567
+ return { value: parseQuantityValue(trimmed) };
1556
1568
  }
1557
1569
  const valuePart = trimmed.slice(0, separatorIndex).trim();
1558
1570
  const unitPart = trimmed.slice(separatorIndex + 1).trim();
1559
1571
  return {
1560
- value: parseQuantityInput(valuePart),
1572
+ value: parseQuantityValue(valuePart),
1561
1573
  unit: unitPart || void 0
1562
1574
  };
1563
1575
  }
@@ -1747,7 +1759,7 @@ function parseArbitraryQuantity(raw) {
1747
1759
  "Arbitrary quantities must have a numerical value"
1748
1760
  );
1749
1761
  }
1750
- const value = parseQuantityInput(quantityMatch.groups.quantity);
1762
+ const value = parseQuantityValue(quantityMatch.groups.quantity);
1751
1763
  const unit = quantityMatch.groups.unit;
1752
1764
  if (!value || value.type === "fixed" && value.value.type === "text") {
1753
1765
  throw new InvalidQuantityFormat(
@@ -1761,40 +1773,40 @@ function parseArbitraryQuantity(raw) {
1761
1773
  if (unit) arbitrary.unit = unit;
1762
1774
  return arbitrary;
1763
1775
  }
1764
- function parseScalingMetaVar(content, varName) {
1765
- const complexMatch = content.match(scalingMetaValueWithUnitRegex(varName));
1766
- if (complexMatch?.groups?.arbitraryQuantity) {
1767
- const parsed = parseArbitraryQuantity(
1768
- complexMatch.groups.arbitraryQuantity
1769
- );
1770
- const result2 = {
1776
+ function parseServingsMetaVar(content, varName) {
1777
+ const raw = parseSimpleMetaVar(content, varName);
1778
+ if (raw === void 0) return void 0;
1779
+ const num = Number(raw);
1780
+ if (isNaN(num)) {
1781
+ return { numericValue: 1, rawValue: raw };
1782
+ }
1783
+ return { numericValue: num, rawValue: num };
1784
+ }
1785
+ function parseYieldMetaVar(content) {
1786
+ const match = content.match(yieldMetaValueRegex);
1787
+ if (!match) return void 0;
1788
+ if (match.groups?.arbitraryQuantity) {
1789
+ const parsed = parseArbitraryQuantity(match.groups.arbitraryQuantity);
1790
+ const result = {
1771
1791
  quantity: parsed.quantity
1772
1792
  };
1773
- if (parsed.unit) result2.unit = parsed.unit;
1774
- if (complexMatch.groups.servingsPrefix) {
1775
- result2.textBefore = complexMatch.groups.servingsPrefix;
1793
+ if (parsed.unit) result.unit = parsed.unit;
1794
+ if (match.groups.servingsPrefix) {
1795
+ result.textBefore = match.groups.servingsPrefix;
1776
1796
  }
1777
- if (complexMatch.groups.servingsSuffix) {
1778
- result2.textAfter = complexMatch.groups.servingsSuffix;
1797
+ if (match.groups.servingsSuffix) {
1798
+ result.textAfter = match.groups.servingsSuffix;
1779
1799
  }
1780
- return result2;
1781
- }
1782
- const varMatch = content.match(scalingSimpleMetaValueRegex(varName));
1783
- if (!varMatch) return void 0;
1784
- if (isNaN(Number(varMatch[2]?.trim()))) {
1785
- throw new Error("Scaling variables should be numbers");
1800
+ return result;
1786
1801
  }
1787
- const numericValue = Number(varMatch[2]?.trim());
1788
- const result = {
1789
- quantity: {
1790
- type: "fixed",
1791
- value: { type: "decimal", decimal: numericValue }
1792
- }
1793
- };
1794
- if (varMatch[3]) {
1795
- result.text = `${varMatch[3].trim()}`;
1802
+ if (match.groups?.quantity) {
1803
+ const result = {
1804
+ quantity: parseQuantityValue(match.groups.quantity)
1805
+ };
1806
+ if (match.groups.unit) result.unit = match.groups.unit;
1807
+ return result;
1796
1808
  }
1797
- return result;
1809
+ return void 0;
1798
1810
  }
1799
1811
  function parseListMetaVar(content, varName) {
1800
1812
  const listMatch = content.match(
@@ -1912,12 +1924,12 @@ function parseAnyMetaVar(content, varName) {
1912
1924
  if (simple) return parseMetadataValue(simple);
1913
1925
  return void 0;
1914
1926
  }
1915
- function getNumericValueFromMetaVar(v) {
1927
+ function getNumericValueFromYield(v) {
1916
1928
  if (v.quantity.type === "fixed" && v.quantity.value.type !== "text") {
1917
1929
  return getNumericValue(v.quantity.value);
1918
1930
  }
1919
1931
  if (v.quantity.type === "range") return getNumericValue(v.quantity.min);
1920
- return 0;
1932
+ return 1;
1921
1933
  }
1922
1934
  function extractMetadata(content) {
1923
1935
  const metadata = {};
@@ -1963,7 +1975,8 @@ function extractMetadata(content) {
1963
1975
  "yield",
1964
1976
  "serves",
1965
1977
  // List fields
1966
- "tags"
1978
+ "tags",
1979
+ "variants"
1967
1980
  ]);
1968
1981
  for (const metaVar of [
1969
1982
  "title",
@@ -2041,15 +2054,22 @@ function extractMetadata(content) {
2041
2054
  };
2042
2055
  unitSystem = unitSystemMap[unitSystemRaw.toLowerCase()];
2043
2056
  }
2044
- for (const metaVar of ["servings", "yield", "serves"]) {
2045
- const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
2046
- if (scalingMetaValue) {
2047
- metadata[metaVar] = scalingMetaValue;
2048
- servings = getNumericValueFromMetaVar(scalingMetaValue);
2057
+ const yieldValue = parseYieldMetaVar(metadataContent);
2058
+ if (yieldValue) {
2059
+ metadata.yield = yieldValue;
2060
+ servings = getNumericValueFromYield(yieldValue);
2061
+ }
2062
+ for (const metaVar of ["serves", "servings"]) {
2063
+ const result = parseServingsMetaVar(metadataContent, metaVar);
2064
+ if (result !== void 0) {
2065
+ metadata[metaVar] = result.rawValue;
2066
+ servings = result.numericValue;
2049
2067
  }
2050
2068
  }
2051
2069
  const tags = parseListMetaVar(metadataContent, "tags");
2052
2070
  if (tags) metadata.tags = tags;
2071
+ const variants = parseListMetaVar(metadataContent, "variants");
2072
+ if (variants) metadata.variants = variants;
2053
2073
  const allKeys = extractAllMetadataKeys(metadataContent);
2054
2074
  for (const key of allKeys) {
2055
2075
  if (handledKeys.has(key)) continue;
@@ -2324,7 +2344,7 @@ var ProductCatalog = class {
2324
2344
  const sizeStrings = Array.isArray(size) ? size : [size];
2325
2345
  const sizes = sizeStrings.map((sizeStr) => {
2326
2346
  const sizeAndUnitRaw = sizeStr.split("%");
2327
- const sizeParsed = parseQuantityInput(
2347
+ const sizeParsed = parseQuantityValue(
2328
2348
  sizeAndUnitRaw[0]
2329
2349
  );
2330
2350
  const productSize = { size: sizeParsed };
@@ -2437,8 +2457,10 @@ var Section = class {
2437
2457
  /**
2438
2458
  * Creates an instance of Section.
2439
2459
  * @param name - The name of the section. Defaults to an empty string.
2460
+ * @param variants - Optional variant names for this section.
2461
+ * @param optional - Whether the section is optional.
2440
2462
  */
2441
- constructor(name = "") {
2463
+ constructor(name = "", variants, optional) {
2442
2464
  /**
2443
2465
  * The name of the section. Can be an empty string for the default (first) section.
2444
2466
  * @defaultValue `""`
@@ -2446,7 +2468,13 @@ var Section = class {
2446
2468
  __publicField(this, "name");
2447
2469
  /** An array of steps and notes that make up the content of the section. */
2448
2470
  __publicField(this, "content", []);
2471
+ /** Optional list of variant names this section belongs to. */
2472
+ __publicField(this, "variants");
2473
+ /** Whether the section has been marked as optional ([?]) */
2474
+ __publicField(this, "optional");
2449
2475
  this.name = name;
2476
+ if (variants) this.variants = variants;
2477
+ if (optional) this.optional = true;
2450
2478
  }
2451
2479
  /**
2452
2480
  * Checks if the section is blank (has no name and no content).
@@ -2482,34 +2510,7 @@ function findCompatibleQuantityWithinList(list, quantity) {
2482
2510
  }
2483
2511
 
2484
2512
  // src/utils/general.ts
2485
- var legacyDeepClone = (v) => {
2486
- if (v === null || typeof v !== "object") {
2487
- return v;
2488
- }
2489
- if (v instanceof Map) {
2490
- return new Map(
2491
- Array.from(v.entries()).map(([k, val]) => [
2492
- legacyDeepClone(k),
2493
- legacyDeepClone(val)
2494
- ])
2495
- );
2496
- }
2497
- if (v instanceof Set) {
2498
- return new Set(Array.from(v).map((val) => legacyDeepClone(val)));
2499
- }
2500
- if (v instanceof Date) {
2501
- return new Date(v.getTime());
2502
- }
2503
- if (Array.isArray(v)) {
2504
- return v.map((item) => legacyDeepClone(item));
2505
- }
2506
- const cloned = {};
2507
- for (const key of Object.keys(v)) {
2508
- cloned[key] = legacyDeepClone(v[key]);
2509
- }
2510
- return cloned;
2511
- };
2512
- var deepClone = (v) => typeof structuredClone === "function" ? structuredClone(v) : legacyDeepClone(v);
2513
+ var deepClone = (v) => structuredClone(v);
2513
2514
 
2514
2515
  // src/quantities/alternatives.ts
2515
2516
  function getEquivalentUnitsLists(...quantities) {
@@ -2788,6 +2789,47 @@ function addEquivalentsAndSimplify(quantities, system) {
2788
2789
  return { and: regrouped.map(toPlainUnit) };
2789
2790
  }
2790
2791
  }
2792
+ function buildEquivalenceRatioMap(unitsLists) {
2793
+ const ratioMap = {};
2794
+ for (const list of unitsLists) {
2795
+ for (const equiv of list) {
2796
+ const equivValue = getAverageValue(equiv.quantity);
2797
+ for (const primary of list) {
2798
+ if (primary === equiv) continue;
2799
+ const primaryValue = getAverageValue(primary.quantity);
2800
+ const equivUnit = normalizeUnit(equiv.unit.name)?.name ?? equiv.unit.name;
2801
+ const primaryUnit = normalizeUnit(primary.unit.name)?.name ?? primary.unit.name;
2802
+ ratioMap[equivUnit] ?? (ratioMap[equivUnit] = {});
2803
+ ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue;
2804
+ }
2805
+ }
2806
+ }
2807
+ return ratioMap;
2808
+ }
2809
+ function recomputeEquivalents(primaries, ratioMap, equivUnits) {
2810
+ const equivalents = [];
2811
+ for (const equivUnit of equivUnits) {
2812
+ const ratios = ratioMap[normalizeUnit(equivUnit)?.name ?? equivUnit];
2813
+ let total = 0;
2814
+ for (const primary of primaries) {
2815
+ const pUnit = normalizeUnit(primary.unit ?? NO_UNIT)?.name ?? primary.unit ?? NO_UNIT;
2816
+ const ratio = ratios[pUnit];
2817
+ if (ratio === void 0) continue;
2818
+ const pValue = getAverageValue(primary.quantity);
2819
+ total += pValue * ratio;
2820
+ }
2821
+ if (total > 0) {
2822
+ equivalents.push({
2823
+ quantity: {
2824
+ type: "fixed",
2825
+ value: { type: "decimal", decimal: total }
2826
+ },
2827
+ ...equivUnit !== "" && { unit: equivUnit }
2828
+ });
2829
+ }
2830
+ }
2831
+ return equivalents.length > 0 ? equivalents : void 0;
2832
+ }
2791
2833
 
2792
2834
  // src/classes/recipe.ts
2793
2835
  var import_big4 = __toESM(require("big.js"), 1);
@@ -2806,7 +2848,8 @@ var _Recipe = class _Recipe {
2806
2848
  */
2807
2849
  __publicField(this, "choices", {
2808
2850
  ingredientItems: /* @__PURE__ */ new Map(),
2809
- ingredientGroups: /* @__PURE__ */ new Map()
2851
+ ingredientGroups: /* @__PURE__ */ new Map(),
2852
+ variants: []
2810
2853
  });
2811
2854
  /**
2812
2855
  * The parsed recipe ingredients.
@@ -2911,7 +2954,7 @@ var _Recipe = class _Recipe {
2911
2954
  let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
2912
2955
  const quantities = [];
2913
2956
  while (quantityMatch?.groups) {
2914
- const value = quantityMatch.groups.quantity ? parseQuantityInput(quantityMatch.groups.quantity) : void 0;
2957
+ const value = quantityMatch.groups.quantity ? parseQuantityValue(quantityMatch.groups.quantity) : void 0;
2915
2958
  const unit = quantityMatch.groups.unit;
2916
2959
  if (value) {
2917
2960
  const newQuantity = { quantity: value };
@@ -3123,6 +3166,10 @@ var _Recipe = class _Recipe {
3123
3166
  if (itemQuantity) {
3124
3167
  Object.assign(alternative, itemQuantity);
3125
3168
  }
3169
+ const note = groups.gIngredientNote?.trim();
3170
+ if (note) {
3171
+ alternative.note = note;
3172
+ }
3126
3173
  const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3127
3174
  const existingAlternativesFlat = existingSubgroups?.flat();
3128
3175
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
@@ -3216,6 +3263,8 @@ var _Recipe = class _Recipe {
3216
3263
  /** @internal */
3217
3264
  collectQuantityGroups(options) {
3218
3265
  const { section, step, choices } = options || {};
3266
+ const activeVariant = choices?.variant;
3267
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3219
3268
  const sectionsToProcess = section !== void 0 ? (() => {
3220
3269
  const idx = typeof section === "number" ? section : this.sections.indexOf(section);
3221
3270
  return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
@@ -3223,12 +3272,29 @@ var _Recipe = class _Recipe {
3223
3272
  const ingredientGroups = /* @__PURE__ */ new Map();
3224
3273
  const selectedIndices = /* @__PURE__ */ new Set();
3225
3274
  const referencedIndices = /* @__PURE__ */ new Set();
3275
+ const dynamicOptionalIndices = /* @__PURE__ */ new Set();
3226
3276
  for (const currentSection of sectionsToProcess) {
3277
+ if (currentSection.variants) {
3278
+ if (isDefaultVariant) {
3279
+ if (!currentSection.variants.includes("*")) continue;
3280
+ } else {
3281
+ if (!currentSection.variants.includes(activeVariant)) continue;
3282
+ }
3283
+ }
3227
3284
  const allSteps = currentSection.content.filter(
3228
3285
  (item) => item.type === "step"
3229
3286
  );
3287
+ const isOptionalSection = currentSection.optional === true;
3230
3288
  const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
3231
3289
  for (const currentStep of stepsToProcess) {
3290
+ if (currentStep.variants) {
3291
+ if (isDefaultVariant) {
3292
+ if (!currentStep.variants.includes("*")) continue;
3293
+ } else {
3294
+ if (!currentStep.variants.includes(activeVariant)) continue;
3295
+ }
3296
+ }
3297
+ const isOptionalStep = currentStep.optional === true || isOptionalSection;
3232
3298
  for (const item of currentStep.items.filter(
3233
3299
  (item2) => item2.type === "ingredient"
3234
3300
  )) {
@@ -3240,18 +3306,55 @@ var _Recipe = class _Recipe {
3240
3306
  if (isGrouped) {
3241
3307
  const groupChoice = choices?.ingredientGroups?.get(item.group);
3242
3308
  hasExplicitChoice = groupChoice !== void 0;
3243
- const targetSubgroupIndex = groupChoice ?? 0;
3244
- const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3245
- isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3309
+ if (!hasExplicitChoice && !isDefaultVariant) {
3310
+ const matchingSubgroupIdx = groupSubgroups?.findIndex(
3311
+ (sg) => sg.some(
3312
+ (alt) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3313
+ )
3314
+ );
3315
+ if (matchingSubgroupIdx !== void 0 && matchingSubgroupIdx >= 0) {
3316
+ const matchedSubgroup = groupSubgroups[matchingSubgroupIdx];
3317
+ isSelected = matchedSubgroup.some(
3318
+ (alt) => alt.itemId === item.id
3319
+ );
3320
+ hasExplicitChoice = true;
3321
+ selectedAltIndex = 0;
3322
+ } else {
3323
+ const targetSubgroupIndex = 0;
3324
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3325
+ isSelected = selectedSubgroup.some(
3326
+ (alt) => alt.itemId === item.id
3327
+ );
3328
+ }
3329
+ } else {
3330
+ const targetSubgroupIndex = groupChoice ?? 0;
3331
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3332
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3333
+ }
3246
3334
  } else {
3247
3335
  const itemChoice = choices?.ingredientItems?.get(item.id);
3248
3336
  hasExplicitChoice = itemChoice !== void 0;
3249
- selectedAltIndex = itemChoice ?? 0;
3337
+ if (!hasExplicitChoice && !isDefaultVariant) {
3338
+ const matchingIndices = item.alternatives.map((alt, idx) => ({ alt, idx })).filter(
3339
+ ({ alt }) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3340
+ ).map(({ idx }) => idx);
3341
+ if (matchingIndices.length > 0) {
3342
+ selectedAltIndex = matchingIndices[0];
3343
+ hasExplicitChoice = true;
3344
+ } else {
3345
+ selectedAltIndex = itemChoice ?? 0;
3346
+ }
3347
+ } else {
3348
+ selectedAltIndex = itemChoice ?? 0;
3349
+ }
3250
3350
  isSelected = true;
3251
3351
  }
3252
3352
  const alternative = item.alternatives[selectedAltIndex];
3253
3353
  if (!alternative || !isSelected) continue;
3254
3354
  selectedIndices.add(alternative.index);
3355
+ if (isOptionalStep) {
3356
+ dynamicOptionalIndices.add(alternative.index);
3357
+ }
3255
3358
  const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3256
3359
  for (const alt of allAltsFlat) {
3257
3360
  referencedIndices.add(alt.index);
@@ -3357,7 +3460,12 @@ var _Recipe = class _Recipe {
3357
3460
  }
3358
3461
  }
3359
3462
  }
3360
- return { ingredientGroups, selectedIndices, referencedIndices };
3463
+ return {
3464
+ ingredientGroups,
3465
+ selectedIndices,
3466
+ referencedIndices,
3467
+ dynamicOptionalIndices
3468
+ };
3361
3469
  }
3362
3470
  /**
3363
3471
  * Gets the raw (unprocessed) quantity groups for each ingredient, before
@@ -3376,12 +3484,21 @@ var _Recipe = class _Recipe {
3376
3484
  * ```
3377
3485
  */
3378
3486
  getRawQuantityGroups(options) {
3379
- const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3487
+ const {
3488
+ ingredientGroups,
3489
+ selectedIndices,
3490
+ referencedIndices,
3491
+ dynamicOptionalIndices
3492
+ } = this.collectQuantityGroups(options);
3380
3493
  const result = [];
3381
3494
  for (let index = 0; index < this.ingredients.length; index++) {
3382
3495
  if (!referencedIndices.has(index)) continue;
3383
3496
  const orig = this.ingredients[index];
3384
3497
  const usedAsPrimary = selectedIndices.has(index);
3498
+ let flags = orig.flags;
3499
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3500
+ flags = [...flags ?? [], "optional"];
3501
+ }
3385
3502
  const quantities = [];
3386
3503
  if (usedAsPrimary) {
3387
3504
  const groupsForIng = ingredientGroups.get(index);
@@ -3394,7 +3511,7 @@ var _Recipe = class _Recipe {
3394
3511
  result.push({
3395
3512
  name: orig.name,
3396
3513
  ...usedAsPrimary && { usedAsPrimary: true },
3397
- ...orig.flags && { flags: orig.flags },
3514
+ ...flags && { flags },
3398
3515
  quantities
3399
3516
  });
3400
3517
  }
@@ -3428,15 +3545,24 @@ var _Recipe = class _Recipe {
3428
3545
  * ```
3429
3546
  */
3430
3547
  getIngredientQuantities(options) {
3431
- const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3548
+ const {
3549
+ ingredientGroups,
3550
+ selectedIndices,
3551
+ referencedIndices,
3552
+ dynamicOptionalIndices
3553
+ } = this.collectQuantityGroups(options);
3432
3554
  const result = [];
3433
3555
  for (let index = 0; index < this.ingredients.length; index++) {
3434
3556
  if (!referencedIndices.has(index)) continue;
3435
3557
  const orig = this.ingredients[index];
3558
+ let flags = orig.flags;
3559
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3560
+ flags = [...flags ?? [], "optional"];
3561
+ }
3436
3562
  const ing = {
3437
3563
  name: orig.name,
3438
3564
  ...orig.preparation && { preparation: orig.preparation },
3439
- ...orig.flags && { flags: orig.flags },
3565
+ ...flags && { flags },
3440
3566
  ...orig.extras && { extras: orig.extras }
3441
3567
  };
3442
3568
  if (selectedIndices.has(index)) {
@@ -3487,6 +3613,59 @@ var _Recipe = class _Recipe {
3487
3613
  }
3488
3614
  return result;
3489
3615
  }
3616
+ /**
3617
+ * Returns the list of cookware items that are used in the active variant.
3618
+ * Cookware in steps/sections not matching the active variant are excluded.
3619
+ * Hidden cookware is always excluded.
3620
+ *
3621
+ * @param options - Options for filtering:
3622
+ * - `choices`: The choices to apply (only `variant` is used)
3623
+ * @returns Array of Cookware objects referenced by active steps
3624
+ *
3625
+ * @example
3626
+ * ```typescript
3627
+ * // Get all cookware for the default variant
3628
+ * const cookware = recipe.getCookwareForVariant();
3629
+ *
3630
+ * // Get cookware for a specific variant
3631
+ * const veganCookware = recipe.getCookwareForVariant({ choices: { variant: 'vegan' } });
3632
+ * ```
3633
+ */
3634
+ getCookwareForVariant(options) {
3635
+ const { choices } = options || {};
3636
+ const activeVariant = choices?.variant;
3637
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3638
+ const cookwareIndices = /* @__PURE__ */ new Set();
3639
+ for (const currentSection of this.sections) {
3640
+ if (currentSection.variants) {
3641
+ if (isDefaultVariant) {
3642
+ if (!currentSection.variants.includes("*")) continue;
3643
+ } else {
3644
+ if (!currentSection.variants.includes(activeVariant)) continue;
3645
+ }
3646
+ }
3647
+ const allSteps = currentSection.content.filter(
3648
+ (item) => item.type === "step"
3649
+ );
3650
+ for (const currentStep of allSteps) {
3651
+ if (currentStep.variants) {
3652
+ if (isDefaultVariant) {
3653
+ if (!currentStep.variants.includes("*")) continue;
3654
+ } else {
3655
+ if (!currentStep.variants.includes(activeVariant)) continue;
3656
+ }
3657
+ }
3658
+ for (const item of currentStep.items) {
3659
+ if (item.type === "cookware") {
3660
+ cookwareIndices.add(item.index);
3661
+ }
3662
+ }
3663
+ }
3664
+ }
3665
+ return this.cookware.filter(
3666
+ (cw, idx) => cookwareIndices.has(idx) && !cw.flags?.includes("hidden")
3667
+ );
3668
+ }
3490
3669
  /**
3491
3670
  * Parses a recipe from a string.
3492
3671
  * @param content - The recipe content to parse.
@@ -3502,9 +3681,14 @@ var _Recipe = class _Recipe {
3502
3681
  const items = [];
3503
3682
  let noteText = "";
3504
3683
  let inNote = false;
3684
+ let stepVariants;
3685
+ let stepOptional;
3686
+ const discoveredVariants = /* @__PURE__ */ new Set();
3505
3687
  for (const line of cleanContent) {
3506
3688
  if (line.trim().length === 0) {
3507
- flushPendingItems(section, items);
3689
+ flushPendingItems(section, items, stepVariants, stepOptional);
3690
+ stepVariants = void 0;
3691
+ stepOptional = void 0;
3508
3692
  flushPendingNote(
3509
3693
  section,
3510
3694
  noteText ? this._parseNoteText(noteText) : []
@@ -3515,26 +3699,42 @@ var _Recipe = class _Recipe {
3515
3699
  continue;
3516
3700
  }
3517
3701
  if (line.startsWith("=")) {
3518
- flushPendingItems(section, items);
3702
+ flushPendingItems(section, items, stepVariants, stepOptional);
3703
+ stepVariants = void 0;
3704
+ stepOptional = void 0;
3519
3705
  flushPendingNote(
3520
3706
  section,
3521
3707
  noteText ? this._parseNoteText(noteText) : []
3522
3708
  );
3523
3709
  noteText = "";
3524
- if (this.sections.length === 0 && section.isBlank()) {
3525
- section.name = line.replace(/^=+|=+$/g, "").trim();
3526
- } else {
3527
- if (!section.isBlank()) {
3528
- this.sections.push(section);
3710
+ let sectionName = line.replace(/^=+|=+$/g, "").trim();
3711
+ let sectionVariants;
3712
+ let sectionOptional;
3713
+ const sectionVarMatch = sectionName.match(variantTagRegex);
3714
+ if (sectionVarMatch?.groups) {
3715
+ const isOptionalPrefix = sectionVarMatch.groups.variantOptionalPrefix === "?";
3716
+ const names = (sectionVarMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3717
+ if (names.length > 0) {
3718
+ sectionVariants = names;
3719
+ for (const v of names) discoveredVariants.add(v);
3720
+ }
3721
+ if (isOptionalPrefix) {
3722
+ sectionOptional = true;
3529
3723
  }
3530
- section = new Section(line.replace(/^=+|=+$/g, "").trim());
3724
+ sectionName = sectionName.slice(sectionVarMatch[0].length).trim();
3531
3725
  }
3726
+ if (!section.isBlank()) {
3727
+ this.sections.push(section);
3728
+ }
3729
+ section = new Section(sectionName, sectionVariants, sectionOptional);
3532
3730
  blankLineBefore = true;
3533
3731
  inNote = false;
3534
3732
  continue;
3535
3733
  }
3536
3734
  if (blankLineBefore && line.startsWith(">")) {
3537
- flushPendingItems(section, items);
3735
+ flushPendingItems(section, items, stepVariants, stepOptional);
3736
+ stepVariants = void 0;
3737
+ stepOptional = void 0;
3538
3738
  noteText = line.substring(1).trim();
3539
3739
  inNote = true;
3540
3740
  blankLineBefore = false;
@@ -3549,11 +3749,31 @@ var _Recipe = class _Recipe {
3549
3749
  blankLineBefore = false;
3550
3750
  continue;
3551
3751
  }
3752
+ let currentLine = line;
3753
+ if (items.length === 0) {
3754
+ const varMatch = currentLine.match(variantTagRegex);
3755
+ if (varMatch?.groups) {
3756
+ const isOptionalPrefix = varMatch.groups.variantOptionalPrefix === "?";
3757
+ const names = (varMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3758
+ if (names.length > 0) {
3759
+ stepVariants = names;
3760
+ for (const v of names) discoveredVariants.add(v);
3761
+ }
3762
+ if (isOptionalPrefix) {
3763
+ stepOptional = true;
3764
+ }
3765
+ currentLine = currentLine.slice(varMatch[0].length);
3766
+ if (currentLine.trim().length === 0) {
3767
+ blankLineBefore = false;
3768
+ continue;
3769
+ }
3770
+ }
3771
+ }
3552
3772
  let cursor = 0;
3553
- for (const match of line.matchAll(tokensRegex)) {
3773
+ for (const match of currentLine.matchAll(tokensRegex)) {
3554
3774
  const idx = match.index;
3555
3775
  if (idx > cursor) {
3556
- items.push(...parseMarkdownSegments(line.slice(cursor, idx)));
3776
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor, idx)));
3557
3777
  }
3558
3778
  const groups = match.groups;
3559
3779
  if (groups.mIngredientName || groups.sIngredientName) {
@@ -3572,7 +3792,7 @@ var _Recipe = class _Recipe {
3572
3792
  if (modifiers !== void 0 && modifiers.includes("-")) {
3573
3793
  flags.push("hidden");
3574
3794
  }
3575
- const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
3795
+ const quantity = quantityRaw ? parseQuantityValue(quantityRaw) : void 0;
3576
3796
  const newCookware = {
3577
3797
  name
3578
3798
  };
@@ -3604,7 +3824,7 @@ var _Recipe = class _Recipe {
3604
3824
  throw new Error("Timer missing unit");
3605
3825
  }
3606
3826
  const name = groups.timerName || void 0;
3607
- const duration = parseQuantityInput(durationStr);
3827
+ const duration = parseQuantityValue(durationStr);
3608
3828
  const timerObj = {
3609
3829
  name,
3610
3830
  duration,
@@ -3614,16 +3834,21 @@ var _Recipe = class _Recipe {
3614
3834
  }
3615
3835
  cursor = idx + match[0].length;
3616
3836
  }
3617
- if (cursor < line.length) {
3618
- items.push(...parseMarkdownSegments(line.slice(cursor)));
3837
+ if (cursor < currentLine.length) {
3838
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor)));
3619
3839
  }
3620
3840
  blankLineBefore = false;
3621
3841
  }
3622
- flushPendingItems(section, items);
3842
+ flushPendingItems(section, items, stepVariants, stepOptional);
3623
3843
  flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
3624
3844
  if (!section.isBlank()) {
3625
3845
  this.sections.push(section);
3626
3846
  }
3847
+ const metaVariants = this.metadata.variants ?? [];
3848
+ const allVariants = /* @__PURE__ */ new Set([...metaVariants, ...discoveredVariants]);
3849
+ if (allVariants.size > 0) {
3850
+ this.choices.variants = [...allVariants];
3851
+ }
3627
3852
  this._populateIngredientQuantities();
3628
3853
  }
3629
3854
  /**
@@ -3732,9 +3957,15 @@ var _Recipe = class _Recipe {
3732
3957
  }
3733
3958
  newRecipe._populateIngredientQuantities();
3734
3959
  newRecipe.servings = (0, import_big4.default)(originalServings).times(factor).toNumber();
3735
- for (const metaVar of ["servings", "yield", "serves"]) {
3736
- if (newRecipe.metadata[metaVar] && this.metadata[metaVar]) {
3737
- const original = this.metadata[metaVar];
3960
+ for (const metaVar of ["servings", "serves"]) {
3961
+ if (typeof newRecipe.metadata[metaVar] === "number") {
3962
+ newRecipe.metadata[metaVar] = (0, import_big4.default)(newRecipe.metadata[metaVar]).times(factor).toNumber();
3963
+ }
3964
+ }
3965
+ if (newRecipe.metadata.yield && this.metadata.yield) {
3966
+ const original = this.metadata.yield;
3967
+ if (original.quantity.type === "fixed" && original.quantity.value.type === "text") {
3968
+ } else {
3738
3969
  const scaledQuantity = multiplyQuantityValue(
3739
3970
  original.quantity,
3740
3971
  factor
@@ -3749,8 +3980,7 @@ var _Recipe = class _Recipe {
3749
3980
  if (optimized.unit) scaled.unit = optimized.unit;
3750
3981
  if (original.textBefore) scaled.textBefore = original.textBefore;
3751
3982
  if (original.textAfter) scaled.textAfter = original.textAfter;
3752
- if (original.text) scaled.text = original.text;
3753
- newRecipe.metadata[metaVar] = scaled;
3983
+ newRecipe.metadata.yield = scaled;
3754
3984
  }
3755
3985
  }
3756
3986
  return newRecipe;
@@ -3934,7 +4164,11 @@ var _Recipe = class _Recipe {
3934
4164
  newRecipe.metadata = deepClone(this.metadata);
3935
4165
  newRecipe.ingredients = deepClone(this.ingredients);
3936
4166
  newRecipe.sections = this.sections.map((section) => {
3937
- const newSection = new Section(section.name);
4167
+ const newSection = new Section(
4168
+ section.name,
4169
+ section.variants,
4170
+ section.optional
4171
+ );
3938
4172
  newSection.content = deepClone(section.content);
3939
4173
  return newSection;
3940
4174
  });
@@ -3963,7 +4197,7 @@ __publicField(_Recipe, "subgroupIndices", /* @__PURE__ */ new WeakMap());
3963
4197
  var Recipe = _Recipe;
3964
4198
 
3965
4199
  // src/classes/shopping_list.ts
3966
- var ShoppingList = class _ShoppingList {
4200
+ var ShoppingList = class {
3967
4201
  /**
3968
4202
  * Creates a new ShoppingList instance
3969
4203
  * @param categoryConfigStr - The category configuration to parse.
@@ -4058,7 +4292,7 @@ var ShoppingList = class _ShoppingList {
4058
4292
  }
4059
4293
  }
4060
4294
  if (numericEntries.length > 1) {
4061
- const ratioMap = _ShoppingList.buildEquivalenceRatioMap(
4295
+ const ratioMap = buildEquivalenceRatioMap(
4062
4296
  getEquivalentUnitsLists(...numericEntries)
4063
4297
  );
4064
4298
  if (Object.keys(ratioMap).length > 0) {
@@ -4115,10 +4349,12 @@ var ShoppingList = class _ShoppingList {
4115
4349
  const pantryHasUnit = pantryExtended.unit !== void 0 && pantryExtended.unit.name !== "";
4116
4350
  const ratioMap = this.equivalenceRatios.get(ingredient.name);
4117
4351
  const unitMismatch = leafHasUnit !== pantryHasUnit && ratioMap !== void 0;
4352
+ const leafDef = normalizeUnit(leaf.unit);
4353
+ const pantryDef = normalizeUnit(pantryExtended.unit?.name);
4118
4354
  if (unitMismatch) {
4119
4355
  const leafUnit = leaf.unit ?? NO_UNIT;
4120
4356
  const pantryUnit = pantryExtended.unit?.name ?? NO_UNIT;
4121
- const ratioFromPantry = ratioMap[leafUnit]?.[pantryUnit];
4357
+ const ratioFromPantry = ratioMap[normalizeUnit(leafUnit)?.name ?? leafUnit]?.[normalizeUnit(pantryUnit)?.name ?? pantryUnit];
4122
4358
  if (ratioFromPantry !== void 0) {
4123
4359
  const pantryValue = getAverageValue(pantryExtended.quantity);
4124
4360
  const leafValue = getAverageValue(ingredientExtended.quantity);
@@ -4133,7 +4369,7 @@ var ShoppingList = class _ShoppingList {
4133
4369
  type: "fixed",
4134
4370
  value: { type: "decimal", decimal: remainingLeafValue }
4135
4371
  };
4136
- const consumedInPantryUnits = ratioFromPantry !== 0 ? subtracted / ratioFromPantry : pantryValue;
4372
+ const consumedInPantryUnits = subtracted / ratioFromPantry;
4137
4373
  const remainingPantryValue = Math.max(
4138
4374
  pantryValue - consumedInPantryUnits,
4139
4375
  0
@@ -4150,9 +4386,10 @@ var ShoppingList = class _ShoppingList {
4150
4386
  };
4151
4387
  continue;
4152
4388
  }
4389
+ } else {
4390
+ continue;
4153
4391
  }
4154
- }
4155
- try {
4392
+ } else if (leafDef && pantryDef && areUnitsConvertible(leafDef, pantryDef) || (leaf.unit ?? "").toLowerCase() === (pantryExtended.unit?.name ?? "").toLowerCase()) {
4156
4393
  const remaining = subtractQuantities(
4157
4394
  ingredientExtended,
4158
4395
  pantryExtended,
@@ -4167,7 +4404,47 @@ var ShoppingList = class _ShoppingList {
4167
4404
  const updated = toPlainUnit(remaining);
4168
4405
  leaf.quantity = updated.quantity;
4169
4406
  leaf.unit = updated.unit;
4170
- } catch {
4407
+ } else if (ratioMap) {
4408
+ const canonicalLeaf = normalizeUnit(leaf.unit)?.name ?? leaf.unit;
4409
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4410
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4411
+ if (typeof leafValue === "number" && typeof pantryValue === "number" && pantryDef) {
4412
+ for (const [equivUnit, ratios] of Object.entries(ratioMap)) {
4413
+ const ratio = ratios[canonicalLeaf];
4414
+ if (ratio === void 0) continue;
4415
+ const equivDef = normalizeUnit(equivUnit);
4416
+ if (!equivDef || !areUnitsConvertible(equivDef, pantryDef))
4417
+ continue;
4418
+ const pantryInEquiv = pantryValue * getToBase(pantryDef) / getToBase(equivDef);
4419
+ const pantryInLeafUnits = pantryInEquiv / ratio;
4420
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4421
+ const remainingLeafValue = Math.max(
4422
+ leafValue - pantryInLeafUnits,
4423
+ 0
4424
+ );
4425
+ leaf.quantity = {
4426
+ type: "fixed",
4427
+ value: { type: "decimal", decimal: remainingLeafValue }
4428
+ };
4429
+ const consumedInEquiv = subtracted * ratio;
4430
+ const consumedInPantryUnits = consumedInEquiv * getToBase(equivDef) / getToBase(pantryDef);
4431
+ const remainingPantryValue = Math.max(
4432
+ pantryValue - consumedInPantryUnits,
4433
+ 0
4434
+ );
4435
+ pantryExtended = {
4436
+ quantity: {
4437
+ type: "fixed",
4438
+ value: {
4439
+ type: "decimal",
4440
+ decimal: remainingPantryValue
4441
+ }
4442
+ },
4443
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4444
+ };
4445
+ break;
4446
+ }
4447
+ }
4171
4448
  }
4172
4449
  }
4173
4450
  if ("and" in entry) {
@@ -4178,8 +4455,8 @@ var ShoppingList = class _ShoppingList {
4178
4455
  entry.and.push(...nonZero);
4179
4456
  const ratioMap = this.equivalenceRatios.get(ingredient.name);
4180
4457
  if (entry.equivalents && ratioMap) {
4181
- const equivUnits = entry.equivalents.map((e2) => e2.unit ?? NO_UNIT);
4182
- entry.equivalents = _ShoppingList.recomputeEquivalents(
4458
+ const equivUnits = entry.equivalents.map((e2) => e2.unit);
4459
+ entry.equivalents = recomputeEquivalents(
4183
4460
  entry.and,
4184
4461
  ratioMap,
4185
4462
  equivUnits
@@ -4190,17 +4467,17 @@ var ShoppingList = class _ShoppingList {
4190
4467
  ingredient.quantities[i2] = {
4191
4468
  quantity: single.quantity,
4192
4469
  ...single.unit && { unit: single.unit },
4193
- ...entry.equivalents && { equivalents: entry.equivalents },
4194
- ...entry.alternatives && { alternatives: entry.alternatives }
4470
+ ...entry.equivalents && { equivalents: entry.equivalents }
4195
4471
  };
4196
4472
  }
4197
4473
  } else if ("equivalents" in entry && entry.equivalents) {
4198
4474
  const ratioMap = this.equivalenceRatios.get(ingredient.name);
4199
4475
  if (ratioMap) {
4200
4476
  const equivUnits = entry.equivalents.map(
4201
- (e2) => e2.unit ?? NO_UNIT
4477
+ (e2) => e2.unit
4478
+ // equivalents always have units
4202
4479
  );
4203
- const recomputed = _ShoppingList.recomputeEquivalents(
4480
+ const recomputed = recomputeEquivalents(
4204
4481
  [entry],
4205
4482
  ratioMap,
4206
4483
  equivUnits
@@ -4223,57 +4500,6 @@ var ShoppingList = class _ShoppingList {
4223
4500
  }
4224
4501
  this.resultingPantry = clonedPantry;
4225
4502
  }
4226
- /**
4227
- * Builds a ratio map from equivalence lists.
4228
- * For each equivalence list, stores ratio = equiv_value / primary_value
4229
- * for every pair of units, so equivalents can be recomputed after
4230
- * pantry subtraction modifies primary quantities.
4231
- */
4232
- static buildEquivalenceRatioMap(unitsLists) {
4233
- const ratioMap = {};
4234
- for (const list of unitsLists) {
4235
- for (const equiv of list) {
4236
- const equivValue = getAverageValue(equiv.quantity);
4237
- for (const primary of list) {
4238
- if (primary === equiv) continue;
4239
- const primaryValue = getAverageValue(primary.quantity);
4240
- const equivUnit = equiv.unit.name;
4241
- const primaryUnit = primary.unit.name;
4242
- ratioMap[equivUnit] ?? (ratioMap[equivUnit] = {});
4243
- ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue;
4244
- }
4245
- }
4246
- }
4247
- return ratioMap;
4248
- }
4249
- /**
4250
- * Recomputes equivalent quantities from current primary values and stored ratios.
4251
- * For each equivalent unit in equivUnits, new_value = Σ (primary_value × ratio[equivUnit][primaryUnit]).
4252
- * Returns undefined if all equivalents compute to zero.
4253
- */
4254
- static recomputeEquivalents(primaries, ratioMap, equivUnits) {
4255
- const equivalents = [];
4256
- for (const equivUnit of equivUnits) {
4257
- const ratios = ratioMap[equivUnit];
4258
- let total = 0;
4259
- for (const primary of primaries) {
4260
- const pUnit = primary.unit ?? NO_UNIT;
4261
- const ratio = ratios[pUnit];
4262
- const pValue = getAverageValue(primary.quantity);
4263
- total += pValue * ratio;
4264
- }
4265
- if (total > 0) {
4266
- equivalents.push({
4267
- quantity: {
4268
- type: "fixed",
4269
- value: { type: "decimal", decimal: total }
4270
- },
4271
- ...equivUnit !== "" && { unit: equivUnit }
4272
- });
4273
- }
4274
- }
4275
- return equivalents.length > 0 ? equivalents : void 0;
4276
- }
4277
4503
  /**
4278
4504
  * Adds a recipe to the shopping list, then automatically
4279
4505
  * recalculates the quantities and recategorize the ingredients.
@@ -4807,13 +5033,53 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4807
5033
  const selectedIndex = choices?.ingredientItems?.get(item.id);
4808
5034
  return alternativeIndex === selectedIndex;
4809
5035
  }
5036
+ function isSectionActive(section, variant) {
5037
+ if (!section.variants) return true;
5038
+ const isDefault = variant === void 0 || variant === "*";
5039
+ if (isDefault) {
5040
+ return section.variants.includes("*");
5041
+ }
5042
+ return section.variants.includes(variant);
5043
+ }
5044
+ function isStepActive(step, variant) {
5045
+ if (!step.variants) return true;
5046
+ const isDefault = variant === void 0 || variant === "*";
5047
+ if (isDefault) {
5048
+ return step.variants.includes("*");
5049
+ }
5050
+ return step.variants.includes(variant);
5051
+ }
5052
+ function getEffectiveChoices(recipe, variant) {
5053
+ const choices = { variant };
5054
+ if (variant === void 0 || variant === "*") return choices;
5055
+ const variantLower = variant.toLowerCase();
5056
+ for (const [itemId, alternatives] of recipe.choices.ingredientItems) {
5057
+ const matchIdx = alternatives.findIndex(
5058
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5059
+ );
5060
+ if (matchIdx >= 0) {
5061
+ if (!choices.ingredientItems) choices.ingredientItems = /* @__PURE__ */ new Map();
5062
+ choices.ingredientItems.set(itemId, matchIdx);
5063
+ }
5064
+ }
5065
+ for (const [groupId, subgroups] of recipe.choices.ingredientGroups) {
5066
+ const matchIdx = subgroups.findIndex(
5067
+ (sg) => sg.some(
5068
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5069
+ )
5070
+ );
5071
+ if (matchIdx >= 0) {
5072
+ if (!choices.ingredientGroups) choices.ingredientGroups = /* @__PURE__ */ new Map();
5073
+ choices.ingredientGroups.set(groupId, matchIdx);
5074
+ }
5075
+ }
5076
+ return choices;
5077
+ }
4810
5078
  // Annotate the CommonJS export names for ESM import in node:
4811
5079
  0 && (module.exports = {
4812
- BadIndentationError,
4813
5080
  CategoryConfig,
4814
5081
  NoProductCatalogForCartError,
4815
5082
  NoShoppingListForCartError,
4816
- NoTabAsIndentError,
4817
5083
  Pantry,
4818
5084
  ProductCatalog,
4819
5085
  Recipe,
@@ -4828,11 +5094,14 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4828
5094
  formatQuantityWithUnit,
4829
5095
  formatSingleValue,
4830
5096
  formatUnit,
5097
+ getEffectiveChoices,
4831
5098
  hasAlternatives,
4832
5099
  isAlternativeSelected,
4833
5100
  isAndGroup,
4834
5101
  isGroupedItem,
5102
+ isSectionActive,
4835
5103
  isSimpleGroup,
5104
+ isStepActive,
4836
5105
  renderFractionAsVulgar
4837
5106
  });
4838
5107
  /* v8 ignore else -- @preserve */
@@ -4845,6 +5114,9 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4845
5114
  // v8 ignore if -- @preserve: defensive type guard
4846
5115
  /* v8 ignore if -- @preserve */
4847
5116
  // v8 ignore next -- @preserve
5117
+ // v8 ignore else -- @preserve: text quantities never reach the equivalence path
4848
5118
  // v8 ignore else --@preserve: defensive type guard
4849
5119
  // v8 ignore else -- @preserve: detection if
5120
+ /* v8 ignore else -- @preserve: only act when there are matches */
5121
+ /* v8 ignore else -- @preserve: initialization pattern */
4850
5122
  //# sourceMappingURL=index.cjs.map