From 5abe468cbde91dbe00201db4293130f294fa3eac Mon Sep 17 00:00:00 2001
From: Romain Menke <11521496+romainmenke@users.noreply.github.com>
Date: Sun, 25 Jun 2023 09:32:52 +0200
Subject: [PATCH] Add `media-query-no-invalid` (#6963)
---
.changeset/early-jobs-hunt.md | 5 +
docs/user-guide/rules.md | 1 +
lib/rules/index.js | 1 +
.../__tests__/index.js | 11 +
.../index.js | 3 +
lib/rules/media-query-no-invalid/README.md | 64 +++++
.../media-query-no-invalid/__tests__/index.js | 267 ++++++++++++++++++
lib/rules/media-query-no-invalid/index.js | 158 +++++++++++
package-lock.json | 16 +-
package.json | 2 +-
10 files changed, 519 insertions(+), 9 deletions(-)
create mode 100644 .changeset/early-jobs-hunt.md
create mode 100644 lib/rules/media-query-no-invalid/README.md
create mode 100644 lib/rules/media-query-no-invalid/__tests__/index.js
create mode 100644 lib/rules/media-query-no-invalid/index.js
diff --git a/.changeset/early-jobs-hunt.md b/.changeset/early-jobs-hunt.md
new file mode 100644
index 0000000000..5620cd66a5
--- /dev/null
+++ b/.changeset/early-jobs-hunt.md
@@ -0,0 +1,5 @@
+---
+"stylelint": minor
+---
+
+Added: `media-query-no-invalid`
diff --git a/docs/user-guide/rules.md b/docs/user-guide/rules.md
index 1dd541d7d0..59faba2cd4 100644
--- a/docs/user-guide/rules.md
+++ b/docs/user-guide/rules.md
@@ -58,6 +58,7 @@ Disallow invalid syntax with these (sometimes implicit) `no-invalid` rules.
| [`color-no-invalid-hex`](../../lib/rules/color-no-invalid-hex/README.md)
Disallow invalid hex colors. | ✅ | |
| [`function-calc-no-unspaced-operator`](../../lib/rules/function-calc-no-unspaced-operator/README.md)
Disallow invalid unspaced operator within `calc` functions. | ✅ | 🔧 |
| [`keyframe-declaration-no-important`](../../lib/rules/keyframe-declaration-no-important/README.md)
Disallow invalid `!important` within keyframe declarations. | ✅ | |
+| [`media-query-no-invalid`](../../lib/rules/media-query-no-invalid/README.md)
Disallow invalid media queries. | | |
| [`named-grid-areas-no-invalid`](../../lib/rules/named-grid-areas-no-invalid/README.md)
Disallow invalid named grid areas. | ✅ | |
| [`no-invalid-double-slash-comments`](../../lib/rules/no-invalid-double-slash-comments/README.md)
Disallow invalid double-slash comments. | ✅ | |
| [`no-invalid-position-at-import-rule`](../../lib/rules/no-invalid-position-at-import-rule/README.md)
Disallow invalid position `@import` rules. | ✅ | |
diff --git a/lib/rules/index.js b/lib/rules/index.js
index 68f5fd871e..6693e5f47b 100644
--- a/lib/rules/index.js
+++ b/lib/rules/index.js
@@ -225,6 +225,7 @@ const rules = {
'media-query-list-comma-space-before': importLazy(() =>
require('./media-query-list-comma-space-before'),
)(),
+ 'media-query-no-invalid': importLazy(() => require('./media-query-no-invalid'))(),
'named-grid-areas-no-invalid': importLazy(() => require('./named-grid-areas-no-invalid'))(),
'no-descending-specificity': importLazy(() => require('./no-descending-specificity'))(),
'no-duplicate-at-import-rules': importLazy(() => require('./no-duplicate-at-import-rules'))(),
diff --git a/lib/rules/media-feature-name-no-unknown/__tests__/index.js b/lib/rules/media-feature-name-no-unknown/__tests__/index.js
index 691b5f264a..d95c25b52b 100644
--- a/lib/rules/media-feature-name-no-unknown/__tests__/index.js
+++ b/lib/rules/media-feature-name-no-unknown/__tests__/index.js
@@ -62,6 +62,9 @@ testRule({
{
code: '@media () { }',
},
+ {
+ code: '@media (grid: 1) {}',
+ },
],
reject: [
@@ -153,6 +156,14 @@ testRule({
endLine: 1,
endColumn: 49,
},
+ {
+ code: '@media (min-grid: 1) { }',
+ message: messages.rejected('min-grid'),
+ line: 1,
+ column: 9,
+ endLine: 1,
+ endColumn: 17,
+ },
],
});
diff --git a/lib/rules/media-feature-name-value-no-unknown/index.js b/lib/rules/media-feature-name-value-no-unknown/index.js
index 235e482820..62950526ce 100644
--- a/lib/rules/media-feature-name-value-no-unknown/index.js
+++ b/lib/rules/media-feature-name-value-no-unknown/index.js
@@ -6,6 +6,7 @@ const {
isMediaFeature,
isMediaFeatureValue,
matchesRatioExactly,
+ isMediaQueryInvalid,
} = require('@csstools/media-query-list-parser');
const atRuleParamIndex = require('../../utils/atRuleParamIndex');
@@ -239,6 +240,8 @@ const rule = (primary) => {
};
parseMediaQuery(atRule).forEach((mediaQuery) => {
+ if (isMediaQueryInvalid(mediaQuery)) return;
+
mediaQuery.walk((entry) => {
if (!entry.state) return;
diff --git a/lib/rules/media-query-no-invalid/README.md b/lib/rules/media-query-no-invalid/README.md
new file mode 100644
index 0000000000..7e462c55c8
--- /dev/null
+++ b/lib/rules/media-query-no-invalid/README.md
@@ -0,0 +1,64 @@
+# media-query-no-invalid
+
+Disallow invalid media queries.
+
+
+```css
+@media not(min-width: 300px) {}
+/** ↑
+ * This media query */
+```
+
+Media queries must be grammatically valid according to the [Media Queries Level 5](https://www.w3.org/TR/mediaqueries-5/) specification.
+
+This rule is only appropriate for CSS. You should not turn it on for CSS-like languages, such as Sass or Less, as they have their own syntaxes.
+
+It works well with other rules that validate feature names and values:
+
+- [`media-feature-name-no-unknown`](../media-feature-name-no-unknown/README.md)
+- [`media-feature-name-value-no-unknown`](../media-feature-name-value-no-unknown/README.md)
+
+The [`message` secondary option](../../../docs/user-guide/configure.md#message) can accept the arguments of this rule.
+
+## Options
+
+### `true`
+
+The following patterns are considered problems:
+
+
+```css
+@media not(min-width: 300px) {}
+```
+
+
+```css
+@media (width == 100px) {}
+```
+
+
+```css
+@media (color) and (hover) or (width) {}
+```
+
+The following patterns are _not_ considered problems:
+
+
+```css
+@media not (min-width: 300px) {}
+```
+
+
+```css
+@media (width = 100px) {}
+```
+
+
+```css
+@media ((color) and (hover)) or (width) {}
+```
+
+
+```css
+@media (color) and ((hover) or (width)) {}
+```
diff --git a/lib/rules/media-query-no-invalid/__tests__/index.js b/lib/rules/media-query-no-invalid/__tests__/index.js
new file mode 100644
index 0000000000..1582375c40
--- /dev/null
+++ b/lib/rules/media-query-no-invalid/__tests__/index.js
@@ -0,0 +1,267 @@
+'use strict';
+
+const { messages, ruleName } = require('..');
+
+testRule({
+ ruleName,
+ config: [true],
+
+ accept: [
+ {
+ code: '@media screen {}',
+ },
+ {
+ code: '@media print {}',
+ },
+ {
+ code: '@media all {}',
+ },
+ {
+ code: '@media {}',
+ },
+ {
+ code: '@media screen and (color) {}',
+ },
+ {
+ code: '@media only screen and (color) {}',
+ },
+ {
+ code: '@media speech and (device-aspect-ratio: 16/9) {}',
+ },
+ {
+ code: '@media (width >= 600px) {}',
+ },
+ {
+ code: '@media (width: 600px) {}',
+ },
+ {
+ code: '@media not (width <= -100px) {}',
+ },
+ {
+ code: '@media not (resolution: -300dpi) {}',
+ },
+ {
+ code: '@media (min-width: 320.01px) {}',
+ },
+ {
+ code: '@media not (color) {}',
+ },
+ {
+ code: '@media (width < 600px) and (height < 600px) {}',
+ },
+ {
+ code: '@media (width < 600px) and (height < 600px) {}',
+ },
+ {
+ code: '@media (not (color)) or (hover) {}',
+ },
+ {
+ code: '@media (not (color)) and (not (hover)) {}',
+ },
+ {
+ code: '@media (not (color)) and (not (hover)) {}',
+ },
+ {
+ code: '@media screen and (max-weight: 3kg) and (color), (color) {}',
+ },
+ {
+ code: '@media not unknown {}',
+ },
+ ],
+
+ reject: [
+ {
+ code: '@media @foo {}',
+ message: messages.rejected('@foo'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 12,
+ },
+ {
+ code: '@media screen or (min-width > 500px) {}',
+ message: messages.rejected('screen or (min-width > 500px)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 37,
+ },
+ {
+ code: '@media ((min-width: 300px) and (hover: hover) or (aspect-ratio: 1 / 1)) {}',
+ message: messages.rejected(
+ '((min-width: 300px) and (hover: hover) or (aspect-ratio: 1 / 1))',
+ ),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 72,
+ },
+ {
+ code: '@media (min-width: var(--foo)) {}',
+ message: messages.rejected('(min-width: var(--foo))'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 31,
+ },
+ {
+ code: '@media (min-width: 50%) {}',
+ message: messages.rejected('(min-width: 50%)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 24,
+ },
+ {
+ code: '@media ((color: 2) and (min-width: 50%)) {}',
+ message: messages.rejected('(min-width: 50%)'),
+ line: 1,
+ column: 24,
+ endLine: 1,
+ endColumn: 40,
+ },
+ {
+ code: '@media ((color: foo(--bar)) and (min-width: 50%)) {}',
+ warnings: [
+ {
+ message: messages.rejected('(color: foo(--bar))'),
+ line: 1,
+ column: 9,
+ endLine: 1,
+ endColumn: 28,
+ },
+ {
+ message: messages.rejected('(min-width: 50%)'),
+ line: 1,
+ column: 33,
+ endLine: 1,
+ endColumn: 49,
+ },
+ ],
+ },
+ {
+ code: '@media (color: foo(--bar)), (min-width: 50%) {}',
+ warnings: [
+ {
+ message: messages.rejected('(color: foo(--bar))'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 27,
+ },
+ {
+ message: messages.rejected('(min-width: 50%)'),
+ line: 1,
+ column: 29,
+ endLine: 1,
+ endColumn: 45,
+ },
+ ],
+ },
+ {
+ code: '@media (--foo: 2) {}',
+ message: messages.rejected('(--foo: 2)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 18,
+ },
+ {
+ code: '@media (min-width < 500px) {}',
+ message: messages.rejected('(min-width < 500px)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 27,
+ },
+ {
+ code: '@media (min-width) {}',
+ message: messages.rejected('(min-width)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 19,
+ },
+ {
+ code: '@media (grid < 0) {}',
+ message: messages.rejected('(grid < 0)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 18,
+ },
+ {
+ code: '@media (not (color) and (hover)) {}',
+ message: messages.rejected('(not (color) and (hover))'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 33,
+ },
+
+ {
+ code: '@media (color) and (hover) or (width) {}',
+ message: messages.rejected('(color) and (hover) or (width)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 38,
+ },
+ {
+ code: '@media (width => 100px) {}',
+ message: messages.rejected('(width => 100px)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 24,
+ },
+ {
+ code: '@media (width == 100px) {}',
+ message: messages.rejected('(width == 100px)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 24,
+ },
+ {
+ code: '@media not(min-width: 100px) {}',
+ message: messages.rejected('not(min-width: 100px)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 29,
+ },
+ {
+ code: '@media foo screen {}',
+ message: messages.rejected('foo screen'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 18,
+ },
+ {
+ code: '@media not screen foo (min-width: 300px) {}',
+ message: messages.rejected('not screen foo (min-width: 300px)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 41,
+ },
+ {
+ code: '@media (--foo: 300px) {}',
+ message: messages.rejected('(--foo: 300px)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 22,
+ },
+ {
+ code: '@media (--foo < 300px) {}',
+ message: messages.rejected('(--foo < 300px)'),
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 23,
+ },
+ ],
+});
diff --git a/lib/rules/media-query-no-invalid/index.js b/lib/rules/media-query-no-invalid/index.js
new file mode 100644
index 0000000000..47a8f4c2ea
--- /dev/null
+++ b/lib/rules/media-query-no-invalid/index.js
@@ -0,0 +1,158 @@
+'use strict';
+
+const {
+ isMediaQueryInvalid,
+ isGeneralEnclosed,
+ isMediaFeaturePlain,
+ isMediaFeatureRange,
+ isMediaFeatureBoolean,
+} = require('@csstools/media-query-list-parser');
+
+const atRuleParamIndex = require('../../utils/atRuleParamIndex');
+const parseMediaQuery = require('../../utils/parseMediaQuery');
+const report = require('../../utils/report');
+const ruleMessages = require('../../utils/ruleMessages');
+const validateOptions = require('../../utils/validateOptions');
+const isCustomMediaQuery = require('../../utils/isCustomMediaQuery');
+const { rangeTypeMediaFeatureNames } = require('../../reference/mediaFeatures');
+
+const ruleName = 'media-query-no-invalid';
+
+const messages = ruleMessages(ruleName, {
+ rejected: (query) => `Unexpected invalid media query "${query}"`,
+});
+
+const HAS_MIN_MAX_PREFIX = /^(?:min|max)-/i;
+
+const meta = {
+ url: 'https://stylelint.io/user-guide/rules/media-query-no-invalid',
+};
+
+/** @type {import('stylelint').Rule} */
+const rule = (primary) => {
+ return (root, result) => {
+ const validOptions = validateOptions(result, ruleName, { actual: primary });
+
+ if (!validOptions) {
+ return;
+ }
+
+ root.walkAtRules(/^media$/i, (atRule) => {
+ /** @type {Array<{tokens(): Array}>} */
+ let invalidNodes = [];
+
+ parseMediaQuery(atRule).forEach((mediaQuery) => {
+ if (isMediaQueryInvalid(mediaQuery)) {
+ // Queries that fail to parse are invalid.
+ invalidNodes.push(mediaQuery);
+
+ return;
+ }
+
+ mediaQuery.walk((entry) => {
+ // All general enclosured nodes are invalid.
+ if (isGeneralEnclosed(entry.node)) {
+ invalidNodes.push(entry.node);
+
+ return;
+ }
+
+ // Invalid plain media features.
+ if (isMediaFeaturePlain(entry.node)) {
+ const name = entry.node.getName();
+
+ if (isCustomMediaQuery(name)) {
+ // In a plain context, custom media queries are invalid.
+ invalidNodes.push(entry.parent);
+
+ return;
+ }
+
+ return;
+ }
+
+ // Invalid range media features.
+ if (isMediaFeatureRange(entry.node)) {
+ const name = entry.node.getName().toLowerCase();
+
+ if (isCustomMediaQuery(name)) {
+ // In a range context, custom media queries are invalid.
+ invalidNodes.push(entry.parent);
+
+ return;
+ }
+
+ if (HAS_MIN_MAX_PREFIX.test(name)) {
+ // In a range context, min- and max- prefixed feature names are invalid.
+ invalidNodes.push(entry.parent);
+
+ return;
+ }
+
+ if (!rangeTypeMediaFeatureNames.has(name)) {
+ // In a range context, non-range typed features are invalid.
+ invalidNodes.push(entry.parent);
+
+ return;
+ }
+
+ return;
+ }
+
+ // Invalid boolean media features.
+ if (isMediaFeatureBoolean(entry.node)) {
+ const name = entry.node.getName().toLowerCase();
+
+ if (HAS_MIN_MAX_PREFIX.test(name)) {
+ // In a range context, min- and max- prefixed feature names are invalid
+ invalidNodes.push(entry.parent);
+ }
+ }
+ });
+ });
+
+ if (invalidNodes.length === 0) return;
+
+ const atRuleParamIndexValue = atRuleParamIndex(atRule);
+
+ invalidNodes.forEach((invalidNode) => {
+ const [start, end] = startAndEndIndex(invalidNode);
+
+ report({
+ message: messages.rejected,
+ messageArgs: [invalidNode.toString()],
+ index: atRuleParamIndexValue + start,
+ endIndex: atRuleParamIndexValue + end + 1,
+ node: atRule,
+ ruleName,
+ result,
+ });
+ });
+ });
+ };
+};
+
+/**
+ * A function that returns the start and end boundaries of a node.
+ *
+ * @param {{ tokens(): Array }} node
+ * @returns {[number, number]}
+ */
+function startAndEndIndex(node) {
+ const tokens = node.tokens();
+
+ const firstToken = tokens[0];
+ const lastToken = tokens[tokens.length - 1];
+
+ if (!firstToken || !lastToken) return [0, 0];
+
+ const start = firstToken[2];
+ const end = lastToken[3];
+
+ return [start, end];
+}
+
+rule.ruleName = ruleName;
+rule.messages = messages;
+rule.meta = meta;
+module.exports = rule;
diff --git a/package-lock.json b/package-lock.json
index 9ae1e7a3e4..2881de2379 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,7 @@
"dependencies": {
"@csstools/css-parser-algorithms": "^2.2.0",
"@csstools/css-tokenizer": "^2.1.1",
- "@csstools/media-query-list-parser": "^2.1.0",
+ "@csstools/media-query-list-parser": "^2.1.1",
"@csstools/selector-specificity": "^2.2.0",
"balanced-match": "^2.0.0",
"colord": "^2.9.3",
@@ -1512,9 +1512,9 @@
}
},
"node_modules/@csstools/media-query-list-parser": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.0.tgz",
- "integrity": "sha512-MXkR+TeaS2q9IkpyO6jVCdtA/bfpABJxIrfkLswThFN8EZZgI2RfAHhm6sDNDuYV25d5+b8Lj1fpTccIcSLPsQ==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.1.tgz",
+ "integrity": "sha512-pUjtFbaKbiFNjJo8pprrIaXLvQvWIlwPiFnRI4sEnc4F0NIGTOsw8kaJSR3CmZAKEvV8QYckovgAnWQC0bgLLQ==",
"funding": [
{
"type": "github",
@@ -1529,7 +1529,7 @@
"node": "^14 || ^16 || >=18"
},
"peerDependencies": {
- "@csstools/css-parser-algorithms": "^2.1.1",
+ "@csstools/css-parser-algorithms": "^2.2.0",
"@csstools/css-tokenizer": "^2.1.1"
}
},
@@ -17142,9 +17142,9 @@
"integrity": "sha512-GbrTj2Z8MCTUv+52GE0RbFGM527xuXZ0Xa5g0Z+YN573uveS4G0qi6WNOMyz3yrFM/jaILTTwJ0+umx81EzqfA=="
},
"@csstools/media-query-list-parser": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.0.tgz",
- "integrity": "sha512-MXkR+TeaS2q9IkpyO6jVCdtA/bfpABJxIrfkLswThFN8EZZgI2RfAHhm6sDNDuYV25d5+b8Lj1fpTccIcSLPsQ==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.1.tgz",
+ "integrity": "sha512-pUjtFbaKbiFNjJo8pprrIaXLvQvWIlwPiFnRI4sEnc4F0NIGTOsw8kaJSR3CmZAKEvV8QYckovgAnWQC0bgLLQ==",
"requires": {}
},
"@csstools/selector-specificity": {
diff --git a/package.json b/package.json
index c3e982806b..0dc042bc2f 100644
--- a/package.json
+++ b/package.json
@@ -131,7 +131,7 @@
"dependencies": {
"@csstools/css-parser-algorithms": "^2.2.0",
"@csstools/css-tokenizer": "^2.1.1",
- "@csstools/media-query-list-parser": "^2.1.0",
+ "@csstools/media-query-list-parser": "^2.1.1",
"@csstools/selector-specificity": "^2.2.0",
"balanced-match": "^2.0.0",
"colord": "^2.9.3",