@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.cjs CHANGED
@@ -32,11 +32,9 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
32
32
  // src/index.ts
33
33
  var index_exports = {};
34
34
  __export(index_exports, {
35
- BadIndentationError: () => BadIndentationError,
36
35
  CategoryConfig: () => CategoryConfig,
37
36
  NoProductCatalogForCartError: () => NoProductCatalogForCartError,
38
37
  NoShoppingListForCartError: () => NoShoppingListForCartError,
39
- NoTabAsIndentError: () => NoTabAsIndentError,
40
38
  Pantry: () => Pantry,
41
39
  ProductCatalog: () => ProductCatalog,
42
40
  Recipe: () => Recipe,
@@ -51,11 +49,14 @@ __export(index_exports, {
51
49
  formatQuantityWithUnit: () => formatQuantityWithUnit,
52
50
  formatSingleValue: () => formatSingleValue,
53
51
  formatUnit: () => formatUnit,
52
+ getEffectiveChoices: () => getEffectiveChoices,
54
53
  hasAlternatives: () => hasAlternatives,
55
54
  isAlternativeSelected: () => isAlternativeSelected,
56
55
  isAndGroup: () => isAndGroup,
57
56
  isGroupedItem: () => isGroupedItem,
57
+ isSectionActive: () => isSectionActive,
58
58
  isSimpleGroup: () => isSimpleGroup,
59
+ isStepActive: () => isStepActive,
59
60
  renderFractionAsVulgar: () => renderFractionAsVulgar
60
61
  });
61
62
  module.exports = __toCommonJS(index_exports);
@@ -335,7 +336,7 @@ var nonWordCharStrict = "\\s@#~\\[\\]{(,;:!?|";
335
336
  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();
336
337
  var inlineIngredientAlternativesRegex = new RegExp("\\|" + ingredientWithAlternativeRegex.source.slice(1));
337
338
  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();
338
- 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();
339
+ 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();
339
340
  var ingredientAliasRegex = d().startAnchor().startNamedGroup("ingredientListName").notAnyOf("|").oneOrMore().endGroup().literal("|").startNamedGroup("ingredientDisplayName").notAnyOf("|").oneOrMore().endGroup().endAnchor().toRegExp();
340
341
  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();
341
342
  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();
@@ -363,6 +364,7 @@ var shoppingListRegex = d().literal("[").startNamedGroup("name").anyCharacter().
363
364
  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();
364
365
  var numberLikeRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".,/").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
365
366
  var floatRegex = d().startAnchor().digit().oneOrMore().startGroup().anyOf(".").exactly(1).digit().oneOrMore().endGroup().optional().endAnchor().toRegExp();
367
+ var variantTagRegex = d().startAnchor().literal("[").startNamedGroup("variantOptionalPrefix").literal("?").endGroup().optional().startNamedGroup("variantNames").notAnyOf("\\]").zeroOrMore().endGroup().literal("]").whitespace().zeroOrMore().toRegExp();
366
368
  var mdEscaped = d().literal("\\").startCaptureGroup().anyOf("*_`").endGroup();
367
369
  var mdInlineCode = d().literal("`").startCaptureGroup().notAnyOf("`").oneOrMore().lazy().endGroup().literal("`");
368
370
  var mdLink = d().literal("[").startCaptureGroup().notAnyOf("\\]").oneOrMore().lazy().endGroup().literal("](").startCaptureGroup().notAnyOf(")").oneOrMore().lazy().endGroup().literal(")");
@@ -1417,9 +1419,12 @@ function flushPendingNote(section, noteItems) {
1417
1419
  }
1418
1420
  return noteItems;
1419
1421
  }
