Repository: eslint/css
Pull request #12 (2024-11-19 17:13:24)

feat: no-unknown-at-rules -> no-invalid-at-rules
<!--
    Thank you for contributing!

    ESLint adheres to the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct).
-->

#### Prerequisites checklist

-   [x] I have read the [contributing guidelines](https://github.com/eslint/eslint/blob/HEAD/CONTRIBUTING.md).

<!--
    Please ensure your pull request is ready:

    - Read the pull request guide (https://eslint.org/docs/latest/contribute/pull-requests)
    - Update or create tests
    - If performance-related, include a benchmark
    - Update documentation for this change (if appropriate)
-->

<!--
    The following is required for all pull requests:
-->

#### What is the purpose of this pull request?

Renamed `no-unknown-at-rules` to `no-invalid-at-rules` to cover more cases.

#### What changes did you make? (Give an overview)

- Renamed source files from `no-unknown-at-rules.*` to `no-invalid-at-rules.*`
- Added checks for invalid preludes, unknown descriptors, and invalid descriptor values.
- Added more tests
- Updated docs

#### Related Issues

<!-- include tags like "fixes #123" or "refs #123" -->

#### Is there anything you'd like reviewers to focus on?

There are type errors that will be resolved once #11 is merged and I can rebase on top of it.


<!-- markdownlint-disable-file MD004 -->


jeddy3: Looks ace!

Can I ask if the ambition is to integrate more specific validity checks (not caught by csstree) into this rule or add more specific rules for those?

For example, [`@property` requires some descriptors](https://drafts.css-houdini.org/css-properties-values-api/#at-property-rule):

> [`@property`](https://drafts.css-houdini.org/css-properties-values-api/#at-ruledef-property) rules require a [syntax](https://drafts.css-houdini.org/css-properties-values-api/#descdef-property-syntax) and [inherits](https://drafts.css-houdini.org/css-properties-values-api/#descdef-property-inherits) descriptor; if either are missing, the entire rule is invalid and must be ignored. The [initial-value](https://drafts.css-houdini.org/css-properties-values-api/#descdef-property-initial-value) descriptor is optional only if the syntax is the [universal syntax definition](https://drafts.css-houdini.org/css-properties-values-api/#universal-syntax-definition), otherwise the descriptor is required; if it’s missing, the entire rule is invalid and must be ignored.

It'd catch an easy mistake for people to make.
nzakas: @jeddy3 

> Can I ask if the ambition is to integrate more specific validity cases (not caught by csstree) into this rule or add separate rules for those?

I honestly haven't thought that far ahead yet. :smile: I could imagine wanting that check in this rule, but I'm not 100% sure yet. I'd like to just start with what csstree is providing for the first version and then think about things a bit more once we've got some feedback.
mdjermanovic: There are merge conflicts now. Also, some tests were failing.
jeddy3: Just a quick +1 for the name change. We have an inconsistent mix of `no-unknown` and `no-valid` rules in Stylelint, and in hindsight, we should have consistently used `no-invalid` for spec-compliance things and `no-unknown` (or something like `no-undefined`) for user-defined things like animations and custom properties.
nzakas: @mdjermanovic good point! Because this package hasn't been released yet, "feat" makes more sense.

Patch:
diff --git a/README.md b/README.md
index 5827871..9d9a1d9 100644
--- a/README.md
+++ b/README.md
@@ -60,8 +60,8 @@ export default [
 | :--------------------------------------------------------------- | :------------------------------- | :-------------: |
 | [`no-duplicate-imports`](./docs/rules/no-duplicate-imports.md)   | Disallow duplicate @import rules |       yes       |
 | [`no-empty-blocks`](./docs/rules/no-empty-blocks.md)             | Disallow empty blocks            |       yes       |
+| [`no-invalid-at-rules`](./docs/rules/no-invalid-at-rules.md)     | Disallow invalid at-rules        |       yes       |
 | [`no-invalid-properties`](./docs/rules/no-invalid-properties.md) | Disallow invalid properties      |       yes       |
-| [`no-unknown-at-rules`](./docs/rules/no-unknown-at-rules.md)     | Disallow unknown at-rules        |       yes       |
 
 <!-- Rule Table End -->
 
diff --git a/docs/rules/no-invalid-at-rules.md b/docs/rules/no-invalid-at-rules.md
new file mode 100644
index 0000000..b438883
--- /dev/null
+++ b/docs/rules/no-invalid-at-rules.md
@@ -0,0 +1,80 @@
+# no-invalid-at-rules
+
+Disallow invalid at-rules.
+
+## Background
+
+CSS contains a number of at-rules, each beginning with a `@`, that perform various operations. Some common at-rules include:
+
+-   `@import`
+-   `@media`
+-   `@font-face`
+-   `@keyframes`
+-   `@supports`
+-   `@namespace`
+-   `@page`
+-   `@charset`
+
+It's important to use a known at-rule because unknown at-rules cause the browser to ignore the entire block, including any rules contained within. For example:
+
+```css
+/* typo */
+@charse "UTF-8";
+```
+
+Here, the `@charset` at-rule is incorrectly spelled as `@charse`, which means that it will be ignored.
+
+Each at-rule also has a defined prelude (which may be empty) and potentially one or more descriptors. For example:
+
+```css
+@property --main-bg-color {
+	syntax: "<color>";
+	inherits: false;
+	initial-value: #000000;
+}
+```
+
+Here, `--main-bg-color` is the prelude for `@property` while `syntax`, `inherits`, and `initial-value` are descriptors. The `@property` at-rule requires a specific format for its prelude and only specific descriptors to be present. If any of these are incorrect, the browser ignores the at-rule.
+
+## Rule Details
+
+This rule warns when it finds a CSS at-rule that is unknown or invalid according to the CSS specification. As such, the rule warns for the following problems:
+
+-   An unknown at-rule
+-   An invalid prelude for a known at-rule
+-   An unknown descriptor for a known at-rule
+-   An invalid descriptor value for a known at-rule
+
+The at-rule data is provided via the [CSSTree](https://github.com/csstree/csstree) project.
+
+Examples of incorrect code:
+
+```css
+@charse "UTF-8";
+
+@importx url(foo.css);
+
+@foobar {
+	.my-style {
+		color: red;
+	}
+}
+
+@property main-bg-color {
+	syntax: "<color>";
+	inherits: false;
+	initial-value: #000000;
+}
+
+@property --main-bg-color {
+	syntax: red;
+}
+```
+
+## When Not to Use It
+
+If you are purposely using at-rules that aren't part of the CSS specification, then you can safely disable this rule.
+
+## Prior Art
+
+-   [`at-rule-no-unknown`](https://stylelint.io/user-guide/rules/at-rule-no-unknown)
diff --git a/docs/rules/no-unknown-at-rules.md b/docs/rules/no-unknown-at-rules.md
deleted file mode 100644
index 197a0fb..0000000
--- a/docs/rules/no-unknown-at-rules.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# no-unknown-at-rules
-
-Disallow unknown at-rules.
-
-## Background
-
-CSS contains a number of at-rules, each beginning with a `@`, that perform various operations. Some common at-rules include:
-
--   `@import`
--   `@media`
--   `@font-face`
--   `@keyframes`
--   `@supports`
--   `@namespace`
--   `@page`
--   `@charset`
-
-It's important to use a known at-rule because unknown at-rules cause the browser to ignore the entire block, including any rules contained within. For example:
-
-```css
-/* typo */
-@charse "UTF-8";
-```
-
-Here, the `@charset` at-rule is incorrectly spelled as `@charse`, which means that it will be ignored.
-
-## Rule Details
-
-This rule warns when it finds a CSS at-rule that isn't part of the CSS specification. The at-rule data is provided via the [CSSTree](https://github.com/csstree/csstree) project.
-
-Examples of incorrect code:
-
-```css
-@charse "UTF-8";
-
-@importx url(foo.css);
-
-@foobar {
-	.my-style {
-		color: red;
-	}
-}
-```
-
-## When Not to Use It
-
-If you are purposely using at-rules that aren't part of the CSS specification, then you can safely disable this rule.
-
-## Prior Art
-
--   [`at-rule-no-unknown`](https://stylelint.io/user-guide/rules/at-rule-no-unknown)
diff --git a/src/index.js b/src/index.js
index 9048356..fe8bd2c 100644
--- a/src/index.js
+++ b/src/index.js
@@ -11,8 +11,8 @@ import { CSSLanguage } from "./languages/css-language.js";
 import { CSSSourceCode } from "./languages/css-source-code.js";
 import noEmptyBlocks from "./rules/no-empty-blocks.js";
 import noDuplicateImports from "./rules/no-duplicate-imports.js";
-import noUnknownAtRules from "./rules/no-unknown-at-rules.js";
 import noInvalidProperties from "./rules/no-invalid-properties.js";
+import noInvalidAtRules from "./rules/no-invalid-at-rules.js";
 
 //-----------------------------------------------------------------------------
 // Plugin
@@ -29,7 +29,7 @@ const plugin = {
 	rules: {
 		"no-empty-blocks": noEmptyBlocks,
 		"no-duplicate-imports": noDuplicateImports,
-		"no-unknown-at-rules": noUnknownAtRules,
+		"no-invalid-at-rules": noInvalidAtRules,
 		"no-invalid-properties": noInvalidProperties,
 	},
 	configs: {},
@@ -41,7 +41,7 @@ Object.assign(plugin.configs, {
 		rules: {
 			"css/no-empty-blocks": "error",
 			"css/no-duplicate-imports": "error",
-			"css/no-unknown-at-rules": "error",
+			"css/no-invalid-at-rules": "error",
 			"css/no-invalid-properties": "error",
 		},
 	},
diff --git a/src/rules/no-invalid-at-rules.js b/src/rules/no-invalid-at-rules.js
new file mode 100644
index 0000000..e291270
--- /dev/null
+++ b/src/rules/no-invalid-at-rules.js
@@ -0,0 +1,155 @@
+/**
+ * @fileoverview Rule to prevent the use of unknown at-rules in CSS.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Imports
+//-----------------------------------------------------------------------------
+
+import { lexer } from "css-tree";
+import { isSyntaxMatchError } from "../util.js";
+
+//-----------------------------------------------------------------------------
+// Helpers
+//-----------------------------------------------------------------------------
+
+/**
+ * Extracts metadata from an error object.
+ * @param {SyntaxError} error The error object to extract metadata from.
+ * @returns {Object} The metadata extracted from the error.
+ */
+function extractMetaDataFromError(error) {
+	const message = error.message;
+	const atRuleName = /`@(.*)`/u.exec(message)[1];
+	let messageId = "unknownAtRule";
+
+	if (message.endsWith("prelude")) {
+		messageId = message.includes("should not")
+			? "invalidExtraPrelude"
+			: "missingPrelude";
+	}
+
+	return {
+		messageId,
+		data: {
+			name: atRuleName,
+		},
+	};
+}
+
+//-----------------------------------------------------------------------------
+// Rule Definition
+//-----------------------------------------------------------------------------
+
+export default {
+	meta: {
+		type: "problem",
+
+		docs: {
+			description: "Disallow invalid at-rules",
+			recommended: true,
+		},
+
+		messages: {
+			unknownAtRule: "Unknown at-rule '@{{name}}' found.",
+			invalidPrelude:
+				"Invalid prelude '{{prelude}}' found for at-rule '@{{name}}'. Expected '{{expected}}'.",
+			unknownDescriptor:
+				"Unknown descriptor '{{descriptor}}' found for at-rule '@{{name}}'.",
+			invalidDescriptor:
+				"Invalid value '{{value}}' for descriptor '{{descriptor}}' found for at-rule '@{{name}}'. Expected {{expected}}.",
+			invalidExtraPrelude:
+				"At-rule '@{{name}}' should not contain a prelude.",
+			missingPrelude: "At-rule '@{{name}}' should contain a prelude.",
+		},
+	},
+
+	create(context) {
+		const { sourceCode } = context;
+
+		return {
+			Atrule(node) {
+				// checks both name and prelude
+				const { error } = lexer.matchAtrulePrelude(
+					node.name,
+					node.prelude,
+				);
+
+				if (error) {
+					if (isSyntaxMatchError(error)) {
+						context.report({
+							loc: error.loc,
+							messageId: "invalidPrelude",
+							data: {
+								name: node.name,
+								prelude: error.css,
+								expected: error.syntax,
+							},
+						});
+						return;
+					}
+
+					const loc = node.loc;
+
+					context.report({
+						loc: {
+							start: loc.start,
+							end: {
+								line: loc.start.line,
+
+								// add 1 to account for the @ symbol
+								column: loc.start.column + node.name.length + 1,
+							},
+						},
+						...extractMetaDataFromError(error),
+					});
+				}
+			},
+
+			"AtRule > Block > Declaration"(node) {
+				// get at rule node
+				const atRule = sourceCode.getParent(sourceCode.getParent(node));
+
+				const { error } = lexer.matchAtruleDescriptor(
+					atRule.name,
+					node.property,
+					node.value,
+				);
+
+				if (error) {
+					if (isSyntaxMatchError(error)) {
+						context.report({
+							loc: error.loc,
+							messageId: "invalidDescriptor",
+							data: {
+								name: atRule.name,
+								descriptor: node.property,
+								value: error.css,
+								expected: error.syntax,
+							},
+						});
+						return;
+					}
+
+					const loc = node.loc;
+
+					context.report({
+						loc: {
+							start: loc.start,
+							end: {
+								line: loc.start.line,
+								column: loc.start.column + node.property.length,
+							},
+						},
+						messageId: "unknownDescriptor",
+						data: {
+							name: atRule.name,
+							descriptor: node.property,
+						},
+					});
+				}
+			},
+		};
+	},
+};
diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js
index b281c34..2566948 100644
--- a/src/rules/no-invalid-properties.js
+++ b/src/rules/no-invalid-properties.js
@@ -8,25 +8,7 @@
 //-----------------------------------------------------------------------------
 
 import { lexer } from "css-tree";
-
-//-----------------------------------------------------------------------------
-// Type Definitions
-//-----------------------------------------------------------------------------
-
-/** @typedef {import("css-tree").SyntaxMatchError} SyntaxMatchError */
-
-//-----------------------------------------------------------------------------
-// Helpers
-//-----------------------------------------------------------------------------
-
-/**
- * Determines if an error is a syntax match error.
- * @param {Object} error The error object from the CSS parser.
- * @returns {error is SyntaxMatchError} True if the error is a syntax match error, false if not.
- */
-function isSyntaxMatchError(error) {
-	return typeof error.css === "string";
-}
+import { isSyntaxMatchError } from "../util.js";
 
 //-----------------------------------------------------------------------------
 // Rule Definition
diff --git a/src/rules/no-unknown-at-rules.js b/src/rules/no-unknown-at-rules.js
deleted file mode 100644
index 931e2f8..0000000
--- a/src/rules/no-unknown-at-rules.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @fileoverview Rule to prevent the use of unknown at-rules in CSS.
- * @author Nicholas C. Zakas
- */
-
-//-----------------------------------------------------------------------------
-// Imports
-//-----------------------------------------------------------------------------
-
-import data from "css-tree/definition-syntax-data";
-
-//-----------------------------------------------------------------------------
-// Helpers
-//-----------------------------------------------------------------------------
-
-const knownAtRules = new Set(Object.keys(data.atrules));
-
-//-----------------------------------------------------------------------------
-// Rule Definition
-//-----------------------------------------------------------------------------
-
-export default {
-	meta: {
-		type: "problem",
-
-		docs: {
-			description: "Disallow unknown at-rules",
-			recommended: true,
-		},
-
-		messages: {
-			unknownAtRule: "Unknown at-rule '{{name}}' found.",
-		},
-	},
-
-	create(context) {
-		return {
-			Atrule(node) {
-				if (!knownAtRules.has(node.name)) {
-					const loc = node.loc;
-
-					context.report({
-						loc: {
-							start: loc.start,
-							end: {
-								line: loc.start.line,
-
-								// add 1 to account for the @ symbol
-								column: loc.start.column + node.name.length + 1,
-							},
-						},
-						messageId: "unknownAtRule",
-						data: {
-							name: node.name,
-						},
-					});
-				}
-			},
-		};
-	},
-};
diff --git a/src/util.js b/src/util.js
new file mode 100644
index 0000000..67880a6
--- /dev/null
+++ b/src/util.js
@@ -0,0 +1,23 @@
+/**
+ * @fileoverview Utility functions for ESLint CSS plugin.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Type Definitions
+//-----------------------------------------------------------------------------
+
+/** @typedef {import("css-tree").SyntaxMatchError} SyntaxMatchError */
+
+//-----------------------------------------------------------------------------
+// Helpers
+//-----------------------------------------------------------------------------
+
+/**
+ * Determines if an error is a syntax match error.
+ * @param {Object} error The error object to check.
+ * @returns {error is SyntaxMatchError} True if the error is a syntax match error, false if not.
+ */
+export function isSyntaxMatchError(error) {
+	return typeof error.syntax === "string";
+}


Test patch:
diff --git a/tests/rules/no-unknown-at-rules.test.js b/tests/rules/no-invalid-at-rules.test.js
similarity index 56%
rename from tests/rules/no-unknown-at-rules.test.js
rename to tests/rules/no-invalid-at-rules.test.js
index 1d2ebf4..8e903c0 100644
--- a/tests/rules/no-unknown-at-rules.test.js
+++ b/tests/rules/no-invalid-at-rules.test.js
@@ -1,5 +1,5 @@
 /**
- * @fileoverview Tests for no-unknown-at-rules rule.
+ * @fileoverview Tests for no-invalid-at-rules rule.
  * @author Nicholas C. Zakas
  */
 
@@ -7,7 +7,7 @@
 // Imports
 //------------------------------------------------------------------------------
 
-import rule from "../../src/rules/no-unknown-at-rules.js";
+import rule from "../../src/rules/no-invalid-at-rules.js";
 import css from "../../src/index.js";
 import { RuleTester } from "eslint";
 
@@ -22,7 +22,7 @@ const ruleTester = new RuleTester({
 	language: "css/css",
 });
 
-ruleTester.run("no-unknown-at-rules", rule, {
+ruleTester.run("no-invalid-at-rules", rule, {
 	valid: [
 		"@import url('styles.css');",
 		"@font-face { font-family: 'MyFont'; src: url('myfont.woff2') format('woff2'); }",
@@ -62,7 +62,7 @@ ruleTester.run("no-unknown-at-rules", rule, {
 			code: "@foobaz { }",
 			errors: [
 				{
-					message: "Unknown at-rule 'foobaz' found.",
+					message: "Unknown at-rule '@foobaz' found.",
 					line: 1,
 					column: 1,
 					endLine: 1,
@@ -91,5 +91,79 @@ ruleTester.run("no-unknown-at-rules", rule, {
 				},
 			],
 		},
+		{
+			code: "@property foo {  }",
+			errors: [
+				{
+					messageId: "invalidPrelude",
+					data: {
+						name: "property",
+						prelude: "foo",
+						expected: "<custom-property-name>",
+					},
+					line: 1,
+					column: 11,
+					endLine: 1,
+					endColumn: 14,
+				},
+			],
+		},
+		{
+			code: "@property --foo { baz: red; }",
+			errors: [
+				{
+					messageId: "unknownDescriptor",
+					data: { name: "property", descriptor: "baz" },
+					line: 1,
+					column: 19,
+					endLine: 1,
+					endColumn: 22,
+				},
+			],
+		},
+		{
+			code: "@property --foo { syntax: red; }",
+			errors: [
+				{
+					messageId: "invalidDescriptor",
+					line: 1,
+					data: {
+						name: "property",
+						descriptor: "syntax",
+						value: "red",
+						expected: "<string>",
+					},
+					column: 27,
+					endLine: 1,
+					endColumn: 30,
+				},
+			],
+		},
+		{
+			code: "@supports { a {} }",
+			errors: [
+				{
+					messageId: "missingPrelude",
+					data: { name: "supports" },
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 10,
+				},
+			],
+		},
+		{
+			code: "@font-face foo { }",
+			errors: [
+				{
+					messageId: "invalidExtraPrelude",
+					data: { name: "font-face" },
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 11,
+				},
+			],
+		},
 	],
 });