Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,9 @@ Setting `tolerant` to `true` is necessary if you are using custom syntax, such a

The CSS lexer comes prebuilt with a set of known syntax for CSS that is used in rules like `no-invalid-properties` to validate CSS code. While this works for most cases, there may be cases when you want to define your own extensions to CSS, and this can be done using the `customSyntax` language option.

The `customSyntax` option is an object that uses the [CSSTree format](https://github.com/csstree/csstree/blob/master/data/patch.json) for defining custom syntax, which allows you to specify at-rules, properties, and some types. For example, suppose you'd like to define a custom at-rule that looks like this:
The `customSyntax` option accepts either an object or a function:

**Object-based syntax**: An object that uses the [CSSTree format](https://github.com/csstree/csstree/blob/master/data/patch.json) for defining custom syntax, which allows you to specify at-rules, properties, and some types. For example, suppose you'd like to define a custom at-rule that looks like this:

```css
@my-at-rule "hello world!";
Expand Down Expand Up @@ -222,6 +224,37 @@ export default defineConfig([
]);
```

**Function-based syntax**: A function that receives the default CSS syntax data and returns a custom syntax configuration. This is useful when you want to extend the base syntax rather than replace it. For example:

```js
// eslint.config.js
import { defineConfig } from "eslint/config";
import css from "@eslint/css";

export default defineConfig([
{
files: ["**/*.css"],
plugins: {
css,
},
language: "css/css",
languageOptions: {
customSyntax: (defaultSyntax) => ({
...defaultSyntax,
properties: {
...defaultSyntax.properties,
"-webkit-custom": "<length>",
"-moz-custom": "<color>",
},
}),
},
rules: {
"css/no-empty-blocks": "error",
},
},
]);
```

#### Configuring Tailwind Syntax

[Tailwind](https://tailwindcss.com) specifies some extensions to CSS that will otherwise be flagged as invalid by the rules in this plugin. To properly parse Tailwind-specific syntax, install the [`tailwind-csstree`](https://npmjs.com/package/tailwind-csstree) package:
Expand Down
36 changes: 32 additions & 4 deletions src/languages/css-language.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
toPlainObject,
tokenTypes,
} from "@eslint/css-tree";
import defaultSyntax from "@eslint/css-tree/definition-syntax-data";
import { CSSSourceCode } from "./css-source-code.js";
import { visitorKeys } from "./css-visitor-keys.js";

Expand All @@ -28,10 +29,18 @@ import { visitorKeys } from "./css-visitor-keys.js";

/** @typedef {OkParseResult<StyleSheetPlain> & { comments: Comment[], lexer: Lexer }} CSSOkParseResult */
/** @typedef {ParseResult<StyleSheetPlain>} CSSParseResult */
/**
* DefaultSyntaxConfig type representing the structure returned by @eslint/css-tree/definition-syntax-data.
* This type is defined inline because it's not exported from the main @eslint/css-tree package.
* @typedef {Pick<SyntaxConfig, "atrules" | "types" | "properties">} DefaultSyntaxConfig
*/
/**
* @typedef {(defaultSyntax: DefaultSyntaxConfig) => Partial<SyntaxConfig>} SyntaxExtensionCallback
*/
/**
* @typedef {Object} CSSLanguageOptions
* @property {boolean} [tolerant] Whether to be tolerant of recoverable parsing errors.
* @property {SyntaxConfig} [customSyntax] Custom syntax to use for parsing.
* @property {Partial<SyntaxConfig> | SyntaxExtensionCallback} [customSyntax] Custom syntax to use for parsing.
*/

//-----------------------------------------------------------------------------
Expand Down Expand Up @@ -147,11 +156,20 @@ export class CSSLanguage {

if ("customSyntax" in languageOptions) {
if (
typeof languageOptions.customSyntax !== "object" ||
typeof languageOptions.customSyntax !== "object" &&
typeof languageOptions.customSyntax !== "function"
) {
throw new TypeError(
"Expected an object or function value for 'customSyntax' option.",
);
}

if (
typeof languageOptions.customSyntax === "object" &&
languageOptions.customSyntax === null
) {
throw new TypeError(
"Expected an object value for 'customSyntax' option.",
"Expected an object or function value for 'customSyntax' option.",
);
}
}
Expand All @@ -167,9 +185,15 @@ export class CSSLanguage {
if (!languageOptions?.customSyntax) {
return languageOptions;
}

// Shallow copy
const clone = { ...languageOptions };

// If customSyntax is a function, call it with the default syntax to get the config object
if (typeof languageOptions.customSyntax === "function") {
clone.customSyntax = languageOptions.customSyntax(defaultSyntax);
}

Object.defineProperty(clone, "toJSON", {
value() {
// another shallow copy
Expand Down Expand Up @@ -205,7 +229,11 @@ export class CSSLanguage {

const { tolerant } = languageOptions;
const { parse, lexer } = languageOptions.customSyntax
? fork(languageOptions.customSyntax)
? fork(
/** @type {Partial<SyntaxConfig>} */ (
languageOptions.customSyntax
),
)
: { parse: originalParse, lexer: originalLexer };

/*
Expand Down
107 changes: 106 additions & 1 deletion tests/languages/css-language.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,49 @@ describe("CSSLanguage", () => {

assert.throws(() => {
language.validateLanguageOptions({ customSyntax: null });
}, /Expected an object value for 'customSyntax' option/u);
}, /Expected an object or function value for 'customSyntax' option/u);
});

it("should use function-based custom syntax when provided", () => {
const language = new CSSLanguage();
/**
* Test helper function to create custom syntax.
* @param {Object} defaultSyntax The default syntax configuration.
* @returns {Object} Extended syntax configuration.
*/
function customSyntaxFn(defaultSyntax) {
return {
...defaultSyntax,
properties: {
...defaultSyntax.properties,
"-custom-prop": "<length>",
},
};
}

const languageOptions = language.normalizeLanguageOptions({
customSyntax: customSyntaxFn,
});

const result = language.parse(
{
body: "a { -custom-prop: 5px; }",
path: "test.css",
},
{ languageOptions },
);

assert.strictEqual(result.ok, true);
assert.strictEqual(result.ast.type, "StyleSheet");
assert.strictEqual(result.ast.children[0].type, "Rule");
});

it("should error when invalid custom syntax type is provided", () => {
const language = new CSSLanguage();

assert.throws(() => {
language.validateLanguageOptions({ customSyntax: "string" });
}, /Expected an object or function value for 'customSyntax' option/u);
});

it("should return an error when EOF is discovered before block close", () => {
Expand Down Expand Up @@ -320,5 +362,68 @@ describe("CSSLanguage", () => {
},
});
});

it("should convert function-based customSyntax to object", () => {
const language = new CSSLanguage();
/**
* Test helper function to create custom syntax.
* @param {Object} defaultSyntax The default syntax configuration.
* @returns {Object} Extended syntax configuration.
*/
function customSyntaxFn(defaultSyntax) {
return {
...defaultSyntax,
properties: {
...defaultSyntax.properties,
"-custom-prop": "<length>",
},
};
}
const options = { tolerant: false, customSyntax: customSyntaxFn };
const normalized = language.normalizeLanguageOptions(options);

// Should convert the function to an object
assert.strictEqual(typeof normalized.customSyntax, "object");
assert.ok(normalized.customSyntax.properties);
assert.strictEqual(
normalized.customSyntax.properties["-custom-prop"],
"<length>",
);
});

it("should serialize function-based customSyntax correctly", () => {
const language = new CSSLanguage();
/**
* Test helper function to create custom syntax with nodes.
* @param {Object} defaultSyntax The default syntax configuration.
* @returns {Object} Extended syntax configuration with custom node.
*/
function customSyntaxFn(defaultSyntax) {
return {
...defaultSyntax,
properties: {
...defaultSyntax.properties,
"-custom-prop": "<length>",
},
node: {
CustomNode: {
parse() {},
},
},
};
}
const options = { tolerant: false, customSyntax: customSyntaxFn };
const normalized = language.normalizeLanguageOptions(options);
const json = normalized.toJSON();

// Should have converted function to object and functions inside to true
assert.strictEqual(typeof json.customSyntax, "object");
assert.ok(json.customSyntax.properties);
assert.strictEqual(
json.customSyntax.properties["-custom-prop"],
"<length>",
);
assert.strictEqual(json.customSyntax.node.CustomNode.parse, true);
});
});
});
Loading