Skip to content
This repository was archived by the owner on Feb 26, 2026. It is now read-only.

Commit e4fd47a

Browse files
authored
add contain-nested option for external mixins (#2)
1 parent e64e0e4 commit e4fd47a

7 files changed

Lines changed: 107 additions & 23 deletions

File tree

.stylelintrc.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
"./index.js"
44
],
55
"rules": {
6-
"@apostrophecms/stylelint-no-mixed-decls": true
6+
"@apostrophecms/stylelint-no-mixed-decls": [
7+
true,
8+
{
9+
"contain-nested": [ "external-mixin-known-to-contain-nested-rules" ]
10+
}
11+
]
712
}
813
}

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## UNRELEASED
4+
5+
### Adds
6+
7+
* New `contain-nested` array option to list mixins that are known to contain nested rules.
8+
39
## 1.1.0
410

511
### Adds

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ Add it to your Stylelint configuration:
3131
}
3232
```
3333

34+
You can set a `contain-nested` option to list mixins that are known to contain nested rules.
35+
This is an answer to the [limitations](#limitations) that this plugin cannot analyze mixin definitions from other files.
36+
37+
```js
38+
{
39+
"plugins": [ "@apostrophecms/stylelint-no-mixed-decls" ],
40+
"rules": {
41+
"@apostrophecms/stylelint-no-mixed-decls": [
42+
true,
43+
{
44+
"contain-nested": [ "external-mixin-known-to-contain-nested-rules" ]
45+
}
46+
]
47+
}
48+
}
49+
```
50+
3451
## Example: Correct Usage
3552

3653
```scss
@@ -55,6 +72,7 @@ Add it to your Stylelint configuration:
5572

5673
.foo {
5774
@include foo;
75+
@include external-mixin; // not known to contain nested rules
5876
color: red;
5977
}
6078
```
@@ -144,6 +162,14 @@ Add it to your Stylelint configuration:
144162
}
145163
```
146164

165+
```scss
166+
.foo {
167+
@include external-mixin-known-to-contain-nested-rules;
168+
169+
color: red; // ❌ Cannot mix declarations and nested rules. Group them together or wrap declarations in a nested "& { }" block. See https://sass-lang.com/documentation/breaking-changes/mixed-decls/
170+
}
171+
```
172+
147173
## Why this matters
148174

149175
This plugin ensures your Sass code adheres to modern CSS nesting behavior,
@@ -172,6 +198,12 @@ or colocate them with the code that uses them.
172198
Alternatively, wrap declarations following `@include` statements
173199
in a nested `& { }` block as a safe default when unsure of the mixin's contents.
174200

201+
**Use `contain-nested` option:**
202+
203+
This option can be used to list mixins that are known to contain nested rules,
204+
so that the plugin can treat them accordingly,
205+
even if their definition is not present in the file they are used.
206+
175207
## Please contribute!
176208

177209
We welcome contributions! If you find a bug or something missing,

index.js

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
55
mixed: 'Cannot mix declarations and nested rules. Group them together or wrap declarations in a nested "& { }" block. See https://sass-lang.com/documentation/breaking-changes/mixed-decls/'
66
});
77

