Skip to content

Commit d01eaa0

Browse files
committed
feat(recipe)!: make alternative choices variant-aware
BREAKING CHANGE: varianted choice behavior is now strict. For non-default variants, inactive-variant alternatives are no longer considered during auto-selection or quantity resolution.
1 parent 0d8d11e commit d01eaa0

9 files changed

Lines changed: 301 additions & 27 deletions

File tree

docs/guide-rendering.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,31 @@ Both functions follow the same logic:
338338

339339
When a variant is active, ingredient alternatives can be auto-selected too. See [Applying choices](#applying-choices) for the full explanation and examples.
340340

341+
**Auto-selection by note matching:**
342+
343+
Call [`getEffectiveChoices()`](/api/functions/getEffectiveChoices) to auto-build a `RecipeChoices` that selects alternatives whose `note` matches the variant name:
344+
345+
```typescript
346+
import { getEffectiveChoices } from "@tmlmt/cooklang-parser";
347+
348+
const choices = getEffectiveChoices(recipe, "vegan");
349+
const ingredients = recipe.getIngredientQuantities({ choices });
350+
```
351+
352+
**Filtering available choices by variant:**
353+
354+
To show only the ingredient choices available for a given variant in your UI, use the [`getChoicesForVariant()`](/api/classes/Recipe.html#getchoicesforvariant) method on the recipe. It returns a filtered [`RecipeAlternatives`](/api/interfaces/RecipeAlternatives) containing only alternatives that are linked to the active variant (or untagged, making them available in all variants):
355+
356+
```typescript
357+
const variant = "vegan"; // or undefined for default
358+
359+
// Get variant-filtered alternatives
360+
const variantChoices = recipe.getChoicesForVariant(variant);
361+
362+
// variantChoices.ingredientItems contains only alternatives linked to [vegan]
363+
// variantChoices.ingredientGroups contains only grouped alternatives linked to [vegan]
364+
```
365+
341366
## Handling alternative selections
342367

343368
Ingredient alternatives (both [inline](/guide-extensions#inline-alternatives) and [grouped](/guide-extensions#grouped-alternatives)) appear in the step items as an `alternatives` array on [`IngredientItem`](/api/interfaces/IngredientItem).

playground/app/components/recipe/RecipeChoices.vue

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,25 +77,27 @@ const step = computed(() => {
7777
});
7878
7979
// --- Compute available choices ---
80+
const variantFilteredChoices = computed(() => {
81+
return props.recipe.getChoicesForVariant(selectedVariant.value);
82+
});
83+
8084
const hasInlineChoices = computed(
81-
() => props.recipe.choices.ingredientItems.size > 0,
85+
() => variantFilteredChoices.value.ingredientItems.size > 0,
8286
);
8387
const hasGroupedChoices = computed(
84-
() => props.recipe.choices.ingredientGroups.size > 0,
85-
);
86-
const hasVariants = computed(
87-
() => props.recipe.choices.variants.length > 0,
88+
() => variantFilteredChoices.value.ingredientGroups.size > 0,
8889
);
90+
const hasVariants = computed(() => props.recipe.choices.variants.length > 0);
8991
const hasAnyChoices = computed(
9092
() => hasInlineChoices.value || hasGroupedChoices.value,
9193
);
9294
9395
// Convert Maps to arrays for iteration
9496
const inlineChoicesArray = computed(() => {
95-
return Array.from(props.recipe.choices.ingredientItems.entries());
97+
return Array.from(variantFilteredChoices.value.ingredientItems.entries());
9698
});
9799
const groupedChoicesArray = computed(() => {
98-
return Array.from(props.recipe.choices.ingredientGroups.entries());
100+
return Array.from(variantFilteredChoices.value.ingredientGroups.entries());
99101
});
100102
101103
// --- Variant options ---

src/classes/recipe.ts

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,61 @@ export class Recipe {
190190
return current;
191191
}
192192

193+
/**
194+
* Computes variant linkage for parsed items based on section and step tags.
195+
* Undefined means no variant restriction.
196+
*/
197+
private getLinkedVariants(
198+
sectionVariants?: string[],
199+
stepVariants?: string[],
200+
): string[] | undefined {
201+
if (!sectionVariants && !stepVariants) {
202+
return undefined;
203+
}
204+
if (!sectionVariants) {
205+
return [...stepVariants!];
206+
}
207+
if (!stepVariants) {
208+
return [...sectionVariants];
209+
}
210+
const stepSet = new Set(stepVariants);
211+
const intersection = sectionVariants.filter((v) => stepSet.has(v));
212+
return intersection;
213+
}
214+
215+
/**
216+
* Checks whether an alternative linked to specific variants is active
217+
* for the requested variant.
218+
*/
219+
private isAlternativeLinkedToVariant(
220+
alternative: IngredientAlternative,
221+
variant?: string,
222+
): boolean {
223+
const linked = alternative.linkedVariants;
224+
if (!linked || linked.length === 0) {
225+
return true;
226+
}
227+
const isDefaultVariant = variant === undefined || variant === "*";
228+
if (isDefaultVariant) {
229+
return linked.includes("*");
230+
}
231+
return linked.includes(variant);
232+
}
233+
234+
/**
235+
* Filters grouped choice subgroups based on active variant linkage.
236+
*/
237+
private filterGroupSubgroupsForVariant(
238+
subgroups: IngredientAlternative[][],
239+
variant?: string,
240+
): IngredientAlternative[][] {
241+
return subgroups.filter((subgroup) =>
242+
subgroup.every((alternative) =>
243+
this.isAlternativeLinkedToVariant(alternative, variant),
244+
),
245+
);
246+
}
247+
193248
/**
194249
* Creates a new Recipe instance.
195250
* @param content - The recipe content to parse.
@@ -292,6 +347,7 @@ export class Recipe {
292347
private _parseIngredientWithAlternativeRecursive(
293348
ingredientMatchString: string,
294349
items: Step["items"],
350+
linkedVariants?: string[],
295351
): void {
296352
const alternatives: IngredientAlternative[] = [];
297353
let testString = ingredientMatchString;
@@ -391,6 +447,7 @@ export class Recipe {
391447
const alternative: IngredientAlternative = {
392448
index: idxInList,
393449
displayName,
450+
...(linkedVariants && { linkedVariants: [...linkedVariants] }),
394451
};
395452
// Only add quantity fields and note if they exist
396453
const note = groups.ingredientNote?.trim();
@@ -448,6 +505,7 @@ export class Recipe {
448505
private _parseIngredientWithGroupKey(
449506
ingredientMatchString: string,
450507
items: Step["items"],
508+
linkedVariants?: string[],
451509
): void {
452510
const match = ingredientMatchString.match(ingredientWithGroupKeyRegex);
453511
// This is a type guard to ensure match and match.groups are defined
@@ -542,6 +600,7 @@ export class Recipe {
542600
const alternative: IngredientAlternative = {
543601
index: idxInList,
544602
displayName,
603+
...(linkedVariants && { linkedVariants: [...linkedVariants] }),
545604
};
546605
// Only add quantity fields if it exists
547606
if (itemQuantity) {
@@ -766,24 +825,31 @@ export class Recipe {
766825
(item): item is IngredientItem => item.type === "ingredient",
767826
)) {
768827
const isGrouped = "group" in item && item.group !== undefined;
769-
const groupSubgroups = isGrouped
828+
const allGroupSubgroups = isGrouped
770829
? this.choices.ingredientGroups.get(item.group!)
771830
: undefined;
831+
const groupSubgroups = allGroupSubgroups
832+
? this.filterGroupSubgroupsForVariant(
833+
allGroupSubgroups,
834+
activeVariant,
835+
)
836+
: undefined;
772837

773838
// Determine selection state
774839
let selectedAltIndex = 0;
775840
let isSelected: boolean;
776841
let hasExplicitChoice: boolean;
777842

778843
if (isGrouped) {
844+
const availableSubgroups = groupSubgroups!;
779845
const groupChoice = choices?.ingredientGroups?.get(item.group!);
780846
hasExplicitChoice = groupChoice !== undefined;
781847

782848
// Variant-aware auto-selection for grouped items: when a named
783849
// variant is active and no explicit choice, look for subgroups
784850
// whose alternatives have a note matching the variant name
785851
if (!hasExplicitChoice && !isDefaultVariant) {
786-
const matchingSubgroupIdx = groupSubgroups?.findIndex((sg) =>
852+
const matchingSubgroupIdx = availableSubgroups.findIndex((sg) =>
787853
sg.some(
788854
(alt) =>
789855
alt.note &&
@@ -796,25 +862,25 @@ export class Recipe {
796862
matchingSubgroupIdx !== undefined &&
797863
matchingSubgroupIdx >= 0
798864
) {
799-
const matchedSubgroup = groupSubgroups![matchingSubgroupIdx]!;
865+
const matchedSubgroup =
866+
availableSubgroups[matchingSubgroupIdx]!;
800867
isSelected = matchedSubgroup.some(
801868
(alt) => alt.itemId === item.id,
802869
);
803870
hasExplicitChoice = true; // treat as explicit so alternativeRefs are not built
804871
selectedAltIndex = 0;
805872
} else {
806-
const targetSubgroupIndex = 0;
807-
const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
808-
isSelected = selectedSubgroup!.some(
873+
isSelected = availableSubgroups[0]!.some(
809874
(alt) => alt.itemId === item.id,
810875
);
811876
}
812877
} else {
813878
const targetSubgroupIndex = groupChoice ?? 0;
814-
const selectedSubgroup = groupSubgroups?.[targetSubgroupIndex];
815-
isSelected =
816-
selectedSubgroup?.some((alt) => alt.itemId === item.id) ??
817-
false;
879+
const selectedSubgroup = availableSubgroups[targetSubgroupIndex];
880+
if (!selectedSubgroup) continue;
881+
isSelected = selectedSubgroup.some(
882+
(alt) => alt.itemId === item.id,
883+
);
818884
}
819885
} else {
820886
const itemChoice = choices?.ingredientItems?.get(item.id);
@@ -859,9 +925,9 @@ export class Recipe {
859925
}
860926

861927
// Add all alternatives to referenced set (so indices remain valid in result)
862-
const allAltsFlat = isGrouped
863-
? groupSubgroups!.flat()
864-
: item.alternatives;
928+
const allAltsFlat = (
929+
isGrouped ? groupSubgroups!.flat() : item.alternatives
930+
).filter((alt): alt is IngredientAlternative => alt !== undefined);
865931
for (const alt of allAltsFlat) {
866932
referencedIndices.add(alt.index);
867933
}
@@ -1025,6 +1091,39 @@ export class Recipe {
10251091
};
10261092
}
10271093

1094+
/**
1095+
* Returns choices available for a given active variant.
1096+
*
1097+
* For grouped alternatives, only groups with at least two available
1098+
* subgroups are returned.
1099+
*/
1100+
getChoicesForVariant(variant?: string): RecipeAlternatives {
1101+
const ingredientItems = new Map<string, IngredientAlternative[]>();
1102+
for (const [itemId, alternatives] of this.choices.ingredientItems) {
1103+
const isVisible = alternatives.some((alternative) =>
1104+
this.isAlternativeLinkedToVariant(alternative, variant),
1105+
);
1106+
if (isVisible) {
1107+
ingredientItems.set(itemId, alternatives);
1108+
}
1109+
}
1110+
1111+
const ingredientGroups = new Map<string, IngredientAlternative[][]>();
1112+
for (const [groupId, subgroups] of this.choices.ingredientGroups) {
1113+
const filtered = this.filterGroupSubgroupsForVariant(subgroups, variant);
1114+
// v8 ignore else -- @preserve: only include groups with at least 2 subgroups (otherwise it's not really a choice)
1115+
if (filtered.length > 1) {
1116+
ingredientGroups.set(groupId, filtered);
1117+
}
1118+
}
1119+
1120+
return {
1121+
ingredientItems,
1122+
ingredientGroups,
1123+
variants: [...this.choices.variants],
1124+
};
1125+
}
1126+
10281127
/**
10291128
* Gets the raw (unprocessed) quantity groups for each ingredient, before
10301129
* any summation or equivalents simplification. This is useful for cross-recipe
@@ -1426,6 +1525,10 @@ export class Recipe {
14261525

14271526
// Detecting items
14281527
let cursor = 0;
1528+
const linkedVariants = this.getLinkedVariants(
1529+
section.variants,
1530+
stepVariants,
1531+
);
14291532
for (const match of currentLine.matchAll(tokensRegex)) {
14301533
const idx = match.index;
14311534
/* v8 ignore else -- @preserve */
@@ -1437,11 +1540,15 @@ export class Recipe {
14371540

14381541
// Ingredient items with potential in-line alternatives
14391542
if (groups.mIngredientName || groups.sIngredientName) {
1440-
this._parseIngredientWithAlternativeRecursive(match[0], items);
1543+
this._parseIngredientWithAlternativeRecursive(
1544+
match[0],
1545+
items,
1546+
linkedVariants,
1547+
);
14411548
}
14421549
// Ingredient items part of a group of alternative ingredients
14431550
else if (groups.gmIngredientName || groups.gsIngredientName) {
1444-
this._parseIngredientWithGroupKey(match[0], items);
1551+
this._parseIngredientWithGroupKey(match[0], items, linkedVariants);
14451552
}
14461553
// Cookware items
14471554
else if (groups.mCookwareName || groups.sCookwareName) {

src/classes/shopping_list.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,9 +1130,7 @@ export class ShoppingList {
11301130
const indent = " ".repeat(depth);
11311131
for (const [segment, child] of sorted) {
11321132
const fullPath = `${pathPrefix}/${segment}`;
1133-
if (child.suffix !== undefined) {
1134-
lines.push(`${indent}./${fullPath}${child.suffix}`);
1135-
}
1133+
lines.push(`${indent}./${fullPath}${child.suffix}`);
11361134
emitDescendantsFlat(child, fullPath, depth);
11371135
}
11381136
};

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,11 @@ export type IngredientAlternativeBase = {
396396
index: number;
397397
/** The alias/name of the ingredient as it should be displayed for this occurrence. */
398398
displayName: string;
399+
/**
400+
* Variants this alternative is linked to, based on step/section tags
401+
* where it was parsed. If undefined, it is available in all variants.
402+
*/
403+
linkedVariants?: string[];
399404
/** An optional note for this specific choice (e.g., "for a vegan version"). */
400405
note?: string;
401406
/** When {@link Recipe.choices} is populated for alternatives ingredients

src/utils/render_helpers.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -433,9 +433,10 @@ export function getEffectiveChoices(
433433
if (variant === undefined || variant === "*") return choices;
434434

435435
const variantLower = variant.toLowerCase();
436+
const variantChoices = recipe.getChoicesForVariant(variant);
436437

437438
// Auto-select inline alternatives by note match
438-
for (const [itemId, alternatives] of recipe.choices.ingredientItems) {
439+
for (const [itemId, alternatives] of variantChoices.ingredientItems) {
439440
const matchIdx = alternatives.findIndex(
440441
(alt) => alt.note && alt.note.toLowerCase().includes(variantLower),
441442
);
@@ -448,7 +449,7 @@ export function getEffectiveChoices(
448449
}
449450

450451
// Auto-select grouped alternatives by note match
451-
for (const [groupId, subgroups] of recipe.choices.ingredientGroups) {
452+
for (const [groupId, subgroups] of variantChoices.ingredientGroups) {
452453
const matchIdx = subgroups.findIndex((sg) =>
453454
sg.some(
454455
(alt) => alt.note && alt.note.toLowerCase().includes(variantLower),

0 commit comments

Comments
 (0)