stylelint
Version:
A mighty CSS linter that helps you avoid errors and enforce conventions.
302 lines (249 loc) • 8.21 kB
JavaScript
import { List, parse } from 'css-tree';
import { isRegExp, isString } from '../../utils/validateTypes.mjs';
import { colorProperties } from '../../reference/properties.mjs';
import eachDeclarationBlock from '../../utils/eachDeclarationBlock.mjs';
import isCustomProperty from '../../utils/isCustomProperty.mjs';
import isStandardSyntaxProperty from '../../utils/isStandardSyntaxProperty.mjs';
import isStandardSyntaxValue from '../../utils/isStandardSyntaxValue.mjs';
import { namedColorsKeywords } from '../../reference/keywords.mjs';
import optionsMatches from '../../utils/optionsMatches.mjs';
import report from '../../utils/report.mjs';
import ruleMessages from '../../utils/ruleMessages.mjs';
import validateOptions from '../../utils/validateOptions.mjs';
import vendor from '../../utils/vendor.mjs';
const ruleName = 'declaration-block-no-duplicate-properties';
const messages = ruleMessages(ruleName, {
rejected: (property) => `Unexpected duplicate "${property}"`,
});
const meta = {
url: 'https://stylelint.io/user-guide/rules/declaration-block-no-duplicate-properties',
fixable: true,
};
/** @typedef {import('css-tree').CssNode} CssNode */
/** @typedef {import('postcss').Declaration} Declaration */
/** @type {(node: CssNode) => node is CssNode & { children: import('css-tree').List<CssNode> }} */
const hasChildren = (node) => 'children' in node && node.children instanceof List;
/** @type {(node1: CssNode[], node2: CssNode[], property: string) => boolean} */
const isEqualValueNodes = (nodes1, nodes2, property) => {
// Different lengths indicate different syntaxes.
if (nodes1.length !== nodes2.length) {
return false;
}
for (let i = 0; i < nodes1.length; i++) {
const node1 = nodes1[i];
const node2 = nodes2[i];
// Different types indicate different syntaxes.
if (typeof node1 === 'undefined' || typeof node2 === 'undefined' || node1.type !== node2.type) {
return false;
}
const node1Name = 'name' in node1 ? String(node1.name) : '';
const node2Name = 'name' in node2 ? String(node2.name) : '';
// Custom properties have unknown value syntaxes but are equal for CSS parsers.
if (
node1.type === 'Identifier' &&
isCustomProperty(node1Name) &&
node2.type === 'Identifier' &&
isCustomProperty(node2Name)
) {
continue;
}
// Named colors have the same syntax even if the names differ
// This applies only to color properties (both single and multi-value)
if (
node1.type === 'Identifier' &&
node2.type === 'Identifier' &&
colorProperties.has(property.toLowerCase()) &&
namedColorsKeywords.has(node1Name.toLowerCase()) &&
namedColorsKeywords.has(node2Name.toLowerCase())
) {
continue;
}
// Different ident or function names indicate different syntaxes.
if (node1Name.toLowerCase() !== node2Name.toLowerCase()) {
return false;
}
const node1Unit = 'unit' in node1 ? node1.unit : '';
const node2Unit = 'unit' in node2 ? node2.unit : '';
// Different units indicate different syntaxes.
if (node1Unit !== node2Unit) {
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)) {
if (isEqualValueNodes(node1Children, node2Children, property)) {
continue;
} else {
return false;
}
}
}
return true;
};
/** @type {(value1: string, value2: string, property: string) => boolean} */
const isEqualValueSyntaxes = (value1, value2, property) => {
if (value1 === value2) {
return true;
}
if (!(isStandardSyntaxValue(value1) && isStandardSyntaxValue(value2))) {
return false;
}
let value1Node;
let value2Node;
try {
value1Node = parse(value1, { context: 'value' });
value2Node = parse(value2, { context: 'value' });
} catch {
return false;
}
const node1Children = hasChildren(value1Node) ? value1Node.children.toArray() : [];
const node2Children = hasChildren(value2Node) ? value2Node.children.toArray() : [];
return isEqualValueNodes(node1Children, node2Children, property);
};
/** @type {import('stylelint').CoreRules[ruleName]} */
const rule = (primary, secondaryOptions) => {
return (root, result) => {
const validOptions = validateOptions(
result,
ruleName,
{ actual: primary },
{
actual: secondaryOptions,
possible: {
ignore: [
'consecutive-duplicates',
'consecutive-duplicates-with-different-values',
'consecutive-duplicates-with-different-syntaxes',
'consecutive-duplicates-with-same-prefixless-values',
],
ignoreProperties: [isString, isRegExp],
},
optional: true,
},
);
if (!validOptions) {
return;
}
const ignoreDuplicates = optionsMatches(secondaryOptions, 'ignore', 'consecutive-duplicates');
const ignoreDiffValues = optionsMatches(
secondaryOptions,
'ignore',
'consecutive-duplicates-with-different-values',
);
const ignoreDiffSyntaxes = optionsMatches(
secondaryOptions,
'ignore',
'consecutive-duplicates-with-different-syntaxes',
);
const ignorePrefixlessSameValues = optionsMatches(
secondaryOptions,
'ignore',
'consecutive-duplicates-with-same-prefixless-values',
);
/** @param {Declaration} node */
const fixer = (node) => () => {
node.remove();
};
eachDeclarationBlock(root, (eachDecl) => {
/** @type {Declaration[]} */
const decls = [];
eachDecl((decl) => {
const prop = decl.prop;
const lowerProp = decl.prop.toLowerCase();
const value = decl.value;
const important = decl.important;
if (!isStandardSyntaxProperty(prop)) {
return;
}
if (isCustomProperty(prop)) {
return;
}
// Return early if the property is to be ignored
if (optionsMatches(secondaryOptions, 'ignoreProperties', prop)) {
return;
}
// Ignore the src property as commonly duplicated in at-fontface
if (lowerProp === 'src') {
return;
}
const indexDuplicate = decls.findIndex((d) => d.prop.toLowerCase() === lowerProp);
if (indexDuplicate === -1) {
decls.push(decl);
}
const duplicateDecl = decls[indexDuplicate];
if (!duplicateDecl) {
return;
}
const duplicateValue = duplicateDecl.value || '';
const duplicateImportant = duplicateDecl.important || false;
const duplicateIsMoreImportant = !important && duplicateImportant;
const duplicatesAreConsecutive = indexDuplicate === decls.length - 1;
const unprefixedDuplicatesAreEqual =
vendor.unprefixed(value) === vendor.unprefixed(duplicateValue);
const fixOrReport = () => {
const node = duplicateIsMoreImportant ? decl : duplicateDecl;
const word = duplicateIsMoreImportant ? prop : duplicateDecl.prop;
if (!duplicateIsMoreImportant) {
// replace previous "active" decl with current one
decls[indexDuplicate] = decl;
}
report({
message: messages.rejected,
messageArgs: [word],
node,
result,
ruleName,
word,
fix: {
apply: fixer(node),
node: node.parent,
},
});
};
if (ignoreDiffValues || ignoreDiffSyntaxes || ignorePrefixlessSameValues) {
if (
!duplicatesAreConsecutive ||
(ignorePrefixlessSameValues && !unprefixedDuplicatesAreEqual)
) {
fixOrReport();
return;
}
if (ignoreDiffSyntaxes) {
const duplicateValueSyntaxesAreEqual = isEqualValueSyntaxes(
value,
duplicateValue,
prop,
);
if (duplicateValueSyntaxesAreEqual) {
fixOrReport();
return;
}
}
if (value !== duplicateValue) {
return;
}
return report({
message: messages.rejected,
messageArgs: [duplicateDecl.prop],
node: duplicateDecl,
result,
ruleName,
word: duplicateDecl.prop,
fix: {
apply: fixer(duplicateDecl),
node: duplicateDecl.parent,
},
});
}
if (ignoreDuplicates && duplicatesAreConsecutive) {
return;
}
fixOrReport();
});
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
export default rule;