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

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
@@ -272,13 +272,12 @@ var nestedMetaVarRegex = (varName) => new RegExp(
272
272
  "m"
273
273
  );
274
274
  var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
275
- var scalingMetaValueRegex = (varName) => d().startAnchor().literal(varName).literal(":").anyOf("\\t ").zeroOrMore().startCaptureGroup().startCaptureGroup().notAnyOf(",\\n").oneOrMore().endGroup().startGroup().literal(",").whitespace().zeroOrMore().startCaptureGroup().anyCharacter().oneOrMore().endGroup().endGroup().optional().endGroup().endAnchor().multiline().toRegExp();
276
275
  var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
277
276
  var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
278
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();
279
278
  var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
280
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();
281
- var ingredientWithGroupKeyRegex = d().literal("@|").startNamedGroup("gIngredientGroupKey").notAnyOf(nonWordCharStrict).oneOrMore().endGroup().literal("|").startNamedGroup("gIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("gIngredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("gmIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("gsIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("gIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("gIngredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("gIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
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();
282
281
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
283
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();
284
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();
@@ -293,6 +292,13 @@ var tokensRegex = new RegExp(
293
292
  ].map((r2) => r2.source).join("|"),
294
293
  "gu"
295
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,
300
+ "m"
301
+ );
296
302
  var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
297
303
  var blockCommentRegex = d().literal("[-").anyCharacter().zeroOrMore().lazy().literal("-]").whitespace().zeroOrMore().global().toRegExp();
298
304
  var shoppingListRegex = d().literal("[").startNamedGroup("name").anyCharacter().oneOrMore().endGroup().literal("]").newline().startNamedGroup("items").anyCharacter().zeroOrMore().lazy().endGroup().startGroup().newline().newline().or().endAnchor().endGroup().global().toRegExp();
@@ -1276,17 +1282,21 @@ var flattenPlainUnitGroup = (summed) => {
1276
1282
  }
1277
1283
  };
1278
1284
  function applyBestUnit(q, system) {
1279
- if (!q.unit?.name) {
1285
+ const extended = { quantity: q.quantity };
1286
+ if (q.unit) {
1287
+ extended.unit = typeof q.unit === "string" ? { name: q.unit } : q.unit;
1288
+ }
1289
+ if (!extended.unit?.name) {
1280
1290
  return q;
1281
1291
  }
1282
- const unitDef = resolveUnit(q.unit.name);
1292
+ const unitDef = resolveUnit(extended.unit.name);
1283
1293
  if (unitDef.type === "other") {
1284
1294
  return q;
1285
1295
  }
1286
- if (q.quantity.type === "fixed" && q.quantity.value.type === "text") {
1296
+ if (extended.quantity.type === "fixed" && extended.quantity.value.type === "text") {
1287
1297
  return q;
1288
1298
  }
1289
- const avgValue = getAverageValue(q.quantity);
1299
+ const avgValue = getAverageValue(extended.quantity);
1290
1300
  const effectiveSystem = system ?? (["metric", "JP"].includes(unitDef.system) ? unitDef.system : "US");
1291
1301
  const toBase = getToBase(unitDef, effectiveSystem);
1292
1302
  const valueInBase = avgValue * toBase;
@@ -1296,22 +1306,22 @@ function applyBestUnit(q, system) {
1296
1306
  effectiveSystem,
1297
1307
  [unitDef]
1298
1308
  );
1299
- const originalCanonicalName = normalizeUnit(q.unit.name)?.name;
1309
+ const originalCanonicalName = normalizeUnit(extended.unit.name)?.name;
1300
1310
  if (bestUnit.name === originalCanonicalName) {
1301
1311
  return q;
1302
1312
  }
1303
1313
  const formattedValue = formatOutputValue(bestValue, bestUnit);
1304
- if (q.quantity.type === "range") {
1314
+ if (extended.quantity.type === "range") {
1305
1315
  const bestToBase = getToBase(bestUnit, effectiveSystem);
1306
- const minValue = getNumericValue(q.quantity.min) * toBase / bestToBase;
1307
- const maxValue = getNumericValue(q.quantity.max) * toBase / bestToBase;
1316
+ const minValue = getNumericValue(extended.quantity.min) * toBase / bestToBase;
1317
+ const maxValue = getNumericValue(extended.quantity.max) * toBase / bestToBase;
1308
1318
  return {
1309
1319
  quantity: {
1310
1320
  type: "range",
1311
1321
  min: formatOutputValue(minValue, bestUnit),
1312
1322
  max: formatOutputValue(maxValue, bestUnit)
1313
1323
  },
1314
- unit: { name: bestUnit.name }
1324
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1315
1325
  };
1316
1326
  }
1317
1327
  return {
@@ -1319,7 +1329,7 @@ function applyBestUnit(q, system) {
1319
1329
  type: "fixed",
1320
1330
  value: formattedValue
1321
1331
  },
1322
- unit: { name: bestUnit.name }
1332
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1323
1333
  };
1324
1334
  }
1325
1335
  function subtractQuantities(q1, q2, options = {}) {
@@ -1671,13 +1681,62 @@ function parseBlockScalarMetaVar(content, varName) {
1671
1681
  }
1672
1682
  return stripped.replace(/\n\n/g, "\0").replace(/\n/g, " ").replace(/\0/g, "\n");
1673
1683
  }
1684
+ function parseArbitraryQuantity(raw) {
1685
+ const quantityMatch = raw.trim().match(quantityAlternativeRegex);
1686
+ if (!quantityMatch?.groups) {
1687
+ throw new InvalidQuantityFormat(
1688
+ raw,
1689
+ "Arbitrary quantities must have a numerical value"
1690
+ );
1691
+ }
1692
+ const value = parseQuantityInput(quantityMatch.groups.quantity);
1693
+ const unit = quantityMatch.groups.unit;
1694
+ if (!value || value.type === "fixed" && value.value.type === "text") {
1695
+ throw new InvalidQuantityFormat(
1696
+ raw,
1697
+ "Arbitrary quantities must have a numerical value"
1698
+ );
1699
+ }
1700
+ const arbitrary = {
1701
+ quantity: value
1702
+ };
1703
+ if (unit) arbitrary.unit = unit;
1704
+ return arbitrary;
1705
+ }
1674
1706
  function parseScalingMetaVar(content, varName) {
1675
- const varMatch = content.match(scalingMetaValueRegex(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 = {
1713
+ quantity: parsed.quantity
1714
+ };
1715
+ if (parsed.unit) result2.unit = parsed.unit;
1716
+ if (complexMatch.groups.servingsPrefix) {
1717
+ result2.textBefore = complexMatch.groups.servingsPrefix;
1718
+ }
1719
+ if (complexMatch.groups.servingsSuffix) {
1720
+ result2.textAfter = complexMatch.groups.servingsSuffix;
1721
+ }
1722
+ return result2;
1723
+ }
1724
+ const varMatch = content.match(scalingSimpleMetaValueRegex(varName));
1676
1725
  if (!varMatch) return void 0;
1677
1726
  if (isNaN(Number(varMatch[2]?.trim()))) {
1678
1727
  throw new Error("Scaling variables should be numbers");
1679
1728
  }
1680
- return [Number(varMatch[2]?.trim()), varMatch[1].trim()];
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()}`;
1738
+ }
1739
+ return result;
1681
1740
  }
1682
1741
  function parseListMetaVar(content, varName) {
1683
1742
  const listMatch = content.match(
@@ -1795,6 +1854,13 @@ function parseAnyMetaVar(content, varName) {
1795
1854
  if (simple) return parseMetadataValue(simple);
1796
1855
  return void 0;
1797
1856
  }
1857
+ function getNumericValueFromMetaVar(v) {
1858
+ if (v.quantity.type === "fixed" && v.quantity.value.type !== "text") {
1859
+ return getNumericValue(v.quantity.value);
1860
+ }
1861
+ if (v.quantity.type === "range") return getNumericValue(v.quantity.min);
1862
+ return 0;
1863
+ }
1798
1864
  function extractMetadata(content) {
1799
1865
  const metadata = {};
1800
1866
  let servings = void 0;
@@ -1919,9 +1985,9 @@ function extractMetadata(content) {
1919
1985
  }
1920
1986
  for (const metaVar of ["servings", "yield", "serves"]) {
1921
1987
  const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1922
- if (scalingMetaValue && scalingMetaValue[1]) {
1923
- metadata[metaVar] = scalingMetaValue[1];
1924
- servings = scalingMetaValue[0];
1988
+ if (scalingMetaValue) {
1989
+ metadata[metaVar] = scalingMetaValue;
1990
+ servings = getNumericValueFromMetaVar(scalingMetaValue);
1925
1991
  }
1926
1992
  }
1927
1993
  const tags = parseListMetaVar(metadataContent, "tags");
@@ -2713,6 +2779,7 @@ var _Recipe = class _Recipe {
2713
2779
  */
2714
2780
  __publicField(this, "servings");
2715
2781
  _Recipe.itemCounts.set(this, 0);
2782
+ _Recipe.subgroupIndices.set(this, /* @__PURE__ */ new Map());
2716
2783
  if (content) {
2717
2784
  this.parse(content);
2718
2785
  }
@@ -2748,27 +2815,17 @@ var _Recipe = class _Recipe {
2748
2815
  */
2749
2816
  _parseArbitraryScalable(regexMatchGroups, intoArray) {
2750
2817
  if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
2751
- const quantityMatch = regexMatchGroups.arbitraryQuantity?.trim().match(quantityAlternativeRegex);
2752
- if (quantityMatch?.groups) {
2753
- const value = parseQuantityInput(quantityMatch.groups.quantity);
2754
- const unit = quantityMatch.groups.unit;
2755
- const name = regexMatchGroups.arbitraryName || void 0;
2756
- if (!value || value.type === "fixed" && value.value.type === "text") {
2757
- throw new InvalidQuantityFormat(
2758
- regexMatchGroups.arbitraryQuantity?.trim(),
2759
- "Arbitrary quantities must have a numerical value"
2760
- );
2761
- }
2762
- const arbitrary = {
2763
- quantity: value
2764
- };
2765
- if (name) arbitrary.name = name;
2766
- if (unit) arbitrary.unit = unit;
2767
- intoArray.push({
2768
- type: "arbitrary",
2769
- index: this.arbitraries.push(arbitrary) - 1
2770
- });
2771
- }
2818
+ const parsed = parseArbitraryQuantity(regexMatchGroups.arbitraryQuantity);
2819
+ const name = regexMatchGroups.arbitraryName || void 0;
2820
+ const arbitrary = {
2821
+ quantity: parsed.quantity
2822
+ };
2823
+ if (name) arbitrary.name = name;
2824
+ if (parsed.unit) arbitrary.unit = parsed.unit;
2825
+ intoArray.push({
2826
+ type: "arbitrary",
2827
+ index: this.arbitraries.push(arbitrary) - 1
2828
+ });
2772
2829
  }
2773
2830
  /**
2774
2831
  * Parses text for arbitrary scalables and returns NoteItem array.
@@ -2940,6 +2997,7 @@ var _Recipe = class _Recipe {
2940
2997
  if (!match?.groups) return;
2941
2998
  const groups = match.groups;
2942
2999
  const groupKey = groups.gIngredientGroupKey;
3000
+ const subgroupKey = groups.gIngredientSubgroupKey;
2943
3001
  let name = groups.gmIngredientName || groups.gsIngredientName;
2944
3002
  const preparation = groups.gIngredientPreparation;
2945
3003
  const modifiers = groups.gIngredientModifiers;
@@ -3007,7 +3065,8 @@ var _Recipe = class _Recipe {
3007
3065
  if (itemQuantity) {
3008
3066
  Object.assign(alternative, itemQuantity);
3009
3067
  }
3010
- const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
3068
+ const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3069
+ const existingAlternativesFlat = existingSubgroups?.flat();
3011
3070
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
3012
3071
  const ingredient = ingredients[ingredientIdx];
3013
3072
  if (ingredient) {
@@ -3018,8 +3077,8 @@ var _Recipe = class _Recipe {
3018
3077
  }
3019
3078
  }
3020
3079
  }
3021
- if (existingAlternatives) {
3022
- for (const alt of existingAlternatives) {
3080
+ if (existingAlternativesFlat) {
3081
+ for (const alt of existingAlternativesFlat) {
3023
3082
  upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
3024
3083
  upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
3025
3084
  }
@@ -3031,14 +3090,35 @@ var _Recipe = class _Recipe {
3031
3090
  group: groupKey,
3032
3091
  alternatives: [alternative]
3033
3092
  };
3093
+ if (subgroupKey !== void 0) {
3094
+ newItem.subgroup = subgroupKey;
3095
+ }
3034
3096
  items.push(newItem);
3035
3097
  const choiceAlternative = deepClone(alternative);
3036
3098
  choiceAlternative.itemId = id;
3037
3099
  const existingChoice = this.choices.ingredientGroups.get(groupKey);
3100
+ const sgMap = _Recipe.subgroupIndices.get(this);
3038
3101
  if (!existingChoice) {
3039
- this.choices.ingredientGroups.set(groupKey, [choiceAlternative]);
3102
+ this.choices.ingredientGroups.set(groupKey, [[choiceAlternative]]);
3103
+ if (subgroupKey !== void 0) {
3104
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, 0]]));
3105
+ }
3106
+ } else if (subgroupKey !== void 0) {
3107
+ const groupSgMap = sgMap.get(groupKey);
3108
+ const existingIdx = groupSgMap?.get(subgroupKey);
3109
+ if (existingIdx !== void 0) {
3110
+ existingChoice[existingIdx].push(choiceAlternative);
3111
+ } else {
3112
+ const newIdx = existingChoice.length;
3113
+ existingChoice.push([choiceAlternative]);
3114
+ if (!groupSgMap) {
3115
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, newIdx]]));
3116
+ } else {
3117
+ groupSgMap.set(subgroupKey, newIdx);
3118
+ }
3119
+ }
3040
3120
  } else {
3041
- existingChoice.push(choiceAlternative);
3121
+ existingChoice.push([choiceAlternative]);
3042
3122
  }
3043
3123
  }
3044
3124
  /**
@@ -3095,15 +3175,16 @@ var _Recipe = class _Recipe {
3095
3175
  (item2) => item2.type === "ingredient"
3096
3176
  )) {
3097
3177
  const isGrouped = "group" in item && item.group !== void 0;
3098
- const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3178
+ const groupSubgroups = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3099
3179
  let selectedAltIndex = 0;
3100
- let isSelected = false;
3101
- let hasExplicitChoice = false;
3180
+ let isSelected;
3181
+ let hasExplicitChoice;
3102
3182
  if (isGrouped) {
3103
3183
  const groupChoice = choices?.ingredientGroups?.get(item.group);
3104
3184
  hasExplicitChoice = groupChoice !== void 0;
3105
- const targetIndex = groupChoice ?? 0;
3106
- isSelected = groupAlternatives?.[targetIndex]?.itemId === item.id;
3185
+ const targetSubgroupIndex = groupChoice ?? 0;
3186
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3187
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3107
3188
  } else {
3108
3189
  const itemChoice = choices?.ingredientItems?.get(item.id);
3109
3190
  hasExplicitChoice = itemChoice !== void 0;
@@ -3113,8 +3194,8 @@ var _Recipe = class _Recipe {
3113
3194
  const alternative = item.alternatives[selectedAltIndex];
3114
3195
  if (!alternative || !isSelected) continue;
3115
3196
  selectedIndices.add(alternative.index);
3116
- const allAlts = isGrouped ? groupAlternatives : item.alternatives;
3117
- for (const alt of allAlts) {
3197
+ const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3198
+ for (const alt of allAltsFlat) {
3118
3199
  referencedIndices.add(alt.index);
3119
3200
  }
3120
3201
  if (!alternative.quantity) continue;
@@ -3126,10 +3207,34 @@ var _Recipe = class _Recipe {
3126
3207
  };
3127
3208
  const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
3128
3209
  let alternativeRefs;
3129
- if (!hasExplicitChoice && allAlts.length > 1) {
3130
- alternativeRefs = allAlts.filter(
3131
- (alt) => isGrouped ? alt.itemId !== item.id : alt.index !== alternative.index
3132
- ).map((otherAlt) => {
3210
+ if (!hasExplicitChoice && groupSubgroups && groupSubgroups.length > 1) {
3211
+ const currentSubgroupIdx = groupSubgroups.findIndex(
3212
+ (sg) => sg.some((alt) => alt.itemId === item.id)
3213
+ );
3214
+ alternativeRefs = groupSubgroups.filter((_, idx) => idx !== currentSubgroupIdx).flatMap(
3215
+ (subgroup) => subgroup.map((otherAlt) => {
3216
+ const ref = {
3217
+ index: otherAlt.index
3218
+ };
3219
+ if (otherAlt.quantity) {
3220
+ const altQty = {
3221
+ quantity: otherAlt.quantity,
3222
+ ...otherAlt.unit && {
3223
+ unit: otherAlt.unit.name
3224
+ },
3225
+ ...otherAlt.equivalents && {
3226
+ equivalents: otherAlt.equivalents.map(
3227
+ (eq) => toPlainUnit(eq)
3228
+ )
3229
+ }
3230
+ };
3231
+ ref.quantities = [altQty];
3232
+ }
3233
+ return ref;
3234
+ })
3235
+ );
3236
+ } else if (!hasExplicitChoice && !isGrouped && allAltsFlat.length > 1) {
3237
+ alternativeRefs = allAltsFlat.filter((alt) => alt.index !== alternative.index).map((otherAlt) => {
3133
3238
  const ref = { index: otherAlt.index };
3134
3239
  if (otherAlt.quantity) {
3135
3240
  const altQty = {
@@ -3547,8 +3652,10 @@ var _Recipe = class _Recipe {
3547
3652
  }
3548
3653
  }
3549
3654
  }
3550
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3551
- scaleAlternativesBy(alternatives, factor);
3655
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3656
+ for (const subgroup of subgroups) {
3657
+ scaleAlternativesBy(subgroup, factor);
3658
+ }
3552
3659
  }
3553
3660
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3554
3661
  scaleAlternativesBy(alternatives, factor);
@@ -3558,37 +3665,34 @@ var _Recipe = class _Recipe {
3558
3665
  arbitrary.quantity,
3559
3666
  factor
3560
3667
  );
3668
+ const optimized = applyBestUnit(
3669
+ { quantity: arbitrary.quantity, unit: arbitrary.unit },
3670
+ unitSystem
3671
+ );
3672
+ arbitrary.quantity = optimized.quantity;
3673
+ arbitrary.unit = optimized.unit;
3561
3674
  }
3562
3675
  newRecipe._populateIngredientQuantities();
3563
3676
  newRecipe.servings = Big4(originalServings).times(factor).toNumber();
3564
- if (newRecipe.metadata.servings && this.metadata.servings) {
3565
- if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
3566
- const servingsValue = parseFloat(
3567
- String(this.metadata.servings).replace(",", ".")
3568
- );
3569
- newRecipe.metadata.servings = String(
3570
- Big4(servingsValue).times(factor).toNumber()
3571
- );
3572
- }
3573
- }
3574
- if (newRecipe.metadata.yield && this.metadata.yield) {
3575
- if (floatRegex.test(String(this.metadata.yield).replace(",", ".").trim())) {
3576
- const yieldValue = parseFloat(
3577
- String(this.metadata.yield).replace(",", ".")
3677
+ for (const metaVar of ["servings", "yield", "serves"]) {
3678
+ if (newRecipe.metadata[metaVar] && this.metadata[metaVar]) {
3679
+ const original = this.metadata[metaVar];
3680
+ const scaledQuantity = multiplyQuantityValue(
3681
+ original.quantity,
3682
+ factor
3578
3683
  );
3579
- newRecipe.metadata.yield = String(
3580
- Big4(yieldValue).times(factor).toNumber()
3581
- );
3582
- }
3583
- }
3584
- if (newRecipe.metadata.serves && this.metadata.serves) {
3585
- if (floatRegex.test(String(this.metadata.serves).replace(",", ".").trim())) {
3586
- const servesValue = parseFloat(
3587
- String(this.metadata.serves).replace(",", ".")
3588
- );
3589
- newRecipe.metadata.serves = String(
3590
- Big4(servesValue).times(factor).toNumber()
3684
+ const optimized = applyBestUnit(
3685
+ { quantity: scaledQuantity, unit: original.unit },
3686
+ unitSystem
3591
3687
  );
3688
+ const scaled = {
3689
+ quantity: optimized.quantity
3690
+ };
3691
+ if (optimized.unit) scaled.unit = optimized.unit;
3692
+ if (original.textBefore) scaled.textBefore = original.textBefore;
3693
+ if (original.textAfter) scaled.textAfter = original.textAfter;
3694
+ if (original.text) scaled.text = original.text;
3695
+ newRecipe.metadata[metaVar] = scaled;
3592
3696
  }
3593
3697
  }
3594
3698
  return newRecipe;
@@ -3738,8 +3842,10 @@ var _Recipe = class _Recipe {
3738
3842
  }
3739
3843
  }
3740
3844
  }
3741
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3742
- convertAlternatives(alternatives);
3845
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3846
+ for (const subgroup of subgroups) {
3847
+ convertAlternatives(subgroup);
3848
+ }
3743
3849
  }
3744
3850
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3745
3851
  convertAlternatives(alternatives);
@@ -3791,6 +3897,11 @@ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
3791
3897
  * Used for giving ID numbers to items during parsing.
3792
3898
  */
3793
3899
  __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
3900
+ /**
3901
+ * External storage for subgroup index tracking during parsing.
3902
+ * Maps groupKey → subgroupKey → index within the subgroups array.
3903
+ */
3904
+ __publicField(_Recipe, "subgroupIndices", /* @__PURE__ */ new WeakMap());
3794
3905
  var Recipe = _Recipe;
3795
3906
 
3796
3907
  // src/classes/shopping_list.ts
@@ -4628,10 +4739,10 @@ function isGroupedItem(item) {
4628
4739
  function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4629
4740
  if (item.group) {
4630
4741
  const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
4631
- const groupAlternatives = recipe.choices.ingredientGroups.get(item.group);
4632
- if (groupAlternatives && selectedIndex2 !== void 0 && selectedIndex2 < groupAlternatives.length) {
4633
- const selectedItemId = groupAlternatives[selectedIndex2]?.itemId;
4634
- return selectedItemId === item.id;
4742
+ const groupSubgroups = recipe.choices.ingredientGroups.get(item.group);
4743
+ if (groupSubgroups && selectedIndex2 !== void 0 && selectedIndex2 < groupSubgroups.length) {
4744
+ const selectedSubgroup = groupSubgroups[selectedIndex2];
4745
+ return selectedSubgroup?.some((alt) => alt.itemId === item.id);
4635
4746
  }
4636
4747
  return false;
4637
4748
  }
@@ -4671,6 +4782,7 @@ export {
4671
4782
  // v8 ignore else -- @preserve
4672
4783
  // v8 ignore if -- @preserve
4673
4784
  /* v8 ignore else -- expliciting error type -- @preserve */
4785
+ /* v8 ignore next 4 -- @preserve: defensive guard; regex always matches */
4674
4786
  // v8 ignore if -- @preserve: defensive type guard
4675
4787
  /* v8 ignore if -- @preserve */
4676
4788
  // v8 ignore next -- @preserve