1420
- function flushPendingItems(section, items) {
1422
+ function flushPendingItems(section, items, stepVariants, stepOptional) {
1421
1423
  if (items.length > 0) {
1422
- section.content.push({ type: "step", items: [...items] });
1424
+ const step = { type: "step", items: [...items] };
1425
+ if (stepVariants) step.variants = stepVariants;
1426
+ if (stepOptional) step.optional = true;
1427
+ section.content.push(step);
1423
1428
  items.length = 0;
1424
1429
  return true;
1425
1430
  }
@@ -1963,7 +1968,8 @@ function extractMetadata(content) {
1963
1968
  "yield",
1964
1969
  "serves",
1965
1970
  // List fields
1966
- "tags"
1971
+ "tags",
1972
+ "variants"
1967
1973
  ]);
1968
1974
  for (const metaVar of [
1969
1975
  "title",
@@ -2050,6 +2056,8 @@ function extractMetadata(content) {
2050
2056
  }
2051
2057
  const tags = parseListMetaVar(metadataContent, "tags");
2052
2058
  if (tags) metadata.tags = tags;
2059
+ const variants = parseListMetaVar(metadataContent, "variants");
2060
+ if (variants) metadata.variants = variants;
2053
2061
  const allKeys = extractAllMetadataKeys(metadataContent);
2054
2062
  for (const key of allKeys) {
2055
2063
  if (handledKeys.has(key)) continue;
@@ -2437,8 +2445,10 @@ var Section = class {
2437
2445
  /**
2438
2446
  * Creates an instance of Section.
2439
2447
  * @param name - The name of the section. Defaults to an empty string.
2448
+ * @param variants - Optional variant names for this section.
2449
+ * @param optional - Whether the section is optional.
2440
2450
  */
2441
- constructor(name = "") {
2451
+ constructor(name = "", variants, optional) {
2442
2452
  /**
2443
2453
  * The name of the section. Can be an empty string for the default (first) section.
2444
2454
  * @defaultValue `""`
@@ -2446,7 +2456,13 @@ var Section = class {
2446
2456
  __publicField(this, "name");
2447
2457
  /** An array of steps and notes that make up the content of the section. */
2448
2458
  __publicField(this, "content", []);
2459
+ /** Optional list of variant names this section belongs to. */
2460
+ __publicField(this, "variants");
2461
+ /** Whether the section has been marked as optional ([?]) */
2462
+ __publicField(this, "optional");
2449
2463
  this.name = name;
2464
+ if (variants) this.variants = variants;
2465
+ if (optional) this.optional = true;
2450
2466
  }
2451
2467
  /**
2452
2468
  * Checks if the section is blank (has no name and no content).
@@ -2806,7 +2822,8 @@ var _Recipe = class _Recipe {
2806
2822
  */
2807
2823
  __publicField(this, "choices", {
2808
2824
  ingredientItems: /* @__PURE__ */ new Map(),
2809
- ingredientGroups: /* @__PURE__ */ new Map()
2825
+ ingredientGroups: /* @__PURE__ */ new Map(),
2826
+ variants: []
2810
2827
  });
2811
2828
  /**
2812
2829
  * The parsed recipe ingredients.
@@ -2837,6 +2854,7 @@ var _Recipe = class _Recipe {
2837
2854
  */
2838
2855
  __publicField(this, "servings");
2839
2856
  _Recipe.itemCounts.set(this, 0);
2857
+ _Recipe.subgroupIndices.set(this, /* @__PURE__ */ new Map());
2840
2858
  if (content) {
2841
2859
  this.parse(content);
2842
2860
  }
@@ -3054,6 +3072,7 @@ var _Recipe = class _Recipe {
3054
3072
  if (!match?.groups) return;
3055
3073
  const groups = match.groups;
3056
3074
  const groupKey = groups.gIngredientGroupKey;
3075
+ const subgroupKey = groups.gIngredientSubgroupKey;
3057
3076
  let name = groups.gmIngredientName || groups.gsIngredientName;
3058
3077
  const preparation = groups.gIngredientPreparation;
3059
3078
  const modifiers = groups.gIngredientModifiers;
@@ -3121,7 +3140,12 @@ var _Recipe = class _Recipe {
3121
3140
  if (itemQuantity) {
3122
3141
  Object.assign(alternative, itemQuantity);
3123
3142
  }
3124
- const existingAlternatives = this.choices.ingredientGroups.get(groupKey);
3143
+ const note = groups.gIngredientNote?.trim();
3144
+ if (note) {
3145
+ alternative.note = note;
3146
+ }
3147
+ const existingSubgroups = this.choices.ingredientGroups.get(groupKey);
3148
+ const existingAlternativesFlat = existingSubgroups?.flat();
3125
3149
  function upsertAlternativeToIngredient(ingredients, ingredientIdx, newAlternativeIdx) {
3126
3150
  const ingredient = ingredients[ingredientIdx];
3127
3151
  if (ingredient) {
@@ -3132,8 +3156,8 @@ var _Recipe = class _Recipe {
3132
3156
  }
3133
3157
  }
3134
3158
  }
3135
- if (existingAlternatives) {
3136
- for (const alt of existingAlternatives) {
3159
+ if (existingAlternativesFlat) {
3160
+ for (const alt of existingAlternativesFlat) {
3137
3161
  upsertAlternativeToIngredient(this.ingredients, alt.index, idxInList);
3138
3162
  upsertAlternativeToIngredient(this.ingredients, idxInList, alt.index);
3139
3163
  }
@@ -3145,14 +3169,35 @@ var _Recipe = class _Recipe {
3145
3169
  group: groupKey,
3146
3170
  alternatives: [alternative]
3147
3171
  };
3172
+ if (subgroupKey !== void 0) {
3173
+ newItem.subgroup = subgroupKey;
3174
+ }
3148
3175
  items.push(newItem);
3149
3176
  const choiceAlternative = deepClone(alternative);
3150
3177
  choiceAlternative.itemId = id;
3151
3178
  const existingChoice = this.choices.ingredientGroups.get(groupKey);
3179
+ const sgMap = _Recipe.subgroupIndices.get(this);
3152
3180
  if (!existingChoice) {
3153
- this.choices.ingredientGroups.set(groupKey, [choiceAlternative]);
3181
+ this.choices.ingredientGroups.set(groupKey, [[choiceAlternative]]);
3182
+ if (subgroupKey !== void 0) {
3183
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, 0]]));
3184
+ }
3185
+ } else if (subgroupKey !== void 0) {
3186
+ const groupSgMap = sgMap.get(groupKey);
3187
+ const existingIdx = groupSgMap?.get(subgroupKey);
3188
+ if (existingIdx !== void 0) {
3189
+ existingChoice[existingIdx].push(choiceAlternative);
3190
+ } else {
3191
+ const newIdx = existingChoice.length;
3192
+ existingChoice.push([choiceAlternative]);
3193
+ if (!groupSgMap) {
3194
+ sgMap.set(groupKey, /* @__PURE__ */ new Map([[subgroupKey, newIdx]]));
3195
+ } else {
3196
+ groupSgMap.set(subgroupKey, newIdx);
3197
+ }
3198
+ }
3154
3199
  } else {
3155
- existingChoice.push(choiceAlternative);
3200
+ existingChoice.push([choiceAlternative]);
3156
3201
  }
3157
3202
  }
3158
3203
  /**
@@ -3192,6 +3237,8 @@ var _Recipe = class _Recipe {
3192
3237
  /** @internal */
3193
3238
  collectQuantityGroups(options) {
3194
3239
  const { section, step, choices } = options || {};
3240
+ const activeVariant = choices?.variant;
3241
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3195
3242
  const sectionsToProcess = section !== void 0 ? (() => {
3196
3243
  const idx = typeof section === "number" ? section : this.sections.indexOf(section);
3197
3244
  return idx >= 0 && idx < this.sections.length ? [this.sections[idx]] : [];
@@ -3199,36 +3246,89 @@ var _Recipe = class _Recipe {
3199
3246
  const ingredientGroups = /* @__PURE__ */ new Map();
3200
3247
  const selectedIndices = /* @__PURE__ */ new Set();
3201
3248
  const referencedIndices = /* @__PURE__ */ new Set();
3249
+ const dynamicOptionalIndices = /* @__PURE__ */ new Set();
3202
3250
  for (const currentSection of sectionsToProcess) {
3251
+ if (currentSection.variants) {
3252
+ if (isDefaultVariant) {
3253
+ if (!currentSection.variants.includes("*")) continue;
3254
+ } else {
3255
+ if (!currentSection.variants.includes(activeVariant)) continue;
3256
+ }
3257
+ }
3203
3258
  const allSteps = currentSection.content.filter(
3204
3259
  (item) => item.type === "step"
3205
3260
  );
3261
+ const isOptionalSection = currentSection.optional === true;
3206
3262
  const stepsToProcess = step === void 0 ? allSteps : typeof step === "number" ? step >= 0 && step < allSteps.length ? [allSteps[step]] : [] : allSteps.includes(step) ? [step] : [];
3207
3263
  for (const currentStep of stepsToProcess) {
3264
+ if (currentStep.variants) {
3265
+ if (isDefaultVariant) {
3266
+ if (!currentStep.variants.includes("*")) continue;
3267
+ } else {
3268
+ if (!currentStep.variants.includes(activeVariant)) continue;
3269
+ }
3270
+ }
3271
+ const isOptionalStep = currentStep.optional === true || isOptionalSection;
3208
3272
  for (const item of currentStep.items.filter(
3209
3273
  (item2) => item2.type === "ingredient"
3210
3274
  )) {
3211
3275
  const isGrouped = "group" in item && item.group !== void 0;
3212
- const groupAlternatives = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3276
+ const groupSubgroups = isGrouped ? this.choices.ingredientGroups.get(item.group) : void 0;
3213
3277
  let selectedAltIndex = 0;
3214
3278
  let isSelected;
3215
3279
  let hasExplicitChoice;
3216
3280
  if (isGrouped) {
3217
3281
  const groupChoice = choices?.ingredientGroups?.get(item.group);
3218
3282
  hasExplicitChoice = groupChoice !== void 0;
3219
- const targetIndex = groupChoice ?? 0;
3220
- isSelected = groupAlternatives?.[targetIndex]?.itemId === item.id;
3283
+ if (!hasExplicitChoice && !isDefaultVariant) {
3284
+ const matchingSubgroupIdx = groupSubgroups?.findIndex(
3285
+ (sg) => sg.some(
3286
+ (alt) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3287
+ )
3288
+ );
3289
+ if (matchingSubgroupIdx !== void 0 && matchingSubgroupIdx >= 0) {
3290
+ const matchedSubgroup = groupSubgroups[matchingSubgroupIdx];
3291
+ isSelected = matchedSubgroup.some(
3292
+ (alt) => alt.itemId === item.id
3293
+ );
3294
+ hasExplicitChoice = true;
3295
+ selectedAltIndex = 0;
3296
+ } else {
3297
+ const targetSubgroupIndex = 0;
3298
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3299
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3300
+ }
3301
+ } else {
3302
+ const targetSubgroupIndex = groupChoice ?? 0;
3303
+ const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
3304
+ isSelected = selectedSubgroup?.some((alt) => alt.itemId === item.id) ?? false;
3305
+ }
3221
3306
  } else {
3222
3307
  const itemChoice = choices?.ingredientItems?.get(item.id);
3223
3308
  hasExplicitChoice = itemChoice !== void 0;
3224
- selectedAltIndex = itemChoice ?? 0;
3309
+ if (!hasExplicitChoice && !isDefaultVariant) {
3310
+ const matchingIndices = item.alternatives.map((alt, idx) => ({ alt, idx })).filter(
3311
+ ({ alt }) => alt.note && alt.note.toLowerCase().includes(activeVariant.toLowerCase())
3312
+ ).map(({ idx }) => idx);
3313
+ if (matchingIndices.length > 0) {
3314
+ selectedAltIndex = matchingIndices[0];
3315
+ hasExplicitChoice = true;
3316
+ } else {
3317
+ selectedAltIndex = itemChoice ?? 0;
3318
+ }
3319
+ } else {
3320
+ selectedAltIndex = itemChoice ?? 0;
3321
+ }
3225
3322
  isSelected = true;
3226
3323
  }
3227
3324
  const alternative = item.alternatives[selectedAltIndex];
3228
3325
  if (!alternative || !isSelected) continue;
3229
3326
  selectedIndices.add(alternative.index);
3230
- const allAlts = isGrouped ? groupAlternatives : item.alternatives;
3231
- for (const alt of allAlts) {
3327
+ if (isOptionalStep) {
3328
+ dynamicOptionalIndices.add(alternative.index);
3329
+ }
3330
+ const allAltsFlat = isGrouped ? groupSubgroups.flat() : item.alternatives;
3331
+ for (const alt of allAltsFlat) {
3232
3332
  referencedIndices.add(alt.index);
3233
3333
  }
3234
3334
  if (!alternative.quantity) continue;
@@ -3240,10 +3340,34 @@ var _Recipe = class _Recipe {
3240
3340
  };
3241
3341
  const quantityEntry = alternative.equivalents?.length ? { or: [baseQty, ...alternative.equivalents] } : baseQty;
3242
3342
  let alternativeRefs;
3243
- if (!hasExplicitChoice && allAlts.length > 1) {
3244
- alternativeRefs = allAlts.filter(
3245
- (alt) => isGrouped ? alt.itemId !== item.id : alt.index !== alternative.index
3246
- ).map((otherAlt) => {
3343
+ if (!hasExplicitChoice && groupSubgroups && groupSubgroups.length > 1) {
3344
+ const currentSubgroupIdx = groupSubgroups.findIndex(
3345
+ (sg) => sg.some((alt) => alt.itemId === item.id)
3346
+ );
3347
+ alternativeRefs = groupSubgroups.filter((_, idx) => idx !== currentSubgroupIdx).flatMap(
3348
+ (subgroup) => subgroup.map((otherAlt) => {
3349
+ const ref = {
3350
+ index: otherAlt.index
3351
+ };
3352
+ if (otherAlt.quantity) {
3353
+ const altQty = {
3354
+ quantity: otherAlt.quantity,
3355
+ ...otherAlt.unit && {
3356
+ unit: otherAlt.unit.name
3357
+ },
3358
+ ...otherAlt.equivalents && {
3359
+ equivalents: otherAlt.equivalents.map(
3360
+ (eq) => toPlainUnit(eq)
3361
+ )
3362
+ }
3363
+ };
3364
+ ref.quantities = [altQty];
3365
+ }
3366
+ return ref;
3367
+ })
3368
+ );
3369
+ } else if (!hasExplicitChoice && !isGrouped && allAltsFlat.length > 1) {
3370
+ alternativeRefs = allAltsFlat.filter((alt) => alt.index !== alternative.index).map((otherAlt) => {
3247
3371
  const ref = { index: otherAlt.index };
3248
3372
  if (otherAlt.quantity) {
3249
3373
  const altQty = {
@@ -3308,7 +3432,12 @@ var _Recipe = class _Recipe {
3308
3432
  }
3309
3433
  }
3310
3434
  }
3311
- return { ingredientGroups, selectedIndices, referencedIndices };
3435
+ return {
3436
+ ingredientGroups,
3437
+ selectedIndices,
3438
+ referencedIndices,
3439
+ dynamicOptionalIndices
3440
+ };
3312
3441
  }
3313
3442
  /**
3314
3443
  * Gets the raw (unprocessed) quantity groups for each ingredient, before
@@ -3327,12 +3456,21 @@ var _Recipe = class _Recipe {
3327
3456
  * ```
3328
3457
  */
3329
3458
  getRawQuantityGroups(options) {
3330
- const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3459
+ const {
3460
+ ingredientGroups,
3461
+ selectedIndices,
3462
+ referencedIndices,
3463
+ dynamicOptionalIndices
3464
+ } = this.collectQuantityGroups(options);
3331
3465
  const result = [];
3332
3466
  for (let index = 0; index < this.ingredients.length; index++) {
3333
3467
  if (!referencedIndices.has(index)) continue;
3334
3468
  const orig = this.ingredients[index];
3335
3469
  const usedAsPrimary = selectedIndices.has(index);
3470
+ let flags = orig.flags;
3471
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3472
+ flags = [...flags ?? [], "optional"];
3473
+ }
3336
3474
  const quantities = [];
3337
3475
  if (usedAsPrimary) {
3338
3476
  const groupsForIng = ingredientGroups.get(index);
@@ -3345,7 +3483,7 @@ var _Recipe = class _Recipe {
3345
3483
  result.push({
3346
3484
  name: orig.name,
3347
3485
  ...usedAsPrimary && { usedAsPrimary: true },
3348
- ...orig.flags && { flags: orig.flags },
3486
+ ...flags && { flags },
3349
3487
  quantities
3350
3488
  });
3351
3489
  }
@@ -3379,15 +3517,24 @@ var _Recipe = class _Recipe {
3379
3517
  * ```
3380
3518
  */
3381
3519
  getIngredientQuantities(options) {
3382
- const { ingredientGroups, selectedIndices, referencedIndices } = this.collectQuantityGroups(options);
3520
+ const {
3521
+ ingredientGroups,
3522
+ selectedIndices,
3523
+ referencedIndices,
3524
+ dynamicOptionalIndices
3525
+ } = this.collectQuantityGroups(options);
3383
3526
  const result = [];
3384
3527
  for (let index = 0; index < this.ingredients.length; index++) {
3385
3528
  if (!referencedIndices.has(index)) continue;
3386
3529
  const orig = this.ingredients[index];
3530
+ let flags = orig.flags;
3531
+ if (dynamicOptionalIndices.has(index) && !flags?.includes("optional")) {
3532
+ flags = [...flags ?? [], "optional"];
3533
+ }
3387
3534
  const ing = {
3388
3535
  name: orig.name,
3389
3536
  ...orig.preparation && { preparation: orig.preparation },
3390
- ...orig.flags && { flags: orig.flags },
3537
+ ...flags && { flags },
3391
3538
  ...orig.extras && { extras: orig.extras }
3392
3539
  };
3393
3540
  if (selectedIndices.has(index)) {
@@ -3438,6 +3585,59 @@ var _Recipe = class _Recipe {
3438
3585
  }
3439
3586
  return result;
3440
3587
  }
3588
+ /**
3589
+ * Returns the list of cookware items that are used in the active variant.
3590
+ * Cookware in steps/sections not matching the active variant are excluded.
3591
+ * Hidden cookware is always excluded.
3592
+ *
3593
+ * @param options - Options for filtering:
3594
+ * - `choices`: The choices to apply (only `variant` is used)
3595
+ * @returns Array of Cookware objects referenced by active steps
3596
+ *
3597
+ * @example
3598
+ * ```typescript
3599
+ * // Get all cookware for the default variant
3600
+ * const cookware = recipe.getCookwareForVariant();
3601
+ *
3602
+ * // Get cookware for a specific variant
3603
+ * const veganCookware = recipe.getCookwareForVariant({ choices: { variant: 'vegan' } });
3604
+ * ```
3605
+ */
3606
+ getCookwareForVariant(options) {
3607
+ const { choices } = options || {};
3608
+ const activeVariant = choices?.variant;
3609
+ const isDefaultVariant = activeVariant === void 0 || activeVariant === "*";
3610
+ const cookwareIndices = /* @__PURE__ */ new Set();
3611
+ for (const currentSection of this.sections) {
3612
+ if (currentSection.variants) {
3613
+ if (isDefaultVariant) {
3614
+ if (!currentSection.variants.includes("*")) continue;
3615
+ } else {
3616
+ if (!currentSection.variants.includes(activeVariant)) continue;
3617
+ }
3618
+ }
3619
+ const allSteps = currentSection.content.filter(
3620
+ (item) => item.type === "step"
3621
+ );
3622
+ for (const currentStep of allSteps) {
3623
+ if (currentStep.variants) {
3624
+ if (isDefaultVariant) {
3625
+ if (!currentStep.variants.includes("*")) continue;
3626
+ } else {
3627
+ if (!currentStep.variants.includes(activeVariant)) continue;
3628
+ }
3629
+ }
3630
+ for (const item of currentStep.items) {
3631
+ if (item.type === "cookware") {
3632
+ cookwareIndices.add(item.index);
3633
+ }
3634
+ }
3635
+ }
3636
+ }
3637
+ return this.cookware.filter(
3638
+ (cw, idx) => cookwareIndices.has(idx) && !cw.flags?.includes("hidden")
3639
+ );
3640
+ }
3441
3641
  /**
3442
3642
  * Parses a recipe from a string.
3443
3643
  * @param content - The recipe content to parse.
@@ -3453,9 +3653,14 @@ var _Recipe = class _Recipe {
3453
3653
  const items = [];
3454
3654
  let noteText = "";
3455
3655
  let inNote = false;
3656
+ let stepVariants;
3657
+ let stepOptional;
3658
+ const discoveredVariants = /* @__PURE__ */ new Set();
3456
3659
  for (const line of cleanContent) {
3457
3660
  if (line.trim().length === 0) {
3458
- flushPendingItems(section, items);
3661
+ flushPendingItems(section, items, stepVariants, stepOptional);
3662
+ stepVariants = void 0;
3663
+ stepOptional = void 0;
3459
3664
  flushPendingNote(
3460
3665
  section,
3461
3666
  noteText ? this._parseNoteText(noteText) : []
@@ -3466,26 +3671,48 @@ var _Recipe = class _Recipe {
3466
3671
  continue;
3467
3672
  }
3468
3673
  if (line.startsWith("=")) {
3469
- flushPendingItems(section, items);
3674
+ flushPendingItems(section, items, stepVariants, stepOptional);
3675
+ stepVariants = void 0;
3676
+ stepOptional = void 0;
3470
3677
  flushPendingNote(
3471
3678
  section,
3472
3679
  noteText ? this._parseNoteText(noteText) : []
3473
3680
  );
3474
3681
  noteText = "";
3682
+ let sectionName = line.replace(/^=+|=+$/g, "").trim();
3683
+ let sectionVariants;
3684
+ let sectionOptional;
3685
+ const sectionVarMatch = sectionName.match(variantTagRegex);
3686
+ if (sectionVarMatch?.groups) {
3687
+ const isOptionalPrefix = sectionVarMatch.groups.variantOptionalPrefix === "?";
3688
+ const names = (sectionVarMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3689
+ if (names.length > 0) {
3690
+ sectionVariants = names;
3691
+ for (const v of names) discoveredVariants.add(v);
3692
+ }
3693
+ if (isOptionalPrefix) {
3694
+ sectionOptional = true;
3695
+ }
3696
+ sectionName = sectionName.slice(sectionVarMatch[0].length).trim();
3697
+ }
3475
3698
  if (this.sections.length === 0 && section.isBlank()) {
3476
- section.name = line.replace(/^=+|=+$/g, "").trim();
3699
+ section.name = sectionName;
3700
+ if (sectionVariants) section.variants = sectionVariants;
3701
+ if (sectionOptional) section.optional = true;
3477
3702
  } else {
3478
3703
  if (!section.isBlank()) {
3479
3704
  this.sections.push(section);
3480
3705
  }
3481
- section = new Section(line.replace(/^=+|=+$/g, "").trim());
3706
+ section = new Section(sectionName, sectionVariants, sectionOptional);
3482
3707
  }
3483
3708
  blankLineBefore = true;
3484
3709
  inNote = false;
3485
3710
  continue;
3486
3711
  }
3487
3712
  if (blankLineBefore && line.startsWith(">")) {
3488
- flushPendingItems(section, items);
3713
+ flushPendingItems(section, items, stepVariants, stepOptional);
3714
+ stepVariants = void 0;
3715
+ stepOptional = void 0;
3489
3716
  noteText = line.substring(1).trim();
3490
3717
  inNote = true;
3491
3718
  blankLineBefore = false;
@@ -3500,11 +3727,31 @@ var _Recipe = class _Recipe {
3500
3727
  blankLineBefore = false;
3501
3728
  continue;
3502
3729
  }
3730
+ let currentLine = line;
3731
+ if (items.length === 0) {
3732
+ const varMatch = currentLine.match(variantTagRegex);
3733
+ if (varMatch?.groups) {
3734
+ const isOptionalPrefix = varMatch.groups.variantOptionalPrefix === "?";
3735
+ const names = (varMatch.groups.variantNames ?? "").split(",").map((n2) => n2.trim()).filter((n2) => n2.length > 0);
3736
+ if (names.length > 0) {
3737
+ stepVariants = names;
3738
+ for (const v of names) discoveredVariants.add(v);
3739
+ }
3740
+ if (isOptionalPrefix) {
3741
+ stepOptional = true;
3742
+ }
3743
+ currentLine = currentLine.slice(varMatch[0].length);
3744
+ if (currentLine.trim().length === 0) {
3745
+ blankLineBefore = false;
3746
+ continue;
3747
+ }
3748
+ }
3749
+ }
3503
3750
  let cursor = 0;
3504
- for (const match of line.matchAll(tokensRegex)) {
3751
+ for (const match of currentLine.matchAll(tokensRegex)) {
3505
3752
  const idx = match.index;
3506
3753
  if (idx > cursor) {
3507
- items.push(...parseMarkdownSegments(line.slice(cursor, idx)));
3754
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor, idx)));
3508
3755
  }
3509
3756
  const groups = match.groups;
3510
3757
  if (groups.mIngredientName || groups.sIngredientName) {
@@ -3565,16 +3812,21 @@ var _Recipe = class _Recipe {
3565
3812
  }
3566
3813
  cursor = idx + match[0].length;
3567
3814
  }
3568
- if (cursor < line.length) {
3569
- items.push(...parseMarkdownSegments(line.slice(cursor)));
3815
+ if (cursor < currentLine.length) {
3816
+ items.push(...parseMarkdownSegments(currentLine.slice(cursor)));
3570
3817
  }
3571
3818
  blankLineBefore = false;
3572
3819
  }
3573
- flushPendingItems(section, items);
3820
+ flushPendingItems(section, items, stepVariants, stepOptional);
3574
3821
  flushPendingNote(section, noteText ? this._parseNoteText(noteText) : []);
3575
3822
  if (!section.isBlank()) {
3576
3823
  this.sections.push(section);
3577
3824
  }
3825
+ const metaVariants = this.metadata.variants ?? [];
3826
+ const allVariants = /* @__PURE__ */ new Set([...metaVariants, ...discoveredVariants]);
3827
+ if (allVariants.size > 0) {
3828
+ this.choices.variants = [...allVariants];
3829
+ }
3578
3830
  this._populateIngredientQuantities();
3579
3831
  }
3580
3832
  /**
@@ -3661,8 +3913,10 @@ var _Recipe = class _Recipe {
3661
3913
  }
3662
3914
  }
3663
3915
  }
3664
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3665
- scaleAlternativesBy(alternatives, factor);
3916
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
3917
+ for (const subgroup of subgroups) {
3918
+ scaleAlternativesBy(subgroup, factor);
3919
+ }
3666
3920
  }
3667
3921
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3668
3922
  scaleAlternativesBy(alternatives, factor);
@@ -3849,8 +4103,10 @@ var _Recipe = class _Recipe {
3849
4103
  }
3850
4104
  }
3851
4105
  }
3852
- for (const alternatives of newRecipe.choices.ingredientGroups.values()) {
3853
- convertAlternatives(alternatives);
4106
+ for (const subgroups of newRecipe.choices.ingredientGroups.values()) {
4107
+ for (const subgroup of subgroups) {
4108
+ convertAlternatives(subgroup);
4109
+ }
3854
4110
  }
3855
4111
  for (const alternatives of newRecipe.choices.ingredientItems.values()) {
3856
4112
  convertAlternatives(alternatives);
@@ -3881,7 +4137,11 @@ var _Recipe = class _Recipe {
3881
4137
  newRecipe.metadata = deepClone(this.metadata);
3882
4138
  newRecipe.ingredients = deepClone(this.ingredients);
3883
4139
  newRecipe.sections = this.sections.map((section) => {
3884
- const newSection = new Section(section.name);
4140
+ const newSection = new Section(
4141
+ section.name,
4142
+ section.variants,
4143
+ section.optional
4144
+ );
3885
4145
  newSection.content = deepClone(section.content);
3886
4146
  return newSection;
3887
4147
  });
@@ -3902,6 +4162,11 @@ __publicField(_Recipe, "unitSystems", /* @__PURE__ */ new WeakMap());
3902
4162
  * Used for giving ID numbers to items during parsing.
3903
4163
  */
3904
4164
  __publicField(_Recipe, "itemCounts", /* @__PURE__ */ new WeakMap());
4165
+ /**
4166
+ * External storage for subgroup index tracking during parsing.
4167
+ * Maps groupKey → subgroupKey → index within the subgroups array.
4168
+ */
4169
+ __publicField(_Recipe, "subgroupIndices", /* @__PURE__ */ new WeakMap());
3905
4170
  var Recipe = _Recipe;
3906
4171
 
3907
4172
  // src/classes/shopping_list.ts
@@ -4739,23 +5004,63 @@ function isGroupedItem(item) {
4739
5004
  function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4740
5005
  if (item.group) {
4741
5006
  const selectedIndex2 = choices?.ingredientGroups?.get(item.group);
4742
- const groupAlternatives = recipe.choices.ingredientGroups.get(item.group);
4743
- if (groupAlternatives && selectedIndex2 !== void 0 && selectedIndex2 < groupAlternatives.length) {
4744
- const selectedItemId = groupAlternatives[selectedIndex2]?.itemId;
4745
- return selectedItemId === item.id;
5007
+ const groupSubgroups = recipe.choices.ingredientGroups.get(item.group);
5008
+ if (groupSubgroups && selectedIndex2 !== void 0 && selectedIndex2 < groupSubgroups.length) {
5009
+ const selectedSubgroup = groupSubgroups[selectedIndex2];
5010
+ return selectedSubgroup?.some((alt) => alt.itemId === item.id);
4746
5011
  }
4747
5012
  return false;
4748
5013
  }
4749
5014
  const selectedIndex = choices?.ingredientItems?.get(item.id);
4750
5015
  return alternativeIndex === selectedIndex;
4751
5016
  }
5017
+ function isSectionActive(section, variant) {
5018
+ if (!section.variants) return true;
5019
+ const isDefault = variant === void 0 || variant === "*";
5020
+ if (isDefault) {
5021
+ return section.variants.includes("*");
5022
+ }
5023
+ return section.variants.includes(variant);
5024
+ }
5025
+ function isStepActive(step, variant) {
5026
+ if (!step.variants) return true;
5027
+ const isDefault = variant === void 0 || variant === "*";
5028
+ if (isDefault) {
5029
+ return step.variants.includes("*");
5030
+ }
5031
+ return step.variants.includes(variant);
5032
+ }
5033
+ function getEffectiveChoices(recipe, variant) {
5034
+ const choices = { variant };
5035
+ if (variant === void 0 || variant === "*") return choices;
5036
+ const variantLower = variant.toLowerCase();
5037
+ for (const [itemId, alternatives] of recipe.choices.ingredientItems) {
5038
+ const matchIdx = alternatives.findIndex(
5039
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5040
+ );
5041
+ if (matchIdx >= 0) {
5042
+ if (!choices.ingredientItems) choices.ingredientItems = /* @__PURE__ */ new Map();
5043
+ choices.ingredientItems.set(itemId, matchIdx);
5044
+ }
5045
+ }
5046
+ for (const [groupId, subgroups] of recipe.choices.ingredientGroups) {
5047
+ const matchIdx = subgroups.findIndex(
5048
+ (sg) => sg.some(
5049
+ (alt) => alt.note && alt.note.toLowerCase().includes(variantLower)
5050
+ )
5051
+ );
5052
+ if (matchIdx >= 0) {
5053
+ if (!choices.ingredientGroups) choices.ingredientGroups = /* @__PURE__ */ new Map();
5054
+ choices.ingredientGroups.set(groupId, matchIdx);
5055
+ }
5056
+ }
5057
+ return choices;
5058
+ }
4752
5059
  // Annotate the CommonJS export names for ESM import in node:
4753
5060
  0 && (module.exports = {
4754
- BadIndentationError,
4755
5061
  CategoryConfig,
4756
5062
  NoProductCatalogForCartError,
4757
5063
  NoShoppingListForCartError,
4758
- NoTabAsIndentError,
4759
5064
  Pantry,
4760
5065
  ProductCatalog,
4761
5066
  Recipe,
@@ -4770,11 +5075,14 @@ function isAlternativeSelected(recipe, choices, item, alternativeIndex) {
4770
5075
  formatQuantityWithUnit,
4771
5076
  formatSingleValue,
4772
5077
  formatUnit,
5078
+ getEffectiveChoices,
4773
5079
  hasAlternatives,
4774
5080
  isAlternativeSelected,
4775
5081
  isAndGroup,
4776
5082
  isGroupedItem,
5083
+ isSectionActive,
4777
5084
  isSimpleGroup,
5085
+ isStepActive,
4778
5086
  renderFractionAsVulgar
4779
5087
  });
4780
5088
  /* v8 ignore else -- @preserve */