diff --git a/docs/user-guide/rules/list.md b/docs/user-guide/rules/list.md index bdf6b80a02..440d8b614a 100644 --- a/docs/user-guide/rules/list.md +++ b/docs/user-guide/rules/list.md @@ -165,6 +165,7 @@ Within each cateogory, the rules are grouped by the [_thing_](http://apps.workfl - [`declaration-block-no-redundant-longhand-properties`](../../../lib/rules/declaration-block-no-redundant-longhand-properties/README.md): Disallow longhand properties that can be combined into one shorthand property. - [`declaration-no-important`](../../../lib/rules/declaration-no-important/README.md): Disallow `!important` within declarations. +- [`declaration-property-max-values`](../../../lib/rules/declaration-property-max-values/README.md): Limit the number of values for a list of properties within declarations. - [`declaration-property-unit-allowed-list`](../../../lib/rules/declaration-property-unit-allowed-list/README.md): Specify a list of allowed property and unit pairs within declarations. - [`declaration-property-unit-disallowed-list`](../../../lib/rules/declaration-property-unit-disallowed-list/README.md): Specify a list of disallowed property and unit pairs within declarations. - [`declaration-property-value-allowed-list`](../../../lib/rules/declaration-property-value-allowed-list/README.md): Specify a list of allowed property and value pairs within declarations. diff --git a/lib/rules/declaration-property-max-values/README.md b/lib/rules/declaration-property-max-values/README.md new file mode 100644 index 0000000000..4a514b2aae --- /dev/null +++ b/lib/rules/declaration-property-max-values/README.md @@ -0,0 +1,52 @@ +# declaration-property-max-values + +Limit the number of values for a list of properties within declarations. + +## Options + +`object`: `{ "unprefixed-property-name": int }` + +If a property name is surrounded with `"/"` (e.g. `"/^margin/"`), it is interpreted as a regular expression. This allows, for example, easy targeting of shorthands: `/^margin/` will match `margin`, `margin-top`, `margin-inline`, etc. + +Given: + +```json +{ + "border": 2, + "/^margin/": 1, +}, +``` + +The following patterns are considered problems: + + +```css +a { border: 1px solid blue; } +``` + + +```css +a { margin: 1px 2px; } +``` + + +```css +a { margin-inline: 1px 2px; } +``` + +The following patterns are _not_ considered problems: + + +```css +a { border: 1px solid; } +``` + + +```css +a { margin: 1px; } +``` + + +```css +a { margin-inline: 1px; } +``` diff --git a/lib/rules/declaration-property-max-values/__tests__/index.js b/lib/rules/declaration-property-max-values/__tests__/index.js new file mode 100644 index 0000000000..8876516b94 --- /dev/null +++ b/lib/rules/declaration-property-max-values/__tests__/index.js @@ -0,0 +1,87 @@ +'use strict'; + +const { messages, ruleName } = require('..'); + +testRule({ + ruleName, + + config: [ + { + border: 2, + '/^margin/': 1, + }, + ], + + accept: [ + { + code: 'a { margin: 0; }', + }, + { + code: 'a { margin: 1px; }', + }, + { + code: 'a { margin: var(--foo); }', + description: 'deals with CSS variables', + }, + { + code: 'a { margin: 1px /* 3px */; }', + description: 'ignore values in comments', + }, + { + code: 'a { margin-inline: 1px; }', + }, + { + code: 'a { margin: ; }', + }, + { + code: 'a { border: 1px; }', + }, + { + code: 'a { border: 1px solid; }', + }, + { + code: 'a { transition: margin-right 2s ease-in-out; }', + description: 'irrelevant shorthand', + }, + ], + + reject: [ + { + code: 'a { margin: 1px 2px; }', + message: messages.rejected('margin', 1), + line: 1, + column: 5, + }, + { + code: 'a { margin-inline: 1px 2px; }', + message: messages.rejected('margin-inline', 1), + line: 1, + column: 5, + }, + { + code: 'a { margin: var(--foo) var(--bar); }', + message: messages.rejected('margin', 1), + line: 1, + column: 5, + description: 'deals with CSS variables', + }, + { + code: 'a { margin: 1px 2px 3px 4px; }', + message: messages.rejected('margin', 1), + line: 1, + column: 5, + }, + { + code: 'a { margin: 0 0 0 0; }', + message: messages.rejected('margin', 1), + line: 1, + column: 5, + }, + { + code: 'a { border: 1px solid blue; }', + message: messages.rejected('border', 2), + line: 1, + column: 5, + }, + ], +}); diff --git a/lib/rules/declaration-property-max-values/index.js b/lib/rules/declaration-property-max-values/index.js new file mode 100644 index 0000000000..eecced999c --- /dev/null +++ b/lib/rules/declaration-property-max-values/index.js @@ -0,0 +1,75 @@ +'use strict'; + +const valueParser = require('postcss-value-parser'); + +const matchesStringOrRegExp = require('../../utils/matchesStringOrRegExp'); +const report = require('../../utils/report'); +const ruleMessages = require('../../utils/ruleMessages'); +const vendor = require('../../utils/vendor'); +const validateOptions = require('../../utils/validateOptions'); +const { isNumber } = require('../../utils/validateTypes'); +const validateObjectWithProps = require('../../utils/validateObjectWithProps'); + +const ruleName = 'declaration-property-max-values'; + +const messages = ruleMessages(ruleName, { + rejected: (property, max) => + `Expected "${property}" to have no more than ${max} ${max === 1 ? 'value' : 'values'}`, +}); + +const meta = { + url: 'https://stylelint.io/user-guide/rules/list/declaration-property-max-values', +}; + +/** + * @param {valueParser.Node} node + */ +const isValueNode = (node) => { + return node.type === 'word' || node.type === 'function' || node.type === 'string'; +}; + +/** @type {import('stylelint').Rule>} */ +const rule = (primary) => { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: [validateObjectWithProps(isNumber)], + }); + + if (!validOptions) { + return; + } + + root.walkDecls((decl) => { + const { prop, value } = decl; + const propLength = valueParser(value).nodes.filter(isValueNode).length; + + const unprefixedProp = vendor.unprefixed(prop); + const propKey = Object.keys(primary).find((propIdentifier) => + matchesStringOrRegExp(unprefixedProp, propIdentifier), + ); + + if (!propKey) { + return; + } + + const max = primary[propKey]; + + if (propLength <= max) { + return; + } + + report({ + message: messages.rejected(prop, max), + node: decl, + result, + ruleName, + }); + }); + }; +}; + +rule.ruleName = ruleName; +rule.messages = messages; +rule.meta = meta; +module.exports = rule; diff --git a/lib/rules/index.js b/lib/rules/index.js index e1f338f39a..a300e747b4 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -81,6 +81,7 @@ const rules = { 'declaration-colon-space-before': importLazy('./declaration-colon-space-before'), 'declaration-empty-line-before': importLazy('./declaration-empty-line-before'), 'declaration-no-important': importLazy('./declaration-no-important'), + 'declaration-property-max-values': importLazy('./declaration-property-max-values'), 'declaration-property-unit-allowed-list': importLazy('./declaration-property-unit-allowed-list'), 'declaration-property-unit-disallowed-list': importLazy( './declaration-property-unit-disallowed-list', diff --git a/lib/utils/__tests__/validateObjectWithProps.test.js b/lib/utils/__tests__/validateObjectWithProps.test.js new file mode 100644 index 0000000000..17c7f94de9 --- /dev/null +++ b/lib/utils/__tests__/validateObjectWithProps.test.js @@ -0,0 +1,36 @@ +'use strict'; + +const validateObjectWithProps = require('../validateObjectWithProps'); +const { isNumber } = require('../validateTypes'); + +describe('validateObjectWithProps', () => { + it('should return a function', () => { + expect(validateObjectWithProps((x) => x)).toBeInstanceOf(Function); + }); + + it('should reject non-objects', () => { + expect(validateObjectWithProps((x) => x)(42)).toBeFalsy(); + }); + + describe('simple isNumber validator', () => { + const validator = validateObjectWithProps(isNumber); + + it('should accept an object where the validators are true for any value', () => { + expect( + validator({ + value1: 1, + value2: 2, + }), + ).toBeTruthy(); + }); + + it('should be false if the validator is false for at least one value', () => { + expect( + validator({ + value1: 1, + value2: '2', + }), + ).toBeFalsy(); + }); + }); +}); diff --git a/lib/utils/validateObjectWithProps.js b/lib/utils/validateObjectWithProps.js new file mode 100644 index 0000000000..36c1246449 --- /dev/null +++ b/lib/utils/validateObjectWithProps.js @@ -0,0 +1,28 @@ +'use strict'; + +const { isPlainObject } = require('./validateTypes'); + +/** + * Check whether the variable is an object and all its properties agree with the provided validator. + * + * @example + * config = { + * value1: 1, + * value2: 2, + * value3: 3, + * }; + * validateObjectWithProps(isNumber)(config); + * //=> true + * + * @param {(value: unknown) => boolean} validator + * @returns {(value: unknown) => boolean} + */ +module.exports = (validator) => (value) => { + if (!isPlainObject(value)) { + return false; + } + + return Object.values(value).every((item) => { + return validator(item); + }); +};