Extend Scoping Across At-Rules#
&:extend() in Less must respect the scoping rules of CSS at-rules: an extend declared inside @media (tv) should only apply to selectors within that same block, while an extend at the root level should apply globally. This scoping is implemented entirely in extend-visitor.js through a stack called allExtendsStack.
Two-Phase Processing#
Extend resolution runs in two sequential visitor passes over the AST:
ExtendFinderVisitorβ Scans the tree and attaches anallExtendsarray to every at-rule node, collecting the extend declarations found inside it.ProcessExtendsVisitorβ Walks the tree again, using those per-nodeallExtendsarrays to rewrite selectors.
allExtendsStack Mechanics#
Phase 1 β ExtendFinderVisitor
The stack starts as [[]] β one empty array for the root scope .
When entering a Media or AtRule node, a new empty array is pushed as that node's allExtends property:
visitMediaandvisitAtRule: push a fresh[]onto the stack.visitMediaOutandvisitAtRuleOut: pop the stack.
When visitRuleset encounters an extend declaration (e.g., .tv-lowres :extend(.ext1 all)), it pushes the extend into the top of the stack β which is whichever at-rule is currently in scope . This means extends are automatically segregated to the innermost enclosing at-rule.
Phase 2 β ProcessExtendsVisitor
When entering a Media or AtRule node, the visitor builds a merged extends list for that scope :
newAllExtends = atRuleNode.allExtends // extends declared inside this block
+ allExtendsStack.top() // extends from all outer scopes
+ doExtendChaining(...) // chained extends
This merged list is pushed onto the stack so that visitRuleset (which does the actual selector rewriting) always works against the full set of extends visible in the current scope . On exit, the merged list is popped .
Container Node Coverage#
@container is represented by the Container tree node. Its prototype is set to new AtRule(), giving it type: 'Container' . The Visitor class dispatches to visitor methods by constructing visit${node.type} at runtime ; since no visitContainer method exists, the Visitor falls back to the generic visitAtRule/visitAtRuleOut handlers. Container nodes are therefore handled identically to any other at-rule for extend scoping purposes β no special case needed.
Worked Example#
Given :
.ext1 .ext2 { background: black; }
@media (tv) {
.ext1 .ext3 { color: inherit; }
.tv-lowres :extend(.ext1 all) { background: blue; }
@media (hires) {
.ext1 .ext4 { color: green; }
.tv-hires :extend(.ext1 all) { background: red; }
}
}
.all:extend(.ext1 all) {}
The output CSS shows the scoping in action:
.all(root-level extend) matches.ext1 .ext2outside any at-rule..tv-lowres(inside@media (tv)) matches.ext1 .ext3within that block, and also.ext1 .ext2outside it β because outer extends are merged in during Phase 2..tv-hires(inside@media (tv) and (hires)) similarly picks up selectors from outer scopes.
Root-level extends do not gain selectors from inside at-rules.
Key Files#
| File | Role |
|---|---|
visitors/extend-visitor.js | Both visitor classes; all extend scoping logic |
tree/container.js | @container AST node; inherits from AtRule |
tree/nested-at-rule.js | Shared prototype for nestable at-rules (Media, Container) |
test-data/less/_main/extend-media.less | Test input covering nested @media extend scoping |
test-data/css/_main/extend-media.css | Expected CSS output for above test |