index.mjs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import {
  2. MediaFeatureName,
  3. MediaFeatureRangeNameValue,
  4. isMediaFeature,
  5. isMediaFeaturePlain,
  6. isMediaFeatureRange,
  7. isMediaFeatureRangeNameValue,
  8. isMediaFeatureRangeValueName,
  9. isMediaQueryInvalid,
  10. } from '@csstools/media-query-list-parser';
  11. import { TokenNode, sourceIndices } from '@csstools/css-parser-algorithms';
  12. import { TokenType } from '@csstools/css-tokenizer';
  13. import { atRuleParamIndex } from '../../utils/nodeFieldIndices.mjs';
  14. import { atRuleRegexes } from '../../utils/regexes.mjs';
  15. import optionsMatches from '../../utils/optionsMatches.mjs';
  16. import parseMediaQuery from '../../utils/parseMediaQuery.mjs';
  17. import { rangeTypeMediaFeatureNames } from '../../reference/mediaFeatures.mjs';
  18. import report from '../../utils/report.mjs';
  19. import ruleMessages from '../../utils/ruleMessages.mjs';
  20. import validateOptions from '../../utils/validateOptions.mjs';
  21. const ruleName = 'media-feature-range-notation';
  22. const messages = ruleMessages(ruleName, {
  23. expected: (primary) => `Expected "${primary}" media feature range notation`,
  24. });
  25. const meta = {
  26. url: 'https://stylelint.io/user-guide/rules/media-feature-range-notation',
  27. fixable: true,
  28. };
  29. /** @import {TokenDelim} from '@csstools/css-tokenizer' */
  30. /** @type {import('stylelint').CoreRules[ruleName]} */
  31. const rule = (primary, secondaryOptions) => {
  32. return (root, result) => {
  33. const validOptions = validateOptions(
  34. result,
  35. ruleName,
  36. {
  37. actual: primary,
  38. possible: ['prefix', 'context'],
  39. },
  40. {
  41. actual: secondaryOptions,
  42. possible: {
  43. except: ['exact-value'],
  44. },
  45. optional: true,
  46. },
  47. );
  48. if (!validOptions) {
  49. return;
  50. }
  51. const exceptExactValue = optionsMatches(secondaryOptions, 'except', 'exact-value');
  52. root.walkAtRules(atRuleRegexes.mediaName, (atRule) => {
  53. const mediaQueryList = parseMediaQuery(atRule);
  54. mediaQueryList.forEach((mediaQuery) => {
  55. if (isMediaQueryInvalid(mediaQuery)) return;
  56. mediaQuery.walk(({ node, parent }) => {
  57. // Only look at plain and range notation media features
  58. if (!isMediaFeatureRange(node) && !isMediaFeaturePlain(node)) return;
  59. const featureName = node.getName();
  60. const unprefixedMediaFeature = featureName.replace(/^(?:min|max)-/i, '');
  61. if (!rangeTypeMediaFeatureNames.has(unprefixedMediaFeature)) return;
  62. if (exceptExactValue) {
  63. const isMediaFeatureRangeWithExactOperator =
  64. (isMediaFeatureRangeNameValue(node) || isMediaFeatureRangeValueName(node)) &&
  65. node.operator.length === 1 &&
  66. node.operator[0][1] === '=';
  67. const isMediaFeaturePlainUnprefixed =
  68. isMediaFeaturePlain(node) && featureName.length === unprefixedMediaFeature.length;
  69. if (isMediaFeatureRangeWithExactOperator || isMediaFeaturePlainUnprefixed) {
  70. primary = primary === 'prefix' ? 'context' : 'prefix';
  71. }
  72. }
  73. // Expected plain notation and received plain notation
  74. if (primary === 'prefix' && isMediaFeaturePlain(node)) return;
  75. // Expected range notation and received range notation
  76. if (primary === 'context' && isMediaFeatureRange(node)) return;
  77. /**
  78. * @param {object} entry
  79. * @param {import('@csstools/media-query-list-parser').MediaFeaturePlain} entry.node
  80. * @param {import('@csstools/media-query-list-parser').MediaFeature} entry.parent
  81. */
  82. const contextFixer = (entry) => () => {
  83. /** @type {[TokenDelim]|[TokenDelim, TokenDelim]} */
  84. let operator;
  85. if (/^min-/i.test(featureName)) {
  86. operator = [
  87. [TokenType.Delim, '>', -1, -1, { value: '>' }],
  88. [TokenType.Delim, '=', -1, -1, { value: '=' }],
  89. ];
  90. } else if (/^max-/i.test(featureName)) {
  91. operator = [
  92. [TokenType.Delim, '<', -1, -1, { value: '<' }],
  93. [TokenType.Delim, '=', -1, -1, { value: '=' }],
  94. ];
  95. } else {
  96. operator = [[TokenType.Delim, '=', -1, -1, { value: '=' }]];
  97. }
  98. entry.parent.feature = new MediaFeatureRangeNameValue(
  99. new MediaFeatureName(
  100. new TokenNode([
  101. TokenType.Ident,
  102. unprefixedMediaFeature,
  103. -1,
  104. -1,
  105. { value: unprefixedMediaFeature },
  106. ]),
  107. entry.node.name.before,
  108. entry.node.name.after.length > 0
  109. ? entry.node.name.after
  110. : [[TokenType.Whitespace, ' ', -1, -1, undefined]],
  111. ),
  112. operator,
  113. entry.node.value,
  114. );
  115. const expectedMediaQueryList = mediaQueryList.map((mq) => mq.toString()).join(',');
  116. if (expectedMediaQueryList === atRule.params) return;
  117. atRule.params = expectedMediaQueryList;
  118. };
  119. const hasFix =
  120. primary === 'context' && isMediaFeaturePlain(node) && isMediaFeature(parent);
  121. const fix = hasFix ? contextFixer({ node, parent }) : undefined;
  122. const [startIndex, endIndex] = sourceIndices(node);
  123. const atRuleIndex = atRuleParamIndex(atRule);
  124. report({
  125. message: messages.expected,
  126. messageArgs: [primary],
  127. node: atRule,
  128. index: atRuleIndex + startIndex - 1,
  129. endIndex: atRuleIndex + endIndex + 1 + 1,
  130. result,
  131. ruleName,
  132. fix: {
  133. apply: fix,
  134. node: atRule,
  135. },
  136. });
  137. });
  138. });
  139. });
  140. };
  141. };
  142. rule.ruleName = ruleName;
  143. rule.messages = messages;
  144. rule.meta = meta;
  145. export default rule;