Skip to content

Commit

Permalink
feat: Allow custom syntax (#47)
Browse files Browse the repository at this point in the history
* feat: Allow custom syntax

fixes #37

* Update src/syntax/tailwind-syntax.js

Co-authored-by: Milos Djermanovic <[email protected]>

* Fix no-invalid-at-rules tests

* Add tests for CSSLanguage

* Generate types for /syntax

* Include syntax/index.d.ts in JSR package

---------

Co-authored-by: Milos Djermanovic <[email protected]>
  • Loading branch information
nzakas and mdjermanovic authored Feb 14, 2025
1 parent 3da4963 commit 397888b
Show file tree
Hide file tree
Showing 15 changed files with 501 additions and 32 deletions.
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export default [
];
```

#### Tolerant Mode

By default, the CSS parser runs in strict mode, which reports all parsing errors. If you'd like to allow recoverable parsing errors (those that the browser automatically fixes on its own), you can set the `tolerant` option to `true`:

```js
Expand All @@ -159,6 +161,73 @@ export default [

Setting `tolerant` to `true` is necessary if you are using custom syntax, such as [PostCSS](https://postcss.org/) plugins, that aren't part of the standard CSS syntax.

#### Configuring Custom Syntax

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:

```css
@my-at-rule "hello world!";
```

You can configure that syntax as follows:

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

export default [
{
files: ["**/*.css"],
plugins: {
css,
},
language: "css/css",
languageOptions: {
customSyntax: {
atrules: {
"my-at-rule": {
prelude: "<string>",
},
},
},
},
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. You can configure most of the custom syntax for Tailwind using the builtin `tailwindSyntax` object, like this:

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

export default [
{
files: ["**/*.css"],
plugins: {
css,
},
language: "css/css",
languageOptions: {
customSyntax: tailwindSyntax,
},
rules: {
"css/no-empty-blocks": "error",
},
},
];
```

**Note:** The Tailwind syntax doesn't currently provide for the `theme()` function. This is a [limitation of CSSTree](https://github.com/csstree/csstree/issues/292) that we hope will be resolved soon.

## License

Apache 2.0
Expand Down
7 changes: 6 additions & 1 deletion jsr.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
{
"name": "@eslint/css",
"version": "0.2.0",
"exports": "./dist/esm/index.js",
"exports": {
".": "./dist/esm/index.js",
"./syntax": "./dist/esm/syntax/index.js"
},
"publish": {
"include": [
"dist/esm/index.js",
"dist/esm/index.d.ts",
"dist/esm/syntax/index.js",
"dist/esm/syntax/index.d.ts",
"README.md",
"jsr.json",
"LICENSE",
Expand Down
27 changes: 20 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,25 @@
"main": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"exports": {
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
".": {
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
},
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
}
},
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
"./syntax": {
"require": {
"types": "./dist/cjs/syntax/index.d.cts",
"default": "./dist/cjs/syntax/index.cjs"
},
"import": {
"types": "./dist/esm/syntax/index.d.ts",
"default": "./dist/esm/syntax/index.js"
}
}
},
"files": [
Expand Down Expand Up @@ -51,7 +63,8 @@
"scripts": {
"build:dedupe-types": "node tools/dedupe-types.js dist/cjs/index.cjs dist/esm/index.js",
"build:cts": "node -e \"fs.copyFileSync('dist/esm/index.d.ts', 'dist/cjs/index.d.cts')\"",
"build": "rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json && npm run build:cts",
"build:syntax-cts": "node -e \"fs.copyFileSync('dist/esm/syntax/index.d.ts', 'dist/cjs/syntax/index.d.cts')\"",
"build": "rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json && npm run build:cts && tsc -p tsconfig.syntax.json && npm run build:syntax-cts",
"build:readme": "node tools/update-readme.js",
"build:update-rules-docs": "node tools/update-rules-docs.js",
"build:baseline": "node tools/generate-baseline.js",
Expand Down
44 changes: 30 additions & 14 deletions rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
export default {
input: "src/index.js",
output: [
{
file: "dist/cjs/index.cjs",
format: "cjs",
},
{
file: "dist/esm/index.js",
format: "esm",
banner: '// @ts-self-types="./index.d.ts"',
},
],
};
export default [
{
input: "src/index.js",
output: [
{
file: "dist/cjs/index.cjs",
format: "cjs",
},
{
file: "dist/esm/index.js",
format: "esm",
banner: '// @ts-self-types="./index.d.ts"',
},
],
},
{
input: "src/syntax/index.js",
output: [
{
file: "dist/cjs/syntax/index.cjs",
format: "cjs",
},
{
file: "dist/esm/syntax/index.js",
format: "esm",
banner: '// @ts-self-types="./index.d.ts"',
},
],
},
];
28 changes: 26 additions & 2 deletions src/languages/css-language.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
// Imports
//------------------------------------------------------------------------------

import { parse, toPlainObject } from "css-tree";
import {
parse as originalParse,
lexer as originalLexer,
fork,
toPlainObject,
} from "css-tree";
import { CSSSourceCode } from "./css-source-code.js";
import { visitorKeys } from "./css-visitor-keys.js";

Expand All @@ -19,15 +24,18 @@ import { visitorKeys } from "./css-visitor-keys.js";
/** @typedef {import("css-tree").CssNodePlain} CssNodePlain */
/** @typedef {import("css-tree").StyleSheet} StyleSheet */
/** @typedef {import("css-tree").Comment} Comment */
/** @typedef {import("css-tree").Lexer} Lexer */
/** @typedef {import("css-tree").SyntaxConfig} SyntaxConfig */
/** @typedef {import("@eslint/core").Language} Language */
/** @typedef {import("@eslint/core").OkParseResult<CssNodePlain> & { comments: Comment[] }} OkParseResult */
/** @typedef {import("@eslint/core").OkParseResult<CssNodePlain> & { comments: Comment[], lexer: Lexer }} OkParseResult */
/** @typedef {import("@eslint/core").ParseResult<CssNodePlain>} ParseResult */
/** @typedef {import("@eslint/core").File} File */
/** @typedef {import("@eslint/core").FileError} FileError */

/**
* @typedef {Object} CSSLanguageOptions
* @property {boolean} [tolerant] Whether to be tolerant of recoverable parsing errors.
* @property {SyntaxConfig} [customSyntax] Custom syntax to use for parsing.
*/

//-----------------------------------------------------------------------------
Expand Down Expand Up @@ -91,6 +99,17 @@ export class CSSLanguage {
"Expected a boolean value for 'tolerant' option.",
);
}

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

/**
Expand All @@ -111,6 +130,9 @@ export class CSSLanguage {
const errors = [];

const { tolerant } = languageOptions;
const { parse, lexer } = languageOptions.customSyntax
? fork(languageOptions.customSyntax)
: { parse: originalParse, lexer: originalLexer };

/*
* Check for parsing errors first. If there's a parsing error, nothing
Expand Down Expand Up @@ -150,6 +172,7 @@ export class CSSLanguage {
ok: true,
ast: root,
comments,
lexer,
};
} catch (ex) {
return {
Expand All @@ -170,6 +193,7 @@ export class CSSLanguage {
text: /** @type {string} */ (file.body),
ast: parseResult.ast,
comments: parseResult.comments,
lexer: parseResult.lexer,
});
}
}
11 changes: 10 additions & 1 deletion src/languages/css-source-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { visitorKeys } from "./css-visitor-keys.js";
/** @typedef {import("css-tree").CssNodePlain} CssNodePlain */
/** @typedef {import("css-tree").BlockPlain} BlockPlain */
/** @typedef {import("css-tree").Comment} Comment */
/** @typedef {import("css-tree").Lexer} Lexer */
/** @typedef {import("@eslint/core").SourceRange} SourceRange */
/** @typedef {import("@eslint/core").SourceLocation} SourceLocation */
/** @typedef {import("@eslint/core").SourceLocationWithOffset} SourceLocationWithOffset */
Expand Down Expand Up @@ -105,17 +106,25 @@ export class CSSSourceCode extends TextSourceCodeBase {
*/
comments;

/**
* The lexer for this instance.
* @type {Lexer}
*/
lexer;

/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.text The source code text.
* @param {CssNodePlain} options.ast The root AST node.
* @param {Array<Comment>} options.comments The comment nodes in the source code.
* @param {Lexer} options.lexer The lexer used to parse the source code.
*/
constructor({ text, ast, comments }) {
constructor({ text, ast, comments, lexer }) {
super({ text, ast });
this.ast = ast;
this.comments = comments;
this.lexer = lexer;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/rules/no-invalid-at-rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
// Imports
//-----------------------------------------------------------------------------

import { lexer } from "css-tree";
import { isSyntaxMatchError } from "../util.js";

//-----------------------------------------------------------------------------
Expand Down Expand Up @@ -68,6 +67,7 @@ export default {

create(context) {
const { sourceCode } = context;
const lexer = sourceCode.lexer;

return {
Atrule(node) {
Expand Down
3 changes: 2 additions & 1 deletion src/rules/no-invalid-properties.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
// Imports
//-----------------------------------------------------------------------------

import { lexer } from "css-tree";
import { isSyntaxMatchError } from "../util.js";

//-----------------------------------------------------------------------------
Expand All @@ -32,6 +31,8 @@ export default {
},

create(context) {
const lexer = context.sourceCode.lexer;

return {
"Rule > Block > Declaration"(node) {
// don't validate custom properties
Expand Down
6 changes: 6 additions & 0 deletions src/syntax/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @fileoverview Common extended CSSTree syntax definitions.
* @author Nicholas C. Zakas
*/

export { default as tailwindSyntax } from "./tailwind-syntax.js";
29 changes: 29 additions & 0 deletions src/syntax/tailwind-syntax.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @fileoverview CSSTree syntax for Tailwind CSS extensions.
* @author Nicholas C. Zakas
*/
export default {
atrules: {
apply: {
prelude: "<ident>+",
},
tailwind: {
prelude: "base | components | utilities",
},
config: {
prelude: "<string>",
},
},

/*
* CSSTree doesn't currently support custom functions properly, so leaving
* these out for now.
* https://github.com/csstree/csstree/issues/292
*/
// types: {
// "tailwind-theme-base": "spacing | colors",
// "tailwind-theme-color": "<tailwind-theme-base> [ '.' [ <ident> | <integer> ] ]+",
// "tailwind-theme-name": "<tailwind-theme-color>",
// "tailwind-theme()": "theme( <tailwind-theme-name>)",
// },
};
Loading

0 comments on commit 397888b

Please sign in to comment.