@tmlmt/cooklang-parser 3.0.0-alpha.16 → 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.d.cts CHANGED
@@ -114,6 +114,11 @@ declare class Recipe {
114
114
  * Used for giving ID numbers to items during parsing.
115
115
  */
116
116
  private static itemCounts;
117
+ /**
118
+ * External storage for subgroup index tracking during parsing.
119
+ * Maps groupKey → subgroupKey → index within the subgroups array.
120
+ */
121
+ private static subgroupIndices;
117
122
  /**
118
123
  * Gets the current item count for this recipe.
119
124
  */
@@ -613,6 +618,12 @@ interface IngredientItem {
613
618
  * `@|group|...` syntax), they represent a single logical choice.
614
619
  */
615
620
  group?: string;
621
+ /**
622
+ * An optional subgroup identifier for binding multiple ingredients together
623
+ * within a group. Ingredients sharing the same `group` and `subgroup` are
624
+ * selected together as a unit (e.g., from `@|group/1|...` syntax).
625
+ */
626
+ subgroup?: string;
616
627
  }
617
628
  /**
618
629
  * Represents the choices one can make in a recipe
@@ -626,9 +637,13 @@ interface RecipeAlternatives {
626
637
  ingredientItems: Map<string, IngredientAlternative[]>;
627
638
  /** Map of choices that can be made for Grouped Ingredient StepItem's
628
639
  * - Keys are the Group IDs (e.g. "eggs" for `@|eggs|...`)
629
- * - Values are arrays of IngredientAlternative objects representing the choices available for that group
640
+ * - Values are arrays of subgroups, where each subgroup is an array of
641
+ * bound IngredientAlternative objects that are selected together.
642
+ * Items sharing the same subgroup key (e.g., `@|group/1|...`) are
643
+ * in the same inner array. Items without a subgroup key each form
644
+ * their own single-element subgroup.
630
645
  */
631
- ingredientGroups: Map<string, IngredientAlternative[]>;
646
+ ingredientGroups: Map<string, IngredientAlternative[][]>;
632
647
  }
633
648
  /**
634
649
  * Represents the choices to apply when computing ingredient quantities.
package/dist/index.d.ts CHANGED
@@ -114,6 +114,11 @@ declare class Recipe {
114
114
  * Used for giving ID numbers to items during parsing.
115
115
  */
116
116
  private static itemCounts;
117
+ /**
118
+ * External storage for subgroup index tracking during parsing.
119
+ * Maps groupKey → subgroupKey → index within the subgroups array.
120
+ */
121
+ private static subgroupIndices;
117
122
  /**
118
123
  * Gets the current item count for this recipe.
119
124
  */
@@ -613,6 +618,12 @@ interface IngredientItem {
613
618
  * `@|group|...` syntax), they represent a single logical choice.
614
619
  */
615
620
  group?: string;
621
+ /**
622
+ * An optional subgroup identifier for binding multiple ingredients together
623
+ * within a group. Ingredients sharing the same `group` and `subgroup` are
624
+ * selected together as a unit (e.g., from `@|group/1|...` syntax).
625
+ */
626
+ subgroup?: string;
616
627
  }
617
628
  /**
618
629
  * Represents the choices one can make in a recipe
@@ -626,9 +637,13 @@ interface RecipeAlternatives {
626
637
  ingredientItems: Map<string, IngredientAlternative[]>;
627
638
  /** Map of choices that can be made for Grouped Ingredient StepItem's
628
639
  * - Keys are the Group IDs (e.g. "eggs" for `@|eggs|...`)
629
- * - Values are arrays of IngredientAlternative objects representing the choices available for that group
640
+ * - Values are arrays of subgroups, where each subgroup is an array of
641
+ * bound IngredientAlternative objects that are selected together.
642
+ * Items sharing the same subgroup key (e.g., `@|group/1|...`) are
643
+ * in the same inner array. Items without a subgroup key each form
644
+ * their own single-element subgroup.
630
645
  */
631
- ingredientGroups: Map<string, IngredientAlternative[]>;
646
+ ingredientGroups: Map<string, IngredientAlternative[][]>;
632
647
  }
633
648
  /**
634
649
  * Represents the choices to apply when computing ingredient quantities.
package/dist/index.js CHANGED
@@ -277,7 +277,7 @@ 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
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().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();
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();
@@ -2779,6 +2779,7 @@ var _Recipe = class _Recipe {
2779
2779
  */
2780
2780
  __publicField(this, "servings");
2781
2781
  _Recipe.itemCounts.set(this, 0);
2782
+ _Recipe.subgroupIndices.set(this, /* @__PURE__ */ new Map());
2782
2783
  if (content) {
2783
2784
  this.parse(content);
2784
2785
  }
