diff --git a/.changeset/kind-cobras-peel.md b/.changeset/kind-cobras-peel.md new file mode 100644 index 0000000000..a49cbfac38 --- /dev/null +++ b/.changeset/kind-cobras-peel.md @@ -0,0 +1,5 @@ +--- +"stylelint": minor +--- + +Added: `ignore: ["consecutive-duplicates-with-different-syntaxes"]` to `declaration-block-no-duplicate-properties` diff --git a/lib/rules/declaration-block-no-duplicate-properties/README.md b/lib/rules/declaration-block-no-duplicate-properties/README.md index ef7f883414..adf32bf285 100644 --- a/lib/rules/declaration-block-no-duplicate-properties/README.md +++ b/lib/rules/declaration-block-no-duplicate-properties/README.md @@ -112,6 +112,33 @@ p { } ``` +### `ignore: ["consecutive-duplicates-with-different-syntaxes"]` + +Ignore consecutive duplicated properties with different value syntaxes (type and unit of value). + +The following patterns are considered problems: + + +```css +/* properties with the same value syntax */ +p { + font-size: 16px; + font-size: 14px; + font-weight: 400; +} +``` + +The following patterns are _not_ considered problems: + + +```css +p { + font-size: 16px; + font-size: 16rem; + font-weight: 400; +} +``` + ### `ignore: ["consecutive-duplicates-with-same-prefixless-values"]` Ignore consecutive duplicated properties with identical values, when ignoring their prefix. diff --git a/lib/rules/declaration-block-no-duplicate-properties/__tests__/index.js b/lib/rules/declaration-block-no-duplicate-properties/__tests__/index.js index 45a89130b9..186fc7cdc7 100644 --- a/lib/rules/declaration-block-no-duplicate-properties/__tests__/index.js +++ b/lib/rules/declaration-block-no-duplicate-properties/__tests__/index.js @@ -262,7 +262,10 @@ testRule({ config: [true, { ignore: ['consecutive-duplicates-with-different-values'] }], fix: true, - accept: [{ code: 'p { font-size: 16px; font-size: 1rem; font-weight: 400; }' }], + accept: [ + { code: 'p { font-size: 16px; font-size: 1rem; font-weight: 400; }' }, + { code: 'p { font-size: 16px; font-size: 18px; font-weight: 400; }' }, + ], reject: [ { @@ -288,6 +291,73 @@ testRule({ ], }); +testRule({ + ruleName, + config: [true, { ignore: ['consecutive-duplicates-with-different-syntaxes'] }], + fix: true, + + accept: [ + { code: 'p { width: 100vw; height: 100vh; }' }, + { code: 'p { width: 100vw; width: 100dvw; height: 100vh; }' }, + { code: 'p { margin: 10dvw 10dvw; margin: 10vw 10vw; padding: 0; }' }, + { code: 'p { margin: 10dvh 10dvw 10dvh; margin: 10vw 10vw; padding: 0; }' }, + { code: 'p { width: 100%; width: fit-content; }' }, + { code: 'p { width: calc(10px + 2px); width: calc(10px + 2rem); }' }, + { code: 'p { width: calc(10px + 2px); width: calc(10rem + 2rem); }' }, + { code: 'p { width: min(10px, 11px); width: max(10px, 11px); }' }, + { code: 'p { width: calc((10px + 2px)); width: calc((10rem + 2rem)); }' }, + { code: 'p { width: calc((10px + 2px) + 10px); width: calc((10rem + 2rem) + 10px); }' }, + ], + + reject: [ + { + code: 'p { width: 100vw; height: 100vh; width: 100dvw; }', + fixed: 'p { height: 100vh; width: 100dvw; }', + message: messages.rejected('width'), + }, + { + code: 'p { width: 100vw; width: 100vw; height: 100vh; }', + fixed: 'p { width: 100vw; height: 100vh; }', + message: messages.rejected('width'), + }, + { + code: 'p { margin: 10vw 10vw; margin: 10vw 10vw; padding: 0; }', + fixed: 'p { margin: 10vw 10vw; padding: 0; }', + message: messages.rejected('margin'), + }, + { + code: 'p { width: 100vw; width: 50vw; height: 100vh; }', + fixed: 'p { width: 50vw; height: 100vh; }', + message: messages.rejected('width'), + }, + { + code: 'p { width: 100vw !important; height: 100vh; width: 100dvw; }', + fixed: 'p { width: 100vw !important; height: 100vh; }', + message: messages.rejected('width'), + }, + { + code: 'p { width: 100vw; height: 100vh; width: 100dvw !important; }', + fixed: 'p { height: 100vh; width: 100dvw !important; }', + message: messages.rejected('width'), + }, + { + code: 'p { width: min-content; width: max-content; height: 100%; }', + fixed: 'p { width: max-content; height: 100%; }', + message: messages.rejected('width'), + }, + { + code: 'p { width: calc(10px + 4rem); width: calc(10px + 2rem); }', + fixed: 'p { width: calc(10px + 2rem); }', + message: messages.rejected('width'), + }, + { + code: 'p { width: CaLC(10px + 4rem); width: calc(10px + 2rem); }', + fixed: 'p { width: calc(10px + 2rem); }', + message: messages.rejected('width'), + }, + ], +}); + testRule({ ruleName, config: [true, { ignore: ['consecutive-duplicates-with-same-prefixless-values'] }], diff --git a/lib/rules/declaration-block-no-duplicate-properties/index.js b/lib/rules/declaration-block-no-duplicate-properties/index.js index 6f58c2169c..63d96ad0c6 100644 --- a/lib/rules/declaration-block-no-duplicate-properties/index.js +++ b/lib/rules/declaration-block-no-duplicate-properties/index.js @@ -1,5 +1,6 @@ 'use strict'; +const { parse, List } = require('css-tree'); const eachDeclarationBlock = require('../../utils/eachDeclarationBlock'); const isCustomProperty = require('../../utils/isCustomProperty'); const isStandardSyntaxProperty = require('../../utils/isStandardSyntaxProperty'); @@ -21,6 +22,68 @@ const meta = { fixable: true, }; +/** @typedef {import('css-tree').CssNode} CssNode */ + +/** @type {(node: CssNode) => node is CssNode & { children: List }} */ +const hasChildren = (node) => 'children' in node && node.children instanceof List; + +/** @type {(node1: CssNode[], node2: CssNode[]) => boolean} */ +const isEqualValueNodes = (nodes1, nodes2) => { + if (nodes1.length !== nodes2.length) { + return false; + } + + for (let i = 0; i < nodes1.length; i++) { + const node1 = nodes1[i]; + const node2 = nodes2[i]; + + if (typeof node1 === 'undefined' || typeof node2 === 'undefined' || node1.type !== node2.type) { + return false; + } + + const node1Children = hasChildren(node1) ? node1.children.toArray() : null; + const node2Children = hasChildren(node2) ? node2.children.toArray() : null; + + if (Array.isArray(node1Children) && Array.isArray(node2Children)) { + const node1Name = 'name' in node1 ? String(node1.name) : ''; + const node2Name = 'name' in node2 ? String(node2.name) : ''; + + if (node1Name.toLowerCase() !== node2Name.toLowerCase()) { + return false; + } + + if (isEqualValueNodes(node1Children, node2Children)) { + continue; + } else { + return false; + } + } + + const node1Unit = 'unit' in node1 ? node1.unit : ''; + const node2Unit = 'unit' in node2 ? node2.unit : ''; + + if (node1Unit !== node2Unit) { + return false; + } + } + + return true; +}; + +/** @type {(value1: string, value2: string) => boolean} */ +const isEqualValueSyntaxes = (value1, value2) => { + if (value1 === value2) { + return true; + } + + const value1Node = parse(value1, { context: 'value' }); + const value2Node = parse(value2, { context: 'value' }); + const node1Children = hasChildren(value1Node) ? value1Node.children.toArray() : []; + const node2Children = hasChildren(value2Node) ? value2Node.children.toArray() : []; + + return isEqualValueNodes(node1Children, node2Children); +}; + /** @type {import('stylelint').Rule} */ const rule = (primary, secondaryOptions, context) => { return (root, result) => { @@ -34,6 +97,7 @@ const rule = (primary, secondaryOptions, context) => { ignore: [ 'consecutive-duplicates', 'consecutive-duplicates-with-different-values', + 'consecutive-duplicates-with-different-syntaxes', 'consecutive-duplicates-with-same-prefixless-values', ], ignoreProperties: [isString, isRegExp], @@ -52,6 +116,11 @@ const rule = (primary, secondaryOptions, context) => { 'ignore', 'consecutive-duplicates-with-different-values', ); + const ignoreDiffSyntaxes = optionsMatches( + secondaryOptions, + 'ignore', + 'consecutive-duplicates-with-different-syntaxes', + ); const ignorePrefixlessSameValues = optionsMatches( secondaryOptions, 'ignore', @@ -124,12 +193,24 @@ const rule = (primary, secondaryOptions, context) => { return duplicateDecl.remove(); }; - if (ignoreDiffValues || ignorePrefixlessSameValues) { + if (ignoreDiffValues || ignoreDiffSyntaxes || ignorePrefixlessSameValues) { if ( !duplicatesAreConsecutive || (ignorePrefixlessSameValues && !unprefixedDuplicatesAreEqual) ) { fixOrReport(); + + return; + } + + if (ignoreDiffSyntaxes) { + const duplicateValueSyntaxesAreEqual = isEqualValueSyntaxes(value, duplicateValue); + + if (duplicateValueSyntaxesAreEqual) { + fixOrReport(); + + return; + } } if (value !== duplicateValue) {