@@ -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 ) {
0 commit comments