@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.cjs CHANGED
@@ -330,13 +330,12 @@ var nestedMetaVarRegex = (varName) => new RegExp(
330
330
  "m"
331
331
  );
332
332
  var metadataRegex = d().literal("---").newline().startCaptureGroup().anyCharacter().zeroOrMore().optional().endGroup().newline().literal("---").dotAll().toRegExp();
333
- 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();
334
333
  var nonWordChar = "\\s@#~\\[\\]{(,;:!?";
335
334
  var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
336
335
  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();
337
336
  var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
338
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();
339
- var ingredientWithGroupKeyRegex = d().literal("@|").startNamedGroup("gIngredientGroupKey").notAnyOf(nonWordCharStrict).oneOrMore().endGroup().literal("|").startNamedGroup("gIngredientModifiers").anyOf("@\\-&?").zeroOrMore().endGroup().optional().startNamedGroup("gIngredientRecipeAnchor").literal("./").endGroup().optional().startGroup().startGroup().startNamedGroup("gmIngredientName").notAnyOf(nonWordChar).oneOrMore().startGroup().whitespace().oneOrMore().notAnyOf(nonWordChar).oneOrMore().endGroup().oneOrMore().endGroup().positiveLookahead("\\s*(?:\\{[^\\}]*\\}|\\([^)]*\\))").endGroup().or().startNamedGroup("gsIngredientName").notAnyOf(nonWordChar).zeroOrMore().notAnyOf("\\." + nonWordChar).endGroup().endGroup().startGroup().literal("{").startNamedGroup("gIngredientQuantityModifier").literal("=").exactly(1).endGroup().optional().startNamedGroup("gIngredientQuantity").startGroup().notAnyOf("}|%").oneOrMore().endGroup().optional().startGroup().literal("%").notAnyOf("|}").oneOrMore().lazy().endGroup().optional().startGroup().literal("|").notAnyOf("}").oneOrMore().lazy().endGroup().zeroOrMore().endGroup().literal("}").endGroup().optional().startGroup().literal("(").startNamedGroup("gIngredientPreparation").notAnyOf(")").oneOrMore().lazy().endGroup().literal(")").endGroup().optional().toRegExp();
338
+ var 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();
340
339
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
341
340
  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();
342
341
  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();
@@ -351,6 +350,13 @@ var tokensRegex = new RegExp(
351
350
  ].map((r2) => r2.source).join("|"),
352
351
  "gu"
353
352
  );
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,
358
+ "m"
359
+ );
354
360
  var commentRegex = d().literal("--").anyCharacter().zeroOrMore().global().toRegExp();
355
361
  var blockCommentRegex = d().literal("[-").anyCharacter().zeroOrMore().lazy().literal("-]").whitespace().zeroOrMore().global().toRegExp();
356
362
  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();
@@ -1334,17 +1340,21 @@ var flattenPlainUnitGroup = (summed) => {
1334
1340
  }
1335
1341
  };