8-
module.exports = stylelint.createPlugin(ruleName, () => {
8+
module.exports = stylelint.createPlugin(ruleName, (primary, secondaryOptions) => {
99
return (root, result) => {
1010
root.walkRules(rule => {
1111
let seenNested = false;
@@ -25,25 +25,34 @@ module.exports = stylelint.createPlugin(ruleName, () => {
2525
});
2626
}
2727

28-
// Inspect the included mixin
28+
// If the incuded mixin is known to contain nested rules,
29+
// we can skip checking it and just set `seenNested` to true.
30+
// Any declarations after this point will be
31+
// reported, even the ones inside the mixin.
2932
if (isInclude(node)) {
33+
const nameWithoutArgs = node.params.replace(/\(.*$/, '');
34+
35+
// If the mixin is known to contain nested rules because
36+
// it's listed as such in the options:
37+
if (secondaryOptions?.['contain-nested']?.includes(nameWithoutArgs)) {
38+
seenNested = true;
39+
return;
40+
}
41+
42+
// Otherwise, we need to find the mixin definition:
3043
root.walkAtRules('mixin', mixinRule => {
31-
// Skip other mixins that don't match
32-
// the name of the current include:
3344
if (mixinRule.params !== node.params) {
3445
return;
3546
}
3647

3748
mixinRule.each(mixinNode => {
49+
// If the mixin is actually seen to contain nested rules:
3850
if (isNested(mixinNode)) {
39-
// If we find a nested rule inside the mixin,
40-
// flag this "global" variable to true so that
41-
// any declarations after this point will be
42-
// reported, even the ones inside the mixin.
4351
seenNested = true;
4452
return;
4553
}
4654

55+
// If the mixin itself contains declarations after nested rules:
4756
if (isDecl(mixinNode) && seenNested) {
4857
stylelint.utils.report({
4958
message: messages.mixed,

test/bad.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,11 @@
7171
@include flat-2;
7272
@include mixed-2;
7373
}
74+
75+
// This is bad because the mixin is known to contain nested rules,
76+
// therefore the rule that comes after it should be wrapped.
77+
.d {
78+
@include external-mixin-known-to-contain-nested-rules;
79+
80+
color: red;
81+
}

test/good.scss

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,24 @@
110110

111111
color: red;
112112
}
113+
114+
// This is okay because the mixin isn't known to contain nested rules.
115+
.m {
116+
@include external-mixin;
117+
118+
color: red;
119+
}
120+
121+
// This is okay because nothings comes after the mixin.
122+
.n {
123+
@include external-mixin-known-to-contain-nested-rules;
124+
}
125+
126+
// This is okay because the rule that comes after the mixin is wrapped.
127+
.o {
128+
@include external-mixin-known-to-contain-nested-rules;
129+
130+
& {
131+
color: red;
132+
}
133+
}

test/index.js

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,35 +18,38 @@ describe('@apostrophecms/stylelint-no-mixed-decls stylelint rule', function() {
1818
throw new Error(`Unexpected output: ${stdout}`);
1919
}
2020

21-
const expectedOccurrences = 12;
22-
const occurrences = countOccurences(ERROR_MESSAGE, stderr);
23-
2421
const expectedErrorPositions = [
2522
[ 7, 3 ],
2623
[ 8, 3 ],
2724
[ 29, 3 ],
25+
[ 30, 3 ],
2826
[ 34, 3 ],
2927
[ 35, 3 ],
3028
[ 45, 3 ],
3129
[ 46, 3 ],
3230
[ 56, 3 ],
3331
[ 57, 3 ],
3432
[ 64, 3 ],
35-
[ 70, 3 ]
33+
[ 70, 3 ],
34+
[ 78, 3 ]
3635
];
36+
const expectedErrorOccurrences = expectedErrorPositions.length;
37+
const actualErrorOccurrences = countOccurences(ERROR_MESSAGE, stderr);
38+
39+
const errorPositionsMessages = expectedErrorPositions
40+
.map(position =>
41+
!stderr.includes(position.join(':')) &&
42+
`Expected error message to include line ${position.join(':')} but it did not`
43+
)
44+
.filter(Boolean);
3745

3846
assert.strictEqual(
39-
occurrences,
40-
expectedOccurrences,
41-
`Expected 12 occurrences of "${ERROR_MESSAGE}" but found ${occurrences}`
42-
);
47+
actualErrorOccurrences,
48+
expectedErrorOccurrences,
49+
`Expected ${expectedErrorOccurrences} occurrences of "${ERROR_MESSAGE}" but found ${actualErrorOccurrences}.
4350
44-
expectedErrorPositions.forEach(position => {
45-
assert.ok(
46-
stderr.includes(position.join(':')),
47-
`Expected error message to include line ${position.join(':')} but it did not`
48-
);
49-
});
51+
${errorPositionsMessages.join('\n')}`
52+
);
5053
});
5154

5255
it('should pass when css contains nested rules and scoped declarations', async function() {

0 commit comments

Comments
 (0)