@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.js CHANGED
@@ -276,8 +276,8 @@ var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
276
276
  var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
277
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();
278
278
  var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
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().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();
281
281
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
282
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();
283
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();
@@ -292,11 +292,18 @@ var tokensRegex = new RegExp(
292
292
  ].map((r2) => r2.source).join("|"),
293
293
  "gu"
294
294
  );
295
- 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();
296
- var servingsSuffixPart = d().anyOf(" ").zeroOrMore().startNamedGroup("servingsSuffix").nonWhitespace().startGroup().anyCharacter().zeroOrMore().nonWhitespace().endGroup().optional().endGroup().optional().anyOf(" ").zeroOrMore().endAnchor().toRegExp();
297
- 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();
298
- var scalingMetaValueWithUnitRegex = (varName) => new RegExp(
299
- servingsPrefixPart(varName).source + arbitraryScalableRegex.source + servingsSuffixPart.source,
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("|"),
300
307
  "m"
301
308
  );
302
309
  var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
@@ -305,6 +312,7 @@ var shoppingListRegex = d().literal("[").startNamedGroup("name").anyCharacter().
305
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();
306
313
  var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
307
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();
308
316
  var mdEscaped = d().literal("\\").startCaptureGroup().anyOf("*_`").endGroup();
309
317
  var mdInlineCode = d().literal("`").startCaptureGroup().notAnyOf("`").oneOrMore().lazy().endGroup().literal("`");
310
318
  var mdLink = d().literal("[").startCaptureGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("](").startCaptureGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")");
