index.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. 'use strict';
  2. const resolvedNestedSelector = require('postcss-resolve-nested-selector');
  3. const { selectorSpecificity, compare } = require('@csstools/selector-specificity');
  4. const isStandardSyntaxRule = require('../../utils/isStandardSyntaxRule');
  5. const isStandardSyntaxSelector = require('../../utils/isStandardSyntaxSelector');
  6. const {
  7. aNPlusBOfSNotationPseudoClasses,
  8. aNPlusBNotationPseudoClasses,
  9. linguisticPseudoClasses,
  10. } = require('../../reference/selectors');
  11. const optionsMatches = require('../../utils/optionsMatches');
  12. const parseSelector = require('../../utils/parseSelector');
  13. const report = require('../../utils/report');
  14. const ruleMessages = require('../../utils/ruleMessages');
  15. const validateOptions = require('../../utils/validateOptions');
  16. const { isRegExp, isString, assertNumber } = require('../../utils/validateTypes');
  17. const ruleName = 'selector-max-specificity';
  18. const messages = ruleMessages(ruleName, {
  19. expected: (selector, max) => `Expected "${selector}" to have a specificity no more than "${max}"`,
  20. });
  21. const meta = {
  22. url: 'https://stylelint.io/user-guide/rules/list/selector-max-specificity',
  23. };
  24. /** @typedef {import('@csstools/selector-specificity').Specificity} Specificity */
  25. /**
  26. * Return a zero specificity. We need a new instance each time so that it can mutated.
  27. *
  28. * @returns {Specificity}
  29. */
  30. const zeroSpecificity = () => ({ a: 0, b: 0, c: 0 });
  31. /**
  32. * Calculate the sum of given specificiies.
  33. *
  34. * @param {Specificity[]} specificities
  35. * @returns {Specificity}
  36. */
  37. const specificitySum = (specificities) => {
  38. const sum = zeroSpecificity();
  39. for (const { a, b, c } of specificities) {
  40. sum.a += a;
  41. sum.b += b;
  42. sum.c += c;
  43. }
  44. return sum;
  45. };
  46. /** @type {import('stylelint').Rule<string>} */
  47. const rule = (primary, secondaryOptions) => {
  48. return (root, result) => {
  49. const validOptions = validateOptions(
  50. result,
  51. ruleName,
  52. {
  53. actual: primary,
  54. possible: [
  55. // Check that the max specificity is in the form "a,b,c"
  56. (spec) => isString(spec) && /^\d+,\d+,\d+$/.test(spec),
  57. ],
  58. },
  59. {
  60. actual: secondaryOptions,
  61. possible: {
  62. ignoreSelectors: [isString, isRegExp],
  63. },
  64. optional: true,
  65. },
  66. );
  67. if (!validOptions) {
  68. return;
  69. }
  70. /**
  71. * Calculate the specificity of a simple selector (type, attribute, class, ID, or pseudos's own value).
  72. *
  73. * @param {import('postcss-selector-parser').Node} node
  74. * @returns {Specificity}
  75. */
  76. const simpleSpecificity = (node) => {
  77. if (optionsMatches(secondaryOptions, 'ignoreSelectors', node.toString())) {
  78. return zeroSpecificity();
  79. }
  80. return selectorSpecificity(node);
  81. };
  82. /**
  83. * Calculate the the specificity of the most specific direct child.
  84. *
  85. * @param {import('postcss-selector-parser').Container<unknown>} node
  86. * @returns {Specificity}
  87. */
  88. const maxChildSpecificity = (node) =>
  89. node.reduce((maxSpec, child) => {
  90. const childSpecificity = nodeSpecificity(child); // eslint-disable-line no-use-before-define
  91. return compare(childSpecificity, maxSpec) > 0 ? childSpecificity : maxSpec;
  92. }, zeroSpecificity());
  93. /**
  94. * Calculate the specificity of a pseudo selector including own value and children.
  95. *
  96. * @param {import('postcss-selector-parser').Pseudo} node
  97. * @returns {Specificity}
  98. */
  99. const pseudoSpecificity = (node) => {
  100. // `node.toString()` includes children which should be processed separately,
  101. // so use `node.value` instead
  102. const ownValue = node.value.toLowerCase();
  103. if (ownValue === ':where') {
  104. return zeroSpecificity();
  105. }
  106. let ownSpecificity;
  107. if (optionsMatches(secondaryOptions, 'ignoreSelectors', ownValue)) {
  108. ownSpecificity = zeroSpecificity();
  109. } else if (aNPlusBOfSNotationPseudoClasses.has(ownValue.replace(/^:/, ''))) {
  110. // TODO: We need to support `<complex-selector-list>` in `ignoreSelectors`. E.g. `:nth-child(even of .foo)`.
  111. return selectorSpecificity(node);
  112. } else {
  113. ownSpecificity = selectorSpecificity(node.clone({ nodes: [] }));
  114. }
  115. return specificitySum([ownSpecificity, maxChildSpecificity(node)]);
  116. };
  117. /**
  118. * @param {import('postcss-selector-parser').Node} node
  119. * @returns {boolean}
  120. */
  121. const shouldSkipPseudoClassArgument = (node) => {
  122. // postcss-selector-parser includes the arguments to nth-child() functions
  123. // as "tags", so we need to ignore them ourselves.
  124. // The fake-tag's "parent" is actually a selector node, whose parent
  125. // should be the :nth-child pseudo node.
  126. const parentNode = node.parent && node.parent.parent;
  127. if (parentNode && parentNode.type === 'pseudo' && parentNode.value) {
  128. const pseudoClass = parentNode.value.toLowerCase().replace(/^:/, '');
  129. return (
  130. aNPlusBNotationPseudoClasses.has(pseudoClass) || linguisticPseudoClasses.has(pseudoClass)
  131. );
  132. }
  133. return false;
  134. };
  135. /**
  136. * Calculate the specificity of a node parsed by `postcss-selector-parser`.
  137. *
  138. * @param {import('postcss-selector-parser').Node} node
  139. * @returns {Specificity}
  140. */
  141. const nodeSpecificity = (node) => {
  142. if (shouldSkipPseudoClassArgument(node)) {
  143. return zeroSpecificity();
  144. }
  145. switch (node.type) {
  146. case 'attribute':
  147. case 'class':
  148. case 'id':
  149. case 'tag':
  150. return simpleSpecificity(node);
  151. case 'pseudo':
  152. return pseudoSpecificity(node);
  153. case 'selector':
  154. // Calculate the sum of all the direct children
  155. return specificitySum(node.map((n) => nodeSpecificity(n)));
  156. default:
  157. return zeroSpecificity();
  158. }
  159. };
  160. const [a, b, c] = primary.split(',').map((s) => Number.parseFloat(s));
  161. assertNumber(a);
  162. assertNumber(b);
  163. assertNumber(c);
  164. const maxSpecificity = { a, b, c };
  165. root.walkRules((ruleNode) => {
  166. if (!isStandardSyntaxRule(ruleNode)) {
  167. return;
  168. }
  169. // Using `.selectors` gets us each selector in the eventuality we have a comma separated set
  170. for (const selector of ruleNode.selectors) {
  171. for (const resolvedSelector of resolvedNestedSelector(selector, ruleNode)) {
  172. // Skip non-standard syntax selectors
  173. if (!isStandardSyntaxSelector(resolvedSelector)) {
  174. continue;
  175. }
  176. parseSelector(resolvedSelector, result, ruleNode, (selectorTree) => {
  177. // Check if the selector specificity exceeds the allowed maximum
  178. if (compare(maxChildSpecificity(selectorTree), maxSpecificity) > 0) {
  179. report({
  180. ruleName,
  181. result,
  182. node: ruleNode,
  183. message: messages.expected(resolvedSelector, primary),
  184. word: selector,
  185. });
  186. }
  187. });
  188. }
  189. }
  190. });
  191. };
  192. };
  193. rule.ruleName = ruleName;
  194. rule.messages = messages;
  195. rule.meta = meta;
  196. module.exports = rule;