1336
1342
  function applyBestUnit(q, system) {
1337
- if (!q.unit?.name) {
1343
+ const extended = { quantity: q.quantity };
1344
+ if (q.unit) {
1345
+ extended.unit = typeof q.unit === "string" ? { name: q.unit } : q.unit;
1346
+ }
1347
+ if (!extended.unit?.name) {
1338
1348
  return q;
1339
1349
  }
1340
- const unitDef = resolveUnit(q.unit.name);
1350
+ const unitDef = resolveUnit(extended.unit.name);
1341
1351
  if (unitDef.type === "other") {
1342
1352
  return q;
1343
1353
  }
1344
- if (q.quantity.type === "fixed" && q.quantity.value.type === "text") {
1354
+ if (extended.quantity.type === "fixed" && extended.quantity.value.type === "text") {
1345
1355
  return q;
1346
1356
  }
1347
- const avgValue = getAverageValue(q.quantity);
1357
+ const avgValue = getAverageValue(extended.quantity);
1348
1358
  const effectiveSystem = system ?? (["metric", "JP"].includes(unitDef.system) ? unitDef.system : "US");
1349
1359
  const toBase = getToBase(unitDef, effectiveSystem);
1350
1360
  const valueInBase = avgValue * toBase;
@@ -1354,22 +1364,22 @@ function applyBestUnit(q, system) {
1354
1364
  effectiveSystem,
1355
1365
  [unitDef]
1356
1366
  );
1357
- const originalCanonicalName = normalizeUnit(q.unit.name)?.name;
1367
+ const originalCanonicalName = normalizeUnit(extended.unit.name)?.name;
1358
1368
  if (bestUnit.name === originalCanonicalName) {
1359
1369
  return q;
1360
1370
  }
1361
1371
  const formattedValue = formatOutputValue(bestValue, bestUnit);
1362
- if (q.quantity.type === "range") {
1372
+ if (extended.quantity.type === "range") {
1363
1373
  const bestToBase = getToBase(bestUnit, effectiveSystem);
1364
- const minValue = getNumericValue(q.quantity.min) * toBase / bestToBase;
1365
- const maxValue = getNumericValue(q.quantity.max) * toBase / bestToBase;
1374
+ const minValue = getNumericValue(extended.quantity.min) * toBase / bestToBase;
1375
+ const maxValue = getNumericValue(extended.quantity.max) * toBase / bestToBase;
1366
1376
  return {
1367
1377
  quantity: {
1368
1378
  type: "range",
1369
1379
  min: formatOutputValue(minValue, bestUnit),
1370
1380
  max: formatOutputValue(maxValue, bestUnit)
1371
1381
  },
1372
- unit: { name: bestUnit.name }
1382
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1373
1383
  };
1374
1384
  }
1375
1385
  return {
@@ -1377,7 +1387,7 @@ function applyBestUnit(q, system) {
1377
1387
  type: "fixed",
1378
1388
  value: formattedValue
1379
1389
  },
1380
- unit: { name: bestUnit.name }
1390
+ unit: typeof q.unit === "string" ? bestUnit.name : { name: bestUnit.name }
1381
1391
  };
1382
1392
  }
1383
1393
  function subtractQuantities(q1, q2, options = {}) {
@@ -1729,13 +1739,62 @@ function parseBlockScalarMetaVar(content, varName) {
1729
1739
  }
1730
1740
  return stripped.replace(/\n\n/g, "\0").replace(/\n/g, " ").replace(/\0/g, "\n");
1731
1741
  }
1742
+ function parseArbitraryQuantity(raw) {
1743
+ const quantityMatch = raw.trim().match(quantityAlternativeRegex);
1744
+ if (!quantityMatch?.groups) {
1745
+ throw new InvalidQuantityFormat(
1746
+ raw,
1747
+ "Arbitrary quantities must have a numerical value"
1748
+ );
1749
+ }
1750
+ const value = parseQuantityInput(quantityMatch.groups.quantity);
1751
+ const unit = quantityMatch.groups.unit;
1752
+ if (!value || value.type === "fixed" && value.value.type === "text") {
1753
+ throw new InvalidQuantityFormat(
1754
+ raw,
1755
+ "Arbitrary quantities must have a numerical value"
1756
+ );
1757
+ }
1758
+ const arbitrary = {
1759
+ quantity: value
1760
+ };
1761
+ if (unit) arbitrary.unit = unit;
1762
+ return arbitrary;
1763
+ }
1732
1764
  function parseScalingMetaVar(content, varName) {
1733
- const varMatch = content.match(scalingMetaValueRegex(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 = {
1771
+ quantity: parsed.quantity
1772
+ };
1773
+ if (parsed.unit) result2.unit = parsed.unit;
1774
+ if (complexMatch.groups.servingsPrefix) {
1775
+ result2.textBefore = complexMatch.groups.servingsPrefix;
1776
+ }
1777
+ if (complexMatch.groups.servingsSuffix) {
1778
+ result2.textAfter = complexMatch.groups.servingsSuffix;
1779
+ }
1780
+ return result2;
1781
+ }
1782
+ const varMatch = content.match(scalingSimpleMetaValueRegex(varName));
1734
1783
  if (!varMatch) return void 0;
1735
1784
  if (isNaN(Number(varMatch[2]?.trim()))) {
1736
1785
  throw new Error("Scaling variables should be numbers");
1737
1786
  }
1738
- return [Number(varMatch[2]?.trim()), varMatch[1].trim()];
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()}`;
1796
+ }
1797
+ return result;
1739
1798
  }
1740
1799
  function parseListMetaVar(content, varName) {
1741
1800
  const listMatch = content.match(
@@ -1853,6 +1912,13 @@ function parseAnyMetaVar(content, varName) {
1853
1912
  if (simple) return parseMetadataValue(simple);
1854
1913
  return void 0;
1855
1914
  }
1915
+ function getNumericValueFromMetaVar(v) {
1916
+ if (v.quantity.type === "fixed" && v.quantity.value.type !== "text") {
1917
+ return getNumericValue(v.quantity.value);
1918
+ }
1919
+ if (v.quantity.type === "range") return getNumericValue(v.quantity.min);
1920
+ return 0;
1921
+ }
1856
1922
  function extractMetadata(content) {
1857
1923
  const metadata = {};
1858
1924
  let servings = void 0;
@@ -1977,9 +2043,9 @@ function extractMetadata(content) {
1977
2043
  }
1978
2044
  for (const metaVar of ["servings", "yield", "serves"]) {
1979
2045
  const scalingMetaValue = parseScalingMetaVar(metadataContent, metaVar);
1980
- if (scalingMetaValue && scalingMetaValue[1]) {
1981
- metadata[metaVar] = scalingMetaValue[1];
1982
- servings = scalingMetaValue[0];
2046
+ if (scalingMetaValue) {
2047
+ metadata[metaVar] = scalingMetaValue;
2048
+ servings = getNumericValueFromMetaVar(scalingMetaValue);
1983
2049
  }
1984
2050
  }
1985
2051
  const tags = parseListMetaVar(metadataContent, "tags");
@@ -2771,6 +2837,7 @@ var _Recipe = class _Recipe {
2771
2837
  */
2772
2838
  __publicField(this, "servings");
2773
2839
  _Recipe.itemCounts.set(this, 0);
2840
+ _Recipe.subgroupIndices.set(this, /* @__PURE__ */ new Map());
2774
2841
  if (content) {
2775
2842
  this.parse(content);
2776
2843
  }
@@ -2806,27 +2873,17 @@ var _Recipe = class _Recipe {
2806
2873
  */
2807
2874
  _parseArbitraryScalable(regexMatchGroups, intoArray) {
2808
2875
  if (!regexMatchGroups || !regexMatchGroups.arbitraryQuantity) return;
2809
- const quantityMatch = regexMatchGroups.arbitraryQuantity?.trim().match(quantityAlternativeRegex);
2810
- if (quantityMatch?.groups) {
2811
- const value = parseQuantityInput(quantityMatch.groups.quantity);
2812
- const unit = quantityMatch.groups.unit;
2813
- const name = regexMatchGroups.arbitraryName || void 0;
2814
- if (!value || value.type === "fixed" && value.value.type === "text") {
2815
- throw new InvalidQuantityFormat(
2816
- regexMatchGroups.arbitraryQuantity?.trim(),
2817
- "Arbitrary quantities must have a numerical value"
2818
- );
2819
- }
2820
- const arbitrary = {
2821
- quantity: value
2822
- };
2823
- if (name) arbitrary.name = name;
2824
- if (unit) arbitrary.unit = unit;
2825
- intoArray.push({
2826
- type: "arbitrary",
2827
- index: this.arbitraries.push(arbitrary) - 1
2828
- });
2829
- }
2876
+ const parsed = parseArbitraryQuantity(regexMatchGroups.arbitraryQuantity);
2877
+ const name = regexMatchGroups.arbitraryName || void 0;
2878
+ const arbitrary = {
2879
+ quantity: parsed.quantity
2880
+ };
2881
+ if (name) arbitrary.name = name;
2882
+ if (parsed.unit) arbitrary.unit = parsed.unit;
2883
+ intoArray.push({
2884
+ type: "arbitrary",
2885
+ index: this.arbitraries.push(arbitrary) - 1
2886
+ });
2830
2887
  }
2831
2888
  /**
2832
2889
  * Parses text for arbitrary scalables and returns NoteItem array.
@@ -2998,6 +3055,7 @@ var _Recipe = class _Recipe {
2998
3055
  if (!match?.groups) return;
2999
3056
  const groups = match.groups;
3000
3057
  const groupKey = groups.gIngredientGroupKey;
3058
+ const subgroupKey = groups.gIngredientSubgroupKey;
3001
3059
  let name = groups.gmIngredientName || groups.gsIngredientName;
3002
3060
  const preparation = groups.gIngredientPreparation;
3003
3061
  const modifiers = groups.gIngredientModifiers;
@@ -3065,7 +3123,8 @@ var _Recipe = class _Recipe {
3065
3123
  if (itemQuantity) {
3066
3124
  Object.assign(alternative, itemQuantity);
3067
3125
  }
3068
- const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
3126
+ const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3127
+ const existingAlternativesFlat = existingSubgroups?.flat();
3069
3128
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
3070
3129
  const ingredient = ingredients[ingredientIdx];
3071
3130
  if (ingredient) {
@@ -3076,8 +3135,8 @@ var _Recipe = class _Recipe {
3076
3135
  }
3077
3136
  }
3078
3137
  }
3079
- if (existingAlternatives) {
3080
- for (const alt of existingAlternatives) {
3138
+ if (existingAlternativesFlat) {
3139
+ for (const alt of existingAlternativesFlat) {
3081
3140
  upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
3082
3141
  upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
3083
3142
  }
@@ -3089,14 +3148,35 @@ var _Recipe = class _Recipe {
3089
3148
  group: groupKey,
3090
3149
  alternatives: [alternative]
3091
3150
  };
3151
+ if (subgroupKey !== void 0) {
3152
+ newItem.subgroup = subgroupKey;
3153
+ }
3092
3154
  items.push(newItem);
3093
3155
  const choiceAlternative = deepClone(alternative);
3094
3156
  choiceAlternative.itemId = id;
3095
3157
  const existingChoice = this.choices.ingredientGroups.get(groupKey);
3158
+ const sgMap = _Recipe.subgroupIndices.get(this);
3096
3159
  if (!existingChoice) {
3097
- this.choices.ingredientGroups.set(groupKey, [choiceAlternative]);
3160
+ this.choices.ingredientGroups.set(groupKey, [[choiceAlternative]]);
3161
+ if (subgroupKey !== void 0) {
3162
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, 0]]));
3163
+ }
3164
+ } else if (subgroupKey !== void 0) {
3165
+ const groupSgMap = sgMap.get(groupKey);
3166
+ const existingIdx = groupSgMap?.get(subgroupKey);
3167
+ if (existingIdx !== void 0) {
3168
+ existingChoice[existingIdx].push(choiceAlternative);
3169
+ } else {
3170
+ const newIdx = existingChoice.length;
3171
+ existingChoice.push([choiceAlternative]);
3172
+ if (!groupSgMap) {
3173
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, newIdx]]));
3174
+ } else {
3175
+ groupSgMap.set(subgroupKey, newIdx);
3176
+ }
3177
+ }
3098
3178
  } else {
3099
- existingChoice.push(choiceAlternative);
3179
+ existingChoice.push([choiceAlternative]);
3100
3180
  }
3101
3181
  }
3102
3182
  /**
@@ -3153,15 +3233,16 @@ var _Recipe = class _Recipe {
3153
3233
  (item2) => item2.type === "ingredient"
3154
3234
  )) {
3155
3235
  const isGrouped = "group" in item && item.group !== void 0;
3156
- const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3236
+ const groupSubgroups = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3157
3237
  let selectedAltIndex = 0;
3158
- let isSelected = false;
3159
- let hasExplicitChoice = false;
3238
+ let isSelected;
3239
+ let hasExplicitChoice;
3160
3240
  if (isGrouped) {
3161
3241
  const groupChoice = choices?.ingredientGroups?.get(item.group);
3162
3242
  hasExplicitChoice = groupChoice !== void 0;
3163
- const targetIndex = groupChoice ?? 0;
3164
- isSelected = groupAlternatives?.[targetIndex]?.itemId === item.id;
3243
+ const targetSubgroupIndex = groupChoice ?? 0;
3244
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3245
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3165
3246
  } else {
3166
3247
  const itemChoice = choices?.ingredientItems?.get(item.id);
3167
3248
  hasExplicitChoice = itemChoice !== void 0;
@@ -3171,8 +3252,8 @@ var _Recipe = class _Recipe {
3171
3252
  const alternative = item.alternatives[selectedAltIndex];
3172
3253
  if (!alternative || !isSelected) continue;
3173
3254
  selectedIndices.add(alternative.index);
3174
- const allAlts = isGrouped ? groupAlternatives : item.alternatives;
3175
- for (const alt of allAlts) {
3255
+ const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3256
+ for (const alt of allAltsFlat) {
3176
3257
  referencedIndices.add(alt.index);
3177
3258
  }
3178
3259
  if (!alternative.quantity) continue;
@@ -3184,10 +3265,34 @@ var _Recipe = class _Recipe {
3184
3265
  };
3185
3266
  const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
3186
3267
  let alternativeRefs;
3187
- if (!hasExplicitChoice && allAlts.length > 1) {
3188
- alternativeRefs = allAlts.filter(
3189
- (alt) => isGrouped ? alt.itemId !== item.id : alt.index !== alternative.index
3190
- ).map((otherAlt) => {
3268
+ if (!hasExplicitChoice && groupSubgroups && groupSubgroups.length > 1) {
3269
+ const currentSubgroupIdx = groupSubgroups.findIndex(
3270
+ (sg) => sg.some((alt) => alt.itemId === item.id)
3271
+ );
3272
+ alternativeRefs = groupSubgroups.filter((_, idx) => idx !== currentSubgroupIdx).flatMap(
3273
+ (subgroup) => subgroup.map((otherAlt) => {
3274
+ const ref = {
3275
+ index: otherAlt.index
3276
+ };
3277
+ if (otherAlt.quantity) {
3278
+ const altQty = {
3279
+ quantity: otherAlt.quantity,
3280
+ ...otherAlt.unit && {
3281
+ unit: otherAlt.unit.name
3282
+ },
3283
+ ...otherAlt.equivalents && {
3284
+ equivalents: otherAlt.equivalents.map(
3285
+ (eq) => toPlainUnit(eq)
3286
+ )
3287
+ }
3288
+ };
3289
+ ref.quantities = [altQty];
3290
+ }
3291
+ return ref;
3292
+ })
3293
+ );
3294
+ } else if (!hasExplicitChoice && !isGrouped && allAltsFlat.length > 1) {
3295
+ alternativeRefs = allAltsFlat.filter((alt) => alt.index !== alternative.index).map((otherAlt) => {
3191
3296
  const ref = { index: otherAlt.index };
3192
3297
  if (otherAlt.quantity) {
3193
3298
  const altQty = {
@@ -3605,8 +3710,10 @@ var _Recipe = class _Recipe {
3605
3710
  }
3606
3711
  }
3607
3712
  }
3608
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3609
- scaleAlternativesBy(alternatives, factor);
3713
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3714
+ for (const subgroup of subgroups) {
3715
+ scaleAlternativesBy(subgroup, factor);
3716
+ }
3610
3717
  }
3611
3718
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3612
3719
  scaleAlternativesBy(alternatives, factor);
@@ -3616,37 +3723,34 @@ var _Recipe = class _Recipe {
3616
3723
  arbitrary.quantity,
3617
3724
  factor
3618
3725
  );
3726
+ const optimized = applyBestUnit(
3727
+ { quantity: arbitrary.quantity, unit: arbitrary.unit },
3728
+ unitSystem
3729
+ );
3730
+ arbitrary.quantity = optimized.quantity;
3731
+ arbitrary.unit = optimized.unit;
3619
3732
  }
3620
3733
  newRecipe._populateIngredientQuantities();
3621
3734
  newRecipe.servings = (0, import_big4.default)(originalServings).times(factor).toNumber();
3622
- if (newRecipe.metadata.servings && this.metadata.servings) {
3623
- if (floatRegex.test(String(this.metadata.servings).replace(",", ".").trim())) {
3624
- const servingsValue = parseFloat(
3625
- String(this.metadata.servings).replace(",", ".")
3626
- );
3627
- newRecipe.metadata.servings = String(
3628
- (0, import_big4.default)(servingsValue).times(factor).toNumber()
3629
- );
3630
- }
3631
- }
3632
- if (newRecipe.metadata.yield && this.metadata.yield) {
3633
- if (floatRegex.test(String(this.metadata.yield).replace(",", ".").trim())) {
3634
- const yieldValue = parseFloat(
3635
- String(this.metadata.yield).replace(",", ".")
3735
+ for (const metaVar of ["servings", "yield", "serves"]) {
3736
+ if (newRecipe.metadata[metaVar] && this.metadata[metaVar]) {
3737
+ const original = this.metadata[metaVar];
3738
+ const scaledQuantity = multiplyQuantityValue(
3739
+ original.quantity,
3740
+ factor
3636
3741
  );
3637
- newRecipe.metadata.yield = String(
3638
- (0, import_big4.default)(yieldValue).times(factor).toNumber()
3639
- );
3640
- }
3641
- }
3642
- if (newRecipe.metadata.serves && this.metadata.serves) {
3643
- if (floatRegex.test(String(this.metadata.serves).replace(",", ".").trim())) {
3644
- const servesValue = parseFloat(
3645
- String(this.metadata.serves).replace(",", ".")
3646
- );
3647
- newRecipe.metadata.serves = String(
3648
- (0, import_big4.default)(servesValue).times(factor).toNumber()
3742
+ const optimized = applyBestUnit(
3743
+ { quantity: scaledQuantity, unit: original.unit },
3744
+ unitSystem
3649
3745
  );
3746
+ const scaled = {
3747
+ quantity: optimized.quantity
3748
+ };
3749
+ if (optimized.unit) scaled.unit = optimized.unit;
3750
+ if (original.textBefore) scaled.textBefore = original.textBefore;
3751
+ if (original.textAfter) scaled.textAfter = original.textAfter;
3752
+ if (original.text) scaled.text = original.text;
3753
+ newRecipe.metadata[metaVar] = scaled;
3650
3754
  }
3651
3755
  }
3652
3756
  return newRecipe;
@@ -3796,8 +3900,10 @@ var _Recipe = class _Recipe {
3796
3900
  }
3797
3901
  }
3798
3902
  }
3799
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3800
- convertAlternatives(alternatives);
3903
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3904
+ for (const subgroup of subgroups) {
3905
+ convertAlternatives(subgroup);
3906
+ }
3801
3907
  }
3802
3908
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3803
3909
  convertAlternatives(alternatives);
@@ -3849,6 +3955,11 @@ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
3849
3955
  * Used for giving ID numbers to items during parsing.
3850
3956
  */
3851
3957
  __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
3958
+ /**
3959
+ * External storage for subgroup index tracking during parsing.
3960
+ * Maps groupKey → subgroupKey → index within the subgroups array.
3961
+ */
3962
+ __publicField(_Recipe, "subgroupIndices", /* @__PURE__ */ new WeakMap());
3852
3963
  var Recipe = _Recipe;
3853
3964
 
3854
3965
  // src/classes/shopping_list.ts
@@ -4686,10 +4797,10 @@ function isGroupedItem(item) {
4686
4797
  function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4687
4798
  if (item.group) {
4688
4799
  const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
4689
- const groupAlternatives = recipe.choices.ingredientGroups.get(item.group);
4690
- if (groupAlternatives && selectedIndex2 !== void 0 && selectedIndex2 < groupAlternatives.length) {
4691
- const selectedItemId = groupAlternatives[selectedIndex2]?.itemId;
4692
- return selectedItemId === item.id;
4800
+ const groupSubgroups = recipe.choices.ingredientGroups.get(item.group);
4801
+ if (groupSubgroups && selectedIndex2 !== void 0 && selectedIndex2 < groupSubgroups.length) {
4802
+ const selectedSubgroup = groupSubgroups[selectedIndex2];
4803
+ return selectedSubgroup?.some((alt) => alt.itemId === item.id);
4693
4804
  }
4694
4805
  return false;
4695
4806
  }
@@ -4730,6 +4841,7 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4730
4841
  // v8 ignore else -- @preserve
4731
4842
  // v8 ignore if -- @preserve
4732
4843
  /* v8 ignore else -- expliciting error type -- @preserve */
4844
+ /* v8 ignore next 4 -- @preserve: defensive guard; regex always matches */
4733
4845
  // v8 ignore if -- @preserve: defensive type guard
4734
4846
  /* v8 ignore if -- @preserve */
4735
4847
  // v8 ignore next -- @preserve