@@ -880,7 +888,7 @@ var NoProductMatchError = class extends Error {
880
888
  constructor(item_name, code) {
881
889
  const messageMap = {
882
890
  incompatibleUnits: `The units of the products in the catalogue are incompatible with ingredient ${item_name} in the shopping list.`,
883
- 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`,
884
892
  textValue: `Ingredient ${item_name} has a text value as quantity and can therefore not be matched with any product in the catalogue.`,
885
893
  noQuantity: `Ingredient ${item_name} has no quantity and can therefore not be matched with any product in the catalogue.`,
886
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`
@@ -1359,9 +1367,12 @@ function flushPendingNote(section, noteItems) {
1359
1367
  }
1360
1368
  return noteItems;
1361
1369
  }
1362
- function flushPendingItems(section, items) {
1370
+ function flushPendingItems(section, items, stepVariants, stepOptional) {
1363
1371
  if (items.length > 0) {
1364
- 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);
1365
1376
  items.length = 0;
1366
1377
  return true;
1367
1378
  }
@@ -1480,7 +1491,7 @@ function stringifyFixedValue(quantity) {
1480
1491
  return String(quantity.value.decimal);
1481
1492
  else return quantity.value.text;
1482
1493
  }
1483
- function parseQuantityInput(input_str) {
1494
+ function parseQuantityValue(input_str) {
1484
1495
  const clean_str = String(input_str).trim();
1485
1496
  if (rangeRegex.test(clean_str)) {
1486
1497
  const range_parts = clean_str.split("-");
@@ -1494,12 +1505,12 @@ function parseQuantityWithUnit(input) {
1494
1505
  const trimmed = input.trim();
1495
1506
  const separatorIndex = trimmed.indexOf("%");
1496
1507
  if (separatorIndex === -1) {
1497
- return { value: parseQuantityInput(trimmed) };
1508
+ return { value: parseQuantityValue(trimmed) };
1498
1509
  }
1499
1510
  const valuePart = trimmed.slice(0, separatorIndex).trim();
1500
1511
  const unitPart = trimmed.slice(separatorIndex + 1).trim();
1501
1512
  return {
1502
- value: parseQuantityInput(valuePart),
1513
+ value: parseQuantityValue(valuePart),
1503
1514
  unit: unitPart || void 0
1504
1515
  };
1505
1516
  }
@@ -1689,7 +1700,7 @@ function parseArbitraryQuantity(raw) {
1689
1700
  "Arbitrary quantities must have a numerical value"
1690
1701
  );
1691
1702
  }
1692
- const value = parseQuantityInput(quantityMatch.groups.quantity);
1703
+ const value = parseQuantityValue(quantityMatch.groups.quantity);
1693
1704
  const unit = quantityMatch.groups.unit;
1694
1705
  if (!value || value.type === "fixed" && value.value.type === "text") {
1695
1706
  throw new InvalidQuantityFormat(
@@ -1703,40 +1714,40 @@ function parseArbitraryQuantity(raw) {
1703
1714
  if (unit) arbitrary.unit = unit;
1704
1715
  return arbitrary;
1705
1716
  }
1706
- function parseScalingMetaVar(content, varName) {
1707
- const complexMatch = content.match(scalingMetaValueWithUnitRegex(varName));
1708
- if (complexMatch?.groups?.arbitraryQuantity) {
1709
- const parsed = parseArbitraryQuantity(
1710
- complexMatch.groups.arbitraryQuantity
1711
- );
1712
- const result2 = {
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 = {
1713
1732
  quantity: parsed.quantity
1714
1733
  };
1715
- if (parsed.unit) result2.unit = parsed.unit;
1716
- if (complexMatch.groups.servingsPrefix) {
1717
- result2.textBefore = complexMatch.groups.servingsPrefix;
1734
+ if (parsed.unit) result.unit = parsed.unit;
1735
+ if (match.groups.servingsPrefix) {
1736
+ result.textBefore = match.groups.servingsPrefix;
1718
1737
  }
1719
- if (complexMatch.groups.servingsSuffix) {
1720
- result2.textAfter = complexMatch.groups.servingsSuffix;
1738
+ if (match.groups.servingsSuffix) {
1739
+ result.textAfter = match.groups.servingsSuffix;
1721
1740
  }
1722
- return result2;
1723
- }
1724
- const varMatch = content.match(scalingSimpleMetaValueRegex(varName));
1725
- if (!varMatch) return void 0;
1726
- if (isNaN(Number(varMatch[2]?.trim()))) {
1727
- throw new Error("Scaling variables should be numbers");
1741
+ return result;
1728
1742
  }
1729
- const numericValue = Number(varMatch[2]?.trim());
1730
- const result = {
1731
- quantity: {
1732
- type: "fixed",
1733
- value: { type: "decimal", decimal: numericValue }
1734
- }
1735
- };
1736
- if (varMatch[3]) {
1737
- result.text = `${varMatch[3].trim()}`;
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;
1738
1749
  }
1739
- return result;
1750
+ return void 0;
1740
1751
  }
1741
1752
  function parseListMetaVar(content, varName) {
1742
1753
  const listMatch = content.match(
@@ -1854,12 +1865,12 @@ function parseAnyMetaVar(content, varName) {
1854
1865
  if (simple) return parseMetadataValue(simple);
1855
1866
  return void 0;
1856
1867
  }
1857
- function getNumericValueFromMetaVar(v) {
1868
+ function getNumericValueFromYield(v) {
1858
1869
  if (v.quantity.type === "fixed" && v.quantity.value.type !== "text") {
1859
1870
  return getNumericValue(v.quantity.value);
1860
1871
  }
1861
1872
  if (v.quantity.type === "range") return getNumericValue(v.quantity.min);
1862
- return 0;
1873
+ return 1;
1863
1874
  }
1864
1875
  function extractMetadata(content) {
1865
1876
  const metadata = {};
@@ -1905,7 +1916,8 @@ function extractMetadata(content) {
1905
1916
  "yield",
1906
1917
  "serves",
1907
1918
  // List fields
1908
- "tags"
1919
+ "tags",
1920
+ "variants"
1909
1921
  ]);
1910
1922
  for (const metaVar of [
1911
1923
  "title",
@@ -1983,15 +1995,22 @@ function extractMetadata(content) {
1983
1995
  };
1984
1996
  unitSystem = unitSystemMap[unitSystemRaw.toLowerCase()];
1985
1997
  }
1986
- for (const metaVar of ["servings", "yield", "serves"]) {
1987
- const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1988
- if (scalingMetaValue) {
1989
- metadata[metaVar] = scalingMetaValue;
1990
- servings = getNumericValueFromMetaVar(scalingMetaValue);
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;
1991
2008
  }
1992
2009
  }
1993
2010
  const tags = parseListMetaVar(metadataContent, "tags");
1994
2011
  if (tags) metadata.tags = tags;
2012
+ const variants = parseListMetaVar(metadataContent, "variants");
2013
+ if (variants) metadata.variants = variants;
1995
2014
  const allKeys = extractAllMetadataKeys(metadataContent);
1996
2015
  for (const key of allKeys) {
1997
2016
  if (handledKeys.has(key)) continue;
@@ -2266,7 +2285,7 @@ var ProductCatalog = class {
2266
2285
  const sizeStrings = Array.isArray(size) ? size : [size];
2267
2286
  const sizes = sizeStrings.map((sizeStr) => {
2268
2287
  const sizeAndUnitRaw = sizeStr.split("%");
2269
- const sizeParsed = parseQuantityInput(
2288
+ const sizeParsed = parseQuantityValue(
2270
2289
  sizeAndUnitRaw[0]
2271
2290
  );
2272
2291
  const productSize = { size: sizeParsed };
@@ -2379,8 +2398,10 @@ var Section = class {
2379
2398
  /**
2380
2399
  * Creates an instance of Section.
2381
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.
2382
2403
  */
2383
- constructor(name = "") {
2404
+ constructor(name = "", variants, optional) {
2384
2405
  /**
2385
2406
  * The name of the section. Can be an empty string for the default (first) section.
2386
2407
  * @defaultValue `""`
@@ -2388,7 +2409,13 @@ var Section = class {
2388
2409
  __publicField(this, "name");
2389
2410
  /** An array of steps and notes that make up the content of the section. */
2390
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");
2391
2416
  this.name = name;
2417
+ if (variants) this.variants = variants;
2418
+ if (optional) this.optional = true;
2392
2419
  }
2393
2420
  /**
2394
2421
  * Checks if the section is blank (has no name and no content).
@@ -2424,34 +2451,7 @@ function findCompatibleQuantityWithinList(list, quantity) {
2424
2451
  }
2425
2452
 
2426
2453
  // src/utils/general.ts
2427
- var legacyDeepClone = (v) => {
2428
- if (v === null || typeof v !== "object") {
2429
- return v;
2430
- }
2431
- if (v instanceof Map) {
2432
- return new Map(
2433
- Array.from(v.entries()).map(([k, val]) => [
2434
- legacyDeepClone(k),
2435
- legacyDeepClone(val)
2436
- ])
2437
- );
2438
- }
2439
- if (v instanceof Set) {
2440
- return new Set(Array.from(v).map((val) => legacyDeepClone(val)));
2441
- }
2442
- if (v instanceof Date) {
2443
- return new Date(v.getTime());
2444
- }
2445
- if (Array.isArray(v)) {
2446
- return v.map((item) => legacyDeepClone(item));
2447
- }
2448
- const cloned = {};
2449
- for (const key of Object.keys(v)) {
2450
- cloned[key] = legacyDeepClone(v[key]);
2451
- }
2452
- return cloned;
2453
- };
2454
- var deepClone = (v) => typeof structuredClone === "function" ? structuredClone(v) : legacyDeepClone(v);
2454
+ var deepClone = (v) => structuredClone(v);
2455
2455
 
2456
2456
  // src/quantities/alternatives.ts
2457
2457
  function getEquivalentUnitsLists(...quantities) {
@@ -2730,6 +2730,47 @@ function addEquivalentsAndSimplify(quantities, system) {
2730
2730
  return { and: regrouped.map(toPlainUnit) };
2731
2731
  }
2732
2732
  }
2733
+ function buildEquivalenceRatioMap(unitsLists) {
2734
+ const ratioMap = {};
2735
+ for (const list of unitsLists) {
2736
+ for (const equiv of list) {
2737
+ const equivValue = getAverageValue(equiv.quantity);
2738
+ for (const primary of list) {
2739
+ if (primary === equiv) continue;
2740
+ const primaryValue = getAverageValue(primary.quantity);
2741
+ const equivUnit = normalizeUnit(equiv.unit.name)?.name ?? equiv.unit.name;
2742
+ const primaryUnit = normalizeUnit(primary.unit.name)?.name ?? primary.unit.name;
2743
+ ratioMap[equivUnit] ?? (ratioMap[equivUnit] = {});
2744
+ ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue;
2745
+ }
2746
+ }
2747
+ }
2748
+ return ratioMap;
2749
+ }
2750
+ function recomputeEquivalents(primaries, ratioMap, equivUnits) {
2751
+ const equivalents = [];
2752
+ for (const equivUnit of equivUnits) {
2753
+ const ratios = ratioMap[normalizeUnit(equivUnit)?.name ?? equivUnit];
2754
+ let total = 0;
2755
+ for (const primary of primaries) {
2756
+ const pUnit = normalizeUnit(primary.unit ?? NO_UNIT)?.name ?? primary.unit ?? NO_UNIT;
2757
+ const ratio = ratios[pUnit];
2758
+ if (ratio === void 0) continue;
2759
+ const pValue = getAverageValue(primary.quantity);
2760
+ total += pValue * ratio;
2761
+ }
2762
+ if (total > 0) {
2763
+ equivalents.push({
2764
+ quantity: {
2765
+ type: "fixed",
2766
+ value: { type: "decimal", decimal: total }
2767
+ },
2768
+ ...equivUnit !== "" && { unit: equivUnit }
2769
+ });
2770
+ }
2771
+ }
2772
+ return equivalents.length > 0 ? equivalents : void 0;
2773
+ }
2733
2774
 
2734
2775
  // src/classes/recipe.ts
2735
2776
  import Big4 from "big.js";
@@ -2748,7 +2789,8 @@ var _Recipe = class _Recipe {
2748
2789
  */
2749
2790
  __publicField(this, "choices", {
2750
2791
  ingredientItems: /* @__PURE__ */ new Map(),
2751
- ingredientGroups: /* @__PURE__ */ new Map()
2792
+ ingredientGroups: /* @__PURE__ */ new Map(),
2793
+ variants: []
2752
2794
  });
2753
2795
  /**
2754
2796
  * The parsed recipe ingredients.
@@ -2853,7 +2895,7 @@ var _Recipe = class _Recipe {
2853
2895
  let quantityMatch = quantityRaw.match(quantityAlternativeRegex);
2854
2896
  const quantities = [];
2855
2897
  while (quantityMatch?.groups) {
2856
- const value = quantityMatch.groups.quantity ? parseQuantityInput(quantityMatch.groups.quantity) : void 0;
2898
+ const value = quantityMatch.groups.quantity ? parseQuantityValue(quantityMatch.groups.quantity) : void 0;
2857
2899
  const unit = quantityMatch.groups.unit;
2858
2900
  if (value) {
2859
2901
  const newQuantity = { quantity: value };
@@ -3065,6 +3107,10 @@ var _Recipe = class _Recipe {
3065
3107
  if (itemQuantity) {
3066
3108
  Object.assign(alternative, itemQuantity);
3067
3109
  }
3110
+ const note = groups.gIngredientNote?.trim();
3111
+ if (note) {
3112
+ alternative.note = note;
3113
+ }
3068
3114
  const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3069
3115
  const existingAlternativesFlat = existingSubgroups?.flat();
3070
3116
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
@@ -3158,6 +3204,8 @@ var _Recipe = class _Recipe {
3158
3204
  /** @internal */
3159
3205
  collectQuantityGroups(options) {
3160
3206
  const { section, step, choices } = options || {};
3207
+ const activeVariant = choices?.variant;
3208
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3161
3209
  const sectionsToProcess = section !== void 0 ? (() => {
3162
3210
  const idx = typeof section === "number" ? section : this.sections.indexOf(section);
3163
3211
  return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
@@ -3165,12 +3213,29 @@ var _Recipe = class _Recipe {
3165
3213
  const ingredientGroups = /* @__PURE__ */ new Map();
3166
3214
  const selectedIndices = /* @__PURE__ */ new Set();
3167
3215
  const referencedIndices = /* @__PURE__ */ new Set();
3216
+ const dynamicOptionalIndices = /* @__PURE__ */ new Set();
3168
3217
  for (const currentSection of sectionsToProcess) {
3218
+ if (currentSection.variants) {
3219
+ if (isDefaultVariant) {
3220
+ if (!currentSection.variants.includes("*")) continue;
3221
+ } else {
3222
+ if (!currentSection.variants.includes(activeVariant)) continue;
3223
+ }
3224
+ }
3169
3225
  const allSteps = currentSection.content.filter(
3170
3226
  (item) => item.type === "step"
3171
3227
  );
3228
+ const isOptionalSection = currentSection.optional === true;
3172
3229
  const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
3173
3230
  for (const currentStep of stepsToProcess) {
3231
+ if (currentStep.variants) {
3232
+ if (isDefaultVariant) {
3233
+ if (!currentStep.variants.includes("*")) continue;
3234
+ } else {
3235
+ if (!currentStep.variants.includes(activeVariant)) continue;
3236
+ }
3237
+ }
3238
+ const isOptionalStep = currentStep.optional === true || isOptionalSection;
3174
3239
  for (const item of currentStep.items.filter(
3175
3240
  (item2) => item2.type === "ingredient"
3176
3241
  )) {
@@ -3182,18 +3247,55 @@ var _Recipe = class _Recipe {
3182
3247
  if (isGrouped) {
3183
3248
  const groupChoice = choices?.ingredientGroups?.get(item.group);
3184
3249
  hasExplicitChoice = groupChoice !== void 0;
3185
- const targetSubgroupIndex = groupChoice ?? 0;
3186
- const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3187
- isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3250
+ if (!hasExplicitChoice && !isDefaultVariant) {
3251
+ const matchingSubgroupIdx = groupSubgroups?.findIndex(
3252
+ (sg) => sg.some(
3253
+ (alt) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3254
+ )
3255
+ );
3256
+ if (matchingSubgroupIdx !== void 0 && matchingSubgroupIdx >= 0) {
3257
+ const matchedSubgroup = groupSubgroups[matchingSubgroupIdx];
3258
+ isSelected = matchedSubgroup.some(
3259
+ (alt) => alt.itemId === item.id
3260
+ );
3261
+ hasExplicitChoice = true;
3262
+ selectedAltIndex = 0;
3263
+ } else {
3264
+ const targetSubgroupIndex = 0;
3265
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3266
+ isSelected = selectedSubgroup.some(
3267
+ (alt) => alt.itemId === item.id
3268
+ );
3269
+ }
3270
+ } else {
3271
+ const targetSubgroupIndex = groupChoice ?? 0;
3272
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3273
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3274
+ }
3188
3275
  } else {
3189
3276
  const itemChoice = choices?.ingredientItems?.get(item.id);
3190
3277
  hasExplicitChoice = itemChoice !== void 0;
3191
- selectedAltIndex = itemChoice ?? 0;
3278
+ if (!hasExplicitChoice && !isDefaultVariant) {
3279
+ const matchingIndices = item.alternatives.map((alt, idx) => ({ alt, idx })).filter(
3280
+ ({ alt }) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3281
+ ).map(({ idx }) => idx);
3282
+ if (matchingIndices.length > 0) {
3283
+ selectedAltIndex = matchingIndices[0];
3284
+ hasExplicitChoice = true;
3285
+ } else {
3286
+ selectedAltIndex = itemChoice ?? 0;
3287
+ }
3288
+ } else {
3289
+ selectedAltIndex = itemChoice ?? 0;
3290
+ }
3192
3291
  isSelected = true;
3193
3292
  }
3194
3293
  const alternative = item.alternatives[selectedAltIndex];
3195
3294
  if (!alternative || !isSelected) continue;
3196
3295
  selectedIndices.add(alternative.index);
3296
+ if (isOptionalStep) {
3297
+ dynamicOptionalIndices.add(alternative.index);
3298
+ }
3197
3299
  const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3198
3300
  for (const alt of allAltsFlat) {
3199
3301
  referencedIndices.add(alt.index);
@@ -3299,7 +3401,12 @@ var _Recipe = class _Recipe {
3299
3401
  }
3300
3402
  }
3301
3403
  }
3302
- return { ingredientGroups, selectedIndices, referencedIndices };
3404
+ return {
3405
+ ingredientGroups,
3406
+ selectedIndices,
3407
+ referencedIndices,
3408
+ dynamicOptionalIndices
3409
+ };
3303
3410
  }
3304
3411
  /**
3305
3412
  * Gets the raw (unprocessed) quantity groups for each ingredient, before
@@ -3318,12 +3425,21 @@ var _Recipe = class _Recipe {
3318
3425
  * ```
3319
3426
  */
3320
3427
  getRawQuantityGroups(options) {
3321
- const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3428
+ const {
3429
+ ingredientGroups,
3430
+ selectedIndices,
3431
+ referencedIndices,
3432
+ dynamicOptionalIndices
3433
+ } = this.collectQuantityGroups(options);
3322
3434
  const result = [];
3323
3435
  for (let index = 0; index < this.ingredients.length; index++) {
3324
3436
  if (!referencedIndices.has(index)) continue;
3325
3437
  const orig = this.ingredients[index];
3326
3438
  const usedAsPrimary = selectedIndices.has(index);
3439
+ let flags = orig.flags;
3440
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3441
+ flags = [...flags ?? [], "optional"];
3442
+ }
3327
3443
  const quantities = [];
3328
3444
  if (usedAsPrimary) {
3329
3445
  const groupsForIng = ingredientGroups.get(index);
@@ -3336,7 +3452,7 @@ var _Recipe = class _Recipe {
3336
3452
  result.push({
3337
3453
  name: orig.name,
3338
3454
  ...usedAsPrimary && { usedAsPrimary: true },
3339
- ...orig.flags && { flags: orig.flags },
3455
+ ...flags && { flags },
3340
3456
  quantities
3341
3457
  });
3342
3458
  }
@@ -3370,15 +3486,24 @@ var _Recipe = class _Recipe {
3370
3486
  * ```
3371
3487
  */
3372
3488
  getIngredientQuantities(options) {
3373
- const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3489
+ const {
3490
+ ingredientGroups,
3491
+ selectedIndices,
3492
+ referencedIndices,
3493
+ dynamicOptionalIndices
3494
+ } = this.collectQuantityGroups(options);
3374
3495
  const result = [];
3375
3496
  for (let index = 0; index < this.ingredients.length; index++) {
3376
3497
  if (!referencedIndices.has(index)) continue;
3377
3498
  const orig = this.ingredients[index];
3499
+ let flags = orig.flags;
3500
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3501
+ flags = [...flags ?? [], "optional"];
3502
+ }
3378
3503
  const ing = {
3379
3504
  name: orig.name,
3380
3505
  ...orig.preparation && { preparation: orig.preparation },
3381
- ...orig.flags && { flags: orig.flags },
3506
+ ...flags && { flags },
3382
3507
  ...orig.extras && { extras: orig.extras }
3383
3508
  };
3384
3509
  if (selectedIndices.has(index)) {
@@ -3429,6 +3554,59 @@ var _Recipe = class _Recipe {
3429
3554
  }
3430
3555
  return result;
3431
3556
  }
3557
+ /**
3558
+ * Returns the list of cookware items that are used in the active variant.
3559
+ * Cookware in steps/sections not matching the active variant are excluded.
3560
+ * Hidden cookware is always excluded.
3561
+ *
3562
+ * @param options - Options for filtering:
3563
+ * - `choices`: The choices to apply (only `variant` is used)
3564
+ * @returns Array of Cookware objects referenced by active steps
3565
+ *
3566
+ * @example
3567
+ * ```typescript
3568
+ * // Get all cookware for the default variant
3569
+ * const cookware = recipe.getCookwareForVariant();
3570
+ *
3571
+ * // Get cookware for a specific variant
3572
+ * const veganCookware = recipe.getCookwareForVariant({ choices: { variant: 'vegan' } });
3573
+ * ```
3574
+ */
3575
+ getCookwareForVariant(options) {
3576
+ const { choices } = options || {};
3577
+ const activeVariant = choices?.variant;
3578
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3579
+ const cookwareIndices = /* @__PURE__ */ new Set();
3580
+ for (const currentSection of this.sections) {
3581
+ if (currentSection.variants) {
3582
+ if (isDefaultVariant) {
3583
+ if (!currentSection.variants.includes("*")) continue;
3584
+ } else {
3585
+ if (!currentSection.variants.includes(activeVariant)) continue;
3586
+ }
3587
+ }
3588
+ const allSteps = currentSection.content.filter(
3589
+ (item) => item.type === "step"
3590
+ );
3591
+ for (const currentStep of allSteps) {
3592
+ if (currentStep.variants) {
3593
+ if (isDefaultVariant) {
3594
+ if (!currentStep.variants.includes("*")) continue;
3595
+ } else {
3596
+ if (!currentStep.variants.includes(activeVariant)) continue;
3597
+ }
3598
+ }
3599
+ for (const item of currentStep.items) {
3600
+ if (item.type === "cookware") {
3601
+ cookwareIndices.add(item.index);
3602
+ }
3603
+ }
3604
+ }
3605
+ }
3606
+ return this.cookware.filter(
3607
+ (cw, idx) => cookwareIndices.has(idx) && !cw.flags?.includes("hidden")
3608
+ );
3609
+ }
3432
3610
  /**
3433
3611
  * Parses a recipe from a string.
3434
3612
  * @param content - The recipe content to parse.
@@ -3444,9 +3622,14 @@ var _Recipe = class _Recipe {
3444
3622
  const items = [];
3445
3623
  let noteText = "";
3446
3624
  let inNote = false;
3625
+ let stepVariants;
3626
+ let stepOptional;
3627
+ const discoveredVariants = /* @__PURE__ */ new Set();
3447
3628
  for (const line of cleanContent) {
3448
3629
  if (line.trim().length === 0) {
3449
- flushPendingItems(section, items);
3630
+ flushPendingItems(section, items, stepVariants, stepOptional);
3631
+ stepVariants = void 0;
3632
+ stepOptional = void 0;
3450
3633
  flushPendingNote(
3451
3634
  section,
3452
3635
  noteText ? this._parseNoteText(noteText) : []
@@ -3457,26 +3640,42 @@ var _Recipe = class _Recipe {
3457
3640
  continue;
3458
3641
  }
3459
3642
  if (line.startsWith("=")) {
3460
- flushPendingItems(section, items);
3643
+ flushPendingItems(section, items, stepVariants, stepOptional);
3644
+ stepVariants = void 0;
3645
+ stepOptional = void 0;
3461
3646
  flushPendingNote(
3462
3647
  section,
3463
3648
  noteText ? this._parseNoteText(noteText) : []
3464
3649
  );
3465
3650
  noteText = "";
3466
- if (this.sections.length === 0 && section.isBlank()) {
3467
- section.name = line.replace(/^=+|=+$/g, "").trim();
3468
- } else {
3469
- if (!section.isBlank()) {
3470
- this.sections.push(section);
3651
+ let sectionName = line.replace(/^=+|=+$/g, "").trim();
3652
+ let sectionVariants;
3653
+ let sectionOptional;
3654
+ const sectionVarMatch = sectionName.match(variantTagRegex);
3655
+ if (sectionVarMatch?.groups) {
3656
+ const isOptionalPrefix = sectionVarMatch.groups.variantOptionalPrefix === "?";
3657
+ const names = (sectionVarMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3658
+ if (names.length > 0) {
3659
+ sectionVariants = names;
3660
+ for (const v of names) discoveredVariants.add(v);
3661
+ }
3662
+ if (isOptionalPrefix) {
3663
+ sectionOptional = true;
3471
3664
  }
3472
- section = new Section(line.replace(/^=+|=+$/g, "").trim());
3665
+ sectionName = sectionName.slice(sectionVarMatch[0].length).trim();
3473
3666
  }
3667
+ if (!section.isBlank()) {
3668
+ this.sections.push(section);
3669
+ }
3670
+ section = new Section(sectionName, sectionVariants, sectionOptional);
3474
3671
  blankLineBefore = true;
3475
3672
  inNote = false;
3476
3673
  continue;
3477
3674
  }
3478
3675
  if (blankLineBefore && line.startsWith(">")) {
3479
- flushPendingItems(section, items);
3676
+ flushPendingItems(section, items, stepVariants, stepOptional);
3677
+ stepVariants = void 0;
3678
+ stepOptional = void 0;
3480
3679
  noteText = line.substring(1).trim();
3481
3680
  inNote = true;
3482
3681
  blankLineBefore = false;
@@ -3491,11 +3690,31 @@ var _Recipe = class _Recipe {
3491
3690
  blankLineBefore = false;
3492
3691
  continue;
3493
3692
  }
3693
+ let currentLine = line;
3694
+ if (items.length === 0) {
3695
+ const varMatch = currentLine.match(variantTagRegex);
3696
+ if (varMatch?.groups) {
3697
+ const isOptionalPrefix = varMatch.groups.variantOptionalPrefix === "?";
3698
+ const names = (varMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3699
+ if (names.length > 0) {
3700
+ stepVariants = names;
3701
+ for (const v of names) discoveredVariants.add(v);
3702
+ }
3703
+ if (isOptionalPrefix) {
3704
+ stepOptional = true;
3705
+ }
3706
+ currentLine = currentLine.slice(varMatch[0].length);
3707
+ if (currentLine.trim().length === 0) {
3708
+ blankLineBefore = false;
3709
+ continue;
3710
+ }
3711
+ }
3712
+ }
3494
3713
  let cursor = 0;
3495
- for (const match of line.matchAll(tokensRegex)) {
3714
+ for (const match of currentLine.matchAll(tokensRegex)) {
3496
3715
  const idx = match.index;
3497
3716
  if (idx > cursor) {
3498
- items.push(...parseMarkdownSegments(line.slice(cursor, idx)));
3717
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor, idx)));
3499
3718
  }
3500
3719
  const groups = match.groups;
3501
3720
  if (groups.mIngredientName || groups.sIngredientName) {
@@ -3514,7 +3733,7 @@ var _Recipe = class _Recipe {
3514
3733
  if (modifiers !== void 0 && modifiers.includes("-")) {
3515
3734
  flags.push("hidden");
3516
3735
  }
3517
- const quantity = quantityRaw ? parseQuantityInput(quantityRaw) : void 0;
3736
+ const quantity = quantityRaw ? parseQuantityValue(quantityRaw) : void 0;
3518
3737
  const newCookware = {
3519
3738
  name
3520
3739
  };
@@ -3546,7 +3765,7 @@ var _Recipe = class _Recipe {
3546
3765
  throw new Error("Timer missing unit");
3547
3766
  }
3548
3767
  const name = groups.timerName || void 0;
3549
- const duration = parseQuantityInput(durationStr);
3768
+ const duration = parseQuantityValue(durationStr);
3550
3769
  const timerObj = {
3551
3770
  name,
3552
3771
  duration,
@@ -3556,16 +3775,21 @@ var _Recipe = class _Recipe {
3556
3775
  }
3557
3776
  cursor = idx + match[0].length;
3558
3777
  }
3559
- if (cursor < line.length) {
3560
- items.push(...parseMarkdownSegments(line.slice(cursor)));
3778
+ if (cursor < currentLine.length) {
3779
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor)));
3561
3780
  }
3562
3781
  blankLineBefore = false;
3563
3782
  }
3564
- flushPendingItems(section, items);
3783
+ flushPendingItems(section, items, stepVariants, stepOptional);
3565
3784
  flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
3566
3785
  if (!section.isBlank()) {
3567
3786
  this.sections.push(section);
3568
3787
  }
3788
+ const metaVariants = this.metadata.variants ?? [];
3789
+ const allVariants = /* @__PURE__ */ new Set([...metaVariants, ...discoveredVariants]);
3790
+ if (allVariants.size > 0) {
3791
+ this.choices.variants = [...allVariants];
3792
+ }
3569
3793
  this._populateIngredientQuantities();
3570
3794
  }
3571
3795
  /**
@@ -3674,9 +3898,15 @@ var _Recipe = class _Recipe {
3674
3898
  }
3675
3899
  newRecipe._populateIngredientQuantities();
3676
3900
  newRecipe.servings = Big4(originalServings).times(factor).toNumber();
3677
- for (const metaVar of ["servings", "yield", "serves"]) {
3678
- if (newRecipe.metadata[metaVar] && this.metadata[metaVar]) {
3679
- const original = this.metadata[metaVar];
3901
+ for (const metaVar of ["servings", "serves"]) {
3902
+ if (typeof newRecipe.metadata[metaVar] === "number") {
3903
+ newRecipe.metadata[metaVar] = Big4(newRecipe.metadata[metaVar]).times(factor).toNumber();
3904
+ }
3905
+ }
3906
+ if (newRecipe.metadata.yield && this.metadata.yield) {
3907
+ const original = this.metadata.yield;
3908
+ if (original.quantity.type === "fixed" && original.quantity.value.type === "text") {
3909
+ } else {
3680
3910
  const scaledQuantity = multiplyQuantityValue(
3681
3911
  original.quantity,
3682
3912
  factor
@@ -3691,8 +3921,7 @@ var _Recipe = class _Recipe {
3691
3921
  if (optimized.unit) scaled.unit = optimized.unit;
3692
3922
  if (original.textBefore) scaled.textBefore = original.textBefore;
3693
3923
  if (original.textAfter) scaled.textAfter = original.textAfter;
3694
- if (original.text) scaled.text = original.text;
3695
- newRecipe.metadata[metaVar] = scaled;
3924
+ newRecipe.metadata.yield = scaled;
3696
3925
  }
3697
3926
  }
3698
3927
  return newRecipe;
@@ -3876,7 +4105,11 @@ var _Recipe = class _Recipe {
3876
4105
  newRecipe.metadata = deepClone(this.metadata);
3877
4106
  newRecipe.ingredients = deepClone(this.ingredients);
3878
4107
  newRecipe.sections = this.sections.map((section) => {
3879
- const newSection = new Section(section.name);
4108
+ const newSection = new Section(
4109
+ section.name,
4110
+ section.variants,
4111
+ section.optional
4112
+ );
3880
4113
  newSection.content = deepClone(section.content);
3881
4114
  return newSection;
3882
4115
  });
@@ -3905,7 +4138,7 @@ __publicField(_Recipe, "subgroupIndices", /* @__PURE__ */ new WeakMap());
3905
4138
  var Recipe = _Recipe;
3906
4139
 
3907
4140
  // src/classes/shopping_list.ts
3908
- var ShoppingList = class _ShoppingList {
4141
+ var ShoppingList = class {
3909
4142
  /**
3910
4143
  * Creates a new ShoppingList instance
3911
4144
  * @param categoryConfigStr - The category configuration to parse.
@@ -4000,7 +4233,7 @@ var ShoppingList = class _ShoppingList {
4000
4233
  }
4001
4234
  }
4002
4235
  if (numericEntries.length > 1) {
4003
- const ratioMap = _ShoppingList.buildEquivalenceRatioMap(
4236
+ const ratioMap = buildEquivalenceRatioMap(
4004
4237
  getEquivalentUnitsLists(...numericEntries)
4005
4238
  );
4006
4239
  if (Object.keys(ratioMap).length > 0) {
@@ -4057,10 +4290,12 @@ var ShoppingList = class _ShoppingList {
4057
4290
  const pantryHasUnit = pantryExtended.unit !== void 0 && pantryExtended.unit.name !== "";
4058
4291
  const ratioMap = this.equivalenceRatios.get(ingredient.name);
4059
4292
  const unitMismatch = leafHasUnit !== pantryHasUnit && ratioMap !== void 0;
4293
+ const leafDef = normalizeUnit(leaf.unit);
4294
+ const pantryDef = normalizeUnit(pantryExtended.unit?.name);
4060
4295
  if (unitMismatch) {
4061
4296
  const leafUnit = leaf.unit ?? NO_UNIT;
4062
4297
  const pantryUnit = pantryExtended.unit?.name ?? NO_UNIT;
4063
- const ratioFromPantry = ratioMap[leafUnit]?.[pantryUnit];
4298
+ const ratioFromPantry = ratioMap[normalizeUnit(leafUnit)?.name ?? leafUnit]?.[normalizeUnit(pantryUnit)?.name ?? pantryUnit];
4064
4299
  if (ratioFromPantry !== void 0) {
4065
4300
  const pantryValue = getAverageValue(pantryExtended.quantity);
4066
4301
  const leafValue = getAverageValue(ingredientExtended.quantity);
@@ -4075,7 +4310,7 @@ var ShoppingList = class _ShoppingList {
4075
4310
  type: "fixed",
4076
4311
  value: { type: "decimal", decimal: remainingLeafValue }
4077
4312
  };
4078
- const consumedInPantryUnits = ratioFromPantry !== 0 ? subtracted / ratioFromPantry : pantryValue;
4313
+ const consumedInPantryUnits = subtracted / ratioFromPantry;
4079
4314
  const remainingPantryValue = Math.max(
4080
4315
  pantryValue - consumedInPantryUnits,
4081
4316
  0
@@ -4092,9 +4327,10 @@ var ShoppingList = class _ShoppingList {
4092
4327
  };
4093
4328
  continue;
4094
4329
  }
4330
+ } else {
4331
+ continue;
4095
4332
  }
4096
- }
4097
- try {
4333
+ } else if (leafDef && pantryDef && areUnitsConvertible(leafDef, pantryDef) || (leaf.unit ?? "").toLowerCase() === (pantryExtended.unit?.name ?? "").toLowerCase()) {
4098
4334
  const remaining = subtractQuantities(
4099
4335
  ingredientExtended,
4100
4336
  pantryExtended,
@@ -4109,7 +4345,47 @@ var ShoppingList = class _ShoppingList {
4109
4345
  const updated = toPlainUnit(remaining);
4110
4346
  leaf.quantity = updated.quantity;
4111
4347
  leaf.unit = updated.unit;
4112
- } catch {
4348
+ } else if (ratioMap) {
4349
+ const canonicalLeaf = normalizeUnit(leaf.unit)?.name ?? leaf.unit;
4350
+ const leafValue = getAverageValue(ingredientExtended.quantity);
4351
+ const pantryValue = getAverageValue(pantryExtended.quantity);
4352
+ if (typeof leafValue === "number" && typeof pantryValue === "number" && pantryDef) {
4353
+ for (const [equivUnit, ratios] of Object.entries(ratioMap)) {
4354
+ const ratio = ratios[canonicalLeaf];
4355
+ if (ratio === void 0) continue;
4356
+ const equivDef = normalizeUnit(equivUnit);
4357
+ if (!equivDef || !areUnitsConvertible(equivDef, pantryDef))
4358
+ continue;
4359
+ const pantryInEquiv = pantryValue * getToBase(pantryDef) / getToBase(equivDef);
4360
+ const pantryInLeafUnits = pantryInEquiv / ratio;
4361
+ const subtracted = Math.min(pantryInLeafUnits, leafValue);
4362
+ const remainingLeafValue = Math.max(
4363
+ leafValue - pantryInLeafUnits,
4364
+ 0
4365
+ );
4366
+ leaf.quantity = {
4367
+ type: "fixed",
4368
+ value: { type: "decimal", decimal: remainingLeafValue }
4369
+ };
4370
+ const consumedInEquiv = subtracted * ratio;
4371
+ const consumedInPantryUnits = consumedInEquiv * getToBase(equivDef) / getToBase(pantryDef);
4372
+ const remainingPantryValue = Math.max(
4373
+ pantryValue - consumedInPantryUnits,
4374
+ 0
4375
+ );
4376
+ pantryExtended = {
4377
+ quantity: {
4378
+ type: "fixed",
4379
+ value: {
4380
+ type: "decimal",
4381
+ decimal: remainingPantryValue
4382
+ }
4383
+ },
4384
+ ...pantryExtended.unit && { unit: pantryExtended.unit }
4385
+ };
4386
+ break;
4387
+ }
4388
+ }
4113
4389
  }
4114
4390
  }
4115
4391
  if ("and" in entry) {
@@ -4120,8 +4396,8 @@ var ShoppingList = class _ShoppingList {
4120
4396
  entry.and.push(...nonZero);
4121
4397
  const ratioMap = this.equivalenceRatios.get(ingredient.name);
4122
4398
  if (entry.equivalents && ratioMap) {
4123
- const equivUnits = entry.equivalents.map((e2) => e2.unit ?? NO_UNIT);
4124
- entry.equivalents = _ShoppingList.recomputeEquivalents(
4399
+ const equivUnits = entry.equivalents.map((e2) => e2.unit);
4400
+ entry.equivalents = recomputeEquivalents(
4125
4401
  entry.and,
4126
4402
  ratioMap,
4127
4403
  equivUnits
@@ -4132,17 +4408,17 @@ var ShoppingList = class _ShoppingList {
4132
4408
  ingredient.quantities[i2] = {
4133
4409
  quantity: single.quantity,
4134
4410
  ...single.unit && { unit: single.unit },
4135
- ...entry.equivalents && { equivalents: entry.equivalents },
4136
- ...entry.alternatives && { alternatives: entry.alternatives }
4411
+ ...entry.equivalents && { equivalents: entry.equivalents }
4137
4412
  };
4138
4413
  }
4139
4414
  } else if ("equivalents" in entry && entry.equivalents) {
4140
4415
  const ratioMap = this.equivalenceRatios.get(ingredient.name);
4141
4416
  if (ratioMap) {
4142
4417
  const equivUnits = entry.equivalents.map(
4143
- (e2) => e2.unit ?? NO_UNIT
4418
+ (e2) => e2.unit
4419
+ // equivalents always have units
4144
4420
  );
4145
- const recomputed = _ShoppingList.recomputeEquivalents(
4421
+ const recomputed = recomputeEquivalents(
4146
4422
  [entry],
4147
4423
  ratioMap,
4148
4424
  equivUnits
@@ -4165,57 +4441,6 @@ var ShoppingList = class _ShoppingList {
4165
4441
  }
4166
4442
  this.resultingPantry = clonedPantry;
4167
4443
  }
4168
- /**
4169
- * Builds a ratio map from equivalence lists.
4170
- * For each equivalence list, stores ratio = equiv_value / primary_value
4171
- * for every pair of units, so equivalents can be recomputed after
4172
- * pantry subtraction modifies primary quantities.
4173
- */
4174
- static buildEquivalenceRatioMap(unitsLists) {
4175
- const ratioMap = {};
4176
- for (const list of unitsLists) {
4177
- for (const equiv of list) {
4178
- const equivValue = getAverageValue(equiv.quantity);
4179
- for (const primary of list) {
4180
- if (primary === equiv) continue;
4181
- const primaryValue = getAverageValue(primary.quantity);
4182
- const equivUnit = equiv.unit.name;
4183
- const primaryUnit = primary.unit.name;
4184
- ratioMap[equivUnit] ?? (ratioMap[equivUnit] = {});
4185
- ratioMap[equivUnit][primaryUnit] = equivValue / primaryValue;
4186
- }
4187
- }
4188
- }
4189
- return ratioMap;
4190
- }
4191
- /**
4192
- * Recomputes equivalent quantities from current primary values and stored ratios.
4193
- * For each equivalent unit in equivUnits, new_value = Σ (primary_value × ratio[equivUnit][primaryUnit]).
4194
- * Returns undefined if all equivalents compute to zero.
4195
- */
4196
- static recomputeEquivalents(primaries, ratioMap, equivUnits) {
4197
- const equivalents = [];
4198
- for (const equivUnit of equivUnits) {
4199
- const ratios = ratioMap[equivUnit];
4200
- let total = 0;
4201
- for (const primary of primaries) {
4202
- const pUnit = primary.unit ?? NO_UNIT;
4203
- const ratio = ratios[pUnit];
4204
- const pValue = getAverageValue(primary.quantity);
4205
- total += pValue * ratio;
4206
- }
4207
- if (total > 0) {
4208
- equivalents.push({
4209
- quantity: {
4210
- type: "fixed",
4211
- value: { type: "decimal", decimal: total }
4212
- },
4213
- ...equivUnit !== "" && { unit: equivUnit }
4214
- });
4215
- }
4216
- }
4217
- return equivalents.length > 0 ? equivalents : void 0;
4218
- }
4219
4444
  /**
4220
4445
  * Adds a recipe to the shopping list, then automatically
4221
4446
  * recalculates the quantities and recategorize the ingredients.
@@ -4749,12 +4974,52 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4749
4974
  const selectedIndex = choices?.ingredientItems?.get(item.id);
4750
4975
  return alternativeIndex === selectedIndex;
4751
4976
  }
4977
+ function isSectionActive(section, variant) {
4978
+ if (!section.variants) return true;
4979
+ const isDefault = variant === void 0 || variant === "*";
4980
+ if (isDefault) {
4981
+ return section.variants.includes("*");
4982
+ }
4983
+ return section.variants.includes(variant);
4984
+ }
4985
+ function isStepActive(step, variant) {
4986
+ if (!step.variants) return true;
4987
+ const isDefault = variant === void 0 || variant === "*";
4988
+ if (isDefault) {
4989
+ return step.variants.includes("*");
4990
+ }
4991
+ return step.variants.includes(variant);
4992
+ }
4993
+ function getEffectiveChoices(recipe, variant) {
4994
+ const choices = { variant };
4995
+ if (variant === void 0 || variant === "*") return choices;
4996
+ const variantLower = variant.toLowerCase();
4997
+ for (const [itemId, alternatives] of recipe.choices.ingredientItems) {
4998
+ const matchIdx = alternatives.findIndex(
4999
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5000
+ );
5001
+ if (matchIdx >= 0) {
5002
+ if (!choices.ingredientItems) choices.ingredientItems = /* @__PURE__ */ new Map();
5003
+ choices.ingredientItems.set(itemId, matchIdx);
5004
+ }
5005
+ }
5006
+ for (const [groupId, subgroups] of recipe.choices.ingredientGroups) {
5007
+ const matchIdx = subgroups.findIndex(
5008
+ (sg) => sg.some(
5009
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5010
+ )
5011
+ );
5012
+ if (matchIdx >= 0) {
5013
+ if (!choices.ingredientGroups) choices.ingredientGroups = /* @__PURE__ */ new Map();
5014
+ choices.ingredientGroups.set(groupId, matchIdx);
5015
+ }
5016
+ }
5017
+ return choices;
5018
+ }
4752
5019
  export {
4753
- BadIndentationError,
4754
5020
  CategoryConfig,
4755
5021
  NoProductCatalogForCartError,
4756
5022
  NoShoppingListForCartError,
4757
- NoTabAsIndentError,
4758
5023
  Pantry,
4759
5024
  ProductCatalog,
4760
5025
  Recipe,
@@ -4769,11 +5034,14 @@ export {
4769
5034
  formatQuantityWithUnit,
4770
5035
  formatSingleValue,
4771
5036
  formatUnit,
5037
+ getEffectiveChoices,
4772
5038
  hasAlternatives,
4773
5039
  isAlternativeSelected,
4774
5040
  isAndGroup,
4775
5041
  isGroupedItem,
5042
+ isSectionActive,
4776
5043
  isSimpleGroup,
5044
+ isStepActive,
4777
5045
  renderFractionAsVulgar
4778
5046
  };
4779
5047
  /* v8 ignore else -- @preserve */
@@ -4786,6 +5054,9 @@ export {
4786
5054
  // v8 ignore if -- @preserve: defensive type guard
4787
5055
  /* v8 ignore if -- @preserve */
4788
5056
  // v8 ignore next -- @preserve
5057
+ // v8 ignore else -- @preserve: text quantities never reach the equivalence path
4789
5058
  // v8 ignore else --@preserve: defensive type guard
4790
5059
  // v8 ignore else -- @preserve: detection if
5060
+ /* v8 ignore else -- @preserve: only act when there are matches */
5061
+ /* v8 ignore else -- @preserve: initialization pattern */
4791
5062
  //# sourceMappingURL=index.js.map