@tmlmt/cooklang-parser 3.0.0-alpha.16 → 3.0.0-alpha.19

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
@@ -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().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();
@@ -305,6 +305,7 @@ var shoppingListRegex = d().literal("[").startNamedGroup("name").anyCharacter().
305
305
  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
306
  var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
307
307
  var floatRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
308
+ var variantTagRegex = d().startAnchor().literal("[").startNamedGroup("variantOptionalPrefix").literal("?").endGroup().optional().startNamedGroup("variantNames").notAnyOf("\\]").zeroOrMore().endGroup().literal("]").whitespace().zeroOrMore().toRegExp();
308
309
  var mdEscaped = d().literal("\\").startCaptureGroup().anyOf("*_`").endGroup();
309
310
  var mdInlineCode = d().literal("`").startCaptureGroup().notAnyOf("`").oneOrMore().lazy().endGroup().literal("`");
310
311
  var mdLink = d().literal("[").startCaptureGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("](").startCaptureGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")");
@@ -1359,9 +1360,12 @@ function flushPendingNote(section, noteItems) {
1359
1360
  }
1360
1361
  return noteItems;
1361
1362
  }
1362
- function flushPendingItems(section, items) {
1363
+ function flushPendingItems(section, items, stepVariants, stepOptional) {
1363
1364
  if (items.length > 0) {
1364
- section.content.push({ type: "step", items: [...items] });
1365
+ const step = { type: "step", items: [...items] };
1366
+ if (stepVariants) step.variants = stepVariants;
1367
+ if (stepOptional) step.optional = true;
1368
+ section.content.push(step);
1365
1369
  items.length = 0;
1366
1370
  return true;
1367
1371
  }
@@ -1905,7 +1909,8 @@ function extractMetadata(content) {
1905
1909
  "yield",
1906
1910
  "serves",
1907
1911
  // List fields
1908
- "tags"
1912
+ "tags",
1913
+ "variants"
1909
1914
  ]);
1910
1915
  for (const metaVar of [
1911
1916
  "title",
@@ -1992,6 +1997,8 @@ function extractMetadata(content) {
1992
1997
  }
1993
1998
  const tags = parseListMetaVar(metadataContent, "tags");
1994
1999
  if (tags) metadata.tags = tags;
2000
+ const variants = parseListMetaVar(metadataContent, "variants");
2001
+ if (variants) metadata.variants = variants;
1995
2002
  const allKeys = extractAllMetadataKeys(metadataContent);
1996
2003
  for (const key of allKeys) {
1997
2004
  if (handledKeys.has(key)) continue;
@@ -2379,8 +2386,10 @@ var Section = class {
2379
2386
  /**
2380
2387
  * Creates an instance of Section.
2381
2388
  * @param name - The name of the section. Defaults to an empty string.
2389
+ * @param variants - Optional variant names for this section.
2390
+ * @param optional - Whether the section is optional.
2382
2391
  */
2383
- constructor(name = "") {
2392
+ constructor(name = "", variants, optional) {
2384
2393
  /**
2385
2394
  * The name of the section. Can be an empty string for the default (first) section.
2386
2395
  * @defaultValue `""`
@@ -2388,7 +2397,13 @@ var Section = class {
2388
2397
  __publicField(this, "name");
2389
2398
  /** An array of steps and notes that make up the content of the section. */
2390
2399
  __publicField(this, "content", []);
2400
+ /** Optional list of variant names this section belongs to. */
2401
+ __publicField(this, "variants");
2402
+ /** Whether the section has been marked as optional ([?]) */
2403
+ __publicField(this, "optional");
2391
2404
  this.name = name;
2405
+ if (variants) this.variants = variants;
2406
+ if (optional) this.optional = true;
2392
2407
  }
2393
2408
  /**
2394
2409
  * Checks if the section is blank (has no name and no content).
@@ -2748,7 +2763,8 @@ var _Recipe = class _Recipe {
2748
2763
  */
2749
2764
  __publicField(this, "choices", {
2750
2765
  ingredientItems: /* @__PURE__ */ new Map(),
2751
- ingredientGroups: /* @__PURE__ */ new Map()
2766
+ ingredientGroups: /* @__PURE__ */ new Map(),
2767
+ variants: []
2752
2768
  });
2753
2769
  /**
2754
2770
  * The parsed recipe ingredients.
@@ -2779,6 +2795,7 @@ var _Recipe = class _Recipe {
2779
2795
  */
2780
2796
  __publicField(this, "servings");
2781
2797
  _Recipe.itemCounts.set(this, 0);
2798
+ _Recipe.subgroupIndices.set(this, /* @__PURE__ */ new Map());
2782
2799
  if (content) {
2783
2800
  this.parse(content);
2784
2801
  }
@@ -2996,6 +3013,7 @@ var _Recipe = class _Recipe {
2996
3013
  if (!match?.groups) return;
2997
3014
  const groups = match.groups;
2998
3015
  const groupKey = groups.gIngredientGroupKey;
3016
+ const subgroupKey = groups.gIngredientSubgroupKey;
2999
3017
  let name = groups.gmIngredientName || groups.gsIngredientName;
3000
3018
  const preparation = groups.gIngredientPreparation;
3001
3019
  const modifiers = groups.gIngredientModifiers;
@@ -3063,7 +3081,12 @@ var _Recipe = class _Recipe {
3063
3081
  if (itemQuantity) {
3064
3082
  Object.assign(alternative, itemQuantity);
3065
3083
  }
3066
- const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
3084
+ const note = groups.gIngredientNote?.trim();
3085
+ if (note) {
3086
+ alternative.note = note;
3087
+ }
3088
+ const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3089
+ const existingAlternativesFlat = existingSubgroups?.flat();
3067
3090
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
3068
3091
  const ingredient = ingredients[ingredientIdx];
3069
3092
  if (ingredient) {
@@ -3074,8 +3097,8 @@ var _Recipe = class _Recipe {
3074
3097
  }
3075
3098
  }
3076
3099
  }
3077
- if (existingAlternatives) {
3078
- for (const alt of existingAlternatives) {
3100
+ if (existingAlternativesFlat) {
3101
+ for (const alt of existingAlternativesFlat) {
3079
3102
  upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
3080
3103
  upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
3081
3104
  }
@@ -3087,14 +3110,35 @@ var _Recipe = class _Recipe {
3087
3110
  group: groupKey,
3088
3111
  alternatives: [alternative]
3089
3112
  };
3113
+ if (subgroupKey !== void 0) {
3114
+ newItem.subgroup = subgroupKey;
3115
+ }
3090
3116
  items.push(newItem);
3091
3117
  const choiceAlternative = deepClone(alternative);
3092
3118
  choiceAlternative.itemId = id;
3093
3119
  const existingChoice = this.choices.ingredientGroups.get(groupKey);
3120
+ const sgMap = _Recipe.subgroupIndices.get(this);
3094
3121
  if (!existingChoice) {
3095
- this.choices.ingredientGroups.set(groupKey, [choiceAlternative]);
3122
+ this.choices.ingredientGroups.set(groupKey, [[choiceAlternative]]);
3123
+ if (subgroupKey !== void 0) {
3124
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, 0]]));
3125
+ }
3126
+ } else if (subgroupKey !== void 0) {
3127
+ const groupSgMap = sgMap.get(groupKey);
3128
+ const existingIdx = groupSgMap?.get(subgroupKey);
3129
+ if (existingIdx !== void 0) {
3130
+ existingChoice[existingIdx].push(choiceAlternative);
3131
+ } else {
3132
+ const newIdx = existingChoice.length;
3133
+ existingChoice.push([choiceAlternative]);
3134
+ if (!groupSgMap) {
3135
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, newIdx]]));
3136
+ } else {
3137
+ groupSgMap.set(subgroupKey, newIdx);
3138
+ }
3139
+ }
3096
3140
  } else {
3097
- existingChoice.push(choiceAlternative);
3141
+ existingChoice.push([choiceAlternative]);
3098
3142
  }
3099
3143
  }
3100
3144
  /**
@@ -3134,6 +3178,8 @@ var _Recipe = class _Recipe {
3134
3178
  /** @internal */
3135
3179
  collectQuantityGroups(options) {
3136
3180
  const { section, step, choices } = options || {};
3181
+ const activeVariant = choices?.variant;
3182
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3137
3183
  const sectionsToProcess = section !== void 0 ? (() => {
3138
3184
  const idx = typeof section === "number" ? section : this.sections.indexOf(section);
3139
3185
  return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
@@ -3141,36 +3187,89 @@ var _Recipe = class _Recipe {
3141
3187
  const ingredientGroups = /* @__PURE__ */ new Map();
3142
3188
  const selectedIndices = /* @__PURE__ */ new Set();
3143
3189
  const referencedIndices = /* @__PURE__ */ new Set();
3190
+ const dynamicOptionalIndices = /* @__PURE__ */ new Set();
3144
3191
  for (const currentSection of sectionsToProcess) {
3192
+ if (currentSection.variants) {
3193
+ if (isDefaultVariant) {
3194
+ if (!currentSection.variants.includes("*")) continue;
3195
+ } else {
3196
+ if (!currentSection.variants.includes(activeVariant)) continue;
3197
+ }
3198
+ }
3145
3199
  const allSteps = currentSection.content.filter(
3146
3200
  (item) => item.type === "step"
3147
3201
  );
3202
+ const isOptionalSection = currentSection.optional === true;
3148
3203
  const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
3149
3204
  for (const currentStep of stepsToProcess) {
3205
+ if (currentStep.variants) {
3206
+ if (isDefaultVariant) {
3207
+ if (!currentStep.variants.includes("*")) continue;
3208
+ } else {
3209
+ if (!currentStep.variants.includes(activeVariant)) continue;
3210
+ }
3211
+ }
3212
+ const isOptionalStep = currentStep.optional === true || isOptionalSection;
3150
3213
  for (const item of currentStep.items.filter(
3151
3214
  (item2) => item2.type === "ingredient"
3152
3215
  )) {
3153
3216
  const isGrouped = "group" in item && item.group !== void 0;
3154
- const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3217
+ const groupSubgroups = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3155
3218
  let selectedAltIndex = 0;
3156
3219
  let isSelected;
3157
3220
  let hasExplicitChoice;
3158
3221
  if (isGrouped) {
3159
3222
  const groupChoice = choices?.ingredientGroups?.get(item.group);
3160
3223
  hasExplicitChoice = groupChoice !== void 0;
3161
- const targetIndex = groupChoice ?? 0;
3162
- isSelected = groupAlternatives?.[targetIndex]?.itemId === item.id;
3224
+ if (!hasExplicitChoice && !isDefaultVariant) {
3225
+ const matchingSubgroupIdx = groupSubgroups?.findIndex(
3226
+ (sg) => sg.some(
3227
+ (alt) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3228
+ )
3229
+ );
3230
+ if (matchingSubgroupIdx !== void 0 && matchingSubgroupIdx >= 0) {
3231
+ const matchedSubgroup = groupSubgroups[matchingSubgroupIdx];
3232
+ isSelected = matchedSubgroup.some(
3233
+ (alt) => alt.itemId === item.id
3234
+ );
3235
+ hasExplicitChoice = true;
3236
+ selectedAltIndex = 0;
3237
+ } else {
3238
+ const targetSubgroupIndex = 0;
3239
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3240
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3241
+ }
3242
+ } else {
3243
+ const targetSubgroupIndex = groupChoice ?? 0;
3244
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3245
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3246
+ }
3163
3247
  } else {
3164
3248
  const itemChoice = choices?.ingredientItems?.get(item.id);
3165
3249
  hasExplicitChoice = itemChoice !== void 0;
3166
- selectedAltIndex = itemChoice ?? 0;
3250
+ if (!hasExplicitChoice && !isDefaultVariant) {
3251
+ const matchingIndices = item.alternatives.map((alt, idx) => ({ alt, idx })).filter(
3252
+ ({ alt }) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3253
+ ).map(({ idx }) => idx);
3254
+ if (matchingIndices.length > 0) {
3255
+ selectedAltIndex = matchingIndices[0];
3256
+ hasExplicitChoice = true;
3257
+ } else {
3258
+ selectedAltIndex = itemChoice ?? 0;
3259
+ }
3260
+ } else {
3261
+ selectedAltIndex = itemChoice ?? 0;
3262
+ }
3167
3263
  isSelected = true;
3168
3264
  }
3169
3265
  const alternative = item.alternatives[selectedAltIndex];
3170
3266
  if (!alternative || !isSelected) continue;
3171
3267
  selectedIndices.add(alternative.index);
3172
- const allAlts = isGrouped ? groupAlternatives : item.alternatives;
3173
- for (const alt of allAlts) {
3268
+ if (isOptionalStep) {
3269
+ dynamicOptionalIndices.add(alternative.index);
3270
+ }
3271
+ const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3272
+ for (const alt of allAltsFlat) {
3174
3273
  referencedIndices.add(alt.index);
3175
3274
  }
3176
3275
  if (!alternative.quantity) continue;
@@ -3182,10 +3281,34 @@ var _Recipe = class _Recipe {
3182
3281
  };
3183
3282
  const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
3184
3283
  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) => {
3284
+ if (!hasExplicitChoice && groupSubgroups && groupSubgroups.length > 1) {
3285
+ const currentSubgroupIdx = groupSubgroups.findIndex(
3286
+ (sg) => sg.some((alt) => alt.itemId === item.id)
3287
+ );
3288
+ alternativeRefs = groupSubgroups.filter((_, idx) => idx !== currentSubgroupIdx).flatMap(
3289
+ (subgroup) => subgroup.map((otherAlt) => {
3290
+ const ref = {
3291
+ index: otherAlt.index
3292
+ };
3293
+ if (otherAlt.quantity) {
3294
+ const altQty = {
3295
+ quantity: otherAlt.quantity,
3296
+ ...otherAlt.unit && {
3297
+ unit: otherAlt.unit.name
3298
+ },
3299
+ ...otherAlt.equivalents && {
3300
+ equivalents: otherAlt.equivalents.map(
3301
+ (eq) => toPlainUnit(eq)
3302
+ )
3303
+ }
3304
+ };
3305
+ ref.quantities = [altQty];
3306
+ }
3307
+ return ref;
3308
+ })
3309
+ );
3310
+ } else if (!hasExplicitChoice && !isGrouped && allAltsFlat.length > 1) {
3311
+ alternativeRefs = allAltsFlat.filter((alt) => alt.index !== alternative.index).map((otherAlt) => {
3189
3312
  const ref = { index: otherAlt.index };
3190
3313
  if (otherAlt.quantity) {
3191
3314
  const altQty = {
@@ -3250,7 +3373,12 @@ var _Recipe = class _Recipe {
3250
3373
  }
3251
3374
  }
3252
3375
  }
3253
- return { ingredientGroups, selectedIndices, referencedIndices };
3376
+ return {
3377
+ ingredientGroups,
3378
+ selectedIndices,
3379
+ referencedIndices,
3380
+ dynamicOptionalIndices
3381
+ };
3254
3382
  }
3255
3383
  /**
3256
3384
  * Gets the raw (unprocessed) quantity groups for each ingredient, before
@@ -3269,12 +3397,21 @@ var _Recipe = class _Recipe {
3269
3397
  * ```
3270
3398
  */
3271
3399
  getRawQuantityGroups(options) {
3272
- const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3400
+ const {
3401
+ ingredientGroups,
3402
+ selectedIndices,
3403
+ referencedIndices,
3404
+ dynamicOptionalIndices
3405
+ } = this.collectQuantityGroups(options);
3273
3406
  const result = [];
3274
3407
  for (let index = 0; index < this.ingredients.length; index++) {
3275
3408
  if (!referencedIndices.has(index)) continue;
3276
3409
  const orig = this.ingredients[index];
3277
3410
  const usedAsPrimary = selectedIndices.has(index);
3411
+ let flags = orig.flags;
3412
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3413
+ flags = [...flags ?? [], "optional"];
3414
+ }
3278
3415
  const quantities = [];
3279
3416
  if (usedAsPrimary) {
3280
3417
  const groupsForIng = ingredientGroups.get(index);
@@ -3287,7 +3424,7 @@ var _Recipe = class _Recipe {
3287
3424
  result.push({
3288
3425
  name: orig.name,
3289
3426
  ...usedAsPrimary && { usedAsPrimary: true },
3290
- ...orig.flags && { flags: orig.flags },
3427
+ ...flags && { flags },
3291
3428
  quantities
3292
3429
  });
3293
3430
  }
@@ -3321,15 +3458,24 @@ var _Recipe = class _Recipe {
3321
3458
  * ```
3322
3459
  */
3323
3460
  getIngredientQuantities(options) {
3324
- const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3461
+ const {
3462
+ ingredientGroups,
3463
+ selectedIndices,
3464
+ referencedIndices,
3465
+ dynamicOptionalIndices
3466
+ } = this.collectQuantityGroups(options);
3325
3467
  const result = [];
3326
3468
  for (let index = 0; index < this.ingredients.length; index++) {
3327
3469
  if (!referencedIndices.has(index)) continue;
3328
3470
  const orig = this.ingredients[index];
3471
+ let flags = orig.flags;
3472
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3473
+ flags = [...flags ?? [], "optional"];
3474
+ }
3329
3475
  const ing = {
3330
3476
  name: orig.name,
3331
3477
  ...orig.preparation && { preparation: orig.preparation },
3332
- ...orig.flags && { flags: orig.flags },
3478
+ ...flags && { flags },
3333
3479
  ...orig.extras && { extras: orig.extras }
3334
3480
  };
3335
3481
  if (selectedIndices.has(index)) {
@@ -3380,6 +3526,59 @@ var _Recipe = class _Recipe {
3380
3526
  }
3381
3527
  return result;
3382
3528
  }
3529
+ /**
3530
+ * Returns the list of cookware items that are used in the active variant.
3531
+ * Cookware in steps/sections not matching the active variant are excluded.
3532
+ * Hidden cookware is always excluded.
3533
+ *
3534
+ * @param options - Options for filtering:
3535
+ * - `choices`: The choices to apply (only `variant` is used)
3536
+ * @returns Array of Cookware objects referenced by active steps
3537
+ *
3538
+ * @example
3539
+ * ```typescript
3540
+ * // Get all cookware for the default variant
3541
+ * const cookware = recipe.getCookwareForVariant();
3542
+ *
3543
+ * // Get cookware for a specific variant
3544
+ * const veganCookware = recipe.getCookwareForVariant({ choices: { variant: 'vegan' } });
3545
+ * ```
3546
+ */
3547
+ getCookwareForVariant(options) {
3548
+ const { choices } = options || {};
3549
+ const activeVariant = choices?.variant;
3550
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3551
+ const cookwareIndices = /* @__PURE__ */ new Set();
3552
+ for (const currentSection of this.sections) {
3553
+ if (currentSection.variants) {
3554
+ if (isDefaultVariant) {
3555
+ if (!currentSection.variants.includes("*")) continue;
3556
+ } else {
3557
+ if (!currentSection.variants.includes(activeVariant)) continue;
3558
+ }
3559
+ }
3560
+ const allSteps = currentSection.content.filter(
3561
+ (item) => item.type === "step"
3562
+ );
3563
+ for (const currentStep of allSteps) {
3564
+ if (currentStep.variants) {
3565
+ if (isDefaultVariant) {
3566
+ if (!currentStep.variants.includes("*")) continue;
3567
+ } else {
3568
+ if (!currentStep.variants.includes(activeVariant)) continue;
3569
+ }
3570
+ }
3571
+ for (const item of currentStep.items) {
3572
+ if (item.type === "cookware") {
3573
+ cookwareIndices.add(item.index);
3574
+ }
3575
+ }
3576
+ }
3577
+ }
3578
+ return this.cookware.filter(
3579
+ (cw, idx) => cookwareIndices.has(idx) && !cw.flags?.includes("hidden")
3580
+ );
3581
+ }
3383
3582
  /**
3384
3583
  * Parses a recipe from a string.
3385
3584
  * @param content - The recipe content to parse.
@@ -3395,9 +3594,14 @@ var _Recipe = class _Recipe {
3395
3594
  const items = [];
3396
3595
  let noteText = "";
3397
3596
  let inNote = false;
3597
+ let stepVariants;
3598
+ let stepOptional;
3599
+ const discoveredVariants = /* @__PURE__ */ new Set();
3398
3600
  for (const line of cleanContent) {
3399
3601
  if (line.trim().length === 0) {
3400
- flushPendingItems(section, items);
3602
+ flushPendingItems(section, items, stepVariants, stepOptional);
3603
+ stepVariants = void 0;
3604
+ stepOptional = void 0;
3401
3605
  flushPendingNote(
3402
3606
  section,
3403
3607
  noteText ? this._parseNoteText(noteText) : []
@@ -3408,26 +3612,48 @@ var _Recipe = class _Recipe {
3408
3612
  continue;
3409
3613
  }
3410
3614
  if (line.startsWith("=")) {
3411
- flushPendingItems(section, items);
3615
+ flushPendingItems(section, items, stepVariants, stepOptional);
3616
+ stepVariants = void 0;
3617
+ stepOptional = void 0;
3412
3618
  flushPendingNote(
3413
3619
  section,
3414
3620
  noteText ? this._parseNoteText(noteText) : []
3415
3621
  );
3416
3622
  noteText = "";
3623
+ let sectionName = line.replace(/^=+|=+$/g, "").trim();
3624
+ let sectionVariants;
3625
+ let sectionOptional;
3626
+ const sectionVarMatch = sectionName.match(variantTagRegex);
3627
+ if (sectionVarMatch?.groups) {
3628
+ const isOptionalPrefix = sectionVarMatch.groups.variantOptionalPrefix === "?";
3629
+ const names = (sectionVarMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3630
+ if (names.length > 0) {
3631
+ sectionVariants = names;
3632
+ for (const v of names) discoveredVariants.add(v);
3633
+ }
3634
+ if (isOptionalPrefix) {
3635
+ sectionOptional = true;
3636
+ }
3637
+ sectionName = sectionName.slice(sectionVarMatch[0].length).trim();
3638
+ }
3417
3639
  if (this.sections.length === 0 && section.isBlank()) {
3418
- section.name = line.replace(/^=+|=+$/g, "").trim();
3640
+ section.name = sectionName;
3641
+ if (sectionVariants) section.variants = sectionVariants;
3642
+ if (sectionOptional) section.optional = true;
3419
3643
  } else {
3420
3644
  if (!section.isBlank()) {
3421
3645
  this.sections.push(section);
3422
3646
  }
3423
- section = new Section(line.replace(/^=+|=+$/g, "").trim());
3647
+ section = new Section(sectionName, sectionVariants, sectionOptional);
3424
3648
  }
3425
3649
  blankLineBefore = true;
3426
3650
  inNote = false;
3427
3651
  continue;
3428
3652
  }
3429
3653
  if (blankLineBefore && line.startsWith(">")) {
3430
- flushPendingItems(section, items);
3654
+ flushPendingItems(section, items, stepVariants, stepOptional);
3655
+ stepVariants = void 0;
3656
+ stepOptional = void 0;
3431
3657
  noteText = line.substring(1).trim();
3432
3658
  inNote = true;
3433
3659
  blankLineBefore = false;
@@ -3442,11 +3668,31 @@ var _Recipe = class _Recipe {
3442
3668
  blankLineBefore = false;
3443
3669
  continue;
3444
3670
  }
3671
+ let currentLine = line;
3672
+ if (items.length === 0) {
3673
+ const varMatch = currentLine.match(variantTagRegex);
3674
+ if (varMatch?.groups) {
3675
+ const isOptionalPrefix = varMatch.groups.variantOptionalPrefix === "?";
3676
+ const names = (varMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3677
+ if (names.length > 0) {
3678
+ stepVariants = names;
3679
+ for (const v of names) discoveredVariants.add(v);
3680
+ }
3681
+ if (isOptionalPrefix) {
3682
+ stepOptional = true;
3683
+ }
3684
+ currentLine = currentLine.slice(varMatch[0].length);
3685
+ if (currentLine.trim().length === 0) {
3686
+ blankLineBefore = false;
3687
+ continue;
3688
+ }
3689
+ }
3690
+ }
3445
3691
  let cursor = 0;
3446
- for (const match of line.matchAll(tokensRegex)) {
3692
+ for (const match of currentLine.matchAll(tokensRegex)) {
3447
3693
  const idx = match.index;
3448
3694
  if (idx > cursor) {
3449
- items.push(...parseMarkdownSegments(line.slice(cursor, idx)));
3695
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor, idx)));
3450
3696
  }
3451
3697
  const groups = match.groups;
3452
3698
  if (groups.mIngredientName || groups.sIngredientName) {
@@ -3507,16 +3753,21 @@ var _Recipe = class _Recipe {
3507
3753
  }
3508
3754
  cursor = idx + match[0].length;
3509
3755
  }
3510
- if (cursor < line.length) {
3511
- items.push(...parseMarkdownSegments(line.slice(cursor)));
3756
+ if (cursor < currentLine.length) {
3757
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor)));
3512
3758
  }
3513
3759
  blankLineBefore = false;
3514
3760
  }
3515
- flushPendingItems(section, items);
3761
+ flushPendingItems(section, items, stepVariants, stepOptional);
3516
3762
  flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
3517
3763
  if (!section.isBlank()) {
3518
3764
  this.sections.push(section);
3519
3765
  }
3766
+ const metaVariants = this.metadata.variants ?? [];
3767
+ const allVariants = /* @__PURE__ */ new Set([...metaVariants, ...discoveredVariants]);
3768
+ if (allVariants.size > 0) {
3769
+ this.choices.variants = [...allVariants];
3770
+ }
3520
3771
  this._populateIngredientQuantities();
3521
3772
  }
3522
3773
  /**
@@ -3603,8 +3854,10 @@ var _Recipe = class _Recipe {
3603
3854
  }
3604
3855
  }
3605
3856
  }
3606
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3607
- scaleAlternativesBy(alternatives, factor);
3857
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3858
+ for (const subgroup of subgroups) {
3859
+ scaleAlternativesBy(subgroup, factor);
3860
+ }
3608
3861
  }
3609
3862
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3610
3863
  scaleAlternativesBy(alternatives, factor);
@@ -3791,8 +4044,10 @@ var _Recipe = class _Recipe {
3791
4044
  }
3792
4045
  }
3793
4046
  }
3794
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3795
- convertAlternatives(alternatives);
4047
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
4048
+ for (const subgroup of subgroups) {
4049
+ convertAlternatives(subgroup);
4050
+ }
3796
4051
  }
3797
4052
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3798
4053
  convertAlternatives(alternatives);
@@ -3823,7 +4078,11 @@ var _Recipe = class _Recipe {
3823
4078
  newRecipe.metadata = deepClone(this.metadata);
3824
4079
  newRecipe.ingredients = deepClone(this.ingredients);
3825
4080
  newRecipe.sections = this.sections.map((section) => {
3826
- const newSection = new Section(section.name);
4081
+ const newSection = new Section(
4082
+ section.name,
4083
+ section.variants,
4084
+ section.optional
4085
+ );
3827
4086
  newSection.content = deepClone(section.content);
3828
4087
  return newSection;
3829
4088
  });
@@ -3844,6 +4103,11 @@ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
3844
4103
  * Used for giving ID numbers to items during parsing.
3845
4104
  */
3846
4105
  __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
4106
+ /**
4107
+ * External storage for subgroup index tracking during parsing.
4108
+ * Maps groupKey → subgroupKey → index within the subgroups array.
4109
+ */
4110
+ __publicField(_Recipe, "subgroupIndices", /* @__PURE__ */ new WeakMap());
3847
4111
  var Recipe = _Recipe;
3848
4112
 
3849
4113
  // src/classes/shopping_list.ts
@@ -4681,22 +4945,62 @@ function isGroupedItem(item) {
4681
4945
  function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4682
4946
  if (item.group) {
4683
4947
  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;
4948
+ const groupSubgroups = recipe.choices.ingredientGroups.get(item.group);
4949
+ if (groupSubgroups && selectedIndex2 !== void 0 && selectedIndex2 < groupSubgroups.length) {
4950
+ const selectedSubgroup = groupSubgroups[selectedIndex2];
4951
+ return selectedSubgroup?.some((alt) => alt.itemId === item.id);
4688
4952
  }
4689
4953
  return false;
4690
4954
  }
4691
4955
  const selectedIndex = choices?.ingredientItems?.get(item.id);
4692
4956
  return alternativeIndex === selectedIndex;
4693
4957
  }
4958
+ function isSectionActive(section, variant) {
4959
+ if (!section.variants) return true;
4960
+ const isDefault = variant === void 0 || variant === "*";
4961
+ if (isDefault) {
4962
+ return section.variants.includes("*");
4963
+ }
4964
+ return section.variants.includes(variant);
4965
+ }
4966
+ function isStepActive(step, variant) {
4967
+ if (!step.variants) return true;
4968
+ const isDefault = variant === void 0 || variant === "*";
4969
+ if (isDefault) {
4970
+ return step.variants.includes("*");
4971
+ }
4972
+ return step.variants.includes(variant);
4973
+ }
4974
+ function getEffectiveChoices(recipe, variant) {
4975
+ const choices = { variant };
4976
+ if (variant === void 0 || variant === "*") return choices;
4977
+ const variantLower = variant.toLowerCase();
4978
+ for (const [itemId, alternatives] of recipe.choices.ingredientItems) {
4979
+ const matchIdx = alternatives.findIndex(
4980
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
4981
+ );
4982
+ if (matchIdx >= 0) {
4983
+ if (!choices.ingredientItems) choices.ingredientItems = /* @__PURE__ */ new Map();
4984
+ choices.ingredientItems.set(itemId, matchIdx);
4985
+ }
4986
+ }
4987
+ for (const [groupId, subgroups] of recipe.choices.ingredientGroups) {
4988
+ const matchIdx = subgroups.findIndex(
4989
+ (sg) => sg.some(
4990
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
4991
+ )
4992
+ );
4993
+ if (matchIdx >= 0) {
4994
+ if (!choices.ingredientGroups) choices.ingredientGroups = /* @__PURE__ */ new Map();
4995
+ choices.ingredientGroups.set(groupId, matchIdx);
4996
+ }
4997
+ }
4998
+ return choices;
4999
+ }
4694
5000
  export {
4695
- BadIndentationError,
4696
5001
  CategoryConfig,
4697
5002
  NoProductCatalogForCartError,
4698
5003
  NoShoppingListForCartError,
4699
- NoTabAsIndentError,
4700
5004
  Pantry,
4701
5005
  ProductCatalog,
4702
5006
  Recipe,
@@ -4711,11 +5015,14 @@ export {
4711
5015
  formatQuantityWithUnit,
4712
5016
  formatSingleValue,
4713
5017
  formatUnit,
5018
+ getEffectiveChoices,
4714
5019
  hasAlternatives,
4715
5020
  isAlternativeSelected,
4716
5021
  isAndGroup,
4717
5022
  isGroupedItem,
5023
+ isSectionActive,
4718
5024
  isSimpleGroup,
5025
+ isStepActive,
4719
5026
  renderFractionAsVulgar
4720
5027
  };
4721
5028
  /* v8 ignore else -- @preserve */