Repository: eslint/css
Pull request #7 (2024-11-12 19:02:19)

feat: no-unknown-at-rules rule
<!--
    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?

Implement the `no-unknown-at-rule` rule

#### What changes did you make? (Give an overview)

- Implemented `no-unknown-at-rule`
- Added tests
- Added docs
- Added rule as recommended


#### Related Issues

<!-- include tags like "fixes #123" or "refs #123" -->

#### Is there anything you'd like reviewers to focus on?

This is built on top of #5, so we should merge that first.

<!-- markdownlint-disable-file MD004 -->


mdjermanovic: There're merge conflicts here now.

Patch:
diff --git a/README.md b/README.md
index 53f6900..12ea4d4 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,7 @@ 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-unknown-at-rules`](./docs/rules/no-unknown-at-rules.md)     | Disallow unknown at-rules.        |       yes       |
 | [`no-unknown-properties`](./docs/rules/no-unknown-properties.md) | Disallow unknown properties.      |       yes       |
 
 <!-- Rule Table End -->
diff --git a/docs/rules/no-unknown-at-rules.md b/docs/rules/no-unknown-at-rules.md
new file mode 100644
index 0000000..197a0fb
--- /dev/null
+++ b/docs/rules/no-unknown-at-rules.md
@@ -0,0 +1,51 @@
+# 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 841c4ff..28aebc3 100644
--- a/src/index.js
+++ b/src/index.js
@@ -12,6 +12,7 @@ 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 noUnknownProperties from "./rules/no-unknown-properties.js";
+import noUnknownAtRules from "./rules/no-unknown-at-rules.js";
 
 //-----------------------------------------------------------------------------
 // Plugin
@@ -28,6 +29,7 @@ const plugin = {
 	rules: {
 		"no-empty-blocks": noEmptyBlocks,
 		"no-duplicate-imports": noDuplicateImports,
+		"no-unknown-at-rules": noUnknownAtRules,
 		"no-unknown-properties": noUnknownProperties,
 	},
 	configs: {},
@@ -39,6 +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-unknown-properties": "error",
 		},
 	},
diff --git a/src/rules/no-unknown-at-rules.js b/src/rules/no-unknown-at-rules.js
new file mode 100644
index 0000000..9000868
--- /dev/null
+++ b/src/rules/no-unknown-at-rules.js
@@ -0,0 +1,61 @@
+/**
+ * @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,
+						},
+					});
+				}
+			},
+		};
+	},
+};


Test patch:
diff --git a/tests/rules/no-unknown-at-rules.test.js b/tests/rules/no-unknown-at-rules.test.js
new file mode 100644
index 0000000..1d2ebf4
--- /dev/null
+++ b/tests/rules/no-unknown-at-rules.test.js
@@ -0,0 +1,95 @@
+/**
+ * @fileoverview Tests for no-unknown-at-rules rule.
+ * @author Nicholas C. Zakas
+ */
+
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import rule from "../../src/rules/no-unknown-at-rules.js";
+import css from "../../src/index.js";
+import { RuleTester } from "eslint";
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+	plugins: {
+		css,
+	},
+	language: "css/css",
+});
+
+ruleTester.run("no-unknown-at-rules", rule, {
+	valid: [
+		"@import url('styles.css');",
+		"@font-face { font-family: 'MyFont'; src: url('myfont.woff2') format('woff2'); }",
+		"@keyframes slidein { from { transform: translateX(0%); } to { transform: translateX(100%); } }",
+		"@supports (display: grid) { .grid-container { display: grid; } }",
+		"@namespace url(http://www.w3.org/1999/xhtml);",
+		"@media screen and (max-width: 600px) { body { font-size: 12px; } }",
+	],
+	invalid: [
+		{
+			code: "@foobar { }",
+			errors: [
+				{
+					messageId: "unknownAtRule",
+					data: { name: "foobar" },
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 8,
+				},
+			],
+		},
+		{
+			code: '@charse "test";',
+			errors: [
+				{
+					messageId: "unknownAtRule",
+					data: { name: "charse" },
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 8,
+				},
+			],
+		},
+		{
+			code: "@foobaz { }",
+			errors: [
+				{
+					message: "Unknown at-rule 'foobaz' found.",
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 8,
+				},
+			],
+		},
+		{
+			code: "@unknownrule { } @anotherunknown { }",
+			errors: [
+				{
+					messageId: "unknownAtRule",
+					data: { name: "unknownrule" },
+					line: 1,
+					column: 1,
+					endLine: 1,
+					endColumn: 13,
+				},
+				{
+					messageId: "unknownAtRule",
+					data: { name: "anotherunknown" },
+					line: 1,
+					column: 18,
+					endLine: 1,
+					endColumn: 33,
+				},
+			],
+		},
+	],
+});