@@ -2996,6 +2997,7 @@ var _Recipe = class _Recipe {
2996
2997
  if (!match?.groups) return;
2997
2998
  const groups = match.groups;
2998
2999
  const groupKey = groups.gIngredientGroupKey;
3000
+ const subgroupKey = groups.gIngredientSubgroupKey;
2999
3001
  let name = groups.gmIngredientName || groups.gsIngredientName;
3000
3002
  const preparation = groups.gIngredientPreparation;
3001
3003
  const modifiers = groups.gIngredientModifiers;
@@ -3063,7 +3065,8 @@ var _Recipe = class _Recipe {
3063
3065
  if (itemQuantity) {
3064
3066
  Object.assign(alternative, itemQuantity);
3065
3067
  }
3066
- const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
3068
+ const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3069
+ const existingAlternativesFlat = existingSubgroups?.flat();
3067
3070
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
3068
3071
  const ingredient = ingredients[ingredientIdx];
3069
3072
  if (ingredient) {
@@ -3074,8 +3077,8 @@ var _Recipe = class _Recipe {
3074
3077
  }
3075
3078
  }
3076
3079
  }
3077
- if (existingAlternatives) {
3078
- for (const alt of existingAlternatives) {
3080
+ if (existingAlternativesFlat) {
3081
+ for (const alt of existingAlternativesFlat) {
3079
3082
  upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
3080
3083
  upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
3081
3084
  }
@@ -3087,14 +3090,35 @@ var _Recipe = class _Recipe {
3087
3090
  group: groupKey,
3088
3091
  alternatives: [alternative]
3089
3092
  };
3093
+ if (subgroupKey !== void 0) {
3094
+ newItem.subgroup = subgroupKey;
3095
+ }
3090
3096
  items.push(newItem);
3091
3097
  const choiceAlternative = deepClone(alternative);
3092
3098
  choiceAlternative.itemId = id;
3093
3099
  const existingChoice = this.choices.ingredientGroups.get(groupKey);
3100
+ const sgMap = _Recipe.subgroupIndices.get(this);
3094
3101
  if (!existingChoice) {
3095
- 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
+ }
3096
3120
  } else {
3097
- existingChoice.push(choiceAlternative);
3121
+ existingChoice.push([choiceAlternative]);
3098
3122
  }
3099
3123
  }
3100
3124
  /**
@@ -3151,15 +3175,16 @@ var _Recipe = class _Recipe {
3151
3175
  (item2) => item2.type === "ingredient"
3152
3176
  )) {
3153
3177
  const isGrouped = "group" in item && item.group !== void 0;
3154
- const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3178
+ const groupSubgroups = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3155
3179
  let selectedAltIndex = 0;
3156
3180
  let isSelected;
3157
3181
  let hasExplicitChoice;
3158
3182
  if (isGrouped) {
3159
3183
  const groupChoice = choices?.ingredientGroups?.get(item.group);
3160
3184
  hasExplicitChoice = groupChoice !== void 0;
3161
- const targetIndex = groupChoice ?? 0;
3162
- 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;
3163
3188
  } else {
3164
3189
  const itemChoice = choices?.ingredientItems?.get(item.id);
3165
3190
  hasExplicitChoice = itemChoice !== void 0;
@@ -3169,8 +3194,8 @@ var _Recipe = class _Recipe {
3169
3194
  const alternative = item.alternatives[selectedAltIndex];
3170
3195
  if (!alternative || !isSelected) continue;
3171
3196
  selectedIndices.add(alternative.index);
3172
- const allAlts = isGrouped ? groupAlternatives : item.alternatives;
3173
- for (const alt of allAlts) {
3197
+ const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3198
+ for (const alt of allAltsFlat) {
3174
3199
  referencedIndices.add(alt.index);
3175
3200
  }
3176
3201
  if (!alternative.quantity) continue;
@@ -3182,10 +3207,34 @@ var _Recipe = class _Recipe {
3182
3207
  };
3183
3208
  const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
3184
3209
  let alternativeRefs;
3185
- if (!hasExplicitChoice && allAlts.length > 1) {
3186
- alternativeRefs = allAlts.filter(
3187
- (alt) => isGrouped ? alt.itemId !== item.id : alt.index !== alternative.index
3188
- ).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) => {
3189
3238
  const ref = { index: otherAlt.index };
3190
3239
  if (otherAlt.quantity) {
3191
3240
  const altQty = {
@@ -3603,8 +3652,10 @@ var _Recipe = class _Recipe {
3603
3652
  }
3604
3653
  }
3605
3654
  }
3606
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3607
- scaleAlternativesBy(alternatives, factor);
3655
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3656
+ for (const subgroup of subgroups) {
3657
+ scaleAlternativesBy(subgroup, factor);
3658
+ }
3608
3659
  }
3609
3660
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3610
3661
  scaleAlternativesBy(alternatives, factor);
@@ -3791,8 +3842,10 @@ var _Recipe = class _Recipe {
3791
3842
  }
3792
3843
  }
3793
3844
  }
3794
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3795
- convertAlternatives(alternatives);
3845
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3846
+ for (const subgroup of subgroups) {
3847
+ convertAlternatives(subgroup);
3848
+ }
3796
3849
  }
3797
3850
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3798
3851
  convertAlternatives(alternatives);
@@ -3844,6 +3897,11 @@ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
3844
3897
  * Used for giving ID numbers to items during parsing.
3845
3898
  */
3846
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());
3847
3905
  var Recipe = _Recipe;
3848
3906
 
3849
3907
  // src/classes/shopping_list.ts
@@ -4681,10 +4739,10 @@ function isGroupedItem(item) {
4681
4739
  function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4682
4740
  if (item.group) {
4683
4741
  const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
4684
- const groupAlternatives = recipe.choices.ingredientGroups.get(item.group);
4685
- if (groupAlternatives && selectedIndex2 !== void 0 && selectedIndex2 < groupAlternatives.length) {
4686
- const selectedItemId = groupAlternatives[selectedIndex2]?.itemId;
4687
- 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);
4688
4746
  }
4689
4747
  return false;
4690
4748
  }