Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ignoreSelectors: [] to selector-max-compound-selectors #7544

Merged
merged 18 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/big-trains-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stylelint": minor
---

Added: `ignoreSelectors: []` to `selector-max-compound-selectors`
54 changes: 53 additions & 1 deletion lib/rules/selector-max-compound-selectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ A [compound selector](https://www.w3.org/TR/selectors4/#compound) is a chain of

This rule resolves nested selectors before counting the depth of a selector. Each selector in a [selector list](https://www.w3.org/TR/selectors4/#selector-list) is evaluated separately.

`:not()` is considered one compound selector irrespective to the complexity of the selector inside it. The rule _does_ process that inner selector, but does so separately, independent of the main selector.
<!-- prettier-ignore -->
> [!WARNING]
> `:not()` is considered one compound selector irrespective to the complexity of the selector inside it. The rule _does_ process that inner selector, but does so separately, independent of the main selector.

The [`message` secondary option](../../../docs/user-guide/configure.md#message) can accept the arguments of this rule.

Expand Down Expand Up @@ -59,3 +61,53 @@ div {}
```css
.foo + div :not (a b ~ c) {} /* `a b ~ c` is inside `:not()`, so it is evaluated separately */
```

## Optional secondary options

### `ignoreSelectors: ["/regex/", /regex/, "non-regex"]`

Ignore some compound selectors. This may be useful for deep selectors like Vue's `::v-deep` or Angular's `::ng-deep` that behave more like combinators than compound selectors.

For example, with `2`.

Given:

```json
["::v-deep", "/ignored/", ":not"]
```

The following patterns are considered problems:

<!-- prettier-ignore -->
```css
.foo .bar ::v-deep .baz {}
```

<!-- prettier-ignore -->
```css
p a :not(.foo .bar .baz) {}
```

The following patterns are _not_ considered problems:

<!-- prettier-ignore -->
```css
.foo::v-deep .bar {}
```

<!-- prettier-ignore -->
```css
.foo ::v-deep .baz {}
```

<!-- prettier-ignore -->
```css
p a :not(.foo .bar) {}
```

<!-- prettier-ignore -->
```css
.foo {
&.some-ignored-class ::v-deep > .bar {}
Mouvedia marked this conversation as resolved.
Show resolved Hide resolved
}
```
ybiquitous marked this conversation as resolved.
Show resolved Hide resolved
105 changes: 105 additions & 0 deletions lib/rules/selector-max-compound-selectors/__tests__/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,108 @@ testRule({
},
],
});

// Testing ignoreSelectors
testRule({
ruleName,
config: [2, { ignoreSelectors: ['::v-deep', '/-moz-.*/', /-screen$/] }],
ybiquitous marked this conversation as resolved.
Show resolved Hide resolved

accept: [
FloEdelmann marked this conversation as resolved.
Show resolved Hide resolved
{
code: '::v-deep .foo .bar {}',
ybiquitous marked this conversation as resolved.
Show resolved Hide resolved
description: 'ignored selector at the beginning',
},
{
code: '.foo ::v-deep .bar {}',
description: 'ignored selector in the middle',
},
{
code: '.foo ::v-deep > .bar {}',
description: 'ignored selector in the middle followed by a combinator',
},
{
code: '.foo::v-deep .bar {}',
description: 'ignored selector as pseudo-element',
},
{
code: '.foo::v-deep > .bar {}',
description: 'ignored selector as pseudo-element followed by a combinator',
},
{
code: '.foo > ::v-deep .bar {}',
description: 'ignored selector preceded by a combinator',
},
{
code: '.foo input::-moz-placeholder {}',
description: 'selector ignored by string regex',
},
{
code: '.foo :-webkit-full-screen a {}',
Mouvedia marked this conversation as resolved.
Show resolved Hide resolved
description: 'selector ignored by regex',
},
{
code: '.foo::v-deep :-webkit-full-screen a ::-moz-placeholder {}',
Mouvedia marked this conversation as resolved.
Show resolved Hide resolved
description: 'multiple ignored selectors',
},
{
code: '.foo { & ::v-deep > .bar {} }',
description: 'nested ignored selector',
},
Copy link
Member

@Mouvedia Mouvedia Mar 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question
major

How should '.foo .baz .bar.quz {}' behave for 2 ignore [".bar", ".quz"]?
i.e. does it remove one by one from the compound selector or does it skip it if it's not exactly a match?
Either way is fine with me but it needs to be documented either with a > [!NOTE] or an example in the README.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ybiquitous marked this conversation as resolved.
Show resolved Hide resolved
],

reject: [
{
code: '.foo .bar ::v-deep .baz {}',
description: 'ignored selector in the middle',
message: messages.expected('.foo .bar ::v-deep .baz', 2),
line: 1,
column: 1,
endLine: 1,
endColumn: 24,
},
{
code: '.foo .bar::v-deep .baz {}',
description: 'ignored selector as pseudo-element',
message: messages.expected('.foo .bar::v-deep .baz', 2),
line: 1,
column: 1,
endLine: 1,
endColumn: 23,
},
{
code: '.foo { & ::v-deep > .bar .baz {} }',
description: 'nested ignored selector',
message: messages.expected('.foo ::v-deep > .bar .baz', 2),
line: 1,
column: 8,
endLine: 1,
endColumn: 33,
},
],
});

testRule({
ruleName,
config: [2, { ignoreSelectors: [':not'] }],

accept: [
{
code: '.foo .bar:not(.baz) {}',
},
{
code: 'p a :not(.foo .bar) {}',
},
],

reject: [
{
code: 'p a :not(.foo .bar .baz) {}',
ybiquitous marked this conversation as resolved.
Show resolved Hide resolved
description: 'still evaluates compound selectors inside :not',
message: messages.expected('.foo .bar .baz', 2),
line: 1,
column: 10,
endLine: 1,
endColumn: 24,
},
],
});
42 changes: 37 additions & 5 deletions lib/rules/selector-max-compound-selectors/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
'use strict';

const resolvedNestedSelector = require('postcss-resolve-nested-selector');
const validateTypes = require('../../utils/validateTypes.cjs');
const isContextFunctionalPseudoClass = require('../../utils/isContextFunctionalPseudoClass.cjs');
const isNonNegativeInteger = require('../../utils/isNonNegativeInteger.cjs');
const isStandardSyntaxRule = require('../../utils/isStandardSyntaxRule.cjs');
const optionsMatches = require('../../utils/optionsMatches.cjs');
const parseSelector = require('../../utils/parseSelector.cjs');
const report = require('../../utils/report.cjs');
const ruleMessages = require('../../utils/ruleMessages.cjs');
Expand All @@ -25,17 +27,42 @@ const meta = {
};

/** @type {import('stylelint').Rule} */
const rule = (primary) => {
const rule = (primary, secondaryOptions) => {
return (root, result) => {
const validOptions = validateOptions(result, ruleName, {
actual: primary,
possible: isNonNegativeInteger,
});
const validOptions = validateOptions(
result,
ruleName,
{
actual: primary,
possible: isNonNegativeInteger,
},
{
actual: secondaryOptions,
possible: {
ignoreSelectors: [validateTypes.isString, validateTypes.isRegExp],
},
optional: true,
},
);

if (!validOptions) {
return;
}

/**
* @param {import('postcss-selector-parser').Node} selectorNode
* @returns {boolean}
*/
function isSelectorIgnored(selectorNode) {
const selectorString = selectorNode.toString();
const selectorStringWithoutParentheses = selectorString.replace(/\(.*\)$/, '');

return (
optionsMatches(secondaryOptions, 'ignoreSelectors', selectorString) ||
optionsMatches(secondaryOptions, 'ignoreSelectors', selectorStringWithoutParentheses)
);
}

/**
* Finds actual selectors in selectorNode object and checks them.
*
Expand All @@ -44,6 +71,7 @@ const rule = (primary) => {
*/
function checkSelector(selectorNode, ruleNode) {
let compoundCount = 1;
let previousNodeWasCompoundSeparator = true; // first node is implicitly preceded by a separator

selectorNode.each((childNode) => {
// Only traverse inside actual selectors and context functional pseudo-classes
Expand All @@ -54,7 +82,11 @@ const rule = (primary) => {
// Compound selectors are separated by combinators, so increase count when meeting one
if (childNode.type === 'combinator') {
compoundCount++;
} else if (isSelectorIgnored(childNode)) {
compoundCount -= previousNodeWasCompoundSeparator ? 1 : 0;
}

previousNodeWasCompoundSeparator = childNode.type === 'combinator';
});

if (
Expand Down
42 changes: 37 additions & 5 deletions lib/rules/selector-max-compound-selectors/index.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import resolvedNestedSelector from 'postcss-resolve-nested-selector';

import { isRegExp, isString } from '../../utils/validateTypes.mjs';
import isContextFunctionalPseudoClass from '../../utils/isContextFunctionalPseudoClass.mjs';
import isNonNegativeInteger from '../../utils/isNonNegativeInteger.mjs';
import isStandardSyntaxRule from '../../utils/isStandardSyntaxRule.mjs';
import optionsMatches from '../../utils/optionsMatches.mjs';
import parseSelector from '../../utils/parseSelector.mjs';
import report from '../../utils/report.mjs';
import ruleMessages from '../../utils/ruleMessages.mjs';
Expand All @@ -22,17 +24,42 @@ const meta = {
};

/** @type {import('stylelint').Rule} */
const rule = (primary) => {
const rule = (primary, secondaryOptions) => {
return (root, result) => {
const validOptions = validateOptions(result, ruleName, {
actual: primary,
possible: isNonNegativeInteger,
});
const validOptions = validateOptions(
result,
ruleName,
{
actual: primary,
possible: isNonNegativeInteger,
},
{
actual: secondaryOptions,
possible: {
ignoreSelectors: [isString, isRegExp],
},
optional: true,
},
);

if (!validOptions) {
return;
}

/**
* @param {import('postcss-selector-parser').Node} selectorNode
* @returns {boolean}
*/
function isSelectorIgnored(selectorNode) {
const selectorString = selectorNode.toString();
const selectorStringWithoutParentheses = selectorString.replace(/\(.*\)$/, '');

return (
optionsMatches(secondaryOptions, 'ignoreSelectors', selectorString) ||
optionsMatches(secondaryOptions, 'ignoreSelectors', selectorStringWithoutParentheses)
);
ybiquitous marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Finds actual selectors in selectorNode object and checks them.
*
Expand All @@ -41,6 +68,7 @@ const rule = (primary) => {
*/
function checkSelector(selectorNode, ruleNode) {
let compoundCount = 1;
let previousNodeWasCompoundSeparator = true; // first node is implicitly preceded by a separator
ybiquitous marked this conversation as resolved.
Show resolved Hide resolved

selectorNode.each((childNode) => {
ybiquitous marked this conversation as resolved.
Show resolved Hide resolved
// Only traverse inside actual selectors and context functional pseudo-classes
Expand All @@ -51,7 +79,11 @@ const rule = (primary) => {
// Compound selectors are separated by combinators, so increase count when meeting one
if (childNode.type === 'combinator') {
compoundCount++;
} else if (isSelectorIgnored(childNode)) {
compoundCount -= previousNodeWasCompoundSeparator ? 1 : 0;
Mouvedia marked this conversation as resolved.
Show resolved Hide resolved
}

previousNodeWasCompoundSeparator = childNode.type === 'combinator';
ybiquitous marked this conversation as resolved.
Show resolved Hide resolved
});

if (
Expand Down