diff --git a/.changeset/few-monkeys-fold.md b/.changeset/few-monkeys-fold.md new file mode 100644 index 0000000000..f1dc1a67a0 --- /dev/null +++ b/.changeset/few-monkeys-fold.md @@ -0,0 +1,5 @@ +--- +"stylelint": minor +--- + +Added: `media-feature-name-unit-allowed-list` rule diff --git a/docs/user-guide/rules.md b/docs/user-guide/rules.md index 48e2038e17..00a27915d5 100644 --- a/docs/user-guide/rules.md +++ b/docs/user-guide/rules.md @@ -136,6 +136,7 @@ Allow, disallow or require things with these `allowed-list`, `disallowed-list`, - [`media-feature-name-allowed-list`](../../lib/rules/media-feature-name-allowed-list/README.md): Specify a list of allowed media feature names. - [`media-feature-name-disallowed-list`](../../lib/rules/media-feature-name-disallowed-list/README.md): Specify a list of disallowed media feature names. - [`media-feature-name-no-vendor-prefix`](../../lib/rules/media-feature-name-no-vendor-prefix/README.md): Disallow vendor prefixes for media feature names (Autofixable). +- [`media-feature-name-unit-allowed-list`](../../lib/rules/media-feature-name-unit-allowed-list/README.md): Specify a list of allowed name and unit pairs within media features. - [`media-feature-name-value-allowed-list`](../../lib/rules/media-feature-name-value-allowed-list/README.md): Specify a list of allowed media feature name and value pairs. #### Property diff --git a/lib/rules/index.js b/lib/rules/index.js index e0118bd01d..f502a8781d 100644 --- a/lib/rules/index.js +++ b/lib/rules/index.js @@ -191,6 +191,9 @@ const rules = { 'media-feature-name-no-vendor-prefix': importLazy(() => require('./media-feature-name-no-vendor-prefix'), )(), + 'media-feature-name-unit-allowed-list': importLazy(() => + require('./media-feature-name-unit-allowed-list'), + )(), 'media-feature-name-value-allowed-list': importLazy(() => require('./media-feature-name-value-allowed-list'), )(), diff --git a/lib/rules/media-feature-name-unit-allowed-list/README.md b/lib/rules/media-feature-name-unit-allowed-list/README.md new file mode 100644 index 0000000000..da2bc1b3c5 --- /dev/null +++ b/lib/rules/media-feature-name-unit-allowed-list/README.md @@ -0,0 +1,69 @@ +# media-feature-name-unit-allowed-list + +Specify a list of allowed name and unit pairs within media features. + + +```css +@media (width: 50em) {} +/** ↑ ↑ + * This media feature name and these units */ +``` + +## Options + +`object`: `{ "name": ["array", "of", "units"]|"unit" }` + +If a feature name is surrounded with `"/"` (e.g. `"/height/"`), it is interpreted as a regular expression. This allows, for example, easy targeting of shorthands: `/height/` will match `height`, `min-height`, `max-height`, etc. + +Given: + +```json +{ + "width": "em", + "/height/": ["em", "rem"] +} +``` + +The following patterns are considered problems: + + +```css +@media (width: 50rem) {} +``` + + +```css +@media (height: 1000px) {} +``` + + +```css +@media (min-height: 1000px) {} +``` + + +```css +@media (height <= 1000px) {} +``` + +The following patterns are _not_ considered problems: + + +```css +@media (width: 50em) {} +``` + + +```css +@media (width <= 50em) {} +``` + + +```css +@media (height: 50em) {} +``` + + +```css +@media (min-height: 50rem) {} +``` diff --git a/lib/rules/media-feature-name-unit-allowed-list/__tests__/index.js b/lib/rules/media-feature-name-unit-allowed-list/__tests__/index.js new file mode 100644 index 0000000000..813026e3de --- /dev/null +++ b/lib/rules/media-feature-name-unit-allowed-list/__tests__/index.js @@ -0,0 +1,170 @@ +'use strict'; + +const { messages, ruleName } = require('..'); + +testRule({ + ruleName, + config: { width: 'em', '/height/': ['em', 'rem'] }, + + accept: [ + { + code: '@media (width: 50em) {}', + }, + { + code: '@media (width <= 50em) {}', + description: 'media queries level 4 - inequality', + }, + { + code: '@media (min-width: 50rem) {}', + }, + { + code: '@media (max-width: 50rem) {}', + }, + { + code: '@media (30em <= width <= 50em) {}', + description: 'media queries level 4 - range', + }, + { + code: '@media print and (width: 50em) {}', + description: 'compound selector', + }, + { + code: '@media (height: 50rem) {}', + }, + { + code: '@media (min-height: 50rem) {}', + }, + { + code: '@media (max-height: 50rem) {}', + }, + { + code: '@media (30rem <= height <= 50rem) {}', + description: 'media queries level 4 - range', + }, + ], + + reject: [ + { + code: '@media (width: 50rem) {}', + message: messages.rejected('rem', 'width'), + line: 1, + column: 16, + endLine: 1, + endColumn: 21, + }, + { + code: '@media (width <= 50rem) {}', + message: messages.rejected('rem', 'width'), + description: 'media queries level 4 - inequality', + line: 1, + column: 18, + endLine: 1, + endColumn: 23, + }, + { + code: '@media (30rem <= width <= 50rem) {}', + description: 'media queries level 4 - range', + warnings: [ + { + message: messages.rejected('rem', 'width'), + line: 1, + column: 9, + endLine: 1, + endColumn: 14, + }, + { + message: messages.rejected('rem', 'width'), + line: 1, + column: 27, + endLine: 1, + endColumn: 32, + }, + ], + }, + { + code: '@media (width: 1000px) {}', + message: messages.rejected('px', 'width'), + line: 1, + column: 16, + endLine: 1, + endColumn: 22, + }, + { + code: '@media (height: 1000px) {}', + message: messages.rejected('px', 'height'), + line: 1, + column: 17, + endLine: 1, + endColumn: 23, + }, + { + code: '@media (height <= 1000px) {}', + message: messages.rejected('px', 'height'), + description: 'media queries level 4 - inequality', + line: 1, + column: 19, + endLine: 1, + endColumn: 25, + }, + { + code: '@media (100px <= height <= 1000px) {}', + description: 'media queries level 4 - range', + warnings: [ + { + message: messages.rejected('px', 'height'), + line: 1, + column: 9, + endLine: 1, + endColumn: 14, + }, + { + message: messages.rejected('px', 'height'), + line: 1, + column: 28, + endLine: 1, + endColumn: 34, + }, + ], + }, + { + code: '@media (min-height: 1000px) {}', + message: messages.rejected('px', 'min-height'), + line: 1, + column: 21, + endLine: 1, + endColumn: 27, + }, + { + code: '@media (max-height: 1000px) {}', + message: messages.rejected('px', 'max-height'), + line: 1, + column: 21, + endLine: 1, + endColumn: 27, + }, + ], +}); + +testRule({ + ruleName, + config: { grid: [], 'prefers-reduced-motion': [], 'video-dynamic-range': [] }, + + accept: [ + { + code: '@media (prefers-reduced-motion) {}', + description: 'name-only value', + }, + { + code: '@media (color) {}', + description: 'name-only value', + }, + { + code: '@media (grid: 0) {}', + description: 'unitless value, number', + }, + { + code: '@media (video-dynamic-range: high) {}', + description: 'unitless value, keyword', + }, + ], +}); diff --git a/lib/rules/media-feature-name-unit-allowed-list/index.js b/lib/rules/media-feature-name-unit-allowed-list/index.js new file mode 100644 index 0000000000..a5ecddc650 --- /dev/null +++ b/lib/rules/media-feature-name-unit-allowed-list/index.js @@ -0,0 +1,102 @@ +'use strict'; + +const report = require('../../utils/report'); +const ruleMessages = require('../../utils/ruleMessages'); +const validateOptions = require('../../utils/validateOptions'); +const { TokenType } = require('@csstools/css-tokenizer'); +const { isTokenNode } = require('@csstools/css-parser-algorithms'); +const { + isMediaFeaturePlain, + isMediaFeatureRange, + parse, +} = require('@csstools/media-query-list-parser'); +const validateObjectWithArrayProps = require('../../utils/validateObjectWithArrayProps'); +const { isString } = require('../../utils/validateTypes'); +const matchesStringOrRegExp = require('../../utils/matchesStringOrRegExp'); +const atRuleParamIndex = require('../../utils/atRuleParamIndex'); + +const ruleName = 'media-feature-name-unit-allowed-list'; + +const messages = ruleMessages(ruleName, { + rejected: (unit, name) => `Unexpected unit "${unit}" for name "${name}"`, +}); + +const meta = { + url: 'https://stylelint.io/user-guide/rules/media-feature-name-unit-allowed-list', +}; + +/** @type {import('stylelint').Rule>} */ +const rule = (primary) => { + return (root, result) => { + const validOptions = validateOptions(result, ruleName, { + actual: primary, + possible: [validateObjectWithArrayProps(isString)], + }); + + if (!validOptions) { + return; + } + + const primaryPairs = Object.entries(primary); + const primaryUnitList = (/** @type {string} */ featureName) => { + for (const [name, unit] of primaryPairs) { + if (matchesStringOrRegExp(featureName, name)) return [unit].flat(); + } + + return undefined; + }; + + root.walkAtRules(/^media$/i, (atRule) => { + const mediaQueryList = parse(atRule.params); + + mediaQueryList.forEach((mediaQuery) => { + mediaQuery.walk((entry) => { + if (!isMediaFeaturePlain(entry.node) && !isMediaFeatureRange(entry.node)) { + return; + } + + const featureName = entry.node.name.getName(); + const unitList = primaryUnitList(featureName); + + if (!unitList) { + return; + } + + entry.node.walk(({ node: childNode }) => { + if (!isTokenNode(childNode)) { + return; + } + + const [tokenType, , startIndex, endIndex] = childNode.value; + + if (tokenType !== TokenType.Dimension) { + return; + } + + const unit = childNode.value[4].unit; + + if (unitList.includes(unit.toLowerCase())) { + return; + } + + const atRuleIndex = atRuleParamIndex(atRule); + + report({ + message: messages.rejected(unit, featureName), + node: atRule, + index: atRuleIndex + startIndex, + endIndex: atRuleIndex + endIndex + 1, + result, + ruleName, + }); + }); + }); + }); + }); + }; +}; + +rule.ruleName = ruleName; +rule.messages = messages; +rule.meta = meta; +module.exports = rule; diff --git a/package-lock.json b/package-lock.json index d72d9a9f7a..c12de211a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "14.16.1", "license": "MIT", "dependencies": { + "@csstools/css-parser-algorithms": "^1.0.0", + "@csstools/css-tokenizer": "^1.0.0", + "@csstools/media-query-list-parser": "^1.0.0", "@csstools/selector-specificity": "^2.0.2", "balanced-match": "^2.0.0", "colord": "^2.9.3", @@ -1393,6 +1396,49 @@ "prettier": "^2.7.1" } }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-1.0.0.tgz", + "integrity": "sha512-lPphY34yfV15tEXiz/SYaU8hwqAhbAwqiTExv5tOfc7QZxT70VVYrsiPBaX1osdWZFowrDEAhHe4H3JnyzbjhA==", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^1.0.0" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-1.0.0.tgz", + "integrity": "sha512-xdFjdQ+zqqkOsmee+kYRieZD9Cqh4hr01YBQ2/8NtTkMMxbtRX18MC50LX6cMrtaLryqmIdZHN9e16/l0QqnQw==", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-1.0.0.tgz", + "integrity": "sha512-HsTj5ejI8NKKZ4IEd6kK2kQZA/JmIVlUV8+XvO/YS9ntrlYPnbmFT3rkqtbxOVfEafblYCNOpeNw1c+fKGkAqw==", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^1.0.0", + "@csstools/css-tokenizer": "^1.0.0" + } + }, "node_modules/@csstools/selector-specificity": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz", @@ -15084,6 +15130,23 @@ "prettier": "^2.7.1" } }, + "@csstools/css-parser-algorithms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-1.0.0.tgz", + "integrity": "sha512-lPphY34yfV15tEXiz/SYaU8hwqAhbAwqiTExv5tOfc7QZxT70VVYrsiPBaX1osdWZFowrDEAhHe4H3JnyzbjhA==", + "requires": {} + }, + "@csstools/css-tokenizer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-1.0.0.tgz", + "integrity": "sha512-xdFjdQ+zqqkOsmee+kYRieZD9Cqh4hr01YBQ2/8NtTkMMxbtRX18MC50LX6cMrtaLryqmIdZHN9e16/l0QqnQw==" + }, + "@csstools/media-query-list-parser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-1.0.0.tgz", + "integrity": "sha512-HsTj5ejI8NKKZ4IEd6kK2kQZA/JmIVlUV8+XvO/YS9ntrlYPnbmFT3rkqtbxOVfEafblYCNOpeNw1c+fKGkAqw==", + "requires": {} + }, "@csstools/selector-specificity": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz", diff --git a/package.json b/package.json index 3ef1f083b5..8ccbe0df7b 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,9 @@ ] }, "dependencies": { + "@csstools/css-parser-algorithms": "^1.0.0", + "@csstools/css-tokenizer": "^1.0.0", + "@csstools/media-query-list-parser": "^1.0.0", "@csstools/selector-specificity": "^2.0.2", "balanced-match": "^2.0.0", "colord": "^2.9.3",