diff --git a/docs/user-guide/example-config.md b/docs/user-guide/example-config.md index aed1a8f08e..2e63e11e6c 100644 --- a/docs/user-guide/example-config.md +++ b/docs/user-guide/example-config.md @@ -150,9 +150,11 @@ You might want to learn a little about [how rules are named and how they work to "selector-pseudo-class-no-unknown": true, "selector-pseudo-class-parentheses-space-inside": "always"|"never", "selector-pseudo-class-whitelist": string|[], + "selector-pseudo-element-blacklist": string|[], "selector-pseudo-element-case": "lower"|"upper", "selector-pseudo-element-colon-notation": "single"|"double", "selector-pseudo-element-no-unknown": true, + "selector-pseudo-element-whitelist": string|[], "selector-type-case": "lower"|"upper", "selector-type-no-unknown": true, "shorthand-property-no-redundant-values": true, diff --git a/docs/user-guide/rules.md b/docs/user-guide/rules.md index f66cbbd115..c76cf27c48 100644 --- a/docs/user-guide/rules.md +++ b/docs/user-guide/rules.md @@ -157,6 +157,8 @@ Here are all the rules within stylelint, grouped first [by category](../../VISIO - [`selector-no-vendor-prefix`](../../lib/rules/selector-no-vendor-prefix/README.md): Disallow vendor prefixes for selectors. - [`selector-pseudo-class-blacklist`](../../lib/rules/selector-pseudo-class-blacklist/README.md): Specify a blacklist of disallowed pseudo-class selectors. - [`selector-pseudo-class-whitelist`](../../lib/rules/selector-pseudo-class-whitelist/README.md): Specify a whitelist of allowed pseudo-class selectors. +- [`selector-pseudo-element-blacklist`](../../lib/rules/selector-pseudo-element-blacklist/README.md): Specify a blacklist of disallowed pseudo-element selectors. +- [`selector-pseudo-element-whitelist`](../../lib/rules/selector-pseudo-element-whitelist/README.md): Specify a whitelist of allowed pseudo-element selectors. #### Media feature diff --git a/jest-setup.js b/jest-setup.js index 8de7713276..3adde122b6 100644 --- a/jest-setup.js +++ b/jest-setup.js @@ -40,7 +40,7 @@ global.testRule = (rule, schema) => { describe("accept", () => { passingTestCases.forEach(testCase => { const spec = testCase.only ? it.only : it; - describe(JSON.stringify(schema.config), () => { + describe(JSON.stringify(schema.config, replacer), () => { describe(JSON.stringify(testCase.code), () => { spec(testCase.description || "no description", () => { const options = { @@ -72,7 +72,7 @@ global.testRule = (rule, schema) => { describe("reject", () => { schema.reject.forEach(testCase => { const spec = testCase.only ? it.only : it; - describe(JSON.stringify(schema.config), () => { + describe(JSON.stringify(schema.config, replacer), () => { describe(JSON.stringify(testCase.code), () => { spec(testCase.description || "no description", () => { const options = { @@ -131,3 +131,7 @@ function getOutputCss(output) { } return css; } + +function replacer(key, value) { + return value instanceof RegExp ? `[RegExp] ${value.toString()}` : value; +} diff --git a/lib/rules/index.js b/lib/rules/index.js index 4335fa91e3..7428aaa3a3 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -143,9 +143,11 @@ const selectorPseudoClassCase = require("./selector-pseudo-class-case"); const selectorPseudoClassNoUnknown = require("./selector-pseudo-class-no-unknown"); const selectorPseudoClassParenthesesSpaceInside = require("./selector-pseudo-class-parentheses-space-inside"); const selectorPseudoClassWhitelist = require("./selector-pseudo-class-whitelist"); +const selectorPseudoElementBlacklist = require("./selector-pseudo-element-blacklist"); const selectorPseudoElementCase = require("./selector-pseudo-element-case"); const selectorPseudoElementColonNotation = require("./selector-pseudo-element-colon-notation"); const selectorPseudoElementNoUnknown = require("./selector-pseudo-element-no-unknown"); +const selectorPseudoElementWhitelist = require("./selector-pseudo-element-whitelist"); const selectorTypeCase = require("./selector-type-case"); const selectorTypeNoUnknown = require("./selector-type-no-unknown"); const shorthandPropertyNoRedundantValues = require("./shorthand-property-no-redundant-values"); @@ -307,9 +309,11 @@ module.exports = { "selector-pseudo-class-no-unknown": selectorPseudoClassNoUnknown, "selector-pseudo-class-parentheses-space-inside": selectorPseudoClassParenthesesSpaceInside, "selector-pseudo-class-whitelist": selectorPseudoClassWhitelist, + "selector-pseudo-element-blacklist": selectorPseudoElementBlacklist, "selector-pseudo-element-case": selectorPseudoElementCase, "selector-pseudo-element-colon-notation": selectorPseudoElementColonNotation, "selector-pseudo-element-no-unknown": selectorPseudoElementNoUnknown, + "selector-pseudo-element-whitelist": selectorPseudoElementWhitelist, "selector-type-case": selectorTypeCase, "selector-type-no-unknown": selectorTypeNoUnknown, "shorthand-property-no-redundant-values": shorthandPropertyNoRedundantValues, diff --git a/lib/rules/selector-pseudo-element-blacklist/README.md b/lib/rules/selector-pseudo-element-blacklist/README.md new file mode 100644 index 0000000000..cc7b9490e7 --- /dev/null +++ b/lib/rules/selector-pseudo-element-blacklist/README.md @@ -0,0 +1,48 @@ +# selector-pseudo-element-blacklist + +Specify a blacklist of disallowed pseudo-element selectors. + +```css + a::before {} +/** ↑ + * These pseudo-element selectors */ +``` + +This rule ignores CSS2 pseudo-elements i.e. those prefixed with a single colon. + +This rule ignores selectors that use variable interpolation e.g. `::#{$variable} {}`. + +## Options + +`array|string|regex`: `["array", "of", "unprefixed", "pseudo-elements" or "regex"]|"pseudo-element"|/regex/` + +Given: + +```js +["before", "/^my-/i"] +``` + +The following patterns are considered violations: + +```css +a::before {} +``` + +```css +a::my-pseudo-element {} +``` + +```css +a::MY-OTHER-pseudo-element {} +``` + + +The following patterns are *not* considered violations: + +```css +a::after {} +``` + +```css +a::not-my-pseudo-element {} +``` diff --git a/lib/rules/selector-pseudo-element-blacklist/__tests__/index.js b/lib/rules/selector-pseudo-element-blacklist/__tests__/index.js new file mode 100644 index 0000000000..b61f2eb022 --- /dev/null +++ b/lib/rules/selector-pseudo-element-blacklist/__tests__/index.js @@ -0,0 +1,107 @@ +"use strict"; + +const messages = require("..").messages; +const ruleName = require("..").ruleName; +const rules = require("../../../rules"); + +const rule = rules[ruleName]; + +testRule(rule, { + ruleName, + config: ["before", "selection", /^my/i], + skipBasicChecks: true, + + accept: [ + { + code: "a {}" + }, + { + code: "a:hover {}" + }, + { + code: "a::BEFORE {}" + }, + { + code: "a::after {}" + }, + { + code: "::first-line {}" + }, + { + code: "::-webkit-first-line {}" + }, + { + code: "a:not(::first-line) {}" + }, + { + code: "a::their-pseudo-element {}" + }, + { + code: "a::THEIR-other-pseudo-element {}" + } + ], + + reject: [ + { + code: "a::before {}", + message: messages.rejected("before"), + line: 1, + column: 2 + }, + { + code: "a,\nb::before {}", + message: messages.rejected("before"), + line: 2, + column: 2 + }, + { + code: "::selection {}", + message: messages.rejected("selection"), + line: 1, + column: 1 + }, + { + code: "::-webkit-selection {}", + message: messages.rejected("-webkit-selection"), + line: 1, + column: 1 + }, + { + code: "a:not(::selection) {}", + message: messages.rejected("selection"), + line: 1, + column: 7 + }, + { + code: "a::my-pseudo-element {}", + message: messages.rejected("my-pseudo-element"), + line: 1, + column: 2 + }, + { + code: "a::MY-OTHER-pseudo-element {}", + message: messages.rejected("MY-OTHER-pseudo-element"), + line: 1, + column: 2 + } + ] +}); + +testRule(rule, { + ruleName, + config: ["before"], + skipBasicChecks: true, + syntax: "scss", + + accept: [ + { + code: "::#{$variable} {}" + }, + { + code: "::#{$VARIABLE} {}" + }, + { + code: "a::#{$variable} {}" + } + ] +}); diff --git a/lib/rules/selector-pseudo-element-blacklist/index.js b/lib/rules/selector-pseudo-element-blacklist/index.js new file mode 100644 index 0000000000..bb6e3ae8ca --- /dev/null +++ b/lib/rules/selector-pseudo-element-blacklist/index.js @@ -0,0 +1,78 @@ +"use strict"; + +const _ = require("lodash"); +const isStandardSyntaxRule = require("../../utils/isStandardSyntaxRule"); +const isStandardSyntaxSelector = require("../../utils/isStandardSyntaxSelector"); +const matchesStringOrRegExp = require("../../utils/matchesStringOrRegExp"); +const parseSelector = require("../../utils/parseSelector"); +const postcss = require("postcss"); +const report = require("../../utils/report"); +const ruleMessages = require("../../utils/ruleMessages"); +const validateOptions = require("../../utils/validateOptions"); + +const ruleName = "selector-pseudo-element-blacklist"; + +const messages = ruleMessages(ruleName, { + rejected: selector => `Unexpected pseudo-element "${selector}"` +}); + +const rule = function(blacklist) { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: blacklist, + possible: [_.isString, _.isRegExp] + }); + if (!validOptions) { + return; + } + + root.walkRules(rule => { + if (!isStandardSyntaxRule(rule)) { + return; + } + + const selector = rule.selector; + + if (!isStandardSyntaxSelector(selector)) { + return; + } + + if (selector.indexOf("::") === -1) { + return; + } + + parseSelector(selector, result, rule, selectorTree => { + selectorTree.walkPseudos(pseudoNode => { + const value = pseudoNode.value; + + // Ignore pseudo-classes + if (value[1] !== ":") { + return; + } + + const name = value.slice(2); + + if ( + !matchesStringOrRegExp(postcss.vendor.unprefixed(name), blacklist) + ) { + return; + } + + report({ + index: pseudoNode.sourceIndex, + message: messages.rejected(name), + node: rule, + result, + ruleName + }); + }); + }); + }); + }; +}; + +rule.primaryOptionArray = true; + +rule.ruleName = ruleName; +rule.messages = messages; +module.exports = rule; diff --git a/lib/rules/selector-pseudo-element-whitelist/README.md b/lib/rules/selector-pseudo-element-whitelist/README.md new file mode 100644 index 0000000000..a0c3197110 --- /dev/null +++ b/lib/rules/selector-pseudo-element-whitelist/README.md @@ -0,0 +1,47 @@ +# selector-pseudo-element-whitelist + +Specify a whitelist of allowed pseudo-element selectors. + +```css + a::before {} +/** ↑ + * These pseudo-element selectors */ +``` + +This rule ignores CSS2 pseudo-elements i.e. those prefixed with a single colon. + +This rule ignores selectors that use variable interpolation e.g. `::#{$variable} {}`. + +## Options + +`array|string|regex`: `["array", "of", "unprefixed", "pseudo-elements" or "regex"]|"pseudo-element"|/regex/` + +Given: + +```js +["before", "/^my-/i"] +``` + +The following patterns are considered violations: + +```css +a::after {} +``` + +```css +a::not-my-pseudo-element {} +``` + +The following patterns are *not* considered violations: + +```css +a::before {} +``` + +```css +a::my-pseudo-element {} +``` + +```css +a::MY-OTHER-pseudo-element {} +``` diff --git a/lib/rules/selector-pseudo-element-whitelist/__tests__/index.js b/lib/rules/selector-pseudo-element-whitelist/__tests__/index.js new file mode 100644 index 0000000000..aaef657225 --- /dev/null +++ b/lib/rules/selector-pseudo-element-whitelist/__tests__/index.js @@ -0,0 +1,129 @@ +"use strict"; + +const messages = require("..").messages; +const ruleName = require("..").ruleName; +const rules = require("../../../rules"); + +const rule = rules[ruleName]; + +testRule(rule, { + ruleName, + config: ["before", "selection", /^my/i], + skipBasicChecks: true, + + accept: [ + { + code: "a {}" + }, + { + code: "a:hover {}" + }, + { + code: "a::before {}" + }, + { + code: "::selection {}" + }, + { + code: "::-webkit-selection {}" + }, + { + code: "a:not(::selection) {}" + }, + { + code: "a::my-pseudo-element {}" + }, + { + code: "a::MY-other-pseudo-element {}" + } + ], + + reject: [ + { + code: "a::BEFORE {}", + message: messages.rejected("BEFORE"), + line: 1, + column: 2 + }, + { + code: "a::after {}", + message: messages.rejected("after"), + line: 1, + column: 2 + }, + { + code: "a::AFTER {}", + message: messages.rejected("AFTER"), + line: 1, + column: 2 + }, + { + code: "a,\nb::after {}", + message: messages.rejected("after"), + line: 2, + column: 2 + }, + { + code: "a::not-my-pseudo-element {}", + message: messages.rejected("not-my-pseudo-element"), + line: 1, + column: 2 + } + ] +}); + +testRule(rule, { + ruleName, + config: /^before/, + skipBasicChecks: true, + + accept: [ + { + code: "::before {}" + }, + { + code: "::before-custom {}" + } + ], + reject: [ + { + code: "a::after {}", + message: messages.rejected("after"), + line: 1, + column: 2 + }, + { + code: "a::not-before {}", + message: messages.rejected("not-before"), + line: 1, + column: 2 + } + ] +}); + +testRule(rule, { + ruleName, + config: ["before"], + skipBasicChecks: true, + syntax: "scss", + + accept: [ + { + code: "::#{$variable} {}" + }, + { + code: "::#{$VARIABLE} {}" + }, + { + code: "a::#{$variable} {}" + } + ], + reject: [ + { + code: "a::after {}", + message: messages.rejected("after"), + line: 1, + column: 2 + } + ] +}); diff --git a/lib/rules/selector-pseudo-element-whitelist/index.js b/lib/rules/selector-pseudo-element-whitelist/index.js new file mode 100644 index 0000000000..f993d3e076 --- /dev/null +++ b/lib/rules/selector-pseudo-element-whitelist/index.js @@ -0,0 +1,78 @@ +"use strict"; + +const _ = require("lodash"); +const isStandardSyntaxRule = require("../../utils/isStandardSyntaxRule"); +const isStandardSyntaxSelector = require("../../utils/isStandardSyntaxSelector"); +const matchesStringOrRegExp = require("../../utils/matchesStringOrRegExp"); +const parseSelector = require("../../utils/parseSelector"); +const postcss = require("postcss"); +const report = require("../../utils/report"); +const ruleMessages = require("../../utils/ruleMessages"); +const validateOptions = require("../../utils/validateOptions"); + +const ruleName = "selector-pseudo-element-whitelist"; + +const messages = ruleMessages(ruleName, { + rejected: selector => `Unexpected pseudo-element "${selector}"` +}); + +const rule = function(whitelist) { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: whitelist, + possible: [_.isString, _.isRegExp] + }); + if (!validOptions) { + return; + } + + root.walkRules(rule => { + if (!isStandardSyntaxRule(rule)) { + return; + } + + const selector = rule.selector; + + if (!isStandardSyntaxSelector(selector)) { + return; + } + + if (selector.indexOf("::") === -1) { + return; + } + + parseSelector(selector, result, rule, selectorTree => { + selectorTree.walkPseudos(pseudoNode => { + const value = pseudoNode.value; + + // Ignore pseudo-classes + if (value[1] !== ":") { + return; + } + + const name = value.slice(2); + + if ( + matchesStringOrRegExp(postcss.vendor.unprefixed(name), whitelist) + ) { + return; + } + + report({ + index: pseudoNode.sourceIndex, + message: messages.rejected(name), + node: rule, + result, + ruleName + }); + }); + }); + }); + }; +}; + +rule.primaryOptionArray = true; + +rule.ruleName = ruleName; +rule.messages = messages; +module.exports = rule; diff --git a/lib/utils/__tests__/matchesStringOrRegExp.test.js b/lib/utils/__tests__/matchesStringOrRegExp.test.js index a880398589..9e2835367c 100644 --- a/lib/utils/__tests__/matchesStringOrRegExp.test.js +++ b/lib/utils/__tests__/matchesStringOrRegExp.test.js @@ -82,3 +82,25 @@ it("matchesStringOrRegExp comparing with a RegExp comparisonValue", () => { pattern: "/FOO/" }); }); + +it("matchesStringOrRegExp comparing with a actual RegExp comparisonValue", () => { + expect(matchesStringOrRegExp(".foo", /.foo$/)).toEqual({ + match: ".foo", + pattern: /.foo$/ + }); + expect(matchesStringOrRegExp("bar .foo", /.foo$/)).toEqual({ + match: "bar .foo", + pattern: /.foo$/ + }); + expect(matchesStringOrRegExp("bar .foo bar", /.foo$/)).toBeFalsy(); + expect(matchesStringOrRegExp("foo", /.foo$/)).toBeFalsy(); + expect(matchesStringOrRegExp([".foo", "ebarz"], [/.foo$/, /^bar/])).toEqual({ + match: ".foo", + pattern: /.foo$/ + }); + expect(matchesStringOrRegExp(["foobar"], [/FOO/])).toBeFalsy(); + expect(matchesStringOrRegExp(["FOOBAR"], [/FOO/])).toEqual({ + match: "FOOBAR", + pattern: /FOO/ + }); +}); diff --git a/lib/utils/matchesStringOrRegExp.js b/lib/utils/matchesStringOrRegExp.js index 966088aaee..29b1d56ae7 100644 --- a/lib/utils/matchesStringOrRegExp.js +++ b/lib/utils/matchesStringOrRegExp.js @@ -11,14 +11,14 @@ */ module.exports = function matchesStringOrRegExp( input /*: string | Array*/, - comparison /*: string | Array*/ -) /*: false | { match: string, pattern: string}*/ { + comparison /*: string | Array */ +) /*: false | { match: string, pattern: string }*/ { if (!Array.isArray(input)) { - return testAgainstStringOrArray(input, comparison); + return testAgainstStringOrRegExpOrArray(input, comparison); } for (const inputItem of input) { - const testResult = testAgainstStringOrArray(inputItem, comparison); + const testResult = testAgainstStringOrRegExpOrArray(inputItem, comparison); if (testResult) { return testResult; } @@ -27,13 +27,13 @@ module.exports = function matchesStringOrRegExp( return false; }; -function testAgainstStringOrArray(value, comparison) { +function testAgainstStringOrRegExpOrArray(value, comparison) { if (!Array.isArray(comparison)) { - return testAgainstString(value, comparison); + return testAgainstStringOrRegExp(value, comparison); } for (const comparisonItem of comparison) { - const testResult = testAgainstString(value, comparisonItem); + const testResult = testAgainstStringOrRegExp(value, comparisonItem); if (testResult) { return testResult; } @@ -41,7 +41,15 @@ function testAgainstStringOrArray(value, comparison) { return false; } -function testAgainstString(value, comparison) { +function testAgainstStringOrRegExp(value, comparison) { + // If it's a RegExp, test directly + if (comparison instanceof RegExp) { + return comparison.test(value) + ? { match: value, pattern: comparison } + : false; + } + + // Check if it's RegExp in a string const firstComparisonChar = comparison[0]; const lastComparisonChar = comparison[comparison.length - 1]; const secondToLastComparisonChar = comparison[comparison.length - 2]; @@ -54,6 +62,7 @@ function testAgainstString(value, comparison) { const hasCaseInsensitiveFlag = comparisonIsRegex && lastComparisonChar === "i"; + // If so, create a new RegExp from it if (comparisonIsRegex) { const valueMatches = hasCaseInsensitiveFlag ? new RegExp(comparison.slice(1, -2), "i").test(value) @@ -61,5 +70,6 @@ function testAgainstString(value, comparison) { return valueMatches ? { match: value, pattern: comparison } : false; } + // Otherwise, it's a string. Do a strict comparison return value === comparison ? { match: value, pattern: comparison } : false; }