├── .changeset ├── README.md └── config.json ├── .eslint-doc-generatorrc.js ├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .markdownlint.json ├── .markdownlintignore ├── .prettierrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── rules │ ├── forbid-array-expressions.md │ ├── forbid-false-inside-object-expressions.md │ ├── forbid-true-inside-object-expressions.md │ ├── no-redundant-clsx.md │ ├── no-spreading.md │ ├── prefer-logical-over-objects.md │ ├── prefer-merged-neighboring-elements.md │ └── prefer-objects-over-logical.md ├── eslint.config.mjs ├── generate-all-rules.js ├── package-lock.json ├── package.json ├── src ├── PluginDocs.ts ├── createRule.ts ├── index.ts ├── rules │ ├── allRules.generated.ts │ ├── forbid-array-expressions.ts │ ├── forbid-false-inside-object-expressions.ts │ ├── forbid-true-inside-object-expressions.ts │ ├── no-redundant-clsx.test.ts │ ├── no-redundant-clsx.ts │ ├── no-spreading.ts │ ├── prefer-logical-over-objects.ts │ ├── prefer-merged-neighboring-elements.ts │ ├── prefer-objects-over-logical.test.ts │ ├── prefer-objects-over-logical.ts │ └── tests-setup.mjs └── utils.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.mts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.eslint-doc-generatorrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | const REPO_URL = require('./package.json') 3 | .repository.url.replace(/^git\+/, '') 4 | .replace(/\.git$/, ''); 5 | 6 | /** @type {import('eslint-doc-generator').GenerateOptions} */ 7 | module.exports = { 8 | ignoreConfig: ['all'], 9 | ruleDocSectionInclude: ['Rule Details'], 10 | urlConfigs: `${REPO_URL}#presets`, 11 | }; 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | pull_request: 7 | branches: 8 | - "**" 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22.x 17 | cache: npm 18 | 19 | - run: npm ci 20 | - run: npm run lint 21 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | 7 | concurrency: ${{ github.workflow }}-${{ github.ref }} 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22.x 17 | cache: npm 18 | 19 | - run: npm ci 20 | - name: Create Release Pull Request or Publish 21 | id: changesets 22 | uses: changesets/action@v1 23 | with: 24 | publish: npm run release 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .vscode 4 | .DS_Store -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line-length": false, 3 | "no-inline-html": false 4 | } -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | LICENSE.md 3 | node_modules -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 4, 3 | printWidth: 100, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | proseWrap: 'never', 7 | arrowParens: 'always', 8 | quoteProps: 'preserve', 9 | htmlWhitespaceSensitivity: 'strict', 10 | }; 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-clsx 2 | 3 | ## 0.0.10 4 | 5 | ### Patch Changes 6 | 7 | - 56fbdbd: fix peer dependency warning 8 | 9 | ## 0.0.9 10 | 11 | ### Patch Changes 12 | 13 | - 2241036: fix no-redundant-clsx autofixing object literal wrapped with clsx 14 | 15 | ## 0.0.8 16 | 17 | ### Patch Changes 18 | 19 | - 50b38fc: add redundancy selector for no-redundant-clsx 20 | 21 | ## 0.0.7 22 | 23 | ### Patch Changes 24 | 25 | - 8594323: cleanup published files 26 | 27 | ## 0.0.6 28 | 29 | ### Patch Changes 30 | 31 | - 75d5227: fix #16 critical build issue 32 | 33 | ## 0.0.5 34 | 35 | ### Patch Changes 36 | 37 | - 93d93a8: fix #6 `prefer-objects-over-logical` autofix 38 | 39 | ## 0.0.4 40 | 41 | ### Patch Changes 42 | 43 | - 5eb6a1e: no-redundant-clsx improvement 44 | 45 | ## 0.0.3 46 | 47 | ### Patch Changes 48 | 49 | - 2204a6c: fix `forbid-true-inside-object-expressions` conversion bug 50 | 51 | ## 0.0.2 52 | 53 | ### Patch Changes 54 | 55 | - d3b4c51: switched the project to typescript 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Artem Baranov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-clsx 2 | 3 | An ESLint plugin for clsx/classnames 4 | 5 | 6 | * [Installation](#Installation) 7 | * [Usage](#Usage) 8 | * [Rules](#Rules) 9 | * [Presets](#Presets) 10 | * [Preset usage](#Presetusage) 11 | 12 | 16 | 17 | 18 | ## Installation 19 | 20 | You'll first need to install [ESLint](https://eslint.org): 21 | 22 | ```sh 23 | npm i eslint --save-dev 24 | ``` 25 | 26 | Next, install `eslint-plugin-clsx`: 27 | 28 | ```sh 29 | npm install eslint-plugin-clsx --save-dev 30 | ``` 31 | 32 | ## Usage 33 | 34 | Here's an example ESLint configuration that: 35 | 36 | * Enables the `recommended` configuration 37 | * Enables an optional/non-recommended rule 38 | 39 | ```json 40 | { 41 | "extends": ["plugin:clsx/recommended"], 42 | "rules": { 43 | "clsx/no-redundant-clsx": "error" 44 | } 45 | } 46 | ``` 47 | 48 | ## Rules 49 | 50 | 51 | 52 | ⚠️ [Configurations](https://github.com/temoncher/eslint-plugin-clsx#presets) set to warn in.\ 53 | ✅ Set in the `recommended` [configuration](https://github.com/temoncher/eslint-plugin-clsx#presets).\ 54 | 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). 55 | 56 | | Name                                   | Description | ⚠️ | 🔧 | 57 | | :--------------------------------------------------------------------------------------------- | :-------------------------------------------------------------- | :- | :- | 58 | | [forbid-array-expressions](docs/rules/forbid-array-expressions.md) | forbid usage of array expressions inside clsx | ✅ | 🔧 | 59 | | [forbid-false-inside-object-expressions](docs/rules/forbid-false-inside-object-expressions.md) | forbid usage of false literal inside object expressions of clsx | ✅ | 🔧 | 60 | | [forbid-true-inside-object-expressions](docs/rules/forbid-true-inside-object-expressions.md) | forbid usage of true literal inside object expressions of clsx | ✅ | 🔧 | 61 | | [no-redundant-clsx](docs/rules/no-redundant-clsx.md) | disallow redundant clsx usage | ✅ | 🔧 | 62 | | [no-spreading](docs/rules/no-spreading.md) | forbid usage of object expression inside clsx | ✅ | 🔧 | 63 | | [prefer-logical-over-objects](docs/rules/prefer-logical-over-objects.md) | forbid usage of object expression inside clsx | | 🔧 | 64 | | [prefer-merged-neighboring-elements](docs/rules/prefer-merged-neighboring-elements.md) | enforce merging of neighboring elements | ✅ | 🔧 | 65 | | [prefer-objects-over-logical](docs/rules/prefer-objects-over-logical.md) | forbid usage of logical expressions inside clsx | | 🔧 | 66 | 67 | 68 | 69 | ## Presets 70 | 71 | | | Name | Description | 72 | |:--|:-----|:------------| 73 | | ✅ | `recommended` | enables all recommended rules in this plugin | 74 | | | `all` | enables all rules in this plugin | 75 | 76 | ### Preset usage 77 | 78 | Presets are enabled by adding a line to the `extends` list in your config file. For example, to enable the `recommended` preset, use: 79 | 80 | ```json 81 | { 82 | "extends": ["plugin:clsx/recommended"] 83 | } 84 | ``` 85 | 86 | ## Settings 87 | 88 | This rule can optionally be configured with an object that represents imports that should be considered an clsx usage 89 | 90 | ```json 91 | { 92 | "settings": { 93 | "clsxOptions": { 94 | "myclsx": "default" 95 | } 96 | } 97 | } 98 | ``` 99 | 100 | Examples of **incorrect** code for the `{ myclsx: 'default' }` setting (with no-redundant-clsx rule enabled): 101 | 102 | ```js 103 | import mc from 'myclsx'; 104 | 105 | const singleClasses = mc('single-class'); 106 | ``` 107 | 108 | Examples of **incorrect** code for the `{ myclsx: 'cn' }` setting (with no-redundant-clsx rule enabled): 109 | 110 | ```js 111 | import { cn } from 'myclsx'; 112 | 113 | const singleClasses = cn('single-class'); 114 | ``` 115 | 116 | Examples of **incorrect** code for the `{ myclsx: ['default', 'cn'] }` setting (with no-redundant-clsx rule enabled): 117 | 118 | ```js 119 | import mc, { cn } from 'myclsx'; 120 | 121 | // both report errors 122 | const singleClasses = cn('single-class'); 123 | const singleClasses = mc('single-class'); 124 | ``` 125 | 126 | Default setting value is `{ clsx: ['default', 'clsx'], classnames: 'default' }` 127 | -------------------------------------------------------------------------------- /docs/rules/forbid-array-expressions.md: -------------------------------------------------------------------------------- 1 | # Forbid usage of array expressions inside clsx (`clsx/forbid-array-expressions`) 2 | 3 | ⚠️ This rule _warns_ in the ✅ `recommended` [config](https://github.com/temoncher/eslint-plugin-clsx#presets). 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to forbid usage of array expressions inside clsx 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```js 16 | /* eslint clsx/forbid-array-expressions: error */ 17 | 18 | const singleClasses = clsx(['single-class']); 19 | ``` 20 | 21 | Examples of **correct** code for this rule: 22 | 23 | ```js 24 | /* eslint clsx/forbid-array-expressions: error */ 25 | 26 | const singleClasses = clsx('single-class'); 27 | const twoClasseses = clsx(['first-class', 'second-class']); 28 | const classes = ['first-class', 'second-class']; 29 | const dynamic = clsx('some-class', classes); 30 | ``` 31 | 32 | ## Options 33 | 34 | ### always (default) 35 | 36 | Examples of **incorrect** code for the `always` option: 37 | 38 | ```js 39 | /* eslint clsx/forbid-array-expressions: ['error', 'always'] */ 40 | 41 | const twoClasseses = clsx(['first-class', 'second-class']); 42 | ``` 43 | 44 | Examples of **correct** code for the `always` option: 45 | 46 | ```js 47 | /* eslint clsx/forbid-array-expressions: ['error', 'always'] */ 48 | 49 | const singleClasses = clsx('single-class'); 50 | const twoClasseses = clsx('first-class', 'second-class'); 51 | const classes = ['first-class', 'second-class']; 52 | const dynamic = clsx('some-class', classes); 53 | ``` 54 | 55 | ### onlySingleElement 56 | 57 | Examples of **incorrect** code for the `onlySingleElement` option: 58 | 59 | ```js 60 | /* eslint clsx/forbid-array-expressions: ['error', 'onlySingleElement'] */ 61 | 62 | const singleClasses = clsx(['single-class']); 63 | ``` 64 | 65 | Examples of **correct** code for the `onlySingleElement` option: 66 | 67 | ```js 68 | /* eslint clsx/forbid-array-expressions: ['error', 'onlySingleElement'] */ 69 | 70 | const singleClasses = clsx('single-class'); 71 | const twoClasseses = clsx(['first-class', 'second-class']); 72 | const classes = ['first-class', 'second-class']; 73 | const dynamic = clsx('some-class', classes); 74 | ``` 75 | 76 | ## When Not To Use It 77 | 78 | If you don't want to enforce specific usage of array expressions inside clsx 79 | -------------------------------------------------------------------------------- /docs/rules/forbid-false-inside-object-expressions.md: -------------------------------------------------------------------------------- 1 | # Forbid usage of false literal inside object expressions of clsx (`clsx/forbid-false-inside-object-expressions`) 2 | 3 | ⚠️ This rule _warns_ in the ✅ `recommended` [config](https://github.com/temoncher/eslint-plugin-clsx#presets). 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to enforce specific usage of object expressions inside clsx 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```js 16 | /* eslint clsx/forbid-false-inside-object-expressions: 'error' */ 17 | 18 | const falseClasses = clsx({ 'dynamic-condition-class': condition, 'false-class': false }); 19 | ``` 20 | 21 | Examples of **correct** code for this rule: 22 | 23 | ```js 24 | /* eslint clsx/forbid-false-inside-object-expressions: 'error' */ 25 | 26 | const falseClasses = clsx({ 'dynamic-condition-class': condition }); 27 | ``` 28 | 29 | ## When Not To Use It 30 | 31 | If you don't want to enforce specific usage of object expressions inside clsx 32 | -------------------------------------------------------------------------------- /docs/rules/forbid-true-inside-object-expressions.md: -------------------------------------------------------------------------------- 1 | # Forbid usage of true literal inside object expressions of clsx (`clsx/forbid-true-inside-object-expressions`) 2 | 3 | ⚠️ This rule _warns_ in the ✅ `recommended` [config](https://github.com/temoncher/eslint-plugin-clsx#presets). 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to forbid usage of true literals inside object expressions of clsx 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```js 16 | /* eslint clsx/forbid-true-inside-object-expressions: error */ 17 | 18 | const trueClasses = clsx({ 'true-class-1': true, 'true-class-2': true }); 19 | ``` 20 | 21 | Examples of **correct** code for this rule: 22 | 23 | ```js 24 | /* eslint clsx/forbid-true-inside-object-expressions: error */ 25 | 26 | const trueClasses = clsx('true-class-1', 'true-class-2'); 27 | const dynamicClasses = clsx({ 'dynamic-condition-class': condition }); 28 | ``` 29 | 30 | ## Options 31 | 32 | ### allowMixed (default) 33 | 34 | Examples of **incorrect** code for the `allowMixed` option: 35 | 36 | ```js 37 | /* eslint clsx/forbid-true-inside-object-expressions: ['error', 'allowMixed'] */ 38 | 39 | const trueClasses = clsx({ 'true-class-1': true, 'true-class-2': true }); 40 | ``` 41 | 42 | Examples of **correct** code for the `allowMixed` option: 43 | 44 | ```js 45 | /* eslint clsx/forbid-true-inside-object-expressions: ['error', 'allowMixed'] */ 46 | 47 | const trueClasses = clsx('true-class-1', 'true-class-2'); 48 | const trueClasses2 = clsx({ 'dynamic-condition-class': condition, 'true-class-2': true }); 49 | ``` 50 | 51 | ### always 52 | 53 | Examples of **incorrect** code for the `always` option: 54 | 55 | ```js 56 | /* eslint clsx/forbid-true-inside-object-expressions: ['error', 'always'] */ 57 | 58 | const trueClasses = clsx({ 'true-class-1': true, 'true-class-2': true }); 59 | const trueClasses2 = clsx({ 'dynamic-condition-class': condition, 'true-class-2': true }); 60 | ``` 61 | 62 | Examples of **correct** code for the `always` option: 63 | 64 | ```js 65 | /* eslint clsx/forbid-true-inside-object-expressions: ['error', 'always'] */ 66 | 67 | const trueClasses = clsx('true-class-1', 'true-class-2'); 68 | const trueClasses2 = clsx('true-class-2', { 'dynamic-condition-class': condition }); 69 | ``` 70 | 71 | ## When Not To Use It 72 | 73 | If you don't want to forbid usage of true literals inside object expressions of clsx 74 | -------------------------------------------------------------------------------- /docs/rules/no-redundant-clsx.md: -------------------------------------------------------------------------------- 1 | # Disallow redundant clsx usage (`clsx/no-redundant-clsx`) 2 | 3 | ⚠️ This rule _warns_ in the ✅ `recommended` [config](https://github.com/temoncher/eslint-plugin-clsx#presets). 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to restrict unnecessary usage of clsx 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```js 16 | /* eslint clsx/no-redundant-clsx: error */ 17 | 18 | const singleClasses = clsx('single-class'); 19 | ``` 20 | 21 | Examples of **correct** code for this rule: 22 | 23 | ```js 24 | /* eslint clsx/no-redundant-clsx: error */ 25 | 26 | const singleClasses = 'single-class'; 27 | const twoClasseses = clsx('first-class', 'second-class') 28 | ``` 29 | 30 | ## Options 31 | 32 | This rule has an object option with one optional property: "selector" 33 | 34 | ### selector 35 | 36 | "selector" accepts [esquery selector](https://eslint.org/docs/latest/extend/selectors) to apply to signle argument of `clsx` usage. If argument matches this selector clsx usage is considered redundant 37 | 38 | Examples of **incorrect** code for the `{ selector: ":matches(Literal, TemplateLiteral, MemberExpression[object.name="styles"])" }` option: 39 | 40 | ```js 41 | /* eslint clsx/no-spreading: ['error', { selector: ":matches(Literal, TemplateLiteral, MemberExpression[object.name="styles"])" }] */ 42 | 43 | const classes = clsx(styles.myStyle); 44 | ``` 45 | 46 | Examples of **correct** code for the `{ selector: ":matches(Literal, TemplateLiteral, MemberExpression[object.name="styles"])" }` option: 47 | 48 | ```js 49 | /* eslint clsx/no-spreading: ['error', { selector: ":matches(Literal, TemplateLiteral, MemberExpression[object.name="styles"])" }] */ 50 | 51 | const classes = styles.myStyle; 52 | ``` 53 | 54 | Default value is `{ selector: ":matches(Literal, TemplateLiteral)" }` 55 | 56 | ## When Not To Use It 57 | 58 | If you're ok with clsx used redundantly 59 | -------------------------------------------------------------------------------- /docs/rules/no-spreading.md: -------------------------------------------------------------------------------- 1 | # Forbid usage of object expression inside clsx (`clsx/no-spreading`) 2 | 3 | ⚠️ This rule _warns_ in the ✅ `recommended` [config](https://github.com/temoncher/eslint-plugin-clsx#presets). 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to forbid usage of true literals inside object expressions of clsx 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```js 16 | /* eslint clsx/no-spreading: 'error' */ 17 | 18 | const objectClasses = clsx({ ...firstObj, ...secondObj, 'class-1': true, 'class-2': true, ...someObj, 'class-3': true && true }); 19 | ``` 20 | 21 | Examples of **correct** code for this rule: 22 | 23 | ```js 24 | /* eslint clsx/no-spreading: error */ 25 | 26 | const objectClasses = clsx(firstObj, secondObj, { 'class-1': true, 'class-2': true }, someObj, { 'class-3': true && true }); 27 | ``` 28 | 29 | ## Options 30 | 31 | The rule accepts an array of following values 32 | 33 | ### object 34 | 35 | Examples of **incorrect** code for the `['object']` option: 36 | 37 | ```js 38 | /* eslint clsx/no-spreading: ['error', ['object']] */ 39 | 40 | const objectClasses = clsx({ ...firstObj, ...secondObj, 'class-1': true, 'class-2': true, ...someObj, 'class-3': true && true }); 41 | ``` 42 | 43 | Examples of **correct** code for the `['object']` option: 44 | 45 | ```js 46 | /* eslint clsx/no-spreading: ['error', ['object']] */ 47 | 48 | const objectClasses = clsx(firstObj, secondObj, { 'class-1': true, 'class-2': true }, someObj, { 'class-3': true && true }); 49 | ``` 50 | 51 | Default value is `['object']` 52 | 53 | ## When Not To Use It 54 | 55 | If you don't want to forbid usage of true literals inside object expressions of clsx 56 | -------------------------------------------------------------------------------- /docs/rules/prefer-logical-over-objects.md: -------------------------------------------------------------------------------- 1 | # Forbid usage of object expression inside clsx (`clsx/prefer-logical-over-objects`) 2 | 3 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule aims to forbid usage of object expressions inside clsx 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```js 14 | /* eslint clsx/prefer-logical-over-objects: error */ 15 | 16 | const objectLiteralClasses = clsx({ 'dynamic-condition-class': condition, 'third-class': true }); 17 | ``` 18 | 19 | Examples of **correct** code for this rule: 20 | 21 | ```js 22 | /* eslint clsx/prefer-logical-over-objects: error */ 23 | 24 | const objectLiteralClasses = clsx(condition && 'dynamic-condition-class', true && 'third-class'); 25 | ``` 26 | 27 | ## Options 28 | 29 | This rule has an object option with two optional properties: "startingFrom" and "endingWith" 30 | 31 | ### startingFrom (default: 0) 32 | 33 | Determines minimum number of properties on an object expression to report 34 | 35 | Examples of **incorrect** code for `{ startingFrom: 2 }`: 36 | 37 | ```js 38 | /* eslint clsx/prefer-objects-over-logical: ['error', { startingFrom: 2 }] */ 39 | 40 | const classes = clsx({ 'first-class': true, 'second-class': true }, 'some-class', { 'third-class': true }); 41 | ``` 42 | 43 | Examples of **correct** code for this rule: 44 | 45 | ```js 46 | /* eslint clsx/prefer-objects-over-logical: ['error', { startingFrom: 2 }] */ 47 | 48 | const classes = clsx(true && 'first-class', true && 'second-class', 'some-class', { 'third-class': true }); 49 | ``` 50 | 51 | ### endingWith (default: Infinity) 52 | 53 | Determines maximum number of properties on an object expression to report 54 | 55 | Examples of **incorrect** code for `{ endingWith: 1 }`: 56 | 57 | ```js 58 | /* eslint clsx/prefer-objects-over-logical: ['error', { endingWith: 1 }] */ 59 | 60 | const classes = clsx({ 'first-class': true, 'second-class': true }, 'some-class', { 'third-class': true }); 61 | ``` 62 | 63 | Examples of **correct** code for this rule: 64 | 65 | ```js 66 | /* eslint clsx/prefer-objects-over-logical: ['error', { endingWith: 1 }] */ 67 | 68 | const classes = clsx({ 'first-class': true, 'second-class': true }, 'some-class', true && 'third-class'); 69 | ``` 70 | 71 | ## When Not To Use It 72 | 73 | If you don't want to enforce usage of logical expressions over objects 74 | 75 | ## Related rules 76 | 77 | - [https://github.com/temoncher/eslint-plugin-clsx/blob/main/docs/rules/prefer-objects-over-logical.md](prefer-objects-over-logical) - inverse of prefer-logical-over-objects 78 | Can be combined to achieve logical for 0-2 elements and objects for larger (>2) elements 79 | 80 | Examples of **incorrect** code : 81 | 82 | ```js 83 | /* eslint clsx/prefer-logical-over-objects: ['error', { endingWith: 3 }] */ 84 | /* eslint clsx/prefer-objects-over-logical: ['error', { startingFrom: 3 }] */ 85 | 86 | const classes = clsx(true && 'first-class', true && 'second-class', 'some-class', { 'third-class': true }); 87 | ``` 88 | 89 | Examples of **correct** code : 90 | 91 | ```js 92 | /* eslint clsx/prefer-logical-over-objects: ['error', { endingWith: 3 }] */ 93 | /* eslint clsx/prefer-objects-over-logical: ['error', { startingFrom: 3 }] */ 94 | 95 | const classes = clsx({ 'first-class': true, 'second-class': true }, 'some-class', true && 'third-class'); 96 | ``` 97 | -------------------------------------------------------------------------------- /docs/rules/prefer-merged-neighboring-elements.md: -------------------------------------------------------------------------------- 1 | # Enforce merging of neighboring elements (`clsx/prefer-merged-neighboring-elements`) 2 | 3 | ⚠️ This rule _warns_ in the ✅ `recommended` [config](https://github.com/temoncher/eslint-plugin-clsx#presets). 4 | 5 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 6 | 7 | 8 | 9 | ## Rule Details 10 | 11 | This rule aims to reduce consequent elements into one inside clsx 12 | 13 | Examples of **incorrect** code for this rule: 14 | 15 | ```js 16 | /* eslint clsx/prefer-merged-neighboring-elements: 'error' */ 17 | 18 | const objectClasses = clsx({ ...firstObj }, { ...secondObj }, { 'class-1': true , 'class-2': true }, someObj, { 'class-3': true && true }); 19 | ``` 20 | 21 | Examples of **correct** code for this rule: 22 | 23 | ```js 24 | /* eslint clsx/prefer-merged-neighboring-elements: error */ 25 | 26 | const objectClasses = clsx({ ...firstObj, ...secondObj, 'class-1': true , 'class-2': true }, someObj, { 'class-3': true && true }); 27 | ``` 28 | 29 | ## Options 30 | 31 | The rule accepts an array of following values 32 | 33 | ### object 34 | 35 | Examples of **incorrect** code for the `['object']` option: 36 | 37 | ```js 38 | /* eslint clsx/prefer-merged-neighboring-elements: ['error', ['object']] */ 39 | 40 | const objectClasses = clsx({ ...firstObj }, { ...secondObj }, { 'class-1': true , 'class-2': true }, someObj, { 'class-3': true && true }); 41 | ``` 42 | 43 | Examples of **correct** code for the `['object']` option: 44 | 45 | ```js 46 | /* eslint clsx/prefer-merged-neighboring-elements: ['error', ['object']] */ 47 | 48 | const objectClasses = clsx({ ...firstObj, ...secondObj, 'class-1': true , 'class-2': true }, someObj, { 'class-3': true && true }); 49 | ``` 50 | 51 | Default value is `['object']` 52 | 53 | ## When Not To Use It 54 | 55 | If you don't want to reduce consequent elements into one inside clsx 56 | -------------------------------------------------------------------------------- /docs/rules/prefer-objects-over-logical.md: -------------------------------------------------------------------------------- 1 | # Forbid usage of logical expressions inside clsx (`clsx/prefer-objects-over-logical`) 2 | 3 | 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). 4 | 5 | 6 | 7 | ## Rule Details 8 | 9 | This rule aims to forbid usage of logical expressions inside clsx 10 | 11 | Examples of **incorrect** code for this rule: 12 | 13 | ```js 14 | /* eslint clsx/prefer-objects-over-logical: error */ 15 | 16 | const classWithLogicalExpression = clsx(true && 'single-class'); 17 | ``` 18 | 19 | Examples of **correct** code for this rule: 20 | 21 | ```js 22 | /* eslint clsx/prefer-objects-over-logical: error */ 23 | 24 | const classWithLogicalExpression = clsx('single-class'); 25 | ``` 26 | 27 | ## Options 28 | 29 | This rule has an object option with two optional properties: "startingFrom" and "endingWith" 30 | 31 | ### startingFrom (default: 0) 32 | 33 | Determines minimum number of consequent logical expressions to report 34 | 35 | Examples of **incorrect** code for `{ startingFrom: 2 }`: 36 | 37 | ```js 38 | /* eslint clsx/prefer-objects-over-logical: ['error', { startingFrom: 2 }] */ 39 | 40 | const classes = clsx(true && 'first-class', true && 'second-class', 'some-class', true && 'third-class'); 41 | ``` 42 | 43 | Examples of **correct** code for this rule: 44 | 45 | ```js 46 | /* eslint clsx/prefer-objects-over-logical: ['error', { startingFrom: 2 }] */ 47 | 48 | const classes = clsx({ 'first-class': true, 'second-class': true }, 'some-class', true && 'third-class'); 49 | ``` 50 | 51 | ### endingWith (default: Infinity) 52 | 53 | Determines maximum number of consequent logical expressions to report 54 | 55 | Examples of **incorrect** code for `{ endingWith: 1 }`: 56 | 57 | ```js 58 | /* eslint clsx/prefer-objects-over-logical: ['error', { endingWith: 1 }] */ 59 | 60 | const classes = clsx(true && 'first-class', true && 'second-class', 'some-class', true && 'third-class'); 61 | ``` 62 | 63 | Examples of **correct** code for this rule: 64 | 65 | ```js 66 | /* eslint clsx/prefer-objects-over-logical: ['error', { endingWith: 1 }] */ 67 | 68 | const classes = clsx(true && 'first-class', true && 'second-class', 'some-class', { 'third-class': true }); 69 | ``` 70 | 71 | ## When Not To Use It 72 | 73 | If you don't want to enforce usage of objects over logical expressions 74 | 75 | ## Related rules 76 | 77 | - [https://github.com/temoncher/eslint-plugin-clsx/blob/main/docs/rules/prefer-logical-over-objects.md](prefer-logical-over-objects) - inverse of prefer-objects-over-logical 78 | Can be combined to achieve logical for 0-2 elements and objects for larger (>2) elements 79 | 80 | Examples of **incorrect** code : 81 | 82 | ```js 83 | /* eslint clsx/prefer-logical-over-objects: ['error', { endingWith: 3 }] */ 84 | /* eslint clsx/prefer-objects-over-logical: ['error', { startingFrom: 3 }] */ 85 | 86 | const classes = clsx(true && 'first-class', true && 'second-class', 'some-class', { 'third-class': true }); 87 | ``` 88 | 89 | Examples of **correct** code : 90 | 91 | ```js 92 | /* eslint clsx/prefer-logical-over-objects: ['error', { endingWith: 3 }] */ 93 | /* eslint clsx/prefer-objects-over-logical: ['error', { startingFrom: 3 }] */ 94 | 95 | const classes = clsx({ 'first-class': true, 'second-class': true }, 'some-class', true && 'third-class'); 96 | ``` 97 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tsParser from '@typescript-eslint/parser'; 2 | import importPlugin from 'eslint-plugin-import'; 3 | import prettierRecommended from 'eslint-plugin-prettier/recommended'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | { 8 | ignores: ['node_modules/', 'dist/'], 9 | }, 10 | { 11 | extends: [ 12 | tseslint.configs.recommended, 13 | importPlugin.flatConfigs.recommended, 14 | prettierRecommended, 15 | ], 16 | languageOptions: { 17 | parser: tsParser, 18 | parserOptions: { 19 | ecmaVersion: 'latest', 20 | sourceType: 'module', 21 | }, 22 | }, 23 | linterOptions: { 24 | reportUnusedDisableDirectives: true, 25 | }, 26 | rules: { 27 | 'global-require': 0, 28 | 'no-continue': 0, 29 | 'no-case-declarations': 2, 30 | 'prefer-destructuring': 1, 31 | 'no-param-reassign': 2, 32 | 'no-console': 2, 33 | 'no-self-compare': 2, 34 | 'no-irregular-whitespace': 2, 35 | 'arrow-body-style': 1, 36 | complexity: [1, 7], 37 | 'array-bracket-newline': [1, 'consistent'], 38 | 'function-call-argument-newline': [1, 'consistent'], 39 | 'func-style': [1, 'declaration'], 40 | 'prefer-exponentiation-operator': 2, 41 | 42 | 'padding-line-between-statements': [ 43 | 1, 44 | { 45 | blankLine: 'always', 46 | prev: ['const', 'let', 'var'], 47 | next: '*', 48 | }, 49 | { 50 | blankLine: 'always', 51 | prev: '*', 52 | next: ['if', 'try', 'class', 'export'], 53 | }, 54 | { 55 | blankLine: 'always', 56 | prev: ['if', 'try', 'class', 'export'], 57 | next: '*', 58 | }, 59 | { 60 | blankLine: 'any', 61 | prev: ['const', 'let', 'var', 'export'], 62 | next: ['const', 'let', 'var', 'export'], 63 | }, 64 | { 65 | blankLine: 'always', 66 | prev: ['expression'], 67 | next: ['const', 'let', 'var'], 68 | }, 69 | { 70 | blankLine: 'always', 71 | prev: '*', 72 | next: ['return'], 73 | }, 74 | ], 75 | 76 | 'arrow-spacing': 1, 77 | 78 | 'no-restricted-exports': [ 79 | 1, 80 | { 81 | restrictedNamedExports: ['default', 'then'], 82 | }, 83 | ], 84 | 85 | 'import/order': [ 86 | 1, 87 | { 88 | groups: ['builtin', 'external', 'parent', 'sibling', 'index'], 89 | pathGroupsExcludedImportTypes: ['constants'], 90 | 'newlines-between': 'always', 91 | 92 | alphabetize: { 93 | order: 'asc', 94 | caseInsensitive: false, 95 | }, 96 | 97 | warnOnUnassignedImports: true, 98 | }, 99 | ], 100 | 101 | 'import/prefer-default-export': 0, 102 | 'import/extensions': 0, 103 | 'import/no-unresolved': 0, 104 | }, 105 | }, 106 | { 107 | files: ['**/*.ts', '**/*.tsx'], 108 | extends: [ 109 | tseslint.configs.recommendedTypeChecked, 110 | tseslint.configs.strict, 111 | importPlugin.flatConfigs.typescript, 112 | prettierRecommended, 113 | ], 114 | languageOptions: { 115 | parser: tsParser, 116 | parserOptions: { 117 | project: './tsconfig.json', 118 | }, 119 | }, 120 | rules: { 121 | '@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_.*' }], 122 | }, 123 | } 124 | ); 125 | -------------------------------------------------------------------------------- /generate-all-rules.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | const fs = require('fs'); 3 | 4 | const packageJson = require('./package.json'); 5 | 6 | function camelize(s) { 7 | return s.replace(/-./g, (x) => x[1].toUpperCase()); 8 | } 9 | 10 | const ruleNames = fs 11 | .readdirSync(`${__dirname}/src/rules`) 12 | .filter( 13 | (fileName) => 14 | !fileName.includes('.test') && 15 | !fileName.includes('.generated') && 16 | fileName !== 'tests-setup.mjs' && 17 | fileName !== 'index.ts' 18 | ) 19 | .map((fileName) => fileName.replace(/\.ts$/, '')); 20 | 21 | const imports = ruleNames.map((name) => `import ${camelize(name)} from './${name}';`).join(''); 22 | 23 | const pairs = ruleNames.map((name) => `"${name}": ${camelize(name)}`).join(','); 24 | const REPO_URL = packageJson.repository.url.replace(/^git\+/, '').replace(/\.git$/, ''); 25 | 26 | const content = `${imports}\nexport const allRules = {${pairs}};\nexport const REPO_URL="${REPO_URL}"`; 27 | 28 | fs.writeFileSync(`${__dirname}/src/rules/allRules.generated.ts`, content); 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-clsx", 3 | "version": "0.0.10", 4 | "description": "An ESLint plugin for clsx/classnames", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "rimraf dist && npm run generate-rules && tsup", 8 | "lint": "npm-run-all \"lint:*\"", 9 | "lint:tsc": "tsc", 10 | "lint:docs": "markdownlint \"**/*.md\"", 11 | "lint:eslint-docs": "npm run update:eslint-docs -- --check", 12 | "lint:js": "eslint .", 13 | "update:eslint-docs": "npm run build && eslint-doc-generator", 14 | "generate-rules": "node ./generate-all-rules.js && eslint ./src/rules/*.generated.ts --fix", 15 | "release": "npm run build && changeset publish", 16 | "test": "vitest run" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/temoncher/eslint-plugin-clsx.git" 21 | }, 22 | "keywords": [ 23 | "eslint", 24 | "eslintplugin", 25 | "eslint-plugin", 26 | "clsx", 27 | "classnames" 28 | ], 29 | "author": "Artem Baranov ", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/temoncher/eslint-plugin-clsx/issues" 33 | }, 34 | "homepage": "https://github.com/temoncher/eslint-plugin-clsx#readme", 35 | "devDependencies": { 36 | "@changesets/cli": "^2.27.12", 37 | "@types/eslint": "^9.6.1", 38 | "@types/esquery": "^1.5.4", 39 | "@types/node": "^22.13.0", 40 | "@typescript-eslint/eslint-plugin": "^8.23.0", 41 | "@typescript-eslint/parser": "^8.23.0", 42 | "@typescript-eslint/rule-tester": "^8.23.0", 43 | "eslint": "^9.19.0", 44 | "eslint-config-prettier": "^10.0.1", 45 | "eslint-doc-generator": "2.0.2", 46 | "eslint-plugin-import": "^2.31.0", 47 | "eslint-plugin-prettier": "^5.2.3", 48 | "markdownlint-cli": "^0.44.0", 49 | "npm-run-all": "^4.1.5", 50 | "prettier": "^3.4.2", 51 | "rimraf": "^6.0.1", 52 | "tsup": "^8.3.6", 53 | "typescript": "^5.7.3", 54 | "typescript-eslint": "^8.23.0", 55 | "vitest": "^3.0.5" 56 | }, 57 | "dependencies": { 58 | "@typescript-eslint/types": "^8.23.0", 59 | "@typescript-eslint/utils": "^8.23.0", 60 | "esquery": "^1.6.0", 61 | "remeda": "^2.20.0" 62 | }, 63 | "peerDependencies": { 64 | "eslint": "^8 || ^9" 65 | }, 66 | "files": [ 67 | "*.md", 68 | "!{CONTRIBUTING,RELEASE}.md", 69 | "LICENSE", 70 | "docs", 71 | "dist" 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /src/PluginDocs.ts: -------------------------------------------------------------------------------- 1 | export type PluginDocs = { recommended?: boolean }; 2 | -------------------------------------------------------------------------------- /src/createRule.ts: -------------------------------------------------------------------------------- 1 | import { ESLintUtils } from '@typescript-eslint/utils'; 2 | 3 | import type { PluginDocs } from './PluginDocs'; 4 | 5 | export const createRule = ESLintUtils.RuleCreator( 6 | (name) => `https://github.com/temoncher/eslint-plugin-clsx/blob/main/docs/rules/${name}.md` 7 | ); 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { RuleModule } from '@typescript-eslint/utils/ts-eslint'; 2 | import * as R from 'remeda'; 3 | 4 | import { PluginDocs } from './PluginDocs'; 5 | import { allRules, REPO_URL } from './rules/allRules.generated'; 6 | 7 | const configFilters = { 8 | all: () => true, 9 | recommended: (rule: RuleModule) => rule.meta?.docs?.recommended, 10 | }; 11 | 12 | export = { 13 | rules: R.mapValues(allRules, (rule, ruleName) => ({ 14 | ...rule, 15 | meta: { 16 | ...rule.meta, 17 | url: `${REPO_URL}/tree/HEAD/docs/rules/${ruleName}.md`, 18 | }, 19 | })), 20 | configs: R.mapValues(configFilters, (configPredicate) => ({ 21 | plugins: ['clsx'], 22 | rules: Object.fromEntries( 23 | Object.entries(allRules) 24 | .filter(([_ruleName, rule]) => configPredicate(rule)) 25 | .map(([ruleName]) => [`clsx/${ruleName}`, 1]) 26 | ), 27 | })), 28 | }; 29 | -------------------------------------------------------------------------------- /src/rules/allRules.generated.ts: -------------------------------------------------------------------------------- 1 | import forbidArrayExpressions from './forbid-array-expressions'; 2 | import forbidFalseInsideObjectExpressions from './forbid-false-inside-object-expressions'; 3 | import forbidTrueInsideObjectExpressions from './forbid-true-inside-object-expressions'; 4 | import noRedundantClsx from './no-redundant-clsx'; 5 | import noSpreading from './no-spreading'; 6 | import preferLogicalOverObjects from './prefer-logical-over-objects'; 7 | import preferMergedNeighboringElements from './prefer-merged-neighboring-elements'; 8 | import preferObjectsOverLogical from './prefer-objects-over-logical'; 9 | 10 | export const allRules = { 11 | 'forbid-array-expressions': forbidArrayExpressions, 12 | 'forbid-false-inside-object-expressions': forbidFalseInsideObjectExpressions, 13 | 'forbid-true-inside-object-expressions': forbidTrueInsideObjectExpressions, 14 | 'no-redundant-clsx': noRedundantClsx, 15 | 'no-spreading': noSpreading, 16 | 'prefer-logical-over-objects': preferLogicalOverObjects, 17 | 'prefer-merged-neighboring-elements': preferMergedNeighboringElements, 18 | 'prefer-objects-over-logical': preferObjectsOverLogical, 19 | }; 20 | export const REPO_URL = 'https://github.com/temoncher/eslint-plugin-clsx'; 21 | -------------------------------------------------------------------------------- /src/rules/forbid-array-expressions.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/types'; 2 | 3 | import { createRule } from '../createRule'; 4 | import * as utils from '../utils'; 5 | 6 | export = createRule({ 7 | name: 'forbid-array-expressions', 8 | defaultOptions: ['always' as 'onlySingleElement' | 'always'], 9 | meta: { 10 | type: 'suggestion', 11 | docs: { 12 | description: 'forbid usage of array expressions inside clsx', 13 | recommended: true, 14 | }, 15 | fixable: 'code', 16 | schema: [{ type: 'string', enum: ['onlySingleElement', 'always'] }], 17 | messages: { 18 | onlySingleElement: 'Single element arrays are forbidden inside clsx', 19 | always: 'Usage of array expressions inside clsx is forbidden', 20 | }, 21 | }, 22 | create(context, [ruleOptions]) { 23 | const { sourceCode } = context; 24 | const clsxOptions = utils.extractClsxOptions(context); 25 | 26 | return { 27 | ImportDeclaration(importNode) { 28 | const assignedClsxName = utils.findClsxImport(importNode, clsxOptions); 29 | 30 | if (!assignedClsxName) { 31 | return; 32 | } 33 | 34 | const clsxUsages = utils.getClsxUsages(importNode, sourceCode, assignedClsxName); 35 | 36 | clsxUsages 37 | .flatMap((clsxCallNode) => clsxCallNode.arguments) 38 | .forEach((argumentNode) => { 39 | if (argumentNode.type !== TSESTree.AST_NODE_TYPES.ArrayExpression) return; 40 | 41 | // TODO?: move this check out of visitor for performance 42 | if (ruleOptions === 'always') { 43 | context.report({ 44 | messageId: 'always', 45 | node: argumentNode, 46 | fix: (fixer) => 47 | fixer.replaceText( 48 | argumentNode, 49 | argumentNode.elements 50 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 51 | .map((el) => sourceCode.getText(el!)) 52 | .join(', ') 53 | ), 54 | }); 55 | 56 | return; 57 | } 58 | 59 | if ( 60 | ruleOptions === 'onlySingleElement' && 61 | argumentNode.elements.length === 1 62 | ) { 63 | context.report({ 64 | messageId: 'onlySingleElement', 65 | node: argumentNode, 66 | fix: (fixer) => 67 | fixer.replaceText( 68 | argumentNode, 69 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 70 | sourceCode.getText(argumentNode.elements[0]!) 71 | ), 72 | }); 73 | } 74 | }); 75 | }, 76 | }; 77 | }, 78 | }); 79 | -------------------------------------------------------------------------------- /src/rules/forbid-false-inside-object-expressions.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/types'; 2 | 3 | import { createRule } from '../createRule'; 4 | import * as utils from '../utils'; 5 | 6 | export = createRule({ 7 | name: 'forbid-false-inside-object-expressions', 8 | defaultOptions: [], 9 | meta: { 10 | type: 'suggestion', 11 | docs: { 12 | description: 'forbid usage of false literal inside object expressions of clsx', 13 | recommended: true, 14 | }, 15 | fixable: 'code', 16 | schema: [], 17 | messages: { 18 | falseLiterals: 'Object expression inside clsx should not contain false literals', 19 | }, 20 | }, 21 | create(context) { 22 | const { sourceCode } = context; 23 | const clsxOptions = utils.extractClsxOptions(context); 24 | 25 | return { 26 | ImportDeclaration(importNode) { 27 | const assignedClsxName = utils.findClsxImport(importNode, clsxOptions); 28 | 29 | if (!assignedClsxName) { 30 | return; 31 | } 32 | 33 | const clsxUsages = utils.getClsxUsages(importNode, sourceCode, assignedClsxName); 34 | 35 | clsxUsages 36 | .flatMap((clsxCallNode) => clsxCallNode.arguments) 37 | // TODO: autofix deep into arrays 38 | .forEach((argumentNode) => { 39 | if (argumentNode.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) return; 40 | 41 | const propsWithoutFalseLiterals = argumentNode.properties.filter( 42 | (prop) => 43 | !( 44 | prop.type === TSESTree.AST_NODE_TYPES.Property && 45 | prop.value.type === TSESTree.AST_NODE_TYPES.Literal && 46 | prop.value.value === false 47 | ) 48 | ); 49 | 50 | if (propsWithoutFalseLiterals.length !== argumentNode.properties.length) { 51 | const propsText = propsWithoutFalseLiterals 52 | .map((prop) => sourceCode.getText(prop)) 53 | .join(', '); 54 | 55 | context.report({ 56 | messageId: 'falseLiterals', 57 | node: argumentNode, 58 | fix: (fixer) => fixer.replaceText(argumentNode, `{ ${propsText} }`), 59 | }); 60 | } 61 | }); 62 | }, 63 | }; 64 | }, 65 | }); 66 | -------------------------------------------------------------------------------- /src/rules/forbid-true-inside-object-expressions.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/types'; 2 | import * as R from 'remeda'; 3 | 4 | import { createRule } from '../createRule'; 5 | import * as utils from '../utils'; 6 | 7 | export = createRule({ 8 | name: 'forbid-true-inside-object-expressions', 9 | defaultOptions: ['allowMixed' as 'always' | 'allowMixed'], 10 | meta: { 11 | type: 'suggestion', 12 | docs: { 13 | description: 'forbid usage of true literal inside object expressions of clsx', 14 | recommended: true, 15 | }, 16 | fixable: 'code', 17 | schema: [{ type: 'string', enum: ['always', 'allowMixed'] }], 18 | messages: { 19 | default: 'Object expression inside clsx should not contain true literals', 20 | }, 21 | }, 22 | create(context, [allowTrueLiterals]) { 23 | const { sourceCode } = context; 24 | const clsxOptions = utils.extractClsxOptions(context); 25 | 26 | return { 27 | ImportDeclaration(importNode) { 28 | const assignedClsxName = utils.findClsxImport(importNode, clsxOptions); 29 | 30 | if (!assignedClsxName) { 31 | return; 32 | } 33 | 34 | const clsxUsages = utils.getClsxUsages(importNode, sourceCode, assignedClsxName); 35 | 36 | clsxUsages 37 | .flatMap((clsxCallNode) => clsxCallNode.arguments) 38 | // TODO: autofix deep into arrays 39 | .forEach((argumentNode) => { 40 | if (argumentNode.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) return; 41 | 42 | const [trueLiteralProps, otherProps] = R.partition( 43 | argumentNode.properties, 44 | (prop) => 45 | prop.type === TSESTree.AST_NODE_TYPES.Property && 46 | prop.value.type === TSESTree.AST_NODE_TYPES.Literal && 47 | prop.value.value === true 48 | ); 49 | 50 | if ( 51 | trueLiteralProps.length !== 0 && 52 | (allowTrueLiterals === 'always' || 53 | (allowTrueLiterals === 'allowMixed' && otherProps.length === 0)) 54 | ) { 55 | const trueLiteralPropsText = (trueLiteralProps as TSESTree.Property[]) 56 | .map((el) => { 57 | const keyText = sourceCode.getText(el.key); 58 | 59 | return el.computed ? keyText : `'${keyText}'`; 60 | }) 61 | .join(', '); 62 | const otherPropsText = otherProps 63 | .map((prop) => sourceCode.getText(prop)) 64 | .join(', '); 65 | const otherPropsWrappedInObject = otherPropsText 66 | ? `{ ${otherPropsText} }` 67 | : undefined; 68 | 69 | context.report({ 70 | messageId: 'default', 71 | node: argumentNode, 72 | fix: (fixer) => 73 | fixer.replaceText( 74 | argumentNode, 75 | [trueLiteralPropsText, otherPropsWrappedInObject] 76 | .filter(R.isDefined) 77 | .join(', ') 78 | ), 79 | }); 80 | } 81 | }); 82 | }, 83 | }; 84 | }, 85 | }); 86 | -------------------------------------------------------------------------------- /src/rules/no-redundant-clsx.test.ts: -------------------------------------------------------------------------------- 1 | import { RuleTester } from '@typescript-eslint/rule-tester'; 2 | 3 | import rule from './no-redundant-clsx'; 4 | 5 | new RuleTester().run('no-redundant-clsx', rule, { 6 | valid: [ 7 | { 8 | name: 'Default selector. Two classes with Literals', 9 | code: ` 10 | import clsx from 'clsx'; 11 | 12 | const classes = clsx('flex', 'flex-col'); 13 | `, 14 | }, 15 | { 16 | name: 'Default selector. Object literal', 17 | code: ` 18 | import clsx from 'clsx'; 19 | 20 | const condition = true; 21 | const classes = clsx({ 'flex': condition }); 22 | `, 23 | }, 24 | { 25 | name: 'Default selector. Array literal', 26 | code: ` 27 | import clsx from 'clsx'; 28 | 29 | const condition = true; 30 | const classes = clsx(['flex']); 31 | `, 32 | }, 33 | { 34 | name: 'Only Literal selector. Template literal', 35 | options: [{ selector: 'Literal' }], 36 | code: ` 37 | import clsx from 'clsx'; 38 | 39 | const col = 'col'; 40 | const classes = clsx(\`flex-\${col}\`); 41 | `, 42 | }, 43 | ], 44 | invalid: [ 45 | { 46 | name: 'Default selector. One class with Literal', 47 | code: ` 48 | import clsx from 'clsx'; 49 | 50 | const classes = clsx('flex'); 51 | `, 52 | errors: [ 53 | { 54 | messageId: 'default', 55 | line: 4, 56 | column: 17, 57 | }, 58 | ], 59 | output: ` 60 | import clsx from 'clsx'; 61 | 62 | const classes = 'flex'; 63 | `, 64 | }, 65 | { 66 | name: 'Default selector. One class with TemplateLiteral', 67 | code: ` 68 | import clsx from 'clsx'; 69 | 70 | const col = 'col'; 71 | const classes = clsx(\`flex-\${col}\`); 72 | `, 73 | errors: [ 74 | { 75 | messageId: 'default', 76 | line: 5, 77 | column: 17, 78 | }, 79 | ], 80 | output: ` 81 | import clsx from 'clsx'; 82 | 83 | const col = 'col'; 84 | const classes = \`flex-\${col}\`; 85 | `, 86 | }, 87 | { 88 | name: 'Custom selector for css modules. One class with `styles.prop` property access', 89 | options: [ 90 | { 91 | selector: 92 | ':matches(Literal, TemplateLiteral, MemberExpression[object.name="styles"])', 93 | }, 94 | ], 95 | code: ` 96 | import clsx from 'clsx'; 97 | import styles from './some-css-file.css'; 98 | 99 | const classes = clsx(styles.myStyle); 100 | `, 101 | errors: [ 102 | { 103 | messageId: 'default', 104 | line: 5, 105 | column: 17, 106 | }, 107 | ], 108 | output: ` 109 | import clsx from 'clsx'; 110 | import styles from './some-css-file.css'; 111 | 112 | const classes = styles.myStyle; 113 | `, 114 | }, 115 | ], 116 | }); 117 | -------------------------------------------------------------------------------- /src/rules/no-redundant-clsx.ts: -------------------------------------------------------------------------------- 1 | import { matches, parse } from 'esquery'; 2 | 3 | import { createRule } from '../createRule'; 4 | import * as utils from '../utils'; 5 | 6 | export = createRule({ 7 | name: 'no-redundant-clsx', 8 | defaultOptions: [{ selector: ':matches(Literal, TemplateLiteral)' }], 9 | meta: { 10 | type: 'suggestion', 11 | docs: { 12 | description: 'disallow redundant clsx usage', 13 | recommended: true, 14 | }, 15 | fixable: 'code', 16 | schema: [ 17 | { 18 | type: 'object', 19 | properties: { 20 | selector: { type: 'string' }, 21 | }, 22 | }, 23 | ], 24 | messages: { 25 | default: 'clsx usage is redundant', 26 | }, 27 | }, 28 | create(context, [{ selector }]) { 29 | const { sourceCode } = context; 30 | const clsxOptions = utils.extractClsxOptions(context); 31 | 32 | return { 33 | ImportDeclaration(importNode) { 34 | const assignedClsxName = utils.findClsxImport(importNode, clsxOptions); 35 | 36 | if (!assignedClsxName) { 37 | return; 38 | } 39 | 40 | const clsxUsages = utils.getClsxUsages(importNode, sourceCode, assignedClsxName); 41 | 42 | clsxUsages.forEach((clsxCallNode) => { 43 | if (clsxCallNode.arguments.length !== 1) { 44 | return; 45 | } 46 | 47 | // eslint-disable-next-line prefer-destructuring 48 | const firstArg = clsxCallNode.arguments[0]; 49 | 50 | if (matches(firstArg as import('estree').Node, parse(selector))) { 51 | context.report({ 52 | messageId: 'default', 53 | node: clsxCallNode, 54 | fix: (fixer) => 55 | fixer.replaceText(clsxCallNode, sourceCode.getText(firstArg)), 56 | }); 57 | } 58 | }); 59 | }, 60 | }; 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /src/rules/no-spreading.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/types'; 2 | 3 | import { createRule } from '../createRule'; 4 | import * as utils from '../utils'; 5 | 6 | export = createRule({ 7 | name: 'no-spreading', 8 | defaultOptions: ['object' as const], 9 | meta: { 10 | type: 'suggestion', 11 | docs: { 12 | description: 'forbid usage of object expression inside clsx', 13 | recommended: true, 14 | }, 15 | fixable: 'code', 16 | schema: [{ type: 'array', items: { type: 'string', enum: ['object'] } }], 17 | messages: { 18 | default: 'Usage of object expression inside clsx is forbidden', 19 | }, 20 | }, 21 | create(context, [forbiddenFor]) { 22 | const { sourceCode } = context; 23 | const clsxOptions = utils.extractClsxOptions(context); 24 | 25 | return { 26 | ImportDeclaration(importNode) { 27 | const assignedClsxName = utils.findClsxImport(importNode, clsxOptions); 28 | 29 | if (!assignedClsxName) { 30 | return; 31 | } 32 | 33 | const clsxUsages = utils.getClsxUsages(importNode, sourceCode, assignedClsxName); 34 | 35 | clsxUsages 36 | .flatMap((clsxCallNode) => clsxCallNode.arguments) 37 | // TODO: autofix deep into arrays 38 | .forEach((argumentNode) => { 39 | if ( 40 | forbiddenFor.includes('object') && 41 | argumentNode.type === TSESTree.AST_NODE_TYPES.ObjectExpression && 42 | argumentNode.properties.some( 43 | (prop) => prop.type === TSESTree.AST_NODE_TYPES.SpreadElement 44 | ) 45 | ) { 46 | const alternatingSpreadsAndProps = utils.chunkBy( 47 | argumentNode.properties, 48 | (prop) => prop.type === TSESTree.AST_NODE_TYPES.Property 49 | ); 50 | 51 | const args = alternatingSpreadsAndProps.map((chunk) => { 52 | if (chunk[0]?.type === TSESTree.AST_NODE_TYPES.SpreadElement) { 53 | const spreadsArr = chunk as TSESTree.SpreadElement[]; 54 | 55 | return spreadsArr 56 | .map((se) => sourceCode.getText(se.argument)) 57 | .join(', '); 58 | } 59 | 60 | const propsArr = chunk as TSESTree.Property[]; 61 | const propsText = propsArr 62 | .map((prop) => { 63 | const keyText = sourceCode.getText(prop.key); 64 | const valueText = sourceCode.getText(prop.value); 65 | 66 | return `${ 67 | prop.computed ? `[${keyText}]` : keyText 68 | }: ${valueText}`; 69 | }) 70 | .join(', '); 71 | 72 | return `{ ${propsText} }`; 73 | }); 74 | 75 | context.report({ 76 | messageId: 'default', 77 | node: argumentNode, 78 | fix: (fixer) => fixer.replaceText(argumentNode, args.join(', ')), 79 | }); 80 | } 81 | 82 | // TODO: add support for arrays 83 | }); 84 | }, 85 | }; 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /src/rules/prefer-logical-over-objects.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/types'; 2 | 3 | import { createRule } from '../createRule'; 4 | import * as utils from '../utils'; 5 | 6 | export = createRule({ 7 | name: 'prefer-logical-over-objects', 8 | defaultOptions: [{ startingFrom: 0, endingWith: 999 }], 9 | meta: { 10 | type: 'suggestion', 11 | docs: { 12 | description: 'forbid usage of object expression inside clsx', 13 | }, 14 | fixable: 'code', 15 | schema: [ 16 | { 17 | type: 'object', 18 | properties: { 19 | startingFrom: { type: 'number' }, 20 | endingWith: { type: 'number' }, 21 | }, 22 | }, 23 | ], 24 | messages: { 25 | default: 'Usage of logical expressions is preferred over object expressions', 26 | }, 27 | }, 28 | create(context, [{ startingFrom, endingWith }]) { 29 | const { sourceCode } = context; 30 | const clsxOptions = utils.extractClsxOptions(context); 31 | 32 | return { 33 | ImportDeclaration(importNode) { 34 | const assignedClsxName = utils.findClsxImport(importNode, clsxOptions); 35 | 36 | if (!assignedClsxName) { 37 | return; 38 | } 39 | 40 | const clsxUsages = utils.getClsxUsages(importNode, sourceCode, assignedClsxName); 41 | 42 | clsxUsages 43 | .flatMap((clsxCallNode) => clsxCallNode.arguments) 44 | // TODO: autofix deep into arrays 45 | .forEach((argumentNode) => { 46 | if ( 47 | argumentNode.type !== TSESTree.AST_NODE_TYPES.ObjectExpression || 48 | argumentNode.properties.length < startingFrom || 49 | argumentNode.properties.length >= endingWith 50 | ) 51 | return; 52 | 53 | const alternatingSpreadsAndProps = utils.chunkBy( 54 | argumentNode.properties, 55 | (prop) => prop.type === TSESTree.AST_NODE_TYPES.Property 56 | ); 57 | 58 | const args = alternatingSpreadsAndProps.map((chunk) => { 59 | if (chunk[0]?.type === TSESTree.AST_NODE_TYPES.SpreadElement) { 60 | const spreadsArr = chunk as TSESTree.SpreadElement[]; 61 | const spreadsText = spreadsArr 62 | .map((se) => sourceCode.getText(se)) 63 | .join(', '); 64 | 65 | return `{ ${spreadsText} }`; 66 | } 67 | 68 | const propsArr = chunk as TSESTree.Property[]; 69 | 70 | return propsArr 71 | .map((prop) => { 72 | const keyText = sourceCode.getText(prop.key); 73 | const valueText = sourceCode.getText(prop.value); 74 | const key = 75 | !prop.computed && 76 | prop.key.type === TSESTree.AST_NODE_TYPES.Identifier 77 | ? `'${keyText}'` 78 | : keyText; 79 | 80 | // TODO: apply `()` conditionally only as needed 81 | return `(${valueText}) && ${key}`; 82 | }) 83 | .join(', '); 84 | }); 85 | 86 | if ( 87 | argumentNode.properties.every( 88 | (prop) => prop.type === TSESTree.AST_NODE_TYPES.SpreadElement 89 | ) 90 | ) { 91 | context.report({ 92 | messageId: 'default', 93 | node: argumentNode, 94 | }); 95 | 96 | return; 97 | } 98 | 99 | context.report({ 100 | messageId: 'default', 101 | node: argumentNode, 102 | fix: (fixer) => fixer.replaceText(argumentNode, args.join(', ')), 103 | }); 104 | }); 105 | }, 106 | }; 107 | }, 108 | }); 109 | -------------------------------------------------------------------------------- /src/rules/prefer-merged-neighboring-elements.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/types'; 2 | 3 | import { createRule } from '../createRule'; 4 | import * as utils from '../utils'; 5 | 6 | export = createRule({ 7 | name: 'prefer-merged-neighboring-elements', 8 | defaultOptions: ['object' as const], 9 | meta: { 10 | type: 'suggestion', 11 | docs: { 12 | description: 'enforce merging of neighboring elements', 13 | recommended: true, 14 | }, 15 | fixable: 'code', 16 | schema: [{ type: 'array', items: { type: 'string', enum: ['object'] } }], 17 | messages: { 18 | object: 'Neighboring objects should be merged', 19 | }, 20 | }, 21 | create(context, [mergedFor]) { 22 | const { sourceCode } = context; 23 | const clsxOptions = utils.extractClsxOptions(context); 24 | 25 | return { 26 | ImportDeclaration(importNode) { 27 | const assignedClsxName = utils.findClsxImport(importNode, clsxOptions); 28 | 29 | if (!assignedClsxName) { 30 | return; 31 | } 32 | 33 | const clsxUsages = utils.getClsxUsages(importNode, sourceCode, assignedClsxName); 34 | 35 | clsxUsages 36 | .map((clsxCallNode) => ({ 37 | clsxCallNode, 38 | usageChunks: utils.chunkBy( 39 | clsxCallNode.arguments, 40 | (argumentNode) => argumentNode.type 41 | ), 42 | })) 43 | // TODO: autofix deep into arrays 44 | .forEach(({ clsxCallNode, usageChunks }) => { 45 | if ( 46 | mergedFor.includes('object') && 47 | usageChunks.some( 48 | (chunk) => 49 | chunk[0]?.type === TSESTree.AST_NODE_TYPES.ObjectExpression && 50 | chunk.length > 1 51 | ) 52 | ) { 53 | const args = usageChunks.map((chunk) => { 54 | if (chunk[0]?.type === TSESTree.AST_NODE_TYPES.ObjectExpression) { 55 | const objectsArr = chunk as TSESTree.ObjectExpression[]; 56 | const newObjectPropsText = objectsArr 57 | .flatMap((se) => se.properties) 58 | .map((prop) => sourceCode.getText(prop)) 59 | .join(', '); 60 | 61 | return `{ ${newObjectPropsText} }`; 62 | } 63 | 64 | return chunk.map((el) => sourceCode.getText(el)).join(', '); 65 | }); 66 | 67 | context.report({ 68 | messageId: 'object', 69 | node: clsxCallNode, 70 | fix: (fixer) => 71 | fixer.replaceText( 72 | clsxCallNode, 73 | `${clsxCallNode.callee.name}(${args.join(', ')})` 74 | ), 75 | }); 76 | } 77 | 78 | // TODO: add support for arrays and strings 79 | }); 80 | }, 81 | }; 82 | }, 83 | }); 84 | -------------------------------------------------------------------------------- /src/rules/prefer-objects-over-logical.test.ts: -------------------------------------------------------------------------------- 1 | import { RuleTester } from '@typescript-eslint/rule-tester'; 2 | 3 | import rule from './prefer-objects-over-logical'; 4 | 5 | new RuleTester().run('prefer-objects-over-logical', rule, { 6 | valid: [ 7 | { 8 | code: ` 9 | import clsx from 'clsx'; 10 | 11 | const condition = true; 12 | clsx({ 'flex-col': condition }); 13 | `, 14 | }, 15 | ], 16 | invalid: [ 17 | { 18 | code: ` 19 | import clsx from 'clsx'; 20 | 21 | const condition = true; 22 | clsx(condition && 'flex-col'); 23 | `, 24 | errors: [ 25 | { 26 | messageId: 'default', 27 | line: 5, 28 | column: 1, 29 | }, 30 | ], 31 | output: ` 32 | import clsx from 'clsx'; 33 | 34 | const condition = true; 35 | clsx({ "flex-col": condition }); 36 | `, 37 | }, 38 | ], 39 | }); 40 | -------------------------------------------------------------------------------- /src/rules/prefer-objects-over-logical.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/types'; 2 | 3 | import { createRule } from '../createRule'; 4 | import * as utils from '../utils'; 5 | 6 | export = createRule({ 7 | name: 'prefer-objects-over-logical', 8 | defaultOptions: [{ startingFrom: 0, endingWith: 999 }], 9 | meta: { 10 | type: 'suggestion', 11 | docs: { 12 | description: 'forbid usage of logical expressions inside clsx', 13 | }, 14 | fixable: 'code', 15 | schema: [ 16 | { 17 | type: 'object', 18 | properties: { 19 | startingFrom: { type: 'number' }, 20 | endingWith: { type: 'number' }, 21 | }, 22 | }, 23 | ], 24 | messages: { 25 | default: 'Usage of object expressions is preferred over logical expressions', 26 | }, 27 | }, 28 | create(context, [{ startingFrom, endingWith }]) { 29 | const { sourceCode } = context; 30 | const clsxOptions = utils.extractClsxOptions(context); 31 | 32 | return { 33 | ImportDeclaration(importNode) { 34 | const assignedClsxName = utils.findClsxImport(importNode, clsxOptions); 35 | 36 | if (!assignedClsxName) { 37 | return; 38 | } 39 | 40 | const clsxUsages = utils.getClsxUsages(importNode, sourceCode, assignedClsxName); 41 | 42 | clsxUsages 43 | .map((clsxCallNode) => ({ 44 | clsxCallNode, 45 | usageChunks: utils.chunkBy( 46 | clsxCallNode.arguments, 47 | (argumentNode) => argumentNode.type 48 | ), 49 | })) 50 | .forEach(({ clsxCallNode, usageChunks }) => { 51 | if ( 52 | !usageChunks.some( 53 | (chunk) => 54 | chunk[0]?.type === TSESTree.AST_NODE_TYPES.LogicalExpression && 55 | chunk.length >= startingFrom && 56 | chunk.length < endingWith 57 | ) 58 | ) { 59 | return; 60 | } 61 | 62 | const args = usageChunks.map((chunk) => { 63 | if (chunk[0]?.type === TSESTree.AST_NODE_TYPES.LogicalExpression) { 64 | const logicalExpressions = chunk as TSESTree.LogicalExpression[]; 65 | const newObjectPropsText = logicalExpressions 66 | .map((prop) => { 67 | const keyText = 68 | prop.right.type === TSESTree.AST_NODE_TYPES.Literal && 69 | typeof prop.right.value === 'string' 70 | ? `"${prop.right.value}"` 71 | : `[${sourceCode.getText(prop.right)}]`; 72 | const valueText = sourceCode.getText(prop.left); 73 | 74 | return `${keyText}: ${valueText}`; 75 | }) 76 | .join(', '); 77 | 78 | return `{ ${newObjectPropsText} }`; 79 | } 80 | 81 | return chunk.map((el) => sourceCode.getText(el)).join(', '); 82 | }); 83 | 84 | context.report({ 85 | messageId: 'default', 86 | node: clsxCallNode, 87 | fix: (fixer) => 88 | fixer.replaceText( 89 | clsxCallNode, 90 | `${clsxCallNode.callee.name}(${args.join(', ')})` 91 | ), 92 | }); 93 | }); 94 | }, 95 | }; 96 | }, 97 | }); 98 | -------------------------------------------------------------------------------- /src/rules/tests-setup.mjs: -------------------------------------------------------------------------------- 1 | import { RuleTester } from '@typescript-eslint/rule-tester'; 2 | import * as vitest from 'vitest'; 3 | 4 | RuleTester.afterAll = vitest.afterAll; 5 | 6 | // If you are not using vitest with globals: true (https://vitest.dev/config/#globals): 7 | RuleTester.it = vitest.it; 8 | RuleTester.itOnly = vitest.it.only; 9 | RuleTester.describe = vitest.describe; 10 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { TSESTree } from '@typescript-eslint/types'; 2 | import type { RuleContext, SourceCode } from '@typescript-eslint/utils/ts-eslint'; 3 | import * as R from 'remeda'; 4 | 5 | type ClsxOptions = Record; 6 | 7 | export function chunkBy(collection: T[], chunker: (el: T) => unknown) { 8 | const res = [] as T[][]; 9 | const temp = [collection[0]] as T[]; 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 12 | let lastChunkMarker = chunker(collection[0]!); 13 | 14 | for (const el of collection.slice(1)) { 15 | const currentChunkMarker = chunker(el); 16 | 17 | if (currentChunkMarker === lastChunkMarker) { 18 | temp.push(el); 19 | continue; 20 | } 21 | 22 | if (temp.length !== 0) { 23 | res.push([...temp]); 24 | temp.length = 0; 25 | temp.push(el); 26 | } 27 | 28 | lastChunkMarker = currentChunkMarker; 29 | } 30 | 31 | if (temp.length) res.push(temp); 32 | 33 | return res; 34 | } 35 | 36 | export function findClsxImport(importNode: TSESTree.ImportDeclaration, clsxOptions: ClsxOptions) { 37 | if (typeof importNode.source.value !== 'string') { 38 | throw new Error('import source value is not a string'); 39 | } 40 | 41 | const names = clsxOptions[importNode.source.value]; 42 | const importNames = typeof names === 'string' ? [names] : names; 43 | 44 | return importNames 45 | ?.map((name) => { 46 | if (name === 'default') { 47 | const defaultSpecifier = importNode.specifiers.find( 48 | (s) => s.type === TSESTree.AST_NODE_TYPES.ImportDefaultSpecifier 49 | ); 50 | 51 | return defaultSpecifier?.local.name; 52 | } 53 | 54 | const named = importNode.specifiers.find( 55 | (s) => 56 | s.type === TSESTree.AST_NODE_TYPES.ImportSpecifier && 57 | 'name' in s.imported && 58 | s.imported.name === name 59 | ); 60 | 61 | return named?.local.name; 62 | }) 63 | .filter(R.isDefined); 64 | } 65 | 66 | function isCallExpressionWithName(name: N) { 67 | return ( 68 | node: TSESTree.Node | undefined | null 69 | ): node is TSESTree.CallExpression & { callee: { name: N } } => 70 | !!node && 71 | node.type === TSESTree.AST_NODE_TYPES.CallExpression && 72 | 'name' in node.callee && 73 | node.callee.name === name; 74 | } 75 | 76 | export function getClsxUsages( 77 | importNode: TSESTree.ImportDeclaration, 78 | sourceCode: SourceCode, 79 | assignedClsxNames: string[] 80 | ) { 81 | return assignedClsxNames 82 | .flatMap((assignedClsxName) => 83 | sourceCode.scopeManager 84 | ?.getDeclaredVariables(importNode) 85 | .find((variable) => variable.name === assignedClsxName) 86 | ?.references.map( 87 | (ref) => (ref.identifier as unknown as { parent: TSESTree.Node }).parent 88 | ) 89 | .filter(R.isDefined) 90 | .filter(isCallExpressionWithName(assignedClsxName)) 91 | ) 92 | .filter(R.isDefined); 93 | } 94 | 95 | export function extractClsxOptions( 96 | context: RuleContext 97 | ) { 98 | return (context.settings.clsxOptions ?? { 99 | clsx: ['default', 'clsx'], 100 | classnames: 'default', 101 | }) as ClsxOptions; 102 | } 103 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "lib": ["ESNext"], 5 | "module": "NodeNext", 6 | "resolveJsonModule": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noUncheckedIndexedAccess": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/tests-setup.mjs'], 5 | bundle: false, 6 | }); 7 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | setupFiles: './src/rules/tests-setup.mjs', 7 | }, 8 | }); 9 | --------------------------------------------------------------------------------