Repository: eslint/css
Pull request #11 (2024-11-18 16:28:02)

feat: no-invalid-properties 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?

Create a new `no-invalid-properties` rule.

#### What changes did you make? (Give an overview)

- Moved logic from `no-unknown-properties` into `no-invalid-properties`
- Moved logic from `no-invalid-property-values` into `no-invalid-properties`
- Added tests for `no-invalid-properties`
- Added docs for `no-invalid-properties`
- Removed old code for `no-unknown-properties` and `no-invalid-property-values`

#### Related Issues

Refs #9 
<!-- include tags like "fixes #123" or "refs #123" -->

#### Is there anything you'd like reviewers to focus on?

<!-- markdownlint-disable-file MD004 -->


eslint-github-bot[bot]: Hi @nzakas!, thanks for the Pull Request

The **pull request title** isn't properly formatted. We ask that you update the pull request title to match this format, as we use it to generate changelogs and automate releases.

- The commit message tag wasn't recognized. Did you mean "docs", "fix", or "feat"?
- There should be a space following the initial tag and colon, for example 'feat: Message'.
- The first letter of the tag should be in lowercase

**To Fix:** You can fix this problem by clicking 'Edit' next to the pull request title at the top of this page.

Read more about contributing to ESLint [here](https://eslint.org/docs/developer-guide/contributing/)

jeddy3: Merging the rules looks good. It avoids that unnecessary check and means you can add syntax customisation at either the rule or language level.

You may want to add a valid test for `@property --foo { syntax: "*"; inherits: false; }`. I think the rule currently visits both [property declarations and descriptor declarations](https://drafts.csswg.org/css-syntax-3/#declaration) rather than just property ones and may incorrectly flag `syntax` and `inherits` as (unknown) properties.
nzakas: > I think the rule currently checks both [property declarations and descriptor declarations](https://drafts.csswg.org/css-syntax-3/#declaration) rather than just property ones.

Ah, nice catch! I didn't realize the same node type was used in both cases.

Patch:
diff --git a/README.md b/README.md
index 12ea4d4..e8c9939 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-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       |
-| [`no-unknown-properties`](./docs/rules/no-unknown-properties.md) | Disallow unknown properties.      |       yes       |
 
 <!-- Rule Table End -->
 
diff --git a/docs/rules/no-invalid-properties.md b/docs/rules/no-invalid-properties.md
new file mode 100644
index 0000000..fca7dac
--- /dev/null
+++ b/docs/rules/no-invalid-properties.md
@@ -0,0 +1,53 @@
+# no-invalid-properties
+
+Disallow invalid properties.
+
+## Background
+
+CSS has a defined set of known properties that are expected to have specific values. While CSS may parse correctly, that doesn't mean that the properties are correctly matched with their values. For example, the following is syntactically valid CSS code:
+
+```css
+a {
+	display: black;
+}
+```
+
+The property `display` doesn't accept a color for its value, so while this code will parse without error, it's still invalid CSS.
+
+Similarly, as long as a property name is syntactically valid, it will parse without error even if it's not a known property. For example:
+
+```css
+a {
+	ccolor: black;
+}
+```
+
+Here, `ccolor` is a syntactically valid identifier even though it will be ignored by browsers. Such errors are often caused by typos.
+
+## Rule Details
+
+This rule warns when it finds a CSS property that doesn't exist or a value that doesn't match with the property name in the CSS specification (custom properties such as `--my-color` are ignored). The property data is provided via the [CSSTree](https://github.com/csstree/csstree) project.
+
+Examples of incorrect code:
+
+```css
+a {
+	display: black;
+	ccolor: black;
+}
+
+body {
+	overflow: 100%;
+	bg: red;
+}
+```
+
+## When Not to Use It
+
+If you aren't concerned with invalid properties, then you can safely disable this rule.
+
+## Prior Art
+
+-   [`declaration-property-value-no-unknown`](https://stylelint.io/user-guide/rules/declaration-property-value-no-unknown/)
+
+-   [`property-no-unknown`](https://stylelint.io/user-guide/rules/property-no-unknown)
diff --git a/docs/rules/no-unknown-properties.md b/docs/rules/no-unknown-properties.md
deleted file mode 100644
index dd275ee..0000000
--- a/docs/rules/no-unknown-properties.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# no-unknown-properties
-
-Disallow unknown properties.
-
-## Background
-
-CSS rules may contain any number of properties consisting of a name and a value. As long as the property name is valid CSS, it will parse correctly, even if the property won't be recognized by a web browser. For example:
-
-```css
-a {
-	ccolor: black;
-}
-```
-
-Here, `ccolor` is a syntactically valid identifier even though it will be ignored by browsers. Such errors are often caused by typos.
-
-## Rule Details
-
-This rule warns when it finds a CSS property that isn't part of the CSS specification and aren't custom properties (beginning with `--` as in `--my-color`). The property data is provided via the [CSSTree](https://github.com/csstree/csstree) project.
-
-Examples of incorrect code:
-
-```css
-a {
-	ccolor: black;
-}
-
-body {
-	bg: red;
-}
-```
-
-## When Not to Use It
-
-If you aren't concerned with unknown properties, you can safely disable this rule.
-
-## Prior Art
-
--   [`known-properties`](https://github.com/CSSLint/csslint/wiki/Require-use-of-known-properties)
--   [`property-no-unknown`](https://stylelint.io/user-guide/rules/property-no-unknown)
diff --git a/package.json b/package.json
index 5b2ebcb..38a887e 100644
--- a/package.json
+++ b/package.json
@@ -74,7 +74,6 @@
   "devDependencies": {
     "@eslint/core": "^0.6.0",
     "@eslint/json": "^0.5.0",
-    "@types/css-tree": "^2.3.8",
     "@types/eslint": "^8.56.10",
     "c8": "^9.1.0",
     "dedent": "^1.5.3",
diff --git a/src/index.js b/src/index.js
index 28aebc3..9048356 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 noUnknownProperties from "./rules/no-unknown-properties.js";
 import noUnknownAtRules from "./rules/no-unknown-at-rules.js";
+import noInvalidProperties from "./rules/no-invalid-properties.js";
 
 //-----------------------------------------------------------------------------
 // Plugin
@@ -30,7 +30,7 @@ const plugin = {
 		"no-empty-blocks": noEmptyBlocks,
 		"no-duplicate-imports": noDuplicateImports,
 		"no-unknown-at-rules": noUnknownAtRules,
-		"no-unknown-properties": noUnknownProperties,
+		"no-invalid-properties": noInvalidProperties,
 	},
 	configs: {},
 };
@@ -42,7 +42,7 @@ Object.assign(plugin.configs, {
 			"css/no-empty-blocks": "error",
 			"css/no-duplicate-imports": "error",
 			"css/no-unknown-at-rules": "error",
-			"css/no-unknown-properties": "error",
+			"css/no-invalid-properties": "error",
 		},
 	},
 });
diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js
new file mode 100644
index 0000000..4fdaaeb
--- /dev/null
+++ b/src/rules/no-invalid-properties.js
@@ -0,0 +1,96 @@
+/**
+ * @fileoverview Rule to prevent invalid properties in CSS.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Imports
+//-----------------------------------------------------------------------------
+
+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";
+}
+
+//-----------------------------------------------------------------------------
+// Rule Definition
+//-----------------------------------------------------------------------------
+
+export default {
+	meta: {
+		type: "problem",
+
+		docs: {
+			description: "Disallow invalid properties.",
+			recommended: true,
+		},
+
+		messages: {
+			invalidPropertyValue:
+				"Invalid value '{{value}}' for property '{{property}}'. Expected {{expected}}.",
+			unknownProperty: "Unknown property '{{property}}' found.",
+		},
+	},
+
+	create(context) {
+		return {
+			"Rule > Block > Declaration"(node) {
+				// don't validate custom properties
+				if (node.property.startsWith("--")) {
+					return;
+				}
+
+				const { error } = lexer.matchDeclaration(node);
+
+				if (error) {
+					// validation failure
+					if (isSyntaxMatchError(error)) {
+						context.report({
+							loc: error.loc,
+							messageId: "invalidPropertyValue",
+							data: {
+								property: node.property,
+								value: error.css,
+								expected: error.syntax,
+							},
+						});
+						return;
+					}
+
+					// unknown property
+					context.report({
+						loc: {
+							start: node.loc.start,
+							end: {
+								line: node.loc.start.line,
+								column:
+									node.loc.start.column +
+									node.property.length,
+							},
+						},
+						messageId: "unknownProperty",
+						data: {
+							property: node.property,
+						},
+					});
+				}
+			},
+		};
+	},
+};
diff --git a/src/rules/no-unknown-properties.js b/src/rules/no-unknown-properties.js
deleted file mode 100644
index 9b1b52b..0000000
--- a/src/rules/no-unknown-properties.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @fileoverview Rule to prevent the use of unknown properties in CSS.
- * @author Nicholas C. Zakas
- */
-
-//-----------------------------------------------------------------------------
-// Imports
-//-----------------------------------------------------------------------------
-
-import data from "css-tree/definition-syntax-data";
-
-//-----------------------------------------------------------------------------
-// Helpers
-//-----------------------------------------------------------------------------
-
-const knownProperties = new Set(Object.keys(data.properties));
-
-//-----------------------------------------------------------------------------
-// Rule Definition
-//-----------------------------------------------------------------------------
-
-export default {
-	meta: {
-		type: "problem",
-
-		docs: {
-			description: "Disallow unknown properties.",
-			recommended: true,
-		},
-
-		messages: {
-			unknownProperty: "Unknown property '{{property}}' found.",
-		},
-	},
-
-	create(context) {
-		return {
-			Declaration(node) {
-				if (
-					!node.property.startsWith("--") &&
-					!knownProperties.has(node.property)
-				) {
-					const loc = node.loc;
-
-					context.report({
-						loc: {
-							start: loc.start,
-							end: {
-								line: loc.start.line,
-								column: loc.start.column + node.property.length,
-							},
-						},
-						messageId: "unknownProperty",
-						data: {
-							property: node.property,
-						},
-					});
-				}
-			},
-		};
-	},
-};
diff --git a/tsconfig.json b/tsconfig.json
index 3fa504c..bcaaa13 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,6 @@
 {
   "files": ["src/index.js"],
+  "include": ["typings"],
   "compilerOptions": {
     "declaration": true,
     "emitDeclarationOnly": true,
diff --git a/typings/LICENSE b/typings/LICENSE
new file mode 100644
index 0000000..9e841e7
--- /dev/null
+++ b/typings/LICENSE
@@ -0,0 +1,21 @@
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE
diff --git a/typings/css-tree.d.ts b/typings/css-tree.d.ts
new file mode 100644
index 0000000..ba41d83
--- /dev/null
+++ b/typings/css-tree.d.ts
@@ -0,0 +1,997 @@
+declare module "css-tree" {
+	export interface CssLocation {
+		source: string;
+		start: {
+			offset: number;
+			line: number;
+			column: number;
+		};
+		end: {
+			offset: number;
+			line: number;
+			column: number;
+		};
+	}
+
+	export interface ListItem<TData> {
+		prev: ListItem<TData> | null;
+		next: ListItem<TData> | null;
+		data: TData;
+	}
+
+	export type IteratorFn<TData, TResult, TContext = List<TData>> = (
+		this: TContext,
+		item: TData,
+		node: ListItem<TData>,
+		list: List<TData>,
+	) => TResult;
+	export type FilterFn<
+		TData,
+		TResult extends TData,
+		TContext = List<TData>,
+	> = (
+		this: TContext,
+		item: TData,
+		node: ListItem<TData>,
+		list: List<TData>,
+	) => item is TResult;
+	export type ReduceFn<TData, TValue, TContext = List<TData>> = (
+		this: TContext,
+		accum: TValue,
+		data: TData,
+	) => TValue;
+
+	export class List<TData> {
+		constructor();
+		get size(): number;
+		get isEmpty(): boolean;
+		get first(): TData | null;
+		get last(): TData | null;
+		[Symbol.iterator](): IterableIterator<TData>;
+		fromArray(array: TData[]): List<TData>;
+		createItem(data: TData): ListItem<TData>;
+		toArray(): TData[];
+		toJSON(): TData[];
+		forEach<TContext>(
+			fn: IteratorFn<TData, void, TContext>,
+			context: TContext,
+		): void;
+		forEach(fn: IteratorFn<TData, void>): void;
+		forEachRight<TContext>(
+			fn: IteratorFn<TData, void, TContext>,
+			context: TContext,
+		): void;
+		forEachRight(fn: IteratorFn<TData, void>): void;
+		nextUntil<TContext>(
+			start: ListItem<TData>,
+			fn: IteratorFn<TData, boolean, TContext>,
+			context: TContext,
+		): void;
+		nextUntil(start: ListItem<TData>, fn: IteratorFn<TData, boolean>): void;
+		prevUntil<TContext>(
+			start: ListItem<TData>,
+			fn: IteratorFn<TData, boolean, TContext>,
+			context: TContext,
+		): void;
+		prevUntil(start: ListItem<TData>, fn: IteratorFn<TData, boolean>): void;
+		reduce<TValue, TContext>(
+			fn: ReduceFn<TData, TValue, TContext>,
+			initialValue: TValue,
+			context: TContext,
+		): TValue;
+		reduce<TValue>(
+			fn: ReduceFn<TData, TValue>,
+			initialValue: TValue,
+		): TValue;
+		reduceRight<TValue, TContext>(
+			fn: ReduceFn<TData, TValue, TContext>,
+			initialValue: TValue,
+			context: TContext,
+		): TValue;
+		reduceRight<TValue>(
+			fn: ReduceFn<TData, TValue>,
+			initialValue: TValue,
+		): TValue;
+		some<TContext>(
+			fn: IteratorFn<TData, boolean, TContext>,
+			context: TContext,
+		): boolean;
+		some(fn: IteratorFn<TData, boolean>): boolean;
+		map<TContext, TResult>(
+			fn: IteratorFn<TData, TResult, TContext>,
+			context: TContext,
+		): List<TResult>;
+		map<TResult>(fn: IteratorFn<TData, TResult>): List<TResult>;
+		filter<TContext, TResult extends TData>(
+			fn: FilterFn<TData, TResult, TContext>,
+			context: TContext,
+		): List<TResult>;
+		filter<TResult extends TData>(
+			fn: FilterFn<TData, TResult>,
+		): List<TResult>;
+		filter<TContext>(
+			fn: IteratorFn<TData, boolean, TContext>,
+			context: TContext,
+		): List<TData>;
+		filter(fn: IteratorFn<TData, boolean>): List<TData>;
+		clear(): void;
+		copy(): List<TData>;
+		prepend(item: ListItem<TData>): List<TData>;
+		prependData(data: TData): List<TData>;
+		append(item: ListItem<TData>): List<TData>;
+		appendData(data: TData): List<TData>;
+		insert(item: ListItem<TData>, before: ListItem<TData>): List<TData>;
+		insertData(data: TData, before: ListItem<TData>): List<TData>;
+		remove(item: ListItem<TData>): ListItem<TData>;
+		push(item: TData): void;
+		pop(): ListItem<TData> | undefined;
+		unshift(data: TData): void;
+		shift(): ListItem<TData> | undefined;
+		prependList(list: List<TData>): List<TData>;
+		appendList(list: List<TData>): List<TData>;
+		insertList(list: List<TData>, before: ListItem<TData>): List<TData>;
+		replace(
+			oldItem: ListItem<TData>,
+			newItemOrList: List<TData> | ListItem<TData>,
+		): List<TData>;
+	}
+
+	export interface CssNodeCommon {
+		type: string;
+		loc?: CssLocation | undefined;
+	}
+
+	export interface AnPlusB extends CssNodeCommon {
+		type: "AnPlusB";
+		a: string | null;
+		b: string | null;
+	}
+
+	export interface Atrule extends CssNodeCommon {
+		type: "Atrule";
+		name: string;
+		prelude: AtrulePrelude | Raw | null;
+		block: Block | null;
+	}
+
+	export interface AtrulePlain extends CssNodeCommon {
+		type: "Atrule";
+		name: string;
+		prelude: AtrulePreludePlain | Raw | null;
+		block: BlockPlain | null;
+	}
+
+	export interface AtrulePrelude extends CssNodeCommon {
+		type: "AtrulePrelude";
+		children: List<CssNode>;
+	}
+
+	export interface AtrulePreludePlain extends CssNodeCommon {
+		type: "AtrulePrelude";
+		children: CssNodePlain[];
+	}
+
+	export interface AttributeSelector extends CssNodeCommon {
+		type: "AttributeSelector";
+		name: Identifier;
+		matcher: string | null;
+		value: StringNode | Identifier | null;
+		flags: string | null;
+	}
+
+	export interface Block extends CssNodeCommon {
+		type: "Block";
+		children: List<CssNode>;
+	}
+
+	export interface BlockPlain extends CssNodeCommon {
+		type: "Block";
+		children: CssNodePlain[];
+	}
+
+	export interface Brackets extends CssNodeCommon {
+		type: "Brackets";
+		children: List<CssNode>;
+	}
+
+	export interface BracketsPlain extends CssNodeCommon {
+		type: "Brackets";
+		children: CssNodePlain[];
+	}
+
+	export interface CDC extends CssNodeCommon {
+		type: "CDC";
+	}
+
+	export interface CDO extends CssNodeCommon {
+		type: "CDO";
+	}
+
+	export interface ClassSelector extends CssNodeCommon {
+		type: "ClassSelector";
+		name: string;
+	}
+
+	export interface Combinator extends CssNodeCommon {
+		type: "Combinator";
+		name: string;
+	}
+
+	export interface Comment extends CssNodeCommon {
+		type: "Comment";
+		value: string;
+	}
+
+	export interface Declaration extends CssNodeCommon {
+		type: "Declaration";
+		important: boolean | string;
+		property: string;
+		value: Value | Raw;
+	}
+
+	export interface DeclarationPlain extends CssNodeCommon {
+		type: "Declaration";
+		important: boolean | string;
+		property: string;
+		value: ValuePlain | Raw;
+	}
+
+	export interface DeclarationList extends CssNodeCommon {
+		type: "DeclarationList";
+		children: List<CssNode>;
+	}
+
+	export interface DeclarationListPlain extends CssNodeCommon {
+		type: "DeclarationList";
+		children: CssNodePlain[];
+	}
+
+	export interface Dimension extends CssNodeCommon {
+		type: "Dimension";
+		value: string;
+		unit: string;
+	}
+
+	export interface FunctionNode extends CssNodeCommon {
+		type: "Function";
+		name: string;
+		children: List<CssNode>;
+	}
+
+	export interface FunctionNodePlain extends CssNodeCommon {
+		type: "Function";
+		name: string;
+		children: CssNodePlain[];
+	}
+
+	export interface Hash extends CssNodeCommon {
+		type: "Hash";
+		value: string;
+	}
+
+	export interface IdSelector extends CssNodeCommon {
+		type: "IdSelector";
+		name: string;
+	}
+
+	export interface Identifier extends CssNodeCommon {
+		type: "Identifier";
+		name: string;
+	}
+
+	export interface MediaFeature extends CssNodeCommon {
+		type: "MediaFeature";
+		name: string;
+		value: Identifier | NumberNode | Dimension | Ratio | null;
+	}
+
+	export interface MediaQuery extends CssNodeCommon {
+		type: "MediaQuery";
+		children: List<CssNode>;
+	}
+
+	export interface MediaQueryPlain extends CssNodeCommon {
+		type: "MediaQuery";
+		children: CssNodePlain[];
+	}
+
+	export interface MediaQueryList extends CssNodeCommon {
+		type: "MediaQueryList";
+		children: List<CssNode>;
+	}
+
+	export interface MediaQueryListPlain extends CssNodeCommon {
+		type: "MediaQueryList";
+		children: CssNodePlain[];
+	}
+
+	export interface NestingSelector extends CssNodeCommon {
+		type: "NestingSelector";
+	}
+
+	export interface Nth extends CssNodeCommon {
+		type: "Nth";
+		nth: AnPlusB | Identifier;
+		selector: SelectorList | null;
+	}
+
+	export interface NthPlain extends CssNodeCommon {
+		type: "Nth";
+		nth: AnPlusB | Identifier;
+		selector: SelectorListPlain | null;
+	}
+
+	export interface NumberNode extends CssNodeCommon {
+		type: "Number";
+		value: string;
+	}
+
+	export interface Operator extends CssNodeCommon {
+		type: "Operator";
+		value: string;
+	}
+
+	export interface Parentheses extends CssNodeCommon {
+		type: "Parentheses";
+		children: List<CssNode>;
+	}
+
+	export interface ParenthesesPlain extends CssNodeCommon {
+		type: "Parentheses";
+		children: CssNodePlain[];
+	}
+
+	export interface Percentage extends CssNodeCommon {
+		type: "Percentage";
+		value: string;
+	}
+
+	export interface PseudoClassSelector extends CssNodeCommon {
+		type: "PseudoClassSelector";
+		name: string;
+		children: List<CssNode> | null;
+	}
+
+	export interface PseudoClassSelectorPlain extends CssNodeCommon {
+		type: "PseudoClassSelector";
+		name: string;
+		children: CssNodePlain[] | null;
+	}
+
+	export interface PseudoElementSelector extends CssNodeCommon {
+		type: "PseudoElementSelector";
+		name: string;
+		children: List<CssNode> | null;
+	}
+
+	export interface PseudoElementSelectorPlain extends CssNodeCommon {
+		type: "PseudoElementSelector";
+		name: string;
+		children: CssNodePlain[] | null;
+	}
+
+	export interface Ratio extends CssNodeCommon {
+		type: "Ratio";
+		left: string;
+		right: string;
+	}
+
+	export interface Raw extends CssNodeCommon {
+		type: "Raw";
+		value: string;
+	}
+
+	export interface Rule extends CssNodeCommon {
+		type: "Rule";
+		prelude: SelectorList | Raw;
+		block: Block;
+	}
+
+	export interface RulePlain extends CssNodeCommon {
+		type: "Rule";
+		prelude: SelectorListPlain | Raw;
+		block: BlockPlain;
+	}
+
+	export interface Selector extends CssNodeCommon {
+		type: "Selector";
+		children: List<CssNode>;
+	}
+
+	export interface SelectorPlain extends CssNodeCommon {
+		type: "Selector";
+		children: CssNodePlain[];
+	}
+
+	export interface SelectorList extends CssNodeCommon {
+		type: "SelectorList";
+		children: List<CssNode>;
+	}
+
+	export interface SelectorListPlain extends CssNodeCommon {
+		type: "SelectorList";
+		children: CssNodePlain[];
+	}
+
+	export interface StringNode extends CssNodeCommon {
+		type: "String";
+		value: string;
+	}
+
+	export interface StyleSheet extends CssNodeCommon {
+		type: "StyleSheet";
+		children: List<CssNode>;
+	}
+
+	export interface StyleSheetPlain extends CssNodeCommon {
+		type: "StyleSheet";
+		children: CssNodePlain[];
+	}
+
+	export interface TypeSelector extends CssNodeCommon {
+		type: "TypeSelector";
+		name: string;
+	}
+
+	export interface UnicodeRange extends CssNodeCommon {
+		type: "UnicodeRange";
+		value: string;
+	}
+
+	export interface Url extends CssNodeCommon {
+		type: "Url";
+		value: string;
+	}
+
+	export interface Value extends CssNodeCommon {
+		type: "Value";
+		children: List<CssNode>;
+	}
+
+	export interface ValuePlain extends CssNodeCommon {
+		type: "Value";
+		children: CssNodePlain[];
+	}
+
+	export interface WhiteSpace extends CssNodeCommon {
+		type: "WhiteSpace";
+		value: string;
+	}
+
+	export type CssNode =
+		| AnPlusB
+		| Atrule
+		| AtrulePrelude
+		| AttributeSelector
+		| Block
+		| Brackets
+		| CDC
+		| CDO
+		| ClassSelector
+		| Combinator
+		| Comment
+		| Declaration
+		| DeclarationList
+		| Dimension
+		| FunctionNode
+		| Hash
+		| IdSelector
+		| Identifier
+		| MediaFeature
+		| MediaQuery
+		| MediaQueryList
+		| NestingSelector
+		| Nth
+		| NumberNode
+		| Operator
+		| Parentheses
+		| Percentage
+		| PseudoClassSelector
+		| PseudoElementSelector
+		| Ratio
+		| Raw
+		| Rule
+		| Selector
+		| SelectorList
+		| StringNode
+		| StyleSheet
+		| TypeSelector
+		| UnicodeRange
+		| Url
+		| Value
+		| WhiteSpace;
+
+	export type CssNodePlain =
+		| AnPlusB
+		| AtrulePlain
+		| AtrulePreludePlain
+		| AttributeSelector
+		| BlockPlain
+		| BracketsPlain
+		| CDC
+		| CDO
+		| ClassSelector
+		| Combinator
+		| Comment
+		| DeclarationPlain
+		| DeclarationListPlain
+		| Dimension
+		| FunctionNodePlain
+		| Hash
+		| IdSelector
+		| Identifier
+		| MediaFeature
+		| MediaQueryPlain
+		| MediaQueryListPlain
+		| NthPlain
+		| NumberNode
+		| Operator
+		| ParenthesesPlain
+		| Percentage
+		| PseudoClassSelectorPlain
+		| PseudoElementSelectorPlain
+		| Ratio
+		| Raw
+		| RulePlain
+		| SelectorPlain
+		| SelectorListPlain
+		| StringNode
+		| StyleSheetPlain
+		| TypeSelector
+		| UnicodeRange
+		| Url
+		| ValuePlain
+		| WhiteSpace;
+
+	export interface SyntaxParseError extends SyntaxError {
+		input: string;
+		offset: number;
+		rawMessage: string;
+		formattedMessage: string;
+	}
+
+	export interface ParseOptions {
+		context?: string | undefined;
+		atrule?: string | undefined;
+		positions?: boolean | undefined;
+		onComment?: (value: string, loc: CssLocation) => void;
+		onParseError?:
+			| ((error: SyntaxParseError, fallbackNode: CssNode) => void)
+			| undefined;
+		filename?: string | undefined;
+		offset?: number | undefined;
+		line?: number | undefined;
+		column?: number | undefined;
+		parseAtrulePrelude?: boolean | undefined;
+		parseRulePrelude?: boolean | undefined;
+		parseValue?: boolean | undefined;
+		parseCustomProperty?: boolean | undefined;
+	}
+
+	export function parse(text: string, options?: ParseOptions): CssNode;
+
+	export interface GenerateHandlers {
+		children: (node: CssNode, delimiter?: (node: CssNode) => void) => void;
+		node: (node: CssNode) => void;
+		chunk: (chunk: string) => void;
+		result: () => string;
+	}
+
+	export interface GenerateOptions {
+		sourceMap?: boolean | undefined;
+		decorator?:
+			| ((handlers: GenerateHandlers) => GenerateHandlers)
+			| undefined;
+		mode?: "safe" | "spec" | undefined;
+	}
+
+	export function generate(ast: CssNode, options?: GenerateOptions): string;
+
+	export interface WalkContext {
+		/**
+		 * Stops traversal. No visitor function will be invoked once this value is
+		 * returned by a visitor.
+		 */
+		break: symbol;
+		/**
+		 * Prevent the current node from being iterated. No visitor function will be
+		 * invoked for its properties or children nodes; note that this value only
+		 * has an effect for enter visitor as leave visitor invokes after iterating
+		 * over all node's properties and children.
+		 */
+		skip: symbol;
+		root: CssNode;
+		stylesheet: StyleSheet | null;
+		atrule: Atrule | null;
+		atrulePrelude: AtrulePrelude | null;
+		rule: Rule | null;
+		selector: SelectorList | null;
+		block: Block | null;
+		declaration: Declaration | null;
+		function:
+			| FunctionNode
+			| PseudoClassSelector
+			| PseudoElementSelector
+			| null;
+	}
+
+	export type EnterOrLeaveFn<NodeType = CssNode> = (
+		this: WalkContext,
+		node: NodeType,
+		item: ListItem<CssNode>,
+		list: List<CssNode>,
+	) => void;
+
+	export interface WalkOptionsNoVisit {
+		enter?: EnterOrLeaveFn | undefined;
+		leave?: EnterOrLeaveFn | undefined;
+		reverse?: boolean | undefined;
+	}
+
+	export interface WalkOptionsVisit<NodeType extends CssNode = CssNode> {
+		visit: NodeType["type"];
+		enter?: EnterOrLeaveFn<NodeType> | undefined;
+		leave?: EnterOrLeaveFn<NodeType> | undefined;
+		reverse?: boolean | undefined;
+	}
+
+	export type WalkOptions =
+		| WalkOptionsVisit<AnPlusB>
+		| WalkOptionsVisit<Atrule>
+		| WalkOptionsVisit<AtrulePrelude>
+		| WalkOptionsVisit<AttributeSelector>
+		| WalkOptionsVisit<Block>
+		| WalkOptionsVisit<Brackets>
+		| WalkOptionsVisit<CDC>
+		| WalkOptionsVisit<CDO>
+		| WalkOptionsVisit<ClassSelector>
+		| WalkOptionsVisit<Combinator>
+		| WalkOptionsVisit<Comment>
+		| WalkOptionsVisit<Declaration>
+		| WalkOptionsVisit<DeclarationList>
+		| WalkOptionsVisit<Dimension>
+		| WalkOptionsVisit<FunctionNode>
+		| WalkOptionsVisit<Hash>
+		| WalkOptionsVisit<IdSelector>
+		| WalkOptionsVisit<Identifier>
+		| WalkOptionsVisit<MediaFeature>
+		| WalkOptionsVisit<MediaQuery>
+		| WalkOptionsVisit<MediaQueryList>
+		| WalkOptionsVisit<Nth>
+		| WalkOptionsVisit<NumberNode>
+		| WalkOptionsVisit<Operator>
+		| WalkOptionsVisit<Parentheses>
+		| WalkOptionsVisit<Percentage>
+		| WalkOptionsVisit<PseudoClassSelector>
+		| WalkOptionsVisit<PseudoElementSelector>
+		| WalkOptionsVisit<Ratio>
+		| WalkOptionsVisit<Raw>
+		| WalkOptionsVisit<Rule>
+		| WalkOptionsVisit<Selector>
+		| WalkOptionsVisit<SelectorList>
+		| WalkOptionsVisit<StringNode>
+		| WalkOptionsVisit<StyleSheet>
+		| WalkOptionsVisit<TypeSelector>
+		| WalkOptionsVisit<UnicodeRange>
+		| WalkOptionsVisit<Url>
+		| WalkOptionsVisit<Value>
+		| WalkOptionsVisit<WhiteSpace>
+		| WalkOptionsNoVisit;
+
+	export const walk: {
+		(ast: CssNode, options: EnterOrLeaveFn | WalkOptions): void;
+		/**
+		 * Stops traversal. No visitor function will be invoked once this value is returned by a visitor.
+		 */
+		readonly break: symbol;
+		/**
+		 * Prevent the current node from being iterated. No visitor function will be invoked for its properties or children
+		 * nodes; note that this value only has an effect for enter visitor as leave visitor invokes after iterating over
+		 * all node's properties and children.
+		 */
+		readonly skip: symbol;
+	};
+
+	export type FindFn = (
+		this: WalkContext,
+		node: CssNode,
+		item: ListItem<CssNode>,
+		list: List<CssNode>,
+	) => boolean;
+
+	export function find(ast: CssNode, fn: FindFn): CssNode | null;
+	export function findLast(ast: CssNode, fn: FindFn): CssNode | null;
+	export function findAll(ast: CssNode, fn: FindFn): CssNode[];
+
+	export interface Property {
+		readonly basename: string;
+		readonly name: string;
+		readonly hack: string;
+		readonly vendor: string;
+		readonly prefix: string;
+		readonly custom: boolean;
+	}
+
+	export function property(value: string): Property;
+
+	export interface Keyword {
+		readonly basename: string;
+		readonly name: string;
+		readonly vendor: string;
+		readonly prefix: string;
+		readonly custom: boolean;
+	}
+
+	export function keyword(value: string): Keyword;
+
+	export function clone(node: CssNode): CssNode;
+
+	export function fromPlainObject(node: CssNodePlain): CssNode;
+	export function toPlainObject(node: CssNode): CssNodePlain;
+
+	/**
+	 * Definition syntax AtWord node
+	 */
+	export interface DSNodeAtWord {
+		type: "AtKeyword";
+		name: string;
+	}
+
+	/**
+	 * Definition syntax Comma node
+	 */
+	export interface DSNodeComma {
+		type: "Comma";
+	}
+
+	/**
+	 * Definition syntax Function node
+	 */
+	export interface DSNodeFunction {
+		type: "Function";
+		name: string;
+	}
+
+	export type DSNodeCombinator = "|" | "||" | "&&" | " ";
+
+	/**
+	 * Definition syntax Group node
+	 */
+	export interface DSNodeGroup {
+		type: "Group";
+		terms: DSNode[];
+		combinator: DSNodeCombinator;
+		disallowEmpty: boolean;
+		explicit: boolean;
+	}
+
+	/**
+	 * Definition syntax Keyword node
+	 */
+	export interface DSNodeKeyword {
+		type: "Keyword";
+		name: string;
+	}
+
+	/**
+	 * Definition syntax Multiplier node
+	 */
+	export interface DSNodeMultiplier {
+		type: "Multiplier";
+		comma: boolean;
+		min: number;
+		max: number;
+		term: DSNodeMultiplied;
+	}
+
+	/**
+	 * Definition syntax Property node
+	 */
+	export interface DSNodeProperty {
+		type: "Property";
+		name: string;
+	}
+
+	/**
+	 * Definition syntax String node
+	 */
+	export interface DSNodeString {
+		type: "String";
+		value: string;
+	}
+
+	/**
+	 * Definition syntax Token node
+	 */
+	export interface DSNodeToken {
+		type: "Token";
+		value: string;
+	}
+
+	/**
+	 * Definition syntax Type node options
+	 */
+	export interface DSNodeTypeOpts {
+		type: "Range";
+		min: number | null;
+		max: number | null;
+	}
+
+	/**
+	 * Definition syntax Type node
+	 */
+	export interface DSNodeType {
+		type: "Type";
+		name: string;
+		opts: DSNodeTypeOpts | null;
+	}
+
+	/**
+	 * Definition syntax node
+	 */
+	export type DSNode =
+		| DSNodeAtWord
+		| DSNodeComma
+		| DSNodeFunction
+		| DSNodeGroup
+		| DSNodeKeyword
+		| DSNodeMultiplier
+		| DSNodeProperty
+		| DSNodeString
+		| DSNodeToken
+		| DSNodeType;
+
+	/**
+	 * Definition syntax node compatible with a multiplier
+	 */
+	export type DSNodeMultiplied =
+		| DSNodeFunction
+		| DSNodeGroup
+		| DSNodeKeyword
+		| DSNodeProperty
+		| DSNodeString
+		| DSNodeType;
+
+	/**
+	 * Definition syntax generate options
+	 */
+	export interface DSGenerateOptions {
+		forceBraces?: boolean | undefined;
+		compact?: boolean | undefined;
+		decorate?: ((result: string, node: DSNode) => void) | undefined;
+	}
+
+	/**
+	 * Definition syntax walk options
+	 */
+	export interface DSWalkOptions {
+		enter?: DSWalkEnterOrLeaveFn | undefined;
+		leave?: DSWalkEnterOrLeaveFn | undefined;
+	}
+
+	/**
+	 * Definition syntax walk callback
+	 */
+	export type DSWalkEnterOrLeaveFn = (node: DSNode) => void;
+
+	/**
+	 * DefinitionSyntax
+	 */
+	export interface DefinitionSyntax {
+		/**
+		 * Generates CSS value definition syntax from an AST
+		 *
+		 * @param node - The AST
+		 * @param options - Options that affect generation
+		 *
+		 * @example
+		 *  generate({type: 'Keyword', name: 'foo'}) => 'foo'
+		 */
+		generate(node: DSNode, options?: DSGenerateOptions): string;
+
+		/**
+		 * Generates an AST from a CSS value syntax
+		 *
+		 * @param source - The CSS value syntax to parse
+		 *
+		 * @example
+		 *  parse('foo | bar') =>
+		 *    {
+		 *      type: 'Group',
+		 *      terms: [
+		 *        { type: 'Keyword', name: 'foo' },
+		 *        { type: 'Keyword', name: 'bar' }
+		 *      ],
+		 *      combinator: '|',
+		 *      disallowEmpty: false,
+		 *      explicit: false
+		 *    }
+		 */
+		parse(source: string): DSNodeGroup;
+
+		/**
+		 * Walks definition syntax AST
+		 */
+		walk(
+			node: DSNode,
+			options: DSWalkEnterOrLeaveFn | DSWalkOptions,
+			context?: any,
+		): void;
+
+		/**
+		 * Wrapper for syntax errors
+		 */
+		syntaxError: SyntaxError;
+	}
+
+	export const definitionSyntax: DefinitionSyntax;
+
+	export const ident: {
+		decode(input: string): string;
+		encode(input: string): string;
+	};
+
+	export const string: {
+		encode(input: string, apostrophe?: boolean): string;
+		decode(input: string): string;
+	};
+
+	export const url: {
+		decode(input: string): string;
+		encode(input: string): string;
+	};
+
+	export class SyntaxMatchError extends SyntaxError {
+		rawMessage: string;
+		syntax: string;
+		css: string;
+		mismatchOffset: number;
+		mismatchLength: number;
+		offset: number;
+		line: number;
+		column: number;
+		loc: {
+			source: string;
+			start: { offset: number; line: number; column: number };
+			end: { offset: number; line: number; column: number };
+		};
+	}
+
+	export class SyntaxReferenceError extends SyntaxError {
+		reference: string;
+	}
+
+	export interface LexerMatchResult {
+		error: Error | SyntaxMatchError | SyntaxReferenceError | null;
+	}
+
+	export class Lexer {
+		matchAtruleDescriptor(
+			atruleName: string,
+			descriptorName: string,
+			value: CssNode | string,
+		): LexerMatchResult;
+		matchAtrulePrelude(
+			atruleName: string,
+			prelude: CssNode | string,
+		): LexerMatchResult;
+		matchDeclaration(node: CssNode): LexerMatchResult;
+		matchProperty(
+			propertyName: string,
+			value: CssNode | string,
+		): LexerMatchResult;
+		matchType(typeName: string, value: CssNode | string): LexerMatchResult;
+		match(
+			syntax: DSNode | string,
+			value: CssNode | string,
+		): LexerMatchResult;
+	}
+
+	export const lexer: Lexer;
+
+	export function fork(extension: {
+		atrules?: Record<string, string> | undefined;
+		properties?: Record<string, string> | undefined;
+		types?: Record<string, string> | undefined;
+	}): { lexer: Lexer };
+}


Test patch:
diff --git a/tests/rules/no-invalid-properties.test.js b/tests/rules/no-invalid-properties.test.js
new file mode 100644
index 0000000..9ceced7
--- /dev/null
+++ b/tests/rules/no-invalid-properties.test.js
@@ -0,0 +1,181 @@
+/**
+ * @fileoverview Tests for no-invalid-properties rule.
+ * @author Nicholas C. Zakas
+ */
+
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import rule from "../../src/rules/no-invalid-properties.js";
+import css from "../../src/index.js";
+import { RuleTester } from "eslint";
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester({
+	plugins: {
+		css,
+	},
+	language: "css/css",
+});
+
+ruleTester.run("no-invalid-properties", rule, {
+	valid: [
+		"a { color: red; }",
+		"a { color: red; background-color: blue; }",
+		"a { color: red; transition: none; }",
+		"body { --custom-property: red; }",
+		"body { padding: 0; }",
+		"a { color: red; -moz-transition: bar }",
+		"@font-face { font-weight: 100 400 }",
+		'@property --foo { syntax: "*"; inherits: false; }',
+	],
+	invalid: [
+		{
+			code: "a { color: bar }",
+			errors: [
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "color",
+						value: "bar",
+						expected: "<color>",
+					},
+					line: 1,
+					column: 12,
+					endLine: 1,
+					endColumn: 15,
+				},
+			],
+		},
+		{
+			code: "a { width: red }",
+			errors: [
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "width",
+						value: "red",
+						expected:
+							"auto | <length> | <percentage> | min-content | max-content | fit-content | fit-content( <length-percentage> ) | stretch | <-non-standard-size>",
+					},
+					line: 1,
+					column: 12,
+					endLine: 1,
+					endColumn: 15,
+				},
+			],
+		},
+		{
+			code: "a { margin: 10px 20px 30px 40px 50px }",
+			errors: [
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "margin",
+						value: "10px 20px 30px 40px 50px",
+						expected: "[ <length> | <percentage> | auto ]{1,4}",
+					},
+					line: 1,
+					column: 33,
+					endLine: 1,
+					endColumn: 37,
+				},
+			],
+		},
+		{
+			code: "a { display: flexbox }",
+			errors: [
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "display",
+						value: "flexbox",
+						expected:
+							"[ <display-outside> || <display-inside> ] | <display-listitem> | <display-internal> | <display-box> | <display-legacy> | <-non-standard-display>",
+					},
+					line: 1,
+					column: 14,
+					endLine: 1,
+					endColumn: 21,
+				},
+			],
+		},
+		{
+			code: "a { color: #ff0000X; }",
+			errors: [
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "color",
+						value: "#ff0000X",
+						expected: "<color>",
+					},
+					line: 1,
+					column: 12,
+					endLine: 1,
+					endColumn: 20,
+				},
+			],
+		},
+		{
+			code: "a {\n    color: #ff0000x;\n    display: flexbox;\n}",
+			errors: [
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "color",
+						value: "#ff0000x",
+						expected: "<color>",
+					},
+					line: 2,
+					column: 12,
+					endLine: 2,
+					endColumn: 20,
+				},
+				{
+					messageId: "invalidPropertyValue",
+					data: {
+						property: "display",
+						value: "flexbox",
+						expected:
+							"[ <display-outside> || <display-inside> ] | <display-listitem> | <display-internal> | <display-box> | <display-legacy> | <-non-standard-display>",
+					},
+					line: 3,
+					column: 14,
+					endLine: 3,
+					endColumn: 21,
+				},
+			],
+		},
+		{
+			code: "a { foo: bar }",
+			errors: [
+				{
+					messageId: "unknownProperty",
+					data: { property: "foo" },
+					line: 1,
+					column: 5,
+					endLine: 1,
+					endColumn: 8,
+				},
+			],
+		},
+		{
+			code: "a { my-color: red; -webkit-transition: bar }",
+			errors: [
+				{
+					messageId: "unknownProperty",
+					data: { property: "my-color" },
+					line: 1,
+					column: 5,
+					endLine: 1,
+					endColumn: 13,
+				},
+			],
+		},
+	],
+});
diff --git a/tests/rules/no-unknown-properties.test.js b/tests/rules/no-unknown-properties.test.js
deleted file mode 100644
index 8980e2f..0000000
--- a/tests/rules/no-unknown-properties.test.js
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * @fileoverview Tests for no-unknown-properties rule.
- * @author Nicholas C. Zakas
- */
-
-//------------------------------------------------------------------------------
-// Imports
-//------------------------------------------------------------------------------
-
-import rule from "../../src/rules/no-unknown-properties.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-properties", rule, {
-	valid: [
-		"a { color: red; }",
-		"a { color: red; background-color: blue; }",
-		"a { color: red; transition: none; }",
-		"body { --custom-property: red; }",
-	],
-	invalid: [
-		{
-			code: "a { foo: bar }",
-			errors: [
-				{
-					messageId: "unknownProperty",
-					data: { property: "foo" },
-					line: 1,
-					column: 5,
-					endLine: 1,
-					endColumn: 8,
-				},
-			],
-		},
-		{
-			code: "a { color: red; -moz-transition: bar }",
-			errors: [
-				{
-					messageId: "unknownProperty",
-					data: { property: "-moz-transition" },
-					line: 1,
-					column: 17,
-					endLine: 1,
-					endColumn: 32,
-				},
-			],
-		},
-		{
-			code: "a { my-color: red; -webkit-transition: bar }",
-			errors: [
-				{
-					messageId: "unknownProperty",
-					data: { property: "my-color" },
-					line: 1,
-					column: 5,
-					endLine: 1,
-					endColumn: 13,
-				},
-				{
-					messageId: "unknownProperty",
-					data: { property: "-webkit-transition" },
-					line: 1,
-					column: 20,
-					endLine: 1,
-					endColumn: 38,
-				},
-			],
-		},
-	],
-});