├── .cspell.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .markdownlint.jsonc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── FUNDING.yml ├── LICENSE ├── README.md ├── assets ├── eslint-plugin-better-tailwindcss-demo.png ├── eslint-plugin-better-tailwindcss-logo-dark.svg ├── eslint-plugin-better-tailwindcss-logo-light.svg ├── eslint-plugin-better-tailwindcss-logo.svg ├── sponsor-dark.svg └── sponsor-light.svg ├── build ├── index.ts ├── transform.ts └── utils.ts ├── changelog.config.js ├── docs ├── api │ └── defaults.md ├── configuration │ └── advanced.md ├── parsers │ ├── angular.md │ ├── astro.md │ ├── html.md │ ├── javascript.md │ ├── jsx.md │ ├── svelte.md │ ├── tsx.md │ ├── typescript.md │ └── vue.md ├── rules │ ├── enforce-consistent-variable-syntax.md │ ├── multiline.md │ ├── no-conflicting-classes.md │ ├── no-duplicate-classes.md │ ├── no-restricted-classes.md │ ├── no-unnecessary-whitespace.md │ ├── no-unregistered-classes.md │ └── sort-classes.md └── settings │ └── settings.md ├── eslint.config.js ├── package-lock.json ├── package.json ├── src ├── api │ ├── defaults.ts │ └── types.ts ├── configs │ ├── cjs.ts │ ├── config.ts │ └── esm.ts ├── options │ ├── callees │ │ ├── cc.test.ts │ │ ├── cc.ts │ │ ├── clb.test.ts │ │ ├── clb.ts │ │ ├── clsx.test.ts │ │ ├── clsx.ts │ │ ├── cn.test.ts │ │ ├── cn.ts │ │ ├── cnb.test.ts │ │ ├── cnb.ts │ │ ├── ctl.test.ts │ │ ├── ctl.ts │ │ ├── cva.test.ts │ │ ├── cva.ts │ │ ├── cx.test.ts │ │ ├── cx.ts │ │ ├── dcnb.test.ts │ │ ├── dcnb.ts │ │ ├── objstr.test.ts │ │ ├── objstr.ts │ │ ├── tv.test.ts │ │ ├── tv.ts │ │ ├── twJoin.test.ts │ │ ├── twJoin.ts │ │ ├── twMerge.test.ts │ │ └── twMerge.ts │ ├── default-options.test.ts │ ├── default-options.ts │ ├── descriptions.test.ts │ └── descriptions.ts ├── parsers │ ├── angular.test.ts │ ├── angular.ts │ ├── es.test.ts │ ├── es.ts │ ├── html.test.ts │ ├── html.ts │ ├── jsx.test.ts │ ├── jsx.ts │ ├── svelte.test.ts │ ├── svelte.ts │ ├── vue.test.ts │ └── vue.ts ├── rules │ ├── enforce-consistent-variable-syntax.test.ts │ ├── enforce-consistent-variable-syntax.ts │ ├── multiline.test.ts │ ├── multiline.ts │ ├── no-conflicting-classes.test.ts │ ├── no-conflicting-classes.ts │ ├── no-duplicate-classes.test.ts │ ├── no-duplicate-classes.ts │ ├── no-restricted-classes.test.ts │ ├── no-restricted-classes.ts │ ├── no-unnecessary-whitespace.test.ts │ ├── no-unnecessary-whitespace.ts │ ├── no-unregistered-classes.test.ts │ ├── no-unregistered-classes.ts │ ├── sort-classes.test.ts │ └── sort-classes.ts ├── tailwind │ ├── api │ │ └── interface.ts │ ├── async │ │ ├── class-order.async.ts │ │ ├── class-order.sync.ts │ │ ├── conflicting-classes.async.ts │ │ ├── conflicting-classes.sync.ts │ │ ├── unregistered-classes.async.ts │ │ └── unregistered-classes.sync.ts │ ├── utils │ │ ├── cache.ts │ │ ├── fs.ts │ │ ├── module.ts │ │ ├── platform.ts │ │ ├── resolvers.ts │ │ └── version.ts │ ├── v3 │ │ ├── class-order.ts │ │ ├── config.ts │ │ ├── context.ts │ │ └── unregistered-classes.ts │ └── v4 │ │ ├── class-order.ts │ │ ├── config.ts │ │ ├── conflicting-classes.ts │ │ ├── context.ts │ │ └── unregistered-classes.ts ├── types │ ├── ast.ts │ └── rule.ts └── utils │ ├── matchers.test.ts │ ├── matchers.ts │ ├── options.test.ts │ ├── options.ts │ ├── quotes.test.ts │ ├── quotes.ts │ ├── regex.test.ts │ ├── regex.ts │ ├── rule.ts │ ├── utils.test.ts │ └── utils.ts ├── tests ├── e2e │ ├── commonjs │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── test.html │ │ └── test.test.ts │ └── esm │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── test.html │ │ └── test.test.ts └── utils │ ├── lint.ts │ ├── template.test.ts │ └── template.ts ├── tsconfig.build.cjs.json ├── tsconfig.build.esm.json ├── tsconfig.json └── vite.config.ts /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePaths": [ 3 | "node_modules/**", 4 | ".vscode/**", 5 | "lib/**" 6 | ], 7 | "import": [ 8 | "@schoero/configs/cspell" 9 | ], 10 | "words": [ 11 | "autofix", 12 | "callees", 13 | "classcat", 14 | "classnames", 15 | "clsx", 16 | "cnbuilder", 17 | "dcnb", 18 | "DCNB", 19 | "Declarators", 20 | "ecma", 21 | "eslintrc", 22 | "espree", 23 | "estree", 24 | "jiti", 25 | "linebreak", 26 | "linebreakstyle", 27 | "objstr", 28 | "OBJSTR", 29 | "quasis", 30 | "shadcn", 31 | "synckit", 32 | "Tmpl", 33 | "astro" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | cache: npm 17 | node-version: 24 18 | 19 | - name: Install dependencies 20 | run: npm ci 21 | 22 | - name: Lint 23 | run: npm run lint:ci 24 | 25 | - name: Typecheck 26 | run: npm run typecheck 27 | 28 | - name: Spellcheck 29 | run: npm run spellcheck:ci 30 | 31 | test: 32 | runs-on: ${{ matrix.os }} 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: actions/setup-node@v4 36 | with: 37 | cache: npm 38 | node-version: ${{ matrix.node }} 39 | 40 | - name: Install dependencies 41 | run: npm ci 42 | 43 | - name: Run build 44 | run: npm run build:ci 45 | 46 | - name: Install tailwindcss v3 47 | run: npm run install:v3 48 | 49 | - name: Run tests with tailwindcss v3 50 | run: npm run test:v3 51 | 52 | - name: Run e2e tests with tailwindcss v3 53 | run: npm run test:e2e 54 | 55 | - name: Install tailwindcss v4 56 | run: npm run install:v4 57 | 58 | - name: Run tests with tailwindcss v4 59 | run: npm run test:v4 60 | 61 | - name: Run e2e tests with tailwindcss v4 62 | run: npm run test:e2e 63 | 64 | strategy: 65 | fail-fast: true 66 | matrix: 67 | node: 68 | - 20 69 | - 22 70 | - 24 71 | os: 72 | - ubuntu-latest 73 | - windows-latest 74 | - macos-latest 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tmp 3 | lib 4 | local 5 | .DS_Store 6 | .env 7 | -------------------------------------------------------------------------------- /.markdownlint.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@schoero/configs/markdownlint" 3 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "streetsidesoftware.code-spell-checker", 5 | "davidanson.vscode-markdownlint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "args": [ 5 | "run", 6 | "${relativeFileDirname}/${fileBasenameNoExtension}" 7 | ], 8 | "autoAttachChildProcesses": true, 9 | "console": "integratedTerminal", 10 | "name": "debug current test file", 11 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 12 | "request": "launch", 13 | "skipFiles": ["/**", "**/node_modules/**"], 14 | "smartStep": true, 15 | "type": "node" 16 | }, 17 | { 18 | "args": [ 19 | "run", 20 | "${relativeFileDirname}/${fileBasenameNoExtension}" 21 | ], 22 | "autoAttachChildProcesses": true, 23 | "console": "integratedTerminal", 24 | "name": "debug current test file with node internals", 25 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 26 | "request": "launch", 27 | "skipFiles": [], 28 | "smartStep": true, 29 | "type": "node" 30 | } 31 | ], 32 | "version": "0.2.0" 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | // ESLint 4 | "[javascript][typescript][json][json5][jsonc][yaml]": { 5 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 6 | }, 7 | "eslint.nodePath": "node_modules/eslint", 8 | "eslint.useFlatConfig": true, 9 | "eslint.validate": ["javascript", "typescript", "json", "jsonc", "json5", "yaml"], 10 | 11 | "eslint.rules.customizations": [ 12 | { "rule": "better-tailwindcss/*", "severity": "off" } 13 | ], 14 | "eslint.codeActionsOnSave.rules": [ 15 | "!better-tailwindcss/*", 16 | "*" 17 | ], 18 | 19 | // tailwindcss 20 | "tailwindCSS.lint.cssConflict": "ignore", 21 | 22 | "editor.formatOnSave": false, 23 | 24 | // Prettier 25 | "prettier.enable": false, 26 | 27 | // File nesting 28 | "explorer.fileNesting.enabled": true, 29 | "explorer.fileNesting.expand": false, 30 | "explorer.fileNesting.patterns": { 31 | "*.ts": "$(capture).ts,$(capture).test.ts,$(capture).cts,$(capture).mts,$(capture).test.snap,$(capture).test-d.ts", 32 | "*.js": "$(capture).test.js,$(capture).cjs,$(capture).mjs,$(capture).d.ts,$(capture).d.ts.map,$(capture).js.map", 33 | 34 | "*.sync.ts": "$(capture).async.ts" 35 | }, 36 | 37 | // ES module import 38 | "typescript.preferences.importModuleSpecifier": "non-relative", 39 | "typescript.preferences.importModuleSpecifierEnding": "js", 40 | "typescript.preferences.useAliasesForRenames": true, 41 | "typescript.preferences.autoImportFileExcludePatterns": [ 42 | "@types/node/test.d.ts" 43 | ], 44 | 45 | // Markdown 46 | "[markdown]": { 47 | "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" 48 | }, 49 | 50 | // VSCode 51 | "editor.codeActionsOnSave": { 52 | "source.fixAll.eslint": "explicit", 53 | "source.fixAll.markdownlint": "explicit", 54 | "source.organizeImports": "never" 55 | }, 56 | "editor.rulers": [ 57 | 119 58 | ], 59 | "typescript.preferences.autoImportSpecifierExcludeRegexes": ["lib"], 60 | "search.exclude": { 61 | "lib": true 62 | }, 63 | "typescript.tsdk": "node_modules/typescript/lib" 64 | } 65 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 2 | - schoero 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Roger Schönbächler 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 | -------------------------------------------------------------------------------- /assets/eslint-plugin-better-tailwindcss-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schoero/eslint-plugin-better-tailwindcss/f212b5328ba3ab7cdc437c4e07091040dc608cb2/assets/eslint-plugin-better-tailwindcss-demo.png -------------------------------------------------------------------------------- /assets/eslint-plugin-better-tailwindcss-logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /assets/eslint-plugin-better-tailwindcss-logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /assets/eslint-plugin-better-tailwindcss-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /build/index.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, rm, writeFile } from "fs/promises"; 2 | import { transformDirname, transformImports } from "better-tailwindcss:build/transform.js"; 3 | import { $ } from "better-tailwindcss:build/utils.js"; 4 | 5 | async function build(){ 6 | 7 | const esmDir = "lib/esm" 8 | const cjsDir = "lib/cjs" 9 | 10 | console.info("Building ESM...") 11 | await mkdir(esmDir, { recursive: true }); 12 | await $(`npx tsc --project tsconfig.build.esm.json --outDir ${esmDir}`) 13 | await $(`npx tsc-alias --outDir ${esmDir}`) 14 | await writeFile(`${esmDir}/package.json`, JSON.stringify({ type: "module" }), "utf-8") 15 | await transformImports([`${esmDir}/**/*.js`], "tailwindcss3", "tailwindcss") 16 | await transformImports([`${esmDir}/**/*.js`], "tailwindcss4", "tailwindcss") 17 | 18 | console.info("Building CJS...") 19 | await mkdir(cjsDir, { recursive: true }); 20 | await $(`npx tsc --project tsconfig.build.cjs.json --outDir ${cjsDir}`) 21 | await $(`npx tsc-alias --outDir ${cjsDir}`) 22 | await writeFile(`${cjsDir}/package.json`, JSON.stringify({ type: "commonjs" }), "utf-8") 23 | await transformImports([`${cjsDir}/**/*.js`], "tailwindcss3", "tailwindcss") 24 | await transformImports([`${cjsDir}/**/*.js`], "tailwindcss4", "tailwindcss") 25 | await transformDirname([`${cjsDir}/**/*.js`]) 26 | 27 | console.info("Build complete") 28 | 29 | } 30 | 31 | build().catch(console.error); -------------------------------------------------------------------------------- /build/transform.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'fs/promises'; 2 | import { glob } from 'glob' 3 | 4 | export async function transformImports(globPatterns: string[], search: string, replace: string) { 5 | const files = await glob(globPatterns); 6 | 7 | for(const file of files) { 8 | const content = await readFile(file, 'utf-8'); 9 | const transformed = content.replaceAll(search, replace); 10 | 11 | await writeFile(file, transformed); 12 | } 13 | } 14 | 15 | export async function transformDirname(globPatterns: string[]) { 16 | const files = await glob(globPatterns); 17 | 18 | for(const file of files) { 19 | const content = await readFile(file, 'utf-8'); 20 | const transformed = content 21 | .replaceAll('import.meta.dirname', '__dirname') 22 | .replaceAll('import.meta.url', '__filename'); 23 | 24 | await writeFile(file, transformed); 25 | } 26 | } -------------------------------------------------------------------------------- /build/utils.ts: -------------------------------------------------------------------------------- 1 | import { exec, type ExecOptions } from 'node:child_process' 2 | 3 | export async function $(command: string, options?: ExecOptions): Promise { 4 | return new Promise((resolve, reject) => { 5 | exec(command, options, (error, stdout, stderr) => { 6 | if (error) { 7 | reject(error || stderr) 8 | } 9 | resolve(stdout) 10 | }) 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /changelog.config.js: -------------------------------------------------------------------------------- 1 | export { default } from "@schoero/configs/changelogen"; 2 | -------------------------------------------------------------------------------- /docs/api/defaults.md: -------------------------------------------------------------------------------- 1 | 2 | # Defaults 3 | 4 | The plugin comes with a set of default [matchers](../configuration/advanced.md#matchers) for `attributes`, `callees`, `variables` and `tags`. These matchers are used to [determine how the rules should behave](../configuration/advanced.md#advanced-configuration) when checking your code. 5 | In order to extend the default configuration instead of overwriting it, you can import the default options from `eslint-plugin-better-tailwindcss/api/defaults` and merge them with your own options. 6 | 7 |
8 |
9 | 10 | ## Extending the config 11 | 12 | ```ts 13 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 14 | import { 15 | getDefaultAttributes, 16 | getDefaultCallees, 17 | getDefaultTags, 18 | getDefaultVariables 19 | } from "eslint-plugin-better-tailwindcss/api/defaults"; 20 | import { MatcherType } from "eslint-plugin-better-tailwindcss/api/types"; 21 | 22 | 23 | export default [ 24 | { 25 | plugins: { 26 | "better-tailwindcss": eslintPluginBetterTailwindcss 27 | }, 28 | rules: { 29 | "better-tailwindcss/multiline": ["warn", { 30 | callees: [ 31 | ...getDefaultCallees(), 32 | [ 33 | "myFunction", [ 34 | { 35 | match: MatcherType.String 36 | } 37 | ] 38 | ] 39 | ] 40 | }], 41 | "better-tailwindcss/no-duplicate-classes": ["warn", { 42 | attributes: [ 43 | ...getDefaultAttributes(), 44 | [ 45 | "myAttribute", [ 46 | { 47 | match: MatcherType.String 48 | } 49 | ] 50 | ] 51 | ] 52 | }], 53 | "better-tailwindcss/no-unnecessary-whitespace": ["warn", { 54 | variables: [ 55 | ...getDefaultVariables(), 56 | [ 57 | "myVariable", [ 58 | { 59 | match: MatcherType.String 60 | } 61 | ] 62 | ] 63 | ] 64 | }], 65 | "better-tailwindcss/sort-classes": ["warn", { 66 | attributes: [ 67 | ...getDefaultTags(), 68 | [ 69 | "myTag", [ 70 | { 71 | match: MatcherType.String 72 | } 73 | ] 74 | ] 75 | ] 76 | }] 77 | } 78 | } 79 | ]; 80 | ``` 81 | -------------------------------------------------------------------------------- /docs/parsers/angular.md: -------------------------------------------------------------------------------- 1 | # Angular 2 | 3 | To use ESLint with Angular, install [Angular ESLint](https://github.com/angular-eslint/angular-eslint?tab=readme-ov-file#quick-start). You can follow the [flat config](https://github.com/angular-eslint/angular-eslint/blob/main/docs/CONFIGURING_FLAT_CONFIG.md) or [legacy config](https://github.com/angular-eslint/angular-eslint/blob/main/docs/CONFIGURING_ESLINTRC.md) setup, which includes rules from the Angular ESLint package or you can add the parser directly by following the steps below. 4 | 5 | To enable eslint-plugin-better-tailwindcss, you need to add it to the plugins section of your eslint configuration and enable the rules you want to use. 6 | 7 | ```sh 8 | npm i -D angular-eslint 9 | ``` 10 | 11 |
12 | 13 | ## Usage 14 | 15 | ### Flat config 16 | 17 | Read more about the new [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) 18 | 19 | ```js 20 | // eslint.config.js 21 | import eslintParserTypeScript from "@typescript-eslint/parser"; 22 | import eslintParserAngular from "angular-eslint"; 23 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 24 | 25 | export default [ 26 | { 27 | 28 | files: ["**/*.ts"], 29 | languageOptions: { 30 | parser: eslintParserTypeScript, 31 | parserOptions: { 32 | project: true 33 | } 34 | }, 35 | processor: eslintParserAngular.processInlineTemplates 36 | }, 37 | { 38 | files: ["**/*.html"], 39 | languageOptions: { 40 | parser: eslintParserAngular.templateParser 41 | } 42 | }, 43 | { 44 | plugins: { 45 | "better-tailwindcss": eslintPluginBetterTailwindcss 46 | }, 47 | rules: { 48 | // enable all recommended rules to report a warning 49 | ...eslintPluginBetterTailwindcss.configs["recommended-warn"].rules, 50 | // enable all recommended rules to report an error 51 | ...eslintPluginBetterTailwindcss.configs["recommended-error"].rules, 52 | 53 | // or configure rules individually 54 | "better-tailwindcss/multiline": ["warn", { printWidth: 100 }] 55 | } 56 | } 57 | ]; 58 | ``` 59 | 60 |
61 | 62 | ### Legacy config 63 | 64 | ```jsonc 65 | // .eslintrc.json 66 | { 67 | "overrides": [ 68 | { 69 | "files": ["**/*.ts"], 70 | "parser": "@typescript-eslint/parser", 71 | "extends": [ 72 | "plugin:@angular-eslint/template/process-inline-templates" 73 | ] 74 | }, 75 | { 76 | "files": ["**/*.html"], 77 | "extends": [ 78 | // enable all recommended rules to report a warning 79 | "plugin:better-tailwindcss/recommended-warn", 80 | // enable all recommended rules to report an error 81 | "plugin:better-tailwindcss/recommended-error" 82 | ], 83 | "parser": "@angular-eslint/template-parser", 84 | "plugins": ["better-tailwindcss"], 85 | "rules": { 86 | // or configure rules individually 87 | "better-tailwindcss/multiline": ["warn", { "printWidth": 100 }] 88 | } 89 | } 90 | ] 91 | } 92 | ``` 93 | 94 |
95 | 96 | ### Editor configuration 97 | 98 | #### VSCode 99 | 100 | To enable the [VSCode ESLint plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) to validate HTML files, add the following to your `.vscode/settings.json`: 101 | 102 | ```jsonc 103 | { 104 | // enable ESLint to validate HTML files 105 | "eslint.validate": [/* ...other formats */, "html"], 106 | 107 | // enable ESLint to fix tailwind classes on save 108 | "editor.codeActionsOnSave": { 109 | "source.fixAll.eslint": "explicit" 110 | } 111 | } 112 | ``` 113 | -------------------------------------------------------------------------------- /docs/parsers/astro.md: -------------------------------------------------------------------------------- 1 | # Astro 2 | 3 | To use ESLint with Astro files, first install the [astro-eslint-parser](https://github.com/ota-meshi/astro-eslint-parser). Then, configure ESLint to use this parser for Astro files. 4 | 5 | To use TypeScript within Astro files, you can also install the [@typescript-eslint/parser](https://typescript-eslint.io/packages/parser) and configure it via the `parser` option in the `languageOptions` section of your ESLint configuration. 6 | 7 | To enable eslint-plugin-better-tailwindcss, you need to add it to the plugins section of your eslint configuration and enable the rules you want to use. 8 | 9 |
10 | 11 | ## Usage 12 | 13 | ### Flat config 14 | 15 | Read more about the new [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) 16 | 17 | ```js 18 | // eslint.config.js 19 | import eslintParserTypeScript from "@typescript-eslint/parser"; 20 | import eslintParserAstro from "astro-eslint-parser"; 21 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 22 | 23 | export default [ 24 | { 25 | files: ["*.astro"], 26 | languageOptions: { 27 | parser: eslintParserAstro, 28 | parserOptions: { 29 | // optionally use TypeScript parser within for Astro files 30 | parser: eslintParserTypeScript 31 | } 32 | }, 33 | plugins: { 34 | "better-tailwindcss": eslintPluginBetterTailwindcss 35 | }, 36 | rules: { 37 | // enable all recommended rules to report a warning 38 | ...eslintPluginBetterTailwindcss.configs["recommended-warn"].rules, 39 | // enable all recommended rules to report an error 40 | ...eslintPluginBetterTailwindcss.configs["recommended-error"].rules, 41 | 42 | // or configure rules individually 43 | "better-tailwindcss/multiline": ["warn", { printWidth: 100 }] 44 | } 45 | } 46 | ]; 47 | ``` 48 | 49 |
50 | 51 | ```jsonc 52 | // .eslintrc.json 53 | { 54 | "extends": [ 55 | // enable all recommended rules to report a warning 56 | "plugin:better-tailwindcss/recommended-warn", 57 | // enable all recommended rules to report an error 58 | "plugin:better-tailwindcss/recommended-error" 59 | ], 60 | "parser": "astro-eslint-parser", 61 | "parserOptions": { 62 | // optionally use TypeScript parser within for Astro files 63 | "parser": "@typescript-eslint/parser" 64 | }, 65 | "plugins": ["better-tailwindcss"], 66 | "rules": { 67 | // or configure rules individually 68 | "better-tailwindcss/multiline": ["warn", { "printWidth": 100 }] 69 | } 70 | } 71 | ``` 72 | 73 |
74 | 75 | ### Editor configuration 76 | 77 | #### VSCode 78 | 79 | To enable the [VSCode ESLint plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) to validate Astro files, add the following to your `.vscode/settings.json`: 80 | 81 | ```jsonc 82 | { 83 | // enable ESLint to validate Astro files 84 | "eslint.validate": [/* ...other formats */, "astro"], 85 | 86 | // enable ESLint to fix tailwind classes on save 87 | "editor.codeActionsOnSave": { 88 | "source.fixAll.eslint": "explicit" 89 | } 90 | } 91 | ``` 92 | -------------------------------------------------------------------------------- /docs/parsers/html.md: -------------------------------------------------------------------------------- 1 | # HTML 2 | 3 | To use ESLint with HTML files, first install the [@html-eslint/parser](https://github.com/yeonjuan/html-eslint/tree/main/packages/parser). Then, configure ESLint to use this parser for HTML files. 4 | 5 | To enable eslint-plugin-better-tailwindcss, you need to add it to the plugins section of your eslint configuration and enable the rules you want to use. 6 | 7 | ```sh 8 | npm i -D @html-eslint/parser 9 | ``` 10 | 11 |
12 | 13 | ## Usage 14 | 15 | ### Flat config 16 | 17 | Read more about the new [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) 18 | 19 | ```js 20 | // eslint.config.js 21 | import eslintParserHTML from "@html-eslint/parser"; 22 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 23 | 24 | export default [ 25 | { 26 | files: ["**/*.html"], 27 | languageOptions: { 28 | parser: eslintParserHTML 29 | } 30 | }, 31 | { 32 | plugins: { 33 | "better-tailwindcss": eslintPluginBetterTailwindcss 34 | }, 35 | rules: { 36 | // enable all recommended rules to report a warning 37 | ...eslintPluginBetterTailwindcss.configs["recommended-warn"].rules, 38 | // enable all recommended rules to report an error 39 | ...eslintPluginBetterTailwindcss.configs["recommended-error"].rules, 40 | 41 | // or configure rules individually 42 | "better-tailwindcss/multiline": ["warn", { printWidth: 100 }] 43 | } 44 | } 45 | ]; 46 | ``` 47 | 48 |
49 | 50 | ### Legacy config 51 | 52 | ```jsonc 53 | // .eslintrc.json 54 | { 55 | "extends": [ 56 | // enable all recommended rules to report a warning 57 | "plugin:better-tailwindcss/recommended-warn", 58 | // enable all recommended rules to report an error 59 | "plugin:better-tailwindcss/recommended-error" 60 | ], 61 | "parser": "@html-eslint/parser", 62 | "plugins": ["better-tailwindcss"], 63 | "rules": { 64 | // or configure rules individually 65 | "better-tailwindcss/multiline": ["warn", { "printWidth": 100 }] 66 | } 67 | } 68 | ``` 69 | 70 |
71 | 72 | ### Editor configuration 73 | 74 | #### VSCode 75 | 76 | To enable the [VSCode ESLint plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) to validate HTML files, add the following to your `.vscode/settings.json`: 77 | 78 | ```jsonc 79 | { 80 | // enable ESLint to validate HTML files 81 | "eslint.validate": [/* ...other formats */, "html"], 82 | 83 | // enable ESLint to fix tailwind classes on save 84 | "editor.codeActionsOnSave": { 85 | "source.fixAll.eslint": "explicit" 86 | } 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /docs/parsers/javascript.md: -------------------------------------------------------------------------------- 1 | # JavaScript 2 | 3 | JavaScript files are supported out of the box by eslint. 4 | 5 | To enable eslint-plugin-better-tailwindcss, you need to add it to the plugins section of your eslint configuration and enable the rules you want to use. 6 | 7 |
8 | 9 | ## Usage 10 | 11 | ### Flat config 12 | 13 | Read more about the new [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) 14 | 15 | ```js 16 | // eslint.config.js 17 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 18 | 19 | export default [ 20 | { 21 | plugins: { 22 | "better-tailwindcss": eslintPluginBetterTailwindcss 23 | }, 24 | rules: { 25 | // enable all recommended rules to report a warning 26 | ...eslintPluginBetterTailwindcss.configs["recommended-warn"].rules, 27 | // enable all recommended rules to report an error 28 | ...eslintPluginBetterTailwindcss.configs["recommended-error"].rules, 29 | 30 | // or configure rules individually 31 | "better-tailwindcss/multiline": ["warn", { printWidth: 100 }] 32 | } 33 | } 34 | ]; 35 | ``` 36 | 37 |
38 | 39 | ### Legacy config 40 | 41 | ```jsonc 42 | // .eslintrc.json 43 | { 44 | "extends": [ 45 | // enable all recommended rules to report a warning 46 | "plugin:better-tailwindcss/recommended-warn", 47 | // or enable all recommended rules to report an error 48 | "plugin:better-tailwindcss/recommended-error" 49 | ], 50 | "plugins": ["better-tailwindcss"], 51 | "rules": { 52 | // or configure rules individually 53 | "better-tailwindcss/multiline": ["warn", { "printWidth": 100 }] 54 | } 55 | } 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/parsers/jsx.md: -------------------------------------------------------------------------------- 1 | # JSX 2 | 3 | JSX files are supported out of the box. The only thing you need to do is to enable the `jsx` option in the eslint parser options. 4 | 5 | To enable eslint-plugin-better-tailwindcss, you need to add it to the plugins section of your eslint configuration and enable the rules you want to use. 6 | 7 |
8 | 9 | ## Usage 10 | 11 | ### Flat config 12 | 13 | Read more about the new [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) 14 | 15 | ```js 16 | // eslint.config.js 17 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 18 | 19 | export default [ 20 | { 21 | languageOptions: { 22 | parserOptions: { 23 | ecmaFeatures: { 24 | jsx: true 25 | } 26 | } 27 | }, 28 | plugins: { 29 | "better-tailwindcss": eslintPluginBetterTailwindcss 30 | }, 31 | rules: { 32 | // enable all recommended rules to report a warning 33 | ...eslintPluginBetterTailwindcss.configs["recommended-warn"].rules, 34 | // enable all recommended rules to report an error 35 | ...eslintPluginBetterTailwindcss.configs["recommended-error"].rules, 36 | 37 | // or configure rules individually 38 | "better-tailwindcss/multiline": ["warn", { printWidth: 100 }] 39 | } 40 | } 41 | ]; 42 | ``` 43 | 44 |
45 | 46 | ### Legacy config 47 | 48 | ```jsonc 49 | // .eslintrc.json 50 | { 51 | "extends": [ 52 | // enable all recommended rules to report a warning 53 | "plugin:better-tailwindcss/recommended-warn", 54 | // enable all recommended rules to report an error 55 | "plugin:better-tailwindcss/recommended-error" 56 | ], 57 | "parserOptions": { 58 | "ecmaFeatures": { 59 | "jsx": true 60 | }, 61 | "ecmaVersion": "latest" 62 | }, 63 | "plugins": ["better-tailwindcss"], 64 | "rules": { 65 | // or configure rules individually 66 | "better-tailwindcss/multiline": ["warn", { "printWidth": 100 }] 67 | } 68 | } 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/parsers/svelte.md: -------------------------------------------------------------------------------- 1 | # Svelte 2 | 3 | To use ESLint with Svelte files, first install the [svelte-eslint-parser](https://github.com/sveltejs/svelte-eslint-parser). Then, configure ESLint to use this parser for Svelte files. 4 | 5 | To enable eslint-plugin-better-tailwindcss, you need to add it to the plugins section of your eslint configuration and enable the rules you want to use. 6 | 7 | ```sh 8 | npm i -D svelte-eslint-parser 9 | ``` 10 | 11 |
12 | 13 | ## Usage 14 | 15 | ### Flat config 16 | 17 | Read more about the new [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) 18 | 19 | ```js 20 | // eslint.config.js 21 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 22 | import eslintParserSvelte from "svelte-eslint-parser"; 23 | 24 | export default [ 25 | { 26 | files: ["**/*.svelte"], 27 | languageOptions: { 28 | parser: eslintParserSvelte 29 | } 30 | }, 31 | { 32 | plugins: { 33 | "better-tailwindcss": eslintPluginBetterTailwindcss 34 | }, 35 | rules: { 36 | // enable all recommended rules to report a warning 37 | ...eslintPluginBetterTailwindcss.configs["recommended-warn"].rules, 38 | // enable all recommended rules to report an error 39 | ...eslintPluginBetterTailwindcss.configs["recommended-error"].rules, 40 | 41 | // or configure rules individually 42 | "better-tailwindcss/multiline": ["warn", { printWidth: 100 }] 43 | } 44 | } 45 | ]; 46 | ``` 47 | 48 |
49 | 50 | ### Legacy config 51 | 52 | ```jsonc 53 | // .eslintrc.json 54 | { 55 | "extends": [ 56 | // enable all recommended rules to report a warning 57 | "plugin:better-tailwindcss/recommended-warn", 58 | // enable all recommended rules to report an error 59 | "plugin:better-tailwindcss/recommended-error" 60 | ], 61 | "parser": "svelte-eslint-parser", 62 | "plugins": ["better-tailwindcss"], 63 | "rules": { 64 | // or configure rules individually 65 | "better-tailwindcss/multiline": ["warn", { "printWidth": 100 }] 66 | } 67 | } 68 | ``` 69 | 70 |
71 | 72 | ### Editor configuration 73 | 74 | #### VSCode 75 | 76 | To enable the [VSCode ESLint plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) to validate Svelte files, add the following to your `.vscode/settings.json`: 77 | 78 | ```jsonc 79 | { 80 | // enable ESLint to validate Svelte files 81 | "eslint.validate": [/* ...other formats */, "svelte"], 82 | 83 | // enable ESLint to fix tailwind classes on save 84 | "editor.codeActionsOnSave": { 85 | "source.fixAll.eslint": "explicit" 86 | } 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /docs/parsers/tsx.md: -------------------------------------------------------------------------------- 1 | # TSX 2 | 3 | To use ESLint with TSX files, first install the [@typescript-eslint/parser](https://typescript-eslint.io/packages/parser). Then, configure ESLint to use this parser for TypeScript files. 4 | 5 | In addition, you need to enable `ecmaFeatures.jsx` in the parser options. 6 | 7 | To enable eslint-plugin-better-tailwindcss, you need to add it to the plugins section of your eslint configuration and enable the rules you want to use. 8 | 9 | ```sh 10 | npm i -D @typescript-eslint/parser 11 | ``` 12 | 13 |
14 | 15 | ## Usage 16 | 17 | ### Flat config 18 | 19 | Read more about the new [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) 20 | 21 | ```js 22 | // eslint.config.js 23 | import eslintParserTypeScript from "@typescript-eslint/parser"; 24 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 25 | 26 | export default [ 27 | { 28 | files: ["**/*.{ts,tsx,cts,mts}"], 29 | languageOptions: { 30 | parser: eslintParserTypeScript, 31 | parserOptions: { 32 | project: true 33 | } 34 | } 35 | }, 36 | { 37 | files: ["**/*.{jsx,tsx}"], 38 | languageOptions: { 39 | parserOptions: { 40 | ecmaFeatures: { 41 | jsx: true 42 | } 43 | } 44 | }, 45 | plugins: { 46 | "better-tailwindcss": eslintPluginBetterTailwindcss 47 | }, 48 | rules: { 49 | // enable all recommended rules to report a warning 50 | ...eslintPluginBetterTailwindcss.configs["recommended-warn"].rules, 51 | // enable all recommended rules to report an error 52 | ...eslintPluginBetterTailwindcss.configs["recommended-error"].rules, 53 | 54 | // or configure rules individually 55 | "better-tailwindcss/multiline": ["warn", { printWidth: 100 }] 56 | } 57 | } 58 | ]; 59 | ``` 60 | 61 |
62 | 63 | ### Legacy config 64 | 65 | ```jsonc 66 | // .eslintrc.json 67 | { 68 | "parser": "@typescript-eslint/parser", 69 | "extends": [ 70 | // enable all recommended rules to report a warning 71 | "plugin:better-tailwindcss/recommended-warn", 72 | // enable all recommended rules to report an error 73 | "plugin:better-tailwindcss/recommended-error" 74 | ], 75 | "parserOptions": { 76 | "ecmaFeatures": { 77 | "jsx": true 78 | }, 79 | "ecmaVersion": "latest" 80 | }, 81 | "plugins": ["better-tailwindcss"], 82 | "rules": { 83 | // or configure rules individually 84 | "better-tailwindcss/multiline": ["warn", { "printWidth": 100 }] 85 | } 86 | } 87 | ``` 88 | -------------------------------------------------------------------------------- /docs/parsers/typescript.md: -------------------------------------------------------------------------------- 1 | # TypeScript 2 | 3 | To use ESLint with TypeScript files, first install the [@typescript-eslint/parser](https://typescript-eslint.io/packages/parser). Then, configure ESLint to use this parser for TypeScript files. 4 | 5 | To enable eslint-plugin-better-tailwindcss, you need to add it to the plugins section of your eslint configuration and enable the rules you want to use. 6 | 7 | ```sh 8 | npm i -D @typescript-eslint/parser 9 | ``` 10 | 11 |
12 | 13 | ## Usage 14 | 15 | ### Flat config 16 | 17 | Read more about the new [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) 18 | 19 | ```js 20 | // eslint.config.js 21 | import eslintParserTypeScript from "@typescript-eslint/parser"; 22 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 23 | 24 | export default [ 25 | { 26 | files: ["**/*.{ts,tsx,cts,mts}"], 27 | languageOptions: { 28 | parser: eslintParserTypeScript, 29 | parserOptions: { 30 | project: true 31 | } 32 | } 33 | }, 34 | { 35 | plugins: { 36 | "better-tailwindcss": eslintPluginBetterTailwindcss 37 | }, 38 | rules: { 39 | // enable all recommended rules to report a warning 40 | ...eslintPluginBetterTailwindcss.configs["recommended-warn"].rules, 41 | // enable all recommended rules to report an error 42 | ...eslintPluginBetterTailwindcss.configs["recommended-error"].rules, 43 | 44 | // or configure rules individually 45 | "better-tailwindcss/multiline": ["warn", { printWidth: 100 }] 46 | } 47 | } 48 | ]; 49 | ``` 50 | 51 |
52 | 53 | ### Legacy config 54 | 55 | ```jsonc 56 | // .eslintrc.json 57 | { 58 | "extends": [ 59 | // enable all recommended rules to report a warning 60 | "plugin:better-tailwindcss/recommended-warn", 61 | // or enable all recommended rules to report an error 62 | "plugin:better-tailwindcss/recommended-error" 63 | ], 64 | "parser": "@typescript-eslint/parser", 65 | "plugins": ["better-tailwindcss"], 66 | "rules": { 67 | // or configure rules individually 68 | "better-tailwindcss/multiline": ["warn", { "printWidth": 100 }] 69 | } 70 | } 71 | ``` 72 | -------------------------------------------------------------------------------- /docs/parsers/vue.md: -------------------------------------------------------------------------------- 1 | # Vue 2 | 3 | To use ESLint with Vue files, first install the [vue-eslint-parser](https://github.com/vuejs/vue-eslint-parser). Then, configure ESLint to use this parser for Vue files. 4 | 5 | To enable eslint-plugin-better-tailwindcss, you need to add it to the plugins section of your eslint configuration and enable the rules you want to use. 6 | 7 | ```sh 8 | npm i -D vue-eslint-parser 9 | ``` 10 | 11 |
12 | 13 | ## Usage 14 | 15 | ### Flat config 16 | 17 | Read more about the new [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new) 18 | 19 | ```js 20 | // eslint.config.js 21 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 22 | import eslintParserVue from "vue-eslint-parser"; 23 | 24 | export default [ 25 | { 26 | files: ["**/*.vue"], 27 | languageOptions: { 28 | parser: eslintParserVue 29 | } 30 | }, 31 | { 32 | plugins: { 33 | "better-tailwindcss": eslintPluginBetterTailwindcss 34 | }, 35 | rules: { 36 | // enable all recommended rules to report a warning 37 | ...eslintPluginBetterTailwindcss.configs["recommended-warn"].rules, 38 | // enable all recommended rules to report an error 39 | ...eslintPluginBetterTailwindcss.configs["recommended-error"].rules, 40 | 41 | // or configure rules individually 42 | "better-tailwindcss/multiline": ["warn", { printWidth: 100 }] 43 | } 44 | } 45 | ]; 46 | ``` 47 | 48 |
49 | 50 | ### Legacy config 51 | 52 | ```jsonc 53 | // .eslintrc.json 54 | { 55 | "extends": [ 56 | // enable all recommended rules to report a warning 57 | "plugin:better-tailwindcss/recommended-warn", 58 | // enable all recommended rules to report an error 59 | "plugin:better-tailwindcss/recommended-error" 60 | ], 61 | "parser": "vue-eslint-parser", 62 | "plugins": ["better-tailwindcss"], 63 | "rules": { 64 | // or configure rules individually 65 | "better-tailwindcss/multiline": ["warn", { "printWidth": 100 }] 66 | } 67 | } 68 | ``` 69 | 70 |
71 | 72 | ### Editor configuration 73 | 74 | #### VSCode 75 | 76 | To enable the [VSCode ESLint plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) to validate Vue files, add the following to your `.vscode/settings.json`: 77 | 78 | ```jsonc 79 | { 80 | // enable ESLint to validate Vue files 81 | "eslint.validate": [/* ...other formats */, "vue"], 82 | 83 | // enable ESLint to fix tailwind classes on save 84 | "editor.codeActionsOnSave": { 85 | "source.fixAll.eslint": "explicit" 86 | } 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /docs/rules/enforce-consistent-variable-syntax.md: -------------------------------------------------------------------------------- 1 | # better-tailwindcss/enforce-consistent-variable-syntax 2 | 3 | Enforce consistent css variable syntax in tailwindcss class strings. 4 | 5 |
6 | 7 | ## Options 8 | 9 | ### `syntax` 10 | 11 | The syntax to enforce for css variables in tailwindcss class strings. 12 | 13 | **Type**: `"arbitrary"` | `"parentheses"` 14 | **Default**: `"parentheses"` 15 | 16 | ### `attributes` 17 | 18 | The name of the attribute that contains the tailwind classes. This can also be set globally via the [`settings` object](../settings/settings.md#attributes). 19 | 20 | **Type**: Array of [Matchers](../configuration/advanced.md) 21 | **Default**: [Name](../configuration/advanced.md#name-based-matching) for `"class"` and [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"class", "className"` 22 | 23 |
24 | 25 | ### `callees` 26 | 27 | List of function names which arguments should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#callees). 28 | 29 | **Type**: Array of [Matchers](../configuration/advanced.md) 30 | **Default**: [Matchers](../configuration/advanced.md#types-of-matchers) for `"cc", "clb", "clsx", "cn", "cnb", "ctl", "cva", "cx", "dcnb", "objstr", "tv", "twJoin", "twMerge"` 31 | 32 |
33 | 34 | ### `variables` 35 | 36 | List of variable names whose initializer should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#variables). 37 | 38 | **Type**: Array of [Matchers](../configuration/advanced.md) 39 | **Default**: [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"className", "classNames", "classes", "style", "styles"` 40 | 41 |
42 | 43 | ### `tags` 44 | 45 | List of template literal tag names whose content should get linted. This can also be set globally via the [`settings` object](../settings/settings.md#tags). 46 | 47 | **Type**: Array of [Matchers](../configuration/advanced.md) 48 | **Default**: None 49 | 50 | Note: When using the `tags` option, it is recommended to use the [strings Matcher](../configuration/advanced.md#types-of-matchers) for your tag names. This will ensure that nested expressions get linted correctly. 51 | 52 |
53 | 54 | ## Examples 55 | 56 | ```tsx 57 | // ❌ BAD: Incorrect css variable syntax with option `syntax: "parentheses"` 58 |
; 59 | ``` 60 | 61 | ```tsx 62 | // ✅ GOOD: With option `syntax: "parentheses"` 63 |
; 64 | ``` 65 | 66 | ```tsx 67 | // ❌ BAD: Incorrect css variable syntax with option `syntax: "arbitrary"` 68 |
; 69 | ``` 70 | 71 | ```tsx 72 | // ✅ GOOD: With option `syntax: "arbitrary"` 73 |
; 74 | ``` 75 | -------------------------------------------------------------------------------- /docs/rules/no-conflicting-classes.md: -------------------------------------------------------------------------------- 1 | # better-tailwindcss/no-conflicting-classes 2 | 3 | Disallow conflicting classes in tailwindcss class strings. Conflicting classes are classes that apply the same CSS property on the same element. This can cause unexpected behavior as it is not clear which class will take precedence. 4 | 5 |
6 | 7 | > [!NOTE] 8 | > This rule is similar to `cssConflict` from the [TailwindCSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) VSCode extension. It is recommended to disable `cssConflict` in your projects `.vscode/settings.json` to avoid confusion: 9 | > 10 | > ```jsonc 11 | > { 12 | > "tailwindCSS.lint.cssConflict": "ignore" 13 | > } 14 | > ``` 15 | 16 |
17 | 18 | ## Options 19 | 20 | ### `attributes` 21 | 22 | The name of the attribute that contains the tailwind classes. This can also be set globally via the [`settings` object](../settings/settings.md#attributes). 23 | 24 | **Type**: Array of [Matchers](../configuration/advanced.md) 25 | **Default**: [Name](../configuration/advanced.md#name-based-matching) for `"class"` and [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"class", "className"` 26 | 27 |
28 | 29 | ### `callees` 30 | 31 | List of function names which arguments should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#callees). 32 | 33 | **Type**: Array of [Matchers](../configuration/advanced.md) 34 | **Default**: [Matchers](../configuration/advanced.md#types-of-matchers) for `"cc", "clb", "clsx", "cn", "cnb", "ctl", "cva", "cx", "dcnb", "objstr", "tv", "twJoin", "twMerge"` 35 | 36 |
37 | 38 | ### `variables` 39 | 40 | List of variable names whose initializer should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#variables). 41 | 42 | **Type**: Array of [Matchers](../configuration/advanced.md) 43 | **Default**: [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"className", "classNames", "classes", "style", "styles"` 44 | 45 |
46 | 47 | ### `tags` 48 | 49 | List of template literal tag names whose content should get linted. This can also be set globally via the [`settings` object](../settings/settings.md#tags). 50 | 51 | **Type**: Array of [Matchers](../configuration/advanced.md) 52 | **Default**: None 53 | 54 | Note: When using the `tags` option, it is recommended to use the [strings Matcher](../configuration/advanced.md#types-of-matchers) for your tag names. This will ensure that nested expressions get linted correctly. 55 | 56 |
57 | 58 | ## Examples 59 | 60 | ```tsx 61 | // ❌ BAD: Conflicting class detected: flex -> (display: flex) applies the same css property as grid -> (display: grid) 62 |
; 63 | ``` 64 | 65 | ```tsx 66 | // ✅ GOOD: no conflicting classes 67 |
; 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/rules/no-duplicate-classes.md: -------------------------------------------------------------------------------- 1 | # better-tailwindcss/no-duplicate-classes 2 | 3 | Disallow duplicate classes in tailwindcss class strings. 4 | 5 |
6 | 7 | ## Options 8 | 9 | ### `attributes` 10 | 11 | The name of the attribute that contains the tailwind classes. This can also be set globally via the [`settings` object](../settings/settings.md#attributes). 12 | 13 | **Type**: Array of [Matchers](../configuration/advanced.md) 14 | **Default**: [Name](../configuration/advanced.md#name-based-matching) for `"class"` and [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"class", "className"` 15 | 16 |
17 | 18 | ### `callees` 19 | 20 | List of function names which arguments should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#callees). 21 | 22 | **Type**: Array of [Matchers](../configuration/advanced.md) 23 | **Default**: [Matchers](../configuration/advanced.md#types-of-matchers) for `"cc", "clb", "clsx", "cn", "cnb", "ctl", "cva", "cx", "dcnb", "objstr", "tv", "twJoin", "twMerge"` 24 | 25 |
26 | 27 | ### `variables` 28 | 29 | List of variable names whose initializer should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#variables). 30 | 31 | **Type**: Array of [Matchers](../configuration/advanced.md) 32 | **Default**: [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"className", "classNames", "classes", "style", "styles"` 33 | 34 |
35 | 36 | ### `tags` 37 | 38 | List of template literal tag names whose content should get linted. This can also be set globally via the [`settings` object](../settings/settings.md#tags). 39 | 40 | **Type**: Array of [Matchers](../configuration/advanced.md) 41 | **Default**: None 42 | 43 | Note: When using the `tags` option, it is recommended to use the [strings Matcher](../configuration/advanced.md#types-of-matchers) for your tag names. This will ensure that nested expressions get linted correctly. 44 | 45 |
46 | 47 | ## Examples 48 | 49 | ```tsx 50 | // ❌ BAD: duplicate classes 51 |
; 52 | ``` 53 | 54 | ```tsx 55 | // ✅ GOOD: no duplicate classes 56 |
; 57 | ``` 58 | 59 |
60 | 61 | > [!NOTE] 62 | > This rule is smart. It is able to detect duplicates across template literal boundaries. 63 | 64 | ```tsx 65 | // ❌ BAD: duplicate classes in conditional template literal classes and around template elements 66 |
; 71 | ``` 72 | 73 | ```tsx 74 | // ✅ GOOD: no duplicate classes 75 |
; 79 | ``` 80 | -------------------------------------------------------------------------------- /docs/rules/no-restricted-classes.md: -------------------------------------------------------------------------------- 1 | # better-tailwindcss/no-restricted-classes 2 | 3 | Disallow the usage of certain classes. This can be useful to disallow classes that are not recommended to be used in your project. For example, you can disallow the use of the child variants (`*:`) or the `!important` modifier (`!`) in your project. 4 | 5 |
6 | 7 | ## Options 8 | 9 | ### `restrict` 10 | 11 | The classes that should be disallowed. The entries in this list are treated as regular expressions. 12 | 13 | **Type**: `string[]` 14 | **Default**: `[]` 15 | 16 |
17 | 18 | ### `attributes` 19 | 20 | The name of the attribute that contains the tailwind classes. This can also be set globally via the [`settings` object](../settings/settings.md#attributes). 21 | 22 | **Type**: Array of [Matchers](../configuration/advanced.md) 23 | **Default**: [Name](../configuration/advanced.md#name-based-matching) for `"class"` and [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"class", "className"` 24 | 25 |
26 | 27 | ### `callees` 28 | 29 | List of function names which arguments should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#callees). 30 | 31 | **Type**: Array of [Matchers](../configuration/advanced.md) 32 | **Default**: [Matchers](../configuration/advanced.md#types-of-matchers) for `"cc", "clb", "clsx", "cn", "cnb", "ctl", "cva", "cx", "dcnb", "objstr", "tv", "twJoin", "twMerge"` 33 | 34 |
35 | 36 | ### `variables` 37 | 38 | List of variable names whose initializer should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#variables). 39 | 40 | **Type**: Array of [Matchers](../configuration/advanced.md) 41 | **Default**: [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"className", "classNames", "classes", "style", "styles"` 42 | 43 |
44 | 45 | ### `tags` 46 | 47 | List of template literal tag names whose content should get linted. This can also be set globally via the [`settings` object](../settings/settings.md#tags). 48 | 49 | **Type**: Array of [Matchers](../configuration/advanced.md) 50 | **Default**: None 51 | 52 | Note: When using the `tags` option, it is recommended to use the [strings Matcher](../configuration/advanced.md#types-of-matchers) for your tag names. This will ensure that nested expressions get linted correctly. 53 | 54 |
55 | 56 | ## Examples 57 | 58 | ```tsx 59 | // ❌ BAD: disallow the use of the child variants with option `{ restrict: ["^\\*+:.*"] }` 60 |
; 61 | // ~~~~~~ 62 | ``` 63 | 64 | ```tsx 65 | // ❌ BAD: disallow the use of the important modifier `{ restrict: ["^.*!$"] }` 66 |
; 67 | // ~~~~ 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/rules/no-unnecessary-whitespace.md: -------------------------------------------------------------------------------- 1 | # better-tailwindcss/no-unnecessary-whitespace 2 | 3 | Disallow unnecessary whitespace in between and around tailwind classes. 4 | 5 |
6 | 7 | ## Options 8 | 9 | ### `allowMultiline` 10 | 11 | Allow multi-line class declarations. 12 | If this option is disabled, template literal strings will be collapsed into a single line string wherever possible. Must be set to `true` when used in combination with [better-tailwindcss/multiline](./multiline.md). 13 | 14 | **Type**: `boolean` 15 | **Default**: `true` 16 | 17 |
18 | 19 | ### `attributes` 20 | 21 | The name of the attribute that contains the tailwind classes. This can also be set globally via the [`settings` object](../settings/settings.md#attributes). 22 | 23 | **Type**: Array of [Matchers](../configuration/advanced.md) 24 | **Default**: [Name](../configuration/advanced.md#name-based-matching) for `"class"` and [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"class", "className"` 25 | 26 |
27 | 28 | ### `callees` 29 | 30 | List of function names which arguments should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#callees). 31 | 32 | **Type**: Array of [Matchers](../configuration/advanced.md) 33 | **Default**: [Matchers](../configuration/advanced.md#types-of-matchers) for `"cc", "clb", "clsx", "cn", "cnb", "ctl", "cva", "cx", "dcnb", "objstr", "tv", "twJoin", "twMerge"` 34 | 35 |
36 | 37 | ### `variables` 38 | 39 | List of variable names whose initializer should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#variables). 40 | 41 | **Type**: Array of [Matchers](../configuration/advanced.md) 42 | **Default**: [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"className", "classNames", "classes", "style", "styles"` 43 | 44 |
45 | 46 | ### `tags` 47 | 48 | List of template literal tag names whose content should get linted. This can also be set globally via the [`settings` object](../settings/settings.md#tags). 49 | 50 | **Type**: Array of [Matchers](../configuration/advanced.md) 51 | **Default**: None 52 | 53 | Note: When using the `tags` option, it is recommended to use the [strings Matcher](../configuration/advanced.md#types-of-matchers) for your tag names. This will ensure that nested expressions get linted correctly. 54 | 55 |
56 | 57 | ## Examples 58 | 59 | ```tsx 60 | // ❌ BAD: random unnecessary whitespace 61 |
; 62 | ``` 63 | 64 | ```tsx 65 | // ✅ GOOD: only necessary whitespace is remaining 66 |
; 67 | ``` 68 | -------------------------------------------------------------------------------- /docs/rules/no-unregistered-classes.md: -------------------------------------------------------------------------------- 1 | # better-tailwindcss/no-unregistered-classes 2 | 3 | Disallow unregistered classes in tailwindcss class strings. Unregistered classes are classes that are not defined in your tailwind config file and therefore not recognized by tailwindcss. 4 | 5 |
6 | 7 | ## Options 8 | 9 | ### `ignore` 10 | 11 | List of classes that should not report an error. The entries in this list are treated as regular expressions. 12 | 13 | The rule works, by checking the output that a given class will produce. By default, the utilities `group` and `peer` are ignored, because they don't produce any css output. 14 | 15 | If you want to customize the ignore list, it is recommended to add the default options to the ignore override. You can use the function `getDefaultIgnoredUnregisteredClasses()` exported from `/api/defaults` to get the original ignore list. 16 | 17 | **Type**: `string[]` 18 | **Default**: `["^group(?:\\/(\\S*))?$", "^peer(?:\\/(\\S*))?$"]` 19 | 20 |
21 | 22 | ### `attributes` 23 | 24 | The name of the attribute that contains the tailwind classes. This can also be set globally via the [`settings` object](../settings/settings.md#attributes). 25 | 26 | **Type**: Array of [Matchers](../configuration/advanced.md) 27 | **Default**: [Name](../configuration/advanced.md#name-based-matching) for `"class"` and [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"class", "className"` 28 | 29 |
30 | 31 | ### `callees` 32 | 33 | List of function names which arguments should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#callees). 34 | 35 | **Type**: Array of [Matchers](../configuration/advanced.md) 36 | **Default**: [Matchers](../configuration/advanced.md#types-of-matchers) for `"cc", "clb", "clsx", "cn", "cnb", "ctl", "cva", "cx", "dcnb", "objstr", "tv", "twJoin", "twMerge"` 37 | 38 |
39 | 40 | ### `variables` 41 | 42 | List of variable names whose initializer should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#variables). 43 | 44 | **Type**: Array of [Matchers](../configuration/advanced.md) 45 | **Default**: [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"className", "classNames", "classes", "style", "styles"` 46 | 47 |
48 | 49 | ### `tags` 50 | 51 | List of template literal tag names whose content should get linted. This can also be set globally via the [`settings` object](../settings/settings.md#tags). 52 | 53 | **Type**: Array of [Matchers](../configuration/advanced.md) 54 | **Default**: None 55 | 56 | Note: When using the `tags` option, it is recommended to use the [strings Matcher](../configuration/advanced.md#types-of-matchers) for your tag names. This will ensure that nested expressions get linted correctly. 57 | 58 |
59 | 60 | ## Examples 61 | 62 | ```tsx 63 | // ❌ BAD: unregistered class 64 |
; 65 | ``` 66 | 67 | ```tsx 68 | // ✅ GOOD: only valid tailwindcss classes 69 |
; 70 | ``` 71 | -------------------------------------------------------------------------------- /docs/rules/sort-classes.md: -------------------------------------------------------------------------------- 1 | # better-tailwindcss/sort-classes 2 | 3 | Enforce the order of tailwind classes. It is possible to sort classes alphabetically or logically. 4 | 5 |
6 | 7 | > [!NOTE] 8 | > In order to sort classes logically, the plugin needs to know the order of the tailwind classes. 9 | > This is done by providing the [`configPath`](#tailwindconfig) to the tailwind config file or the [`entryPoint`](#entrypoint) of the css based tailwind config. 10 | 11 | ## Options 12 | 13 | ### `order` 14 | 15 | - `asc`: Sort classes alphabetically in ascending order. 16 | - `desc`: Sort classes alphabetically in descending order. 17 | - `official`: Sort classes according to the official sorting order from tailwindcss. 18 | - `improved`: Same as `official` but sorts variants more strictly. 19 | 20 | **Type**: `"asc" | "desc" | "official" | "improved"` 21 | **Default**: `"improved"` 22 | 23 |
24 | 25 | ### `entryPoint` 26 | 27 | The path to the entry file of the css based tailwind config (eg: `src/global.css`). This can also be set globally via the [`settings` object](../settings/settings.md#entrypoint). 28 | If not specified, the plugin will fall back to the default configuration. 29 | The tailwind config is used to determine the sorting order. 30 | 31 | **Type**: `string` 32 | **Default**: `undefined` 33 | 34 |
35 | 36 | ### `tailwindConfig` 37 | 38 | The path to the `tailwind.config.js` file. If not specified, the plugin will try to find it automatically or falls back to the default configuration. 39 | This can also be set globally via the [`settings` object](../settings/settings.md#tailwindConfig). 40 | 41 | The tailwind config is used to determine the sorting order. 42 | 43 | For tailwindcss v4 and the css based config, use the [`entryPoint`](#entrypoint) option instead. 44 | 45 | **Type**: `string` 46 | **Default**: `undefined` 47 | 48 |
49 | 50 | ### `attributes` 51 | 52 | The name of the attribute that contains the tailwind classes. This can also be set globally via the [`settings` object](../settings/settings.md#attributes). 53 | 54 | **Type**: Array of [Matchers](../configuration/advanced.md) 55 | **Default**: [Name](../configuration/advanced.md#name-based-matching) for `"class"` and [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"class", "className"` 56 | 57 |
58 | 59 | ### `callees` 60 | 61 | List of function names which arguments should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#callees). 62 | 63 | **Type**: Array of [Matchers](../configuration/advanced.md) 64 | **Default**: [Matchers](../configuration/advanced.md#types-of-matchers) for `"cc", "clb", "clsx", "cn", "cnb", "ctl", "cva", "cx", "dcnb", "objstr", "tv", "twJoin", "twMerge"` 65 | 66 |
67 | 68 | ### `variables` 69 | 70 | List of variable names whose initializer should also get linted. This can also be set globally via the [`settings` object](../settings/settings.md#variables). 71 | 72 | **Type**: Array of [Matchers](../configuration/advanced.md) 73 | **Default**: [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"className", "classNames", "classes", "style", "styles"` 74 | 75 |
76 | 77 | ### `tags` 78 | 79 | List of template literal tag names whose content should get linted. This can also be set globally via the [`settings` object](../settings/settings.md#tags). 80 | 81 | **Type**: Array of [Matchers](../configuration/advanced.md) 82 | **Default**: None 83 | 84 | Note: When using the `tags` option, it is recommended to use the [strings Matcher](../configuration/advanced.md#types-of-matchers) for your tag names. This will ensure that nested expressions get linted correctly. 85 | 86 |
87 | 88 | ## Examples 89 | 90 | ```tsx 91 | // ❌ BAD: all classes are in random order 92 |
; 93 | ``` 94 | 95 | ```tsx 96 | // ✅ GOOD: with option { order: 'asc' } 97 |
; 98 | ``` 99 | 100 | ```tsx 101 | // ✅ GOOD: with option { order: 'desc' } 102 |
; 103 | ``` 104 | 105 | ```tsx 106 | // ✅ GOOD: with option { order: 'official' } 107 |
; 108 | ``` 109 | -------------------------------------------------------------------------------- /docs/settings/settings.md: -------------------------------------------------------------------------------- 1 | # Settings 2 | 3 | ## Table of Contents 4 | 5 | - [entryPoint](#entrypoint) 6 | - [tailwindConfig](#tailwindconfig) 7 | - [attributes](#attributes) 8 | - [callees](#callees) 9 | - [variables](#variables) 10 | - [tags](#tags) 11 | 12 |
13 |
14 | 15 | The settings object can be used to globally configure shared options across all rules. Global options will always be overridden by rule-specific options. 16 | To set the settings object, add a `settings` key to the eslint config. 17 | 18 |
19 |
20 | 21 | ```jsonc 22 | { 23 | "plugins": { /* ... */ }, 24 | "rules": { /* ... */ }, 25 | "settings": { 26 | "better-tailwindcss": { 27 | "entryPoint": "...", 28 | "tailwindConfig": "...", 29 | "attributes": [/* ... */], 30 | "callees": [/* ... */], 31 | "variables": [/* ... */], 32 | "tags": [/* ... */] 33 | } 34 | } 35 | } 36 | ``` 37 | 38 |
39 |
40 | 41 | ### `entryPoint` 42 | 43 | The path to the entry file of the css based tailwind config (eg: `src/global.css`). If not specified, the plugin will fall back to the default configuration. 44 | The tailwind config is used to determine the sorting order. 45 | 46 | **Type**: `string` 47 | 48 |
49 | 50 | ### `tailwindConfig` 51 | 52 | The path to the `tailwind.config.js` file. If not specified, the plugin will try to find it automatically or falls back to the default configuration. 53 | The tailwind config is used to determine the sorting order. 54 | 55 | For tailwindcss v4 and the css based config, use the [`entryPoint`](#entrypoint) option instead. 56 | 57 | **Type**: `string` 58 | 59 |
60 | 61 | ### `attributes` 62 | 63 | The name of the attribute that contains the tailwind classes. 64 | 65 | **Type**: Array of [Matchers](../configuration/advanced.md) 66 | **Default**: [Name](../configuration/advanced.md#name-based-matching) for `"class"` and [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"class", "className"` 67 | 68 |
69 | 70 | ### `callees` 71 | 72 | List of function names which arguments should also get linted. 73 | 74 | **Type**: Array of [Matchers](../configuration/advanced.md) 75 | **Default**: [Matchers](../configuration/advanced.md#types-of-matchers) for `"cc", "clb", "clsx", "cn", "cnb", "ctl", "cva", "cx", "dcnb", "objstr", "tv", "twJoin", "twMerge"` 76 | 77 |
78 | 79 | ### `variables` 80 | 81 | List of variable names whose initializer should also get linted. 82 | 83 | **Type**: Array of [Matchers](../configuration/advanced.md) 84 | **Default**: [strings Matcher](../configuration/advanced.md#types-of-matchers) for `"className", "classNames", "classes", "style", "styles"` 85 | 86 |
87 | 88 | ### `tags` 89 | 90 | List of template literal tag names whose content should get linted. 91 | 92 | **Type**: Array of [Matchers](../configuration/advanced.md) 93 | **Default**: None 94 | 95 | Note: When using the `tags` option, it is recommended to use the [strings Matcher](../configuration/advanced.md#types-of-matchers) for your tag names. This will ensure that nested expressions get linted correctly. 96 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import config from "@schoero/configs/eslint"; 2 | 3 | 4 | export default [ 5 | ...config, 6 | { 7 | files: ["**/*.test.{js,jsx,cjs,mjs,ts,tsx}", "**/*.test-d.{ts,tsx}"], 8 | rules: { 9 | "eslint-plugin-stylistic/quotes": ["warn", "double", { allowTemplateLiterals: true, avoidEscape: true }], 10 | "eslint-plugin-typescript/no-unnecessary-condition": "off", 11 | "eslint-plugin-typescript/no-useless-template-literals": "off", 12 | "eslint-plugin-vitest/expect-expect": "off" 13 | } 14 | }, 15 | { 16 | files: ["**/*.test.ts"], 17 | rules: { 18 | "eslint-plugin-perfectionist/sort-objects": [ 19 | "warn", 20 | { 21 | customGroups: [ 22 | { 23 | elementNamePattern: "^(astro|angular|jsx|svelte|vue|html)(Output)?$", 24 | groupName: "markup", 25 | selector: "property" 26 | } 27 | ], 28 | groups: ["markup", { newlinesBetween: "always" }, "unknown"], 29 | ignoreCase: true, 30 | partitionByComment: false, 31 | type: "alphabetical" 32 | } 33 | ], 34 | "eslint-plugin-typescript/naming-convention": "off", 35 | "eslint-plugin-typescript/no-floating-promises": "off" 36 | 37 | } 38 | }, 39 | { 40 | files: ["tests/utils/lint.ts"], 41 | rules: { 42 | "eslint-plugin-typescript/naming-convention": "off" 43 | } 44 | } 45 | ]; 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3.1.0", 3 | "type": "module", 4 | "name": "eslint-plugin-better-tailwindcss", 5 | "description": "auto-wraps tailwind classes after a certain print width or class count into multiple lines to improve readability.", 6 | "license": "MIT", 7 | "author": "Roger Schönbächler", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/schoero/eslint-plugin-better-tailwindcss.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/schoero/eslint-plugin-better-tailwindcss/issues" 14 | }, 15 | "exports": { 16 | ".": { 17 | "require": "./lib/cjs/configs/cjs.js", 18 | "import": "./lib/esm/configs/esm.js" 19 | }, 20 | "./api/defaults": { 21 | "require": "./lib/cjs/api/defaults.js", 22 | "import": "./lib/esm/api/defaults.js" 23 | }, 24 | "./api/types": { 25 | "require": "./lib/cjs/api/types.js", 26 | "import": "./lib/esm/api/types.js" 27 | } 28 | }, 29 | "main": "./lib/cjs/configs/cjs.js", 30 | "scripts": { 31 | "build": "vite-node build", 32 | "build:ci": "vite-node build", 33 | "eslint": "eslint .", 34 | "eslint:ci": "npm run eslint -- --max-warnings 0", 35 | "eslint:fix": "npm run eslint -- --fix", 36 | "install:v3": "npm i tailwindcss@^3 --no-save", 37 | "install:v4": "npm i tailwindcss@^4 --no-save", 38 | "lint": "npm run eslint && npm run markdownlint", 39 | "lint:ci": "npm run eslint:ci && npm run markdownlint:ci", 40 | "lint:fix": "npm run eslint:fix && npm run markdownlint:fix", 41 | "markdownlint": "markdownlint-cli2 '**/*.md' '#**/node_modules'", 42 | "markdownlint:ci": "npm run markdownlint", 43 | "markdownlint:fix": "npm run markdownlint -- --fix", 44 | "postrelease:alpha": "eslint --fix package.json && markdownlint-cli2 --fix 'CHANGELOG.md'", 45 | "postrelease:beta": "eslint --fix package.json && markdownlint-cli2 --fix 'CHANGELOG.md'", 46 | "postrelease:latest": "eslint --fix package.json && markdownlint-cli2 --fix 'CHANGELOG.md'", 47 | "prebuild": "npm run typecheck && npm run lint && npm run spellcheck", 48 | "prerelease:alpha": "npm run test -- --run && npm run build", 49 | "prerelease:beta": "npm run test -- --run && npm run build", 50 | "prerelease:latest": "npm run test -- --run && npm run build", 51 | "pretest:v3": "npm run install:v3", 52 | "pretest:v4": "npm run install:v4", 53 | "publish:alpha": "changelogen gh release && changelogen --publish --publishTag alpha", 54 | "publish:beta": "changelogen gh release && changelogen --publish --publishTag beta", 55 | "publish:latest": "changelogen gh release && changelogen --publish", 56 | "release:alpha": "changelogen --bump --output --prerelease alpha", 57 | "release:beta": "changelogen --bump --output --prerelease beta", 58 | "release:latest": "changelogen --bump --output --no-tag", 59 | "spellcheck": "cspell lint", 60 | "spellcheck:ci": "npm run spellcheck -- --no-progress", 61 | "test": "vitest -c ./vite.config.ts --exclude tests/e2e", 62 | "test:all": "npm run test:v3 && npm run test:v4", 63 | "test:e2e": "vitest -c ./vite.config.ts tests/e2e --run", 64 | "test:v3": "npm run test -- --run", 65 | "test:v4": "npm run test -- --run", 66 | "typecheck": "tsc --noEmit" 67 | }, 68 | "engines": { 69 | "node": ">=v20.11.0" 70 | }, 71 | "files": [ 72 | "lib" 73 | ], 74 | "peerDependencies": { 75 | "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0", 76 | "tailwindcss": "^3.3.0 || ^4.0.0" 77 | }, 78 | "dependencies": { 79 | "enhanced-resolve": "^5.18.1", 80 | "jiti": "^2.4.2", 81 | "postcss": "^8.5.4", 82 | "postcss-import": "^16.1.0", 83 | "synckit": "0.9.2" 84 | }, 85 | "devDependencies": { 86 | "@angular/compiler": "^20.0.0", 87 | "@angular-eslint/template-parser": "^19.7.0", 88 | "@html-eslint/parser": "^0.41.0", 89 | "@schoero/configs": "^1.4.2", 90 | "@types/estree-jsx": "^1.0.5", 91 | "@types/node": "^22.15.29", 92 | "@typescript-eslint/parser": "^8.33.1", 93 | "astro-eslint-parser": "^1.2.2", 94 | "changelogen": "^0.6.1", 95 | "cspell": "^9.0.2", 96 | "es-html-parser": "^0.2.0", 97 | "eslint": "^9.28.0", 98 | "eslint-plugin-better-tailwindcss": "file:./", 99 | "eslint-plugin-eslint-plugin": "^6.4.0", 100 | "glob": "^11.0.2", 101 | "json-schema": "^0.4.0", 102 | "markdownlint": "^0.38.0", 103 | "proper-tags": "^2.0.2", 104 | "svelte": "^5.33.13", 105 | "svelte-eslint-parser": "^1.2.0", 106 | "tailwindcss": "^4.1.8", 107 | "tailwindcss3": "npm:tailwindcss@^3.0.0", 108 | "tailwindcss4": "npm:tailwindcss@^4.0.0", 109 | "ts-node": "^10.9.2", 110 | "tsc-alias": "^1.8.16", 111 | "tsx": "^4.19.4", 112 | "typescript": "^5.8.3", 113 | "vite-node": "^3.2.0", 114 | "vitest": "^3.2.0", 115 | "vue-eslint-parser": "^10.1.3" 116 | }, 117 | "keywords": [ 118 | "eslint", 119 | "eslint-plugin", 120 | "tailwind", 121 | "readable", 122 | "horizontal", 123 | "scrolling", 124 | "multiline", 125 | "multi", 126 | "newline", 127 | "line", 128 | "break", 129 | "linebreak", 130 | "wrap", 131 | "template", 132 | "literal", 133 | "jsx", 134 | "html", 135 | "svelte", 136 | "vue", 137 | "react", 138 | "qwik", 139 | "solid", 140 | "template-literal", 141 | "template-literals", 142 | "tailwindcss", 143 | "tailwind-css", 144 | "tailwind-classes" 145 | ], 146 | "volta": { 147 | "node": "24.0.0" 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/api/defaults.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_ATTRIBUTE_NAMES, 3 | DEFAULT_CALLEE_NAMES, 4 | DEFAULT_TAG_NAMES, 5 | DEFAULT_VARIABLE_NAMES 6 | } from "better-tailwindcss:options/default-options.js"; 7 | import { DEFAULT_IGNORED_UNREGISTERED_CLASSES } from "better-tailwindcss:rules/no-unregistered-classes.js"; 8 | 9 | 10 | export function getDefaultCallees() { 11 | return DEFAULT_CALLEE_NAMES; 12 | } 13 | 14 | export function getDefaultAttributes() { 15 | return DEFAULT_ATTRIBUTE_NAMES; 16 | } 17 | 18 | export function getDefaultVariables() { 19 | return DEFAULT_VARIABLE_NAMES; 20 | } 21 | 22 | export function getDefaultTags() { 23 | return DEFAULT_TAG_NAMES; 24 | } 25 | 26 | export function getDefaultIgnoredUnregisteredClasses() { 27 | return DEFAULT_IGNORED_UNREGISTERED_CLASSES; 28 | } 29 | -------------------------------------------------------------------------------- /src/api/types.ts: -------------------------------------------------------------------------------- 1 | export * from "better-tailwindcss:types/rule.js"; 2 | -------------------------------------------------------------------------------- /src/configs/cjs.ts: -------------------------------------------------------------------------------- 1 | import { config } from "better-tailwindcss:configs/config.js"; 2 | 3 | 4 | export = config; 5 | -------------------------------------------------------------------------------- /src/configs/config.ts: -------------------------------------------------------------------------------- 1 | import { enforceConsistentVariableSyntax } from "better-tailwindcss:rules/enforce-consistent-variable-syntax.js"; 2 | import { multiline } from "better-tailwindcss:rules/multiline.js"; 3 | import { noConflictingClasses } from "better-tailwindcss:rules/no-conflicting-classes.js"; 4 | import { noDuplicateClasses } from "better-tailwindcss:rules/no-duplicate-classes.js"; 5 | import { noRestrictedClasses } from "better-tailwindcss:rules/no-restricted-classes.js"; 6 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 7 | import { noUnregisteredClasses } from "better-tailwindcss:rules/no-unregistered-classes.js"; 8 | import { sortClasses } from "better-tailwindcss:rules/sort-classes.js"; 9 | 10 | import type { ESLint } from "eslint"; 11 | 12 | 13 | const plugin = { 14 | meta: { 15 | name: "better-tailwindcss" 16 | }, 17 | rules: { 18 | [enforceConsistentVariableSyntax.name]: enforceConsistentVariableSyntax.rule, 19 | [multiline.name]: multiline.rule, 20 | [noConflictingClasses.name]: noConflictingClasses.rule, 21 | [noDuplicateClasses.name]: noDuplicateClasses.rule, 22 | [noRestrictedClasses.name]: noRestrictedClasses.rule, 23 | [noUnnecessaryWhitespace.name]: noUnnecessaryWhitespace.rule, 24 | [noUnregisteredClasses.name]: noUnregisteredClasses.rule, 25 | [sortClasses.name]: sortClasses.rule 26 | } 27 | } satisfies ESLint.Plugin; 28 | 29 | const plugins = [plugin.meta.name]; 30 | 31 | 32 | const getStylisticRules = (severity: "error" | "warn" = "warn") => { 33 | return { 34 | [`${plugin.meta.name}/${multiline.name}`]: severity, 35 | [`${plugin.meta.name}/${noDuplicateClasses.name}`]: severity, 36 | [`${plugin.meta.name}/${noUnnecessaryWhitespace.name}`]: severity, 37 | [`${plugin.meta.name}/${sortClasses.name}`]: severity 38 | }; 39 | }; 40 | 41 | const getCorrectnessRules = (severity: "error" | "warn" = "error") => { 42 | return { 43 | [`${plugin.meta.name}/${noConflictingClasses.name}`]: severity, 44 | [`${plugin.meta.name}/${noUnregisteredClasses.name}`]: severity 45 | }; 46 | }; 47 | 48 | 49 | const createConfig = ( 50 | name: string, 51 | getRulesFunction: (severity?: "error" | "warn") => { 52 | [x: string]: "error" | "warn"; 53 | } 54 | ) => { 55 | return { 56 | [`${name}-error`]: { 57 | plugins, 58 | rules: getRulesFunction("error") 59 | }, 60 | [`${name}-warn`]: { 61 | plugins, 62 | rules: getRulesFunction("warn") 63 | }, 64 | [name]: { 65 | plugins, 66 | rules: getRulesFunction() 67 | } 68 | }; 69 | }; 70 | 71 | export const config = { 72 | ...plugin, 73 | 74 | configs: { 75 | ...createConfig("stylistic", getStylisticRules), 76 | ...createConfig("correctness", getCorrectnessRules), 77 | ...createConfig("recommended", severity => ({ 78 | ...getStylisticRules(severity), 79 | ...getCorrectnessRules(severity) 80 | })) 81 | } 82 | } satisfies ESLint.Plugin; 83 | -------------------------------------------------------------------------------- /src/configs/esm.ts: -------------------------------------------------------------------------------- 1 | export { config as default } from "better-tailwindcss:configs/config.js"; 2 | -------------------------------------------------------------------------------- /src/options/callees/cc.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { CC_OBJECT_KEYS, CC_STRINGS } from "better-tailwindcss:options/callees/cc.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("cc", () => { 9 | 10 | it("should lint strings and strings in arrays", () => { 11 | 12 | const dirty = `cc(" lint ", [" lint ", " lint "])`; 13 | const clean = `cc("lint", ["lint", "lint"])`; 14 | 15 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 16 | invalid: [ 17 | { 18 | jsx: dirty, 19 | jsxOutput: clean, 20 | svelte: ``, 21 | svelteOutput: ``, 22 | vue: ``, 23 | vueOutput: ``, 24 | 25 | errors: 3, 26 | options: [{ callees: [CC_STRINGS] }] 27 | } 28 | ] 29 | }); 30 | 31 | }); 32 | 33 | it("should lint object keys", () => { 34 | 35 | const dirty = ` 36 | cc(" ignore ", { 37 | " lint ": { " lint ": " ignore " }, 38 | } 39 | ) 40 | `; 41 | const clean = ` 42 | cc(" ignore ", { 43 | "lint": { "lint": " ignore " }, 44 | } 45 | ) 46 | `; 47 | 48 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 49 | invalid: [ 50 | { 51 | jsx: dirty, 52 | jsxOutput: clean, 53 | svelte: ``, 54 | svelteOutput: ``, 55 | vue: ``, 56 | vueOutput: ``, 57 | 58 | errors: 2, 59 | options: [{ callees: [CC_OBJECT_KEYS] }] 60 | } 61 | ] 62 | }); 63 | }); 64 | 65 | }); 66 | -------------------------------------------------------------------------------- /src/options/callees/cc.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const CC_STRINGS = [ 7 | "cc", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | export const CC_OBJECT_KEYS = [ 16 | "cc", 17 | [ 18 | { 19 | match: MatcherType.ObjectKey 20 | } 21 | ] 22 | ] satisfies CalleeMatchers; 23 | 24 | /** @see https://github.com/jorgebucaran/classcat */ 25 | export const CC = [ 26 | CC_STRINGS, 27 | CC_OBJECT_KEYS 28 | ] satisfies Callees; 29 | -------------------------------------------------------------------------------- /src/options/callees/clb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { 4 | CLB_BASE_VALUES, 5 | CLB_COMPOUND_VARIANTS_CLASSES, 6 | CLB_VARIANT_VALUES 7 | } from "better-tailwindcss:options/callees/clb.js"; 8 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 9 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 10 | 11 | 12 | describe("clb", () => { 13 | 14 | it("should lint object values inside the `base` property", () => { 15 | 16 | const dirty = ` 17 | clb({ 18 | base: " lint ", 19 | " ignore ": " ignore " 20 | } 21 | ) 22 | `; 23 | const clean = ` 24 | clb({ 25 | base: "lint", 26 | " ignore ": " ignore " 27 | } 28 | ) 29 | `; 30 | 31 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 32 | invalid: [ 33 | { 34 | jsx: dirty, 35 | jsxOutput: clean, 36 | svelte: ``, 37 | svelteOutput: ``, 38 | vue: ``, 39 | vueOutput: ``, 40 | 41 | errors: 1, 42 | options: [{ callees: [CLB_BASE_VALUES] }] 43 | } 44 | ] 45 | }); 46 | 47 | }); 48 | 49 | it("should lint object values inside the `variants` property", () => { 50 | 51 | const dirty = ` 52 | clb({ 53 | variants: { " ignore ": " lint " }, 54 | compoundVariants: { " ignore ": " ignore " } 55 | } 56 | ) 57 | `; 58 | const clean = ` 59 | clb({ 60 | variants: { " ignore ": "lint" }, 61 | compoundVariants: { " ignore ": " ignore " } 62 | } 63 | ) 64 | `; 65 | 66 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 67 | invalid: [ 68 | { 69 | jsx: dirty, 70 | jsxOutput: clean, 71 | svelte: ``, 72 | svelteOutput: ``, 73 | vue: ``, 74 | vueOutput: ``, 75 | 76 | errors: 1, 77 | options: [{ callees: [CLB_VARIANT_VALUES] }] 78 | } 79 | ] 80 | }); 81 | 82 | }); 83 | 84 | it("should lint only object values inside the `compoundVariants.classes` property", () => { 85 | 86 | const dirty = ` 87 | clb({ 88 | variants: { " ignore ": " ignore " }, 89 | compoundVariants: [{ 90 | " ignore ": " ignore ", 91 | "classes": " lint " 92 | }] 93 | } 94 | ) 95 | `; 96 | const clean = ` 97 | clb({ 98 | variants: { " ignore ": " ignore " }, 99 | compoundVariants: [{ 100 | " ignore ": " ignore ", 101 | "classes": "lint" 102 | }] 103 | } 104 | ) 105 | `; 106 | 107 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 108 | invalid: [ 109 | { 110 | jsx: dirty, 111 | jsxOutput: clean, 112 | svelte: ``, 113 | svelteOutput: ``, 114 | vue: ``, 115 | vueOutput: ``, 116 | 117 | errors: 1, 118 | options: [{ callees: [CLB_COMPOUND_VARIANTS_CLASSES] }] 119 | } 120 | ] 121 | }); 122 | 123 | }); 124 | 125 | it("should lint all `clb` variations in combination by default", () => { 126 | const dirty = ` 127 | clb({ 128 | base: " lint ", 129 | compoundVariants: [ 130 | { 131 | " ignore ": " ignore ", 132 | "classes": " lint " 133 | } 134 | ], 135 | defaultVariants: { 136 | " ignore ": " ignore " 137 | }, 138 | variants: { 139 | " ignore ": { 140 | " ignore array ": [ 141 | " lint ", 142 | " lint " 143 | ], 144 | " ignore string ": " lint " 145 | } 146 | } 147 | }); 148 | `; 149 | 150 | const clean = ` 151 | clb({ 152 | base: "lint", 153 | compoundVariants: [ 154 | { 155 | " ignore ": " ignore ", 156 | "classes": "lint" 157 | } 158 | ], 159 | defaultVariants: { 160 | " ignore ": " ignore " 161 | }, 162 | variants: { 163 | " ignore ": { 164 | " ignore array ": [ 165 | "lint", 166 | "lint" 167 | ], 168 | " ignore string ": "lint" 169 | } 170 | } 171 | }); 172 | `; 173 | 174 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 175 | invalid: [ 176 | { 177 | jsx: dirty, 178 | jsxOutput: clean, 179 | svelte: ``, 180 | svelteOutput: ``, 181 | vue: ``, 182 | vueOutput: ``, 183 | 184 | errors: 5 185 | } 186 | ] 187 | }); 188 | 189 | }); 190 | 191 | }); 192 | -------------------------------------------------------------------------------- /src/options/callees/clb.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const CLB_BASE_VALUES = [ 7 | "clb", 8 | [ 9 | { 10 | match: MatcherType.ObjectValue, 11 | pathPattern: "^base$" 12 | } 13 | ] 14 | ] satisfies CalleeMatchers; 15 | 16 | export const CLB_VARIANT_VALUES = [ 17 | "clb", 18 | [ 19 | { 20 | match: MatcherType.ObjectValue, 21 | pathPattern: "^variants.*$" 22 | } 23 | ] 24 | ] satisfies CalleeMatchers; 25 | 26 | export const CLB_COMPOUND_VARIANTS_CLASSES = [ 27 | "clb", 28 | [ 29 | { 30 | match: MatcherType.ObjectValue, 31 | pathPattern: "^compoundVariants\\[\\d+\\]\\.classes$" 32 | } 33 | ] 34 | ] satisfies CalleeMatchers; 35 | 36 | /** @see https://github.com/crswll/clb */ 37 | export const CLB = [ 38 | CLB_BASE_VALUES, 39 | CLB_VARIANT_VALUES, 40 | CLB_COMPOUND_VARIANTS_CLASSES 41 | // TODO: add object key matcher: classes 42 | ] satisfies Callees; 43 | -------------------------------------------------------------------------------- /src/options/callees/clsx.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { CLSX_OBJECT_KEYS, CLSX_STRINGS } from "better-tailwindcss:options/callees/clsx.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("clsx", () => { 9 | 10 | it("should lint strings and strings in arrays", () => { 11 | 12 | const dirty = `clsx(" lint ", [" lint ", " lint "])`; 13 | const clean = `clsx("lint", ["lint", "lint"])`; 14 | 15 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 16 | invalid: [ 17 | { 18 | jsx: dirty, 19 | jsxOutput: clean, 20 | svelte: ``, 21 | svelteOutput: ``, 22 | vue: ``, 23 | vueOutput: ``, 24 | 25 | errors: 3, 26 | options: [{ callees: [CLSX_STRINGS] }] 27 | } 28 | ] 29 | }); 30 | 31 | }); 32 | 33 | it("should lint object keys", () => { 34 | 35 | const dirty = ` 36 | clsx(" ignore ", { 37 | " lint ": { " lint ": " ignore " }, 38 | } 39 | ) 40 | `; 41 | const clean = ` 42 | clsx(" ignore ", { 43 | "lint": { "lint": " ignore " }, 44 | } 45 | ) 46 | `; 47 | 48 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 49 | invalid: [ 50 | { 51 | jsx: dirty, 52 | jsxOutput: clean, 53 | svelte: ``, 54 | svelteOutput: ``, 55 | vue: ``, 56 | vueOutput: ``, 57 | 58 | errors: 2, 59 | options: [{ callees: [CLSX_OBJECT_KEYS] }] 60 | } 61 | ] 62 | }); 63 | }); 64 | 65 | }); 66 | -------------------------------------------------------------------------------- /src/options/callees/clsx.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const CLSX_STRINGS = [ 7 | "clsx", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | export const CLSX_OBJECT_KEYS = [ 16 | "clsx", 17 | [ 18 | { 19 | match: MatcherType.ObjectKey 20 | } 21 | ] 22 | ] satisfies CalleeMatchers; 23 | 24 | /** @see https://github.com/lukeed/clsx */ 25 | export const CLSX = [ 26 | CLSX_STRINGS, 27 | CLSX_OBJECT_KEYS 28 | ] satisfies Callees; 29 | -------------------------------------------------------------------------------- /src/options/callees/cn.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { CN_OBJECT_KEYS, CN_STRINGS } from "better-tailwindcss:options/callees/cn.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("cn", () => { 9 | 10 | it("should lint strings and strings in arrays", () => { 11 | 12 | const dirty = `cn(" lint ", [" lint ", " lint "])`; 13 | const clean = `cn("lint", ["lint", "lint"])`; 14 | 15 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 16 | invalid: [ 17 | { 18 | jsx: dirty, 19 | jsxOutput: clean, 20 | svelte: ``, 21 | svelteOutput: ``, 22 | vue: ``, 23 | vueOutput: ``, 24 | 25 | errors: 3, 26 | options: [{ callees: [CN_STRINGS] }] 27 | } 28 | ] 29 | }); 30 | 31 | }); 32 | 33 | it("should lint object keys", () => { 34 | 35 | const dirty = ` 36 | cn(" ignore ", { 37 | " lint ": { " lint ": " ignore " }, 38 | } 39 | ) 40 | `; 41 | const clean = ` 42 | cn(" ignore ", { 43 | "lint": { "lint": " ignore " }, 44 | } 45 | ) 46 | `; 47 | 48 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 49 | invalid: [ 50 | { 51 | jsx: dirty, 52 | jsxOutput: clean, 53 | svelte: ``, 54 | svelteOutput: ``, 55 | vue: ``, 56 | vueOutput: ``, 57 | 58 | errors: 2, 59 | options: [{ callees: [CN_OBJECT_KEYS] }] 60 | } 61 | ] 62 | }); 63 | }); 64 | 65 | }); 66 | -------------------------------------------------------------------------------- /src/options/callees/cn.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const CN_STRINGS = [ 7 | "cn", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | export const CN_OBJECT_KEYS = [ 16 | "cn", 17 | [ 18 | { 19 | match: MatcherType.ObjectKey 20 | } 21 | ] 22 | ] satisfies CalleeMatchers; 23 | 24 | /** @see https://ui.shadcn.com/docs/installation/manual */ 25 | export const CN = [ 26 | CN_STRINGS, 27 | CN_OBJECT_KEYS 28 | ] satisfies Callees; 29 | -------------------------------------------------------------------------------- /src/options/callees/cnb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { CNB_OBJECT_KEYS, CNB_STRINGS } from "better-tailwindcss:options/callees/cnb.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("cnb", () => { 9 | 10 | it("should lint strings and strings in arrays", () => { 11 | 12 | const dirty = `cnb(" lint ", [" lint ", " lint "])`; 13 | const clean = `cnb("lint", ["lint", "lint"])`; 14 | 15 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 16 | invalid: [ 17 | { 18 | jsx: dirty, 19 | jsxOutput: clean, 20 | svelte: ``, 21 | svelteOutput: ``, 22 | vue: ``, 23 | vueOutput: ``, 24 | 25 | errors: 3, 26 | options: [{ callees: [CNB_STRINGS] }] 27 | } 28 | ] 29 | }); 30 | 31 | }); 32 | 33 | it("should lint object keys", () => { 34 | 35 | const dirty = ` 36 | cnb(" ignore ", { 37 | " lint ": { " lint ": " ignore " }, 38 | } 39 | ) 40 | `; 41 | const clean = ` 42 | cnb(" ignore ", { 43 | "lint": { "lint": " ignore " }, 44 | } 45 | ) 46 | `; 47 | 48 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 49 | invalid: [ 50 | { 51 | jsx: dirty, 52 | jsxOutput: clean, 53 | svelte: ``, 54 | svelteOutput: ``, 55 | vue: ``, 56 | vueOutput: ``, 57 | 58 | errors: 2, 59 | options: [{ callees: [CNB_OBJECT_KEYS] }] 60 | } 61 | ] 62 | }); 63 | }); 64 | 65 | }); 66 | -------------------------------------------------------------------------------- /src/options/callees/cnb.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const CNB_STRINGS = [ 7 | "cnb", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | export const CNB_OBJECT_KEYS = [ 16 | "cnb", 17 | [ 18 | { 19 | match: MatcherType.ObjectKey 20 | } 21 | ] 22 | ] satisfies CalleeMatchers; 23 | 24 | /** @see https://github.com/xobotyi/cnbuilder */ 25 | export const CNB = [ 26 | CNB_STRINGS, 27 | CNB_OBJECT_KEYS 28 | ] satisfies Callees; 29 | -------------------------------------------------------------------------------- /src/options/callees/ctl.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { CTL_STRINGS } from "better-tailwindcss:options/callees/ctl.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("ctl", () => { 9 | 10 | it("should lint strings", () => { 11 | 12 | const dirty = `ctl(" lint ")`; 13 | const clean = `ctl("lint")`; 14 | 15 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 16 | invalid: [ 17 | { 18 | jsx: dirty, 19 | jsxOutput: clean, 20 | svelte: ``, 21 | svelteOutput: ``, 22 | vue: ``, 23 | vueOutput: ``, 24 | 25 | errors: 1, 26 | options: [{ callees: [CTL_STRINGS] }] 27 | } 28 | ] 29 | }); 30 | 31 | }); 32 | 33 | }); 34 | -------------------------------------------------------------------------------- /src/options/callees/ctl.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const CTL_STRINGS = [ 7 | "ctl", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | /** @see https://github.com/netlify/classnames-template-literals */ 16 | export const CTL = [ 17 | CTL_STRINGS 18 | ] satisfies Callees; 19 | -------------------------------------------------------------------------------- /src/options/callees/cva.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { 4 | CVA_COMPOUND_VARIANTS_CLASS, 5 | CVA_STRINGS, 6 | CVA_VARIANT_VALUES 7 | } from "better-tailwindcss:options/callees/cva.js"; 8 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 9 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 10 | 11 | 12 | describe("cva", () => { 13 | 14 | it("should lint strings in arrays", () => { 15 | 16 | const dirty = `cva(" lint ", [" lint ", " lint "])`; 17 | const clean = `cva("lint", ["lint", "lint"])`; 18 | 19 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 20 | invalid: [ 21 | { 22 | jsx: dirty, 23 | jsxOutput: clean, 24 | svelte: ``, 25 | svelteOutput: ``, 26 | vue: ``, 27 | vueOutput: ``, 28 | 29 | errors: 3, 30 | options: [{ callees: [CVA_STRINGS] }] 31 | } 32 | ] 33 | }); 34 | 35 | }); 36 | 37 | it("should lint object values inside the `variants` property", () => { 38 | 39 | const dirty = ` 40 | cva(" ignore ", { 41 | variants: { " ignore ": " lint " }, 42 | compoundVariants: { " ignore ": " ignore " } 43 | } 44 | ) 45 | `; 46 | const clean = ` 47 | cva(" ignore ", { 48 | variants: { " ignore ": "lint" }, 49 | compoundVariants: { " ignore ": " ignore " } 50 | } 51 | ) 52 | `; 53 | 54 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 55 | invalid: [ 56 | { 57 | jsx: dirty, 58 | jsxOutput: clean, 59 | svelte: ``, 60 | svelteOutput: ``, 61 | vue: ``, 62 | vueOutput: ``, 63 | 64 | errors: 1, 65 | options: [{ callees: [CVA_VARIANT_VALUES] }] 66 | } 67 | ] 68 | }); 69 | }); 70 | 71 | it("should lint only object values inside the `compoundVariants.class` and `compoundVariants.className` property", () => { 72 | 73 | const dirty = ` 74 | cva(" ignore ", { 75 | variants: { " ignore ": " ignore " }, 76 | compoundVariants: [{ 77 | " ignore ": " ignore ", 78 | "class": " lint ", 79 | "className": " lint " 80 | }] 81 | } 82 | ) 83 | `; 84 | const clean = ` 85 | cva(" ignore ", { 86 | variants: { " ignore ": " ignore " }, 87 | compoundVariants: [{ 88 | " ignore ": " ignore ", 89 | "class": "lint", 90 | "className": "lint" 91 | }] 92 | } 93 | ) 94 | `; 95 | 96 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 97 | invalid: [ 98 | { 99 | jsx: dirty, 100 | jsxOutput: clean, 101 | svelte: ``, 102 | svelteOutput: ``, 103 | vue: ``, 104 | vueOutput: ``, 105 | 106 | errors: 2, 107 | options: [{ callees: [CVA_COMPOUND_VARIANTS_CLASS] }] 108 | } 109 | ] 110 | }); 111 | 112 | }); 113 | 114 | it("should lint all `cva` variations in combination by default", () => { 115 | const dirty = ` 116 | cva([" lint ", " lint "], " lint ", { 117 | compoundVariants: [ 118 | { 119 | " ignore ": " ignore ", 120 | "class": " lint " 121 | } 122 | ], 123 | defaultVariants: { 124 | " ignore ": " ignore " 125 | }, 126 | variants: { 127 | " ignore ": { 128 | " ignore array ": [ 129 | " lint ", 130 | " lint " 131 | ], 132 | " ignore string ": " lint " 133 | } 134 | } 135 | }); 136 | `; 137 | 138 | const clean = ` 139 | cva(["lint", "lint"], "lint", { 140 | compoundVariants: [ 141 | { 142 | " ignore ": " ignore ", 143 | "class": "lint" 144 | } 145 | ], 146 | defaultVariants: { 147 | " ignore ": " ignore " 148 | }, 149 | variants: { 150 | " ignore ": { 151 | " ignore array ": [ 152 | "lint", 153 | "lint" 154 | ], 155 | " ignore string ": "lint" 156 | } 157 | } 158 | }); 159 | `; 160 | 161 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 162 | invalid: [ 163 | { 164 | jsx: dirty, 165 | jsxOutput: clean, 166 | svelte: ``, 167 | svelteOutput: ``, 168 | vue: ``, 169 | vueOutput: ``, 170 | 171 | errors: 7 172 | } 173 | ] 174 | }); 175 | 176 | }); 177 | 178 | }); 179 | -------------------------------------------------------------------------------- /src/options/callees/cva.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const CVA_STRINGS = [ 7 | "cva", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | export const CVA_VARIANT_VALUES = [ 16 | "cva", 17 | [ 18 | { 19 | match: MatcherType.ObjectValue, 20 | pathPattern: "^variants.*$" 21 | } 22 | ] 23 | ] satisfies CalleeMatchers; 24 | 25 | export const CVA_COMPOUND_VARIANTS_CLASS = [ 26 | "cva", 27 | [ 28 | { 29 | match: MatcherType.ObjectValue, 30 | pathPattern: "^compoundVariants\\[\\d+\\]\\.(?:className|class)$" 31 | } 32 | ] 33 | ] satisfies CalleeMatchers; 34 | 35 | /** @see https://github.com/joe-bell/cva */ 36 | export const CVA = [ 37 | CVA_STRINGS, 38 | CVA_VARIANT_VALUES, 39 | CVA_COMPOUND_VARIANTS_CLASS 40 | ] satisfies Callees; 41 | -------------------------------------------------------------------------------- /src/options/callees/cx.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { CX_OBJECT_KEYS, CX_STRINGS } from "better-tailwindcss:options/callees/cx.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("cx", () => { 9 | 10 | it("should lint strings and strings in arrays", () => { 11 | 12 | const dirty = `cx(" lint ", [" lint ", " lint "])`; 13 | const clean = `cx("lint", ["lint", "lint"])`; 14 | 15 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 16 | invalid: [ 17 | { 18 | jsx: dirty, 19 | jsxOutput: clean, 20 | svelte: ``, 21 | svelteOutput: ``, 22 | vue: ``, 23 | vueOutput: ``, 24 | 25 | errors: 3, 26 | options: [{ callees: [CX_STRINGS] }] 27 | } 28 | ] 29 | }); 30 | 31 | }); 32 | 33 | it("should lint object keys", () => { 34 | 35 | const dirty = ` 36 | cx(" ignore ", { 37 | " lint ": { " lint ": " ignore " }, 38 | } 39 | ) 40 | `; 41 | const clean = ` 42 | cx(" ignore ", { 43 | "lint": { "lint": " ignore " }, 44 | } 45 | ) 46 | `; 47 | 48 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 49 | invalid: [ 50 | { 51 | jsx: dirty, 52 | jsxOutput: clean, 53 | svelte: ``, 54 | svelteOutput: ``, 55 | vue: ``, 56 | vueOutput: ``, 57 | 58 | errors: 2, 59 | options: [{ callees: [CX_OBJECT_KEYS] }] 60 | } 61 | ] 62 | }); 63 | }); 64 | 65 | }); 66 | -------------------------------------------------------------------------------- /src/options/callees/cx.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const CX_STRINGS = [ 7 | "cx", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | export const CX_OBJECT_KEYS = [ 16 | "cx", 17 | [ 18 | { 19 | match: MatcherType.ObjectKey 20 | } 21 | ] 22 | ] satisfies CalleeMatchers; 23 | 24 | /** @see https://cva.style/docs/api-reference#cx */ 25 | export const CX = [ 26 | CX_STRINGS, 27 | CX_OBJECT_KEYS 28 | ] satisfies Callees; 29 | -------------------------------------------------------------------------------- /src/options/callees/dcnb.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { DCNB_OBJECT_KEYS, DCNB_STRINGS } from "better-tailwindcss:options/callees/dcnb.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("dcnb", () => { 9 | 10 | it("should lint strings and strings in arrays", () => { 11 | 12 | const dirty = `dcnb(" lint ", [" lint ", " lint "])`; 13 | const clean = `dcnb("lint", ["lint", "lint"])`; 14 | 15 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 16 | invalid: [ 17 | { 18 | jsx: dirty, 19 | jsxOutput: clean, 20 | svelte: ``, 21 | svelteOutput: ``, 22 | vue: ``, 23 | vueOutput: ``, 24 | 25 | errors: 3, 26 | options: [{ callees: [DCNB_STRINGS] }] 27 | } 28 | ] 29 | }); 30 | 31 | }); 32 | 33 | it("should lint object keys", () => { 34 | 35 | const dirty = ` 36 | dcnb(" ignore ", { 37 | " lint ": { " lint ": " ignore " }, 38 | } 39 | ) 40 | `; 41 | const clean = ` 42 | dcnb(" ignore ", { 43 | "lint": { "lint": " ignore " }, 44 | } 45 | ) 46 | `; 47 | 48 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 49 | invalid: [ 50 | { 51 | jsx: dirty, 52 | jsxOutput: clean, 53 | svelte: ``, 54 | svelteOutput: ``, 55 | vue: ``, 56 | vueOutput: ``, 57 | 58 | errors: 2, 59 | options: [{ callees: [DCNB_OBJECT_KEYS] }] 60 | } 61 | ] 62 | }); 63 | }); 64 | 65 | }); 66 | -------------------------------------------------------------------------------- /src/options/callees/dcnb.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const DCNB_STRINGS = [ 7 | "dcnb", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | export const DCNB_OBJECT_KEYS = [ 16 | "dcnb", 17 | [ 18 | { 19 | match: MatcherType.ObjectKey 20 | } 21 | ] 22 | ] satisfies CalleeMatchers; 23 | 24 | /** @see https://github.com/xobotyi/cnbuilder */ 25 | export const DCNB = [ 26 | DCNB_STRINGS, 27 | DCNB_OBJECT_KEYS 28 | ] satisfies Callees; 29 | -------------------------------------------------------------------------------- /src/options/callees/objstr.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { OBJSTR_OBJECT_KEYS } from "better-tailwindcss:options/callees/objstr.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("objstr", () => { 9 | 10 | it("should lint object keys", () => { 11 | 12 | const dirty = ` 13 | objstr(" ignore ", { 14 | " lint ": { " lint ": " ignore " }, 15 | } 16 | ) 17 | `; 18 | const clean = ` 19 | objstr(" ignore ", { 20 | "lint": { "lint": " ignore " }, 21 | } 22 | ) 23 | `; 24 | 25 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 26 | invalid: [ 27 | { 28 | jsx: dirty, 29 | jsxOutput: clean, 30 | svelte: ``, 31 | svelteOutput: ``, 32 | vue: ``, 33 | vueOutput: ``, 34 | 35 | errors: 2, 36 | options: [{ callees: [OBJSTR_OBJECT_KEYS] }] 37 | } 38 | ] 39 | }); 40 | }); 41 | 42 | }); 43 | -------------------------------------------------------------------------------- /src/options/callees/objstr.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const OBJSTR_STRINGS = [ 7 | "objstr", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | export const OBJSTR_OBJECT_KEYS = [ 16 | "objstr", 17 | [ 18 | { 19 | match: MatcherType.ObjectKey 20 | } 21 | ] 22 | ] satisfies CalleeMatchers; 23 | 24 | /** @see https://github.com/lukeed/obj-str */ 25 | export const OBJSTR = [ 26 | OBJSTR_OBJECT_KEYS 27 | ] satisfies Callees; 28 | -------------------------------------------------------------------------------- /src/options/callees/tv.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { TV_COMPOUND_VARIANTS_CLASS, TV_STRINGS, TV_VARIANT_VALUES } from "better-tailwindcss:options/callees/tv.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("tv", () => { 9 | 10 | it("should lint strings in arrays", () => { 11 | 12 | const dirty = `tv(" lint ", [" lint ", " lint "])`; 13 | const clean = `tv("lint", ["lint", "lint"])`; 14 | 15 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 16 | invalid: [ 17 | { 18 | jsx: dirty, 19 | jsxOutput: clean, 20 | svelte: ``, 21 | svelteOutput: ``, 22 | vue: ``, 23 | vueOutput: ``, 24 | 25 | errors: 3, 26 | options: [{ callees: [TV_STRINGS] }] 27 | } 28 | ] 29 | }); 30 | 31 | }); 32 | 33 | it("should lint object values inside the `variants` property", () => { 34 | 35 | const dirty = ` 36 | tv(" ignore ", { 37 | variants: { " ignore ": " lint " }, 38 | compoundVariants: { " ignore ": " ignore " } 39 | } 40 | ) 41 | `; 42 | const clean = ` 43 | tv(" ignore ", { 44 | variants: { " ignore ": "lint" }, 45 | compoundVariants: { " ignore ": " ignore " } 46 | } 47 | ) 48 | `; 49 | 50 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 51 | invalid: [ 52 | { 53 | jsx: dirty, 54 | jsxOutput: clean, 55 | svelte: ``, 56 | svelteOutput: ``, 57 | vue: ``, 58 | vueOutput: ``, 59 | 60 | errors: 1, 61 | options: [{ callees: [TV_VARIANT_VALUES] }] 62 | } 63 | ] 64 | }); 65 | }); 66 | 67 | it("should lint only object values inside the `compoundVariants.class` and `compoundVariants.className` property", () => { 68 | 69 | const dirty = ` 70 | tv(" ignore ", { 71 | variants: { " ignore ": " ignore " }, 72 | compoundVariants: [{ 73 | " ignore ": " ignore ", 74 | "class": " lint ", 75 | "className": " lint " 76 | }] 77 | } 78 | ) 79 | `; 80 | const clean = ` 81 | tv(" ignore ", { 82 | variants: { " ignore ": " ignore " }, 83 | compoundVariants: [{ 84 | " ignore ": " ignore ", 85 | "class": "lint", 86 | "className": "lint" 87 | }] 88 | } 89 | ) 90 | `; 91 | 92 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 93 | invalid: [ 94 | { 95 | jsx: dirty, 96 | jsxOutput: clean, 97 | svelte: ``, 98 | svelteOutput: ``, 99 | vue: ``, 100 | vueOutput: ``, 101 | 102 | errors: 2, 103 | options: [{ callees: [TV_COMPOUND_VARIANTS_CLASS] }] 104 | } 105 | ] 106 | }); 107 | 108 | }); 109 | 110 | it("should lint all `tv` variations in combination by default", () => { 111 | const dirty = ` 112 | tv([" lint ", " lint "], " lint ", { 113 | compoundVariants: [ 114 | { 115 | " ignore ": " ignore ", 116 | "class": " lint " 117 | } 118 | ], 119 | defaultVariants: { 120 | " ignore ": " ignore " 121 | }, 122 | variants: { 123 | " ignore ": { 124 | " ignore array ": [ 125 | " lint ", 126 | " lint " 127 | ], 128 | " ignore string ": " lint " 129 | } 130 | } 131 | }); 132 | `; 133 | 134 | const clean = ` 135 | tv(["lint", "lint"], "lint", { 136 | compoundVariants: [ 137 | { 138 | " ignore ": " ignore ", 139 | "class": "lint" 140 | } 141 | ], 142 | defaultVariants: { 143 | " ignore ": " ignore " 144 | }, 145 | variants: { 146 | " ignore ": { 147 | " ignore array ": [ 148 | "lint", 149 | "lint" 150 | ], 151 | " ignore string ": "lint" 152 | } 153 | } 154 | }); 155 | `; 156 | 157 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 158 | invalid: [ 159 | { 160 | jsx: dirty, 161 | jsxOutput: clean, 162 | svelte: ``, 163 | svelteOutput: ``, 164 | vue: ``, 165 | vueOutput: ``, 166 | 167 | errors: 7 168 | } 169 | ] 170 | }); 171 | 172 | }); 173 | 174 | }); 175 | -------------------------------------------------------------------------------- /src/options/callees/tv.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const TV_STRINGS = [ 7 | "tv", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | export const TV_VARIANT_VALUES = [ 16 | "tv", 17 | [ 18 | { 19 | match: MatcherType.ObjectValue, 20 | pathPattern: "^variants.*$" 21 | } 22 | ] 23 | ] satisfies CalleeMatchers; 24 | 25 | export const TV_COMPOUND_VARIANTS_CLASS = [ 26 | "tv", 27 | [ 28 | { 29 | match: MatcherType.ObjectValue, 30 | pathPattern: "^compoundVariants\\[\\d+\\]\\.(?:className|class)$" 31 | } 32 | ] 33 | ] satisfies CalleeMatchers; 34 | 35 | /** @see https://github.com/nextui-org/tailwind-variants?tab=readme-ov-file */ 36 | export const TV = [ 37 | TV_STRINGS, 38 | TV_VARIANT_VALUES, 39 | TV_COMPOUND_VARIANTS_CLASS 40 | ] satisfies Callees; 41 | -------------------------------------------------------------------------------- /src/options/callees/twJoin.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { TW_JOIN_STRINGS } from "better-tailwindcss:options/callees/twJoin.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("twJoin", () => { 9 | 10 | it("should lint strings and strings in arrays", () => { 11 | 12 | const dirty = `twJoin(" lint ", [" lint ", " lint "])`; 13 | const clean = `twJoin("lint", ["lint", "lint"])`; 14 | 15 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 16 | invalid: [ 17 | { 18 | jsx: dirty, 19 | jsxOutput: clean, 20 | svelte: ``, 21 | svelteOutput: ``, 22 | vue: ``, 23 | vueOutput: ``, 24 | 25 | errors: 3, 26 | options: [{ callees: [TW_JOIN_STRINGS] }] 27 | } 28 | ] 29 | }); 30 | 31 | }); 32 | 33 | }); 34 | -------------------------------------------------------------------------------- /src/options/callees/twJoin.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const TW_JOIN_STRINGS = [ 7 | "twJoin", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | /** @see https://github.com/dcastil/tailwind-merge */ 16 | export const TW_JOIN = [ 17 | TW_JOIN_STRINGS 18 | ] satisfies Callees; 19 | -------------------------------------------------------------------------------- /src/options/callees/twMerge.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { TW_MERGE_STRINGS } from "better-tailwindcss:options/callees/twMerge.js"; 4 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe("twMerge", () => { 9 | 10 | it("should lint strings and strings in arrays", () => { 11 | 12 | const dirty = `twMerge(" lint ", [" lint ", " lint "])`; 13 | const clean = `twMerge("lint", ["lint", "lint"])`; 14 | 15 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 16 | invalid: [ 17 | { 18 | jsx: dirty, 19 | jsxOutput: clean, 20 | svelte: ``, 21 | svelteOutput: ``, 22 | vue: ``, 23 | vueOutput: ``, 24 | 25 | errors: 3, 26 | options: [{ callees: [TW_MERGE_STRINGS] }] 27 | } 28 | ] 29 | }); 30 | 31 | }); 32 | 33 | }); 34 | -------------------------------------------------------------------------------- /src/options/callees/twMerge.ts: -------------------------------------------------------------------------------- 1 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 2 | 3 | import type { CalleeMatchers, Callees } from "better-tailwindcss:types/rule.js"; 4 | 5 | 6 | export const TW_MERGE_STRINGS = [ 7 | "twMerge", 8 | [ 9 | { 10 | match: MatcherType.String 11 | } 12 | ] 13 | ] satisfies CalleeMatchers; 14 | 15 | /** @see https://github.com/dcastil/tailwind-merge */ 16 | export const TW_MERGE = [ 17 | TW_MERGE_STRINGS 18 | ] satisfies Callees; 19 | -------------------------------------------------------------------------------- /src/options/default-options.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { DEFAULT_CALLEE_NAMES } from "better-tailwindcss:options/default-options.js"; 4 | import { getFilesInDirectory } from "better-tailwindcss:tests/utils/lint.js"; 5 | 6 | 7 | describe("default options", () => { 8 | 9 | it("should include all callees by default", () => { 10 | const callees = DEFAULT_CALLEE_NAMES 11 | .map(callee => callee[0]) 12 | .filter((callee, index, arr) => arr.indexOf(callee) === index); 13 | 14 | const exportedFiles = getFilesInDirectory("./src/options/callees/"); 15 | const fileNames = exportedFiles.map(file => file.replace(".ts", "")); 16 | 17 | expect(callees.sort()).toStrictEqual(fileNames.sort()); 18 | 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /src/options/default-options.ts: -------------------------------------------------------------------------------- 1 | import { CC } from "better-tailwindcss:options/callees/cc.js"; 2 | import { CLB } from "better-tailwindcss:options/callees/clb.js"; 3 | import { CLSX } from "better-tailwindcss:options/callees/clsx.js"; 4 | import { CN } from "better-tailwindcss:options/callees/cn.js"; 5 | import { CNB } from "better-tailwindcss:options/callees/cnb.js"; 6 | import { CTL } from "better-tailwindcss:options/callees/ctl.js"; 7 | import { CVA } from "better-tailwindcss:options/callees/cva.js"; 8 | import { CX } from "better-tailwindcss:options/callees/cx.js"; 9 | import { DCNB } from "better-tailwindcss:options/callees/dcnb.js"; 10 | import { OBJSTR } from "better-tailwindcss:options/callees/objstr.js"; 11 | import { TV } from "better-tailwindcss:options/callees/tv.js"; 12 | import { TW_JOIN } from "better-tailwindcss:options/callees/twJoin.js"; 13 | import { TW_MERGE } from "better-tailwindcss:options/callees/twMerge.js"; 14 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 15 | 16 | import type { Attributes, Callees, Tags, Variables } from "better-tailwindcss:types/rule.js"; 17 | 18 | 19 | export const DEFAULT_CALLEE_NAMES = [ 20 | ...CC, 21 | ...CLB, 22 | ...CLSX, 23 | ...CN, 24 | ...CNB, 25 | ...CTL, 26 | ...CVA, 27 | ...CX, 28 | ...DCNB, 29 | ...OBJSTR, 30 | ...TV, 31 | ...TW_JOIN, 32 | ...TW_MERGE 33 | ] satisfies Callees; 34 | 35 | export const DEFAULT_ATTRIBUTE_NAMES = [ 36 | // general 37 | "^class(?:Name)?$", 38 | [ 39 | "^class(?:Name)?$", [ 40 | { 41 | match: MatcherType.String 42 | } 43 | ] 44 | ], 45 | 46 | // angular 47 | "(?:^\\[class\\]$)|(?:^\\[ngClass\\]$)", 48 | [ 49 | "(?:^\\[class\\]$)|(?:^\\[ngClass\\]$)", [ 50 | { 51 | match: MatcherType.String 52 | }, 53 | { 54 | match: MatcherType.ObjectKey 55 | } 56 | ] 57 | ], 58 | 59 | // vue 60 | [ 61 | "^v-bind:class$", [ 62 | { 63 | match: MatcherType.String 64 | }, 65 | { 66 | match: MatcherType.ObjectKey 67 | } 68 | ] 69 | ], 70 | 71 | // astro 72 | [ 73 | "^class:list$", [ 74 | { 75 | match: MatcherType.String 76 | }, 77 | { 78 | match: MatcherType.ObjectKey 79 | } 80 | ] 81 | ] 82 | ] satisfies Attributes; 83 | 84 | export const DEFAULT_VARIABLE_NAMES = [ 85 | [ 86 | "^classNames?$", [ 87 | { 88 | match: MatcherType.String 89 | } 90 | ] 91 | ], 92 | [ 93 | "^classes$", [ 94 | { 95 | match: MatcherType.String 96 | } 97 | ] 98 | ], 99 | [ 100 | "^styles?$", [ 101 | { 102 | match: MatcherType.String 103 | } 104 | ] 105 | ] 106 | ] satisfies Variables; 107 | 108 | export const DEFAULT_TAG_NAMES = [] satisfies Tags; 109 | -------------------------------------------------------------------------------- /src/options/descriptions.test.ts: -------------------------------------------------------------------------------- 1 | import { validate } from "json-schema"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { ATTRIBUTE_SCHEMA, CALLEE_SCHEMA, VARIABLE_SCHEMA } from "better-tailwindcss:options/descriptions.js"; 5 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 6 | 7 | import type { AttributeOption, CalleeOption, VariableOption } from "better-tailwindcss:types/rule.js"; 8 | 9 | 10 | describe("descriptions", () => { 11 | 12 | test("name config", () => { 13 | 14 | const attributes = { 15 | attributes: [ 16 | "class", 17 | "className" 18 | ] 19 | } satisfies AttributeOption; 20 | 21 | expect( 22 | validate(attributes, ATTRIBUTE_SCHEMA) 23 | ).toStrictEqual( 24 | { errors: [], valid: true } 25 | ); 26 | 27 | const callees = { 28 | callees: [ 29 | "callee" 30 | ] 31 | } satisfies CalleeOption; 32 | 33 | expect( 34 | validate(callees, CALLEE_SCHEMA) 35 | ).toStrictEqual( 36 | { errors: [], valid: true } 37 | ); 38 | 39 | const variable = { 40 | variables: [ 41 | "classes", 42 | "styles" 43 | ] 44 | } satisfies VariableOption; 45 | 46 | expect( 47 | validate(variable, VARIABLE_SCHEMA) 48 | ).toStrictEqual( 49 | { errors: [], valid: true } 50 | ); 51 | 52 | }); 53 | 54 | test("regex config", () => { 55 | 56 | const attributes = { 57 | attributes: [ 58 | "(class|className)", 59 | "(.*)" 60 | ] 61 | } satisfies AttributeOption; 62 | 63 | expect( 64 | validate(attributes, ATTRIBUTE_SCHEMA) 65 | ).toStrictEqual( 66 | { errors: [], valid: true } 67 | ); 68 | 69 | const callees = { 70 | callees: [ 71 | "callee(.*)", 72 | "(.*)" 73 | ] 74 | } satisfies CalleeOption; 75 | 76 | expect( 77 | validate(callees, CALLEE_SCHEMA) 78 | ).toStrictEqual( 79 | { errors: [], valid: true } 80 | ); 81 | 82 | const variable = { 83 | variables: [ 84 | "variable = (.*)", 85 | "(.*)" 86 | ] 87 | } satisfies VariableOption; 88 | 89 | expect( 90 | validate(variable, VARIABLE_SCHEMA) 91 | ).toStrictEqual( 92 | { errors: [], valid: true } 93 | ); 94 | 95 | }); 96 | 97 | test("matcher config", () => { 98 | 99 | const attributes: AttributeOption = { 100 | attributes: [ 101 | [ 102 | "class", 103 | [ 104 | { 105 | match: MatcherType.String 106 | }, 107 | { 108 | match: MatcherType.ObjectKey, 109 | pathPattern: "^.*" 110 | }, 111 | { 112 | match: MatcherType.ObjectValue 113 | } 114 | ] 115 | ] 116 | ] 117 | }; 118 | 119 | expect( 120 | validate(attributes, ATTRIBUTE_SCHEMA) 121 | ).toStrictEqual( 122 | { errors: [], valid: true } 123 | ); 124 | 125 | const callees: CalleeOption = { 126 | callees: [ 127 | [ 128 | "callee", 129 | [ 130 | { 131 | match: MatcherType.String 132 | }, 133 | { 134 | match: MatcherType.ObjectKey, 135 | pathPattern: "^.*" 136 | }, 137 | { 138 | match: MatcherType.ObjectValue 139 | } 140 | ] 141 | ] 142 | ] 143 | }; 144 | 145 | expect( 146 | validate(callees, CALLEE_SCHEMA) 147 | ).toStrictEqual( 148 | { errors: [], valid: true } 149 | ); 150 | 151 | const variable: VariableOption = { 152 | variables: [ 153 | [ 154 | "variable", 155 | [ 156 | { 157 | match: MatcherType.String 158 | }, 159 | { 160 | match: MatcherType.ObjectKey, 161 | pathPattern: "^.*" 162 | }, 163 | { 164 | match: MatcherType.ObjectValue 165 | } 166 | ] 167 | ] 168 | ] 169 | }; 170 | 171 | expect( 172 | validate(variable, VARIABLE_SCHEMA) 173 | ).toStrictEqual( 174 | { errors: [], valid: true } 175 | ); 176 | 177 | }); 178 | 179 | }); 180 | -------------------------------------------------------------------------------- /src/parsers/es.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; 4 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 5 | 6 | 7 | describe("es", () => { 8 | 9 | it("should match callees names via regex", () => { 10 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 11 | invalid: [ 12 | { 13 | jsx: `testStyles(" lint ");`, 14 | jsxOutput: `testStyles("lint");`, 15 | svelte: ``, 16 | svelteOutput: ``, 17 | vue: ``, 18 | vueOutput: ``, 19 | 20 | errors: 1, 21 | options: [{ 22 | callees: ["^.*Styles$"] 23 | }] 24 | } 25 | ] 26 | }); 27 | }); 28 | 29 | it("should match variable names via regex", () => { 30 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 31 | invalid: [ 32 | { 33 | jsx: `const testStyles = " lint ";`, 34 | jsxOutput: `const testStyles = "lint";`, 35 | svelte: ``, 36 | svelteOutput: ``, 37 | vue: ``, 38 | vueOutput: ``, 39 | 40 | errors: 1, 41 | options: [{ 42 | variables: ["^.*Styles$"] 43 | }] 44 | } 45 | ] 46 | }); 47 | }); 48 | 49 | it("should match attributes via regex", () => { 50 | lint(noUnnecessaryWhitespace, TEST_SYNTAXES, { 51 | invalid: [ 52 | { 53 | jsx: ``, 54 | jsxOutput: ``, 55 | svelte: ``, 56 | svelteOutput: ``, 57 | vue: ``, 58 | vueOutput: ``, 59 | 60 | errors: 1, 61 | options: [{ 62 | attributes: ["^.*Styles$"] 63 | }] 64 | } 65 | ] 66 | }); 67 | }); 68 | 69 | }); 70 | -------------------------------------------------------------------------------- /src/parsers/html.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { sortClasses } from "better-tailwindcss:rules/sort-classes.js"; 4 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 5 | 6 | 7 | describe("html", () => { 8 | 9 | it("should match attribute names via regex", () => { 10 | lint(sortClasses, TEST_SYNTAXES, { 11 | invalid: [ 12 | { 13 | html: ``, 14 | htmlOutput: ``, 15 | 16 | errors: 1, 17 | options: [{ attributes: [".*Attribute"], order: "asc" }] 18 | } 19 | ] 20 | }); 21 | }); 22 | 23 | }); 24 | -------------------------------------------------------------------------------- /src/parsers/html.ts: -------------------------------------------------------------------------------- 1 | import { isAttributesMatchers, isAttributesName, isAttributesRegex } from "better-tailwindcss:utils/matchers.js"; 2 | import { deduplicateLiterals, getContent, getIndentation, matchesName } from "better-tailwindcss:utils/utils.js"; 3 | 4 | import type { AttributeNode, TagNode } from "es-html-parser"; 5 | import type { Rule } from "eslint"; 6 | 7 | import type { Literal, QuoteMeta } from "better-tailwindcss:types/ast.js"; 8 | import type { Attributes } from "better-tailwindcss:types/rule.js"; 9 | 10 | 11 | export function getLiteralsByHTMLAttribute(ctx: Rule.RuleContext, attribute: AttributeNode, attributes: Attributes): Literal[] { 12 | const literals = attributes.reduce((literals, attributes) => { 13 | if(isAttributesName(attributes)){ 14 | if(!matchesName(attributes.toLowerCase(), attribute.key.value.toLowerCase())){ return literals; } 15 | literals.push(...getLiteralsByHTMLAttributeNode(ctx, attribute)); 16 | } else if(isAttributesRegex(attributes)){ 17 | // console.warn("Regex not supported in HTML"); 18 | } else if(isAttributesMatchers(attributes)){ 19 | // console.warn("Matchers not supported in HTML"); 20 | } 21 | 22 | return literals; 23 | }, []); 24 | 25 | return deduplicateLiterals(literals); 26 | 27 | } 28 | 29 | export function getAttributesByHTMLTag(ctx: Rule.RuleContext, node: TagNode): AttributeNode[] { 30 | return node.attributes; 31 | } 32 | 33 | export function getLiteralsByHTMLAttributeNode(ctx: Rule.RuleContext, attribute: AttributeNode): Literal[] { 34 | 35 | const value = attribute.value; 36 | 37 | if(!value){ 38 | return []; 39 | } 40 | 41 | const line = ctx.sourceCode.lines[attribute.loc.start.line - 1]; 42 | const raw = attribute.startWrapper?.value + value.value + attribute.endWrapper?.value; 43 | 44 | 45 | const quotes = getQuotesByHTMLAttribute(ctx, attribute); 46 | const indentation = getIndentation(line); 47 | const content = getContent(raw, quotes); 48 | 49 | return [{ 50 | ...quotes, 51 | content, 52 | indentation, 53 | loc: value.loc, 54 | range: [value.range[0] - 1, value.range[1] + 1], // include quotes in range 55 | raw, 56 | supportsMultiline: true, 57 | type: "StringLiteral" 58 | }]; 59 | 60 | } 61 | 62 | 63 | function getQuotesByHTMLAttribute(ctx: Rule.RuleContext, attribute: AttributeNode): QuoteMeta { 64 | const openingQuote = attribute.startWrapper?.value; 65 | const closingQuote = attribute.endWrapper?.value; 66 | 67 | return { 68 | closingQuote: closingQuote === "'" || closingQuote === '"' ? closingQuote : undefined, 69 | openingQuote: openingQuote === "'" || openingQuote === '"' ? openingQuote : undefined 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/parsers/jsx.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { sortClasses } from "better-tailwindcss:rules/sort-classes.js"; 4 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 5 | 6 | 7 | describe("jsx", () => { 8 | it("should match attribute names via regex", () => { 9 | lint(sortClasses, TEST_SYNTAXES, { 10 | invalid: [ 11 | { 12 | jsx: ``, 13 | jsxOutput: ``, 14 | 15 | errors: 1, 16 | options: [{ attributes: [".*Attribute"], order: "asc" }] 17 | } 18 | ] 19 | }); 20 | }); 21 | }); 22 | 23 | 24 | describe("astro (jsx)", () => { 25 | it("should match astro syntactic sugar", () => { 26 | lint(sortClasses, TEST_SYNTAXES, { 27 | invalid: [ 28 | { 29 | astro: ``, 30 | astroOutput: ``, 31 | 32 | errors: 1, 33 | options: [{ order: "asc" }] 34 | } 35 | ] 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/parsers/svelte.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { multiline } from "better-tailwindcss:rules/multiline.js"; 4 | import { sortClasses } from "better-tailwindcss:rules/sort-classes.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | import { dedent } from "better-tailwindcss:tests/utils/template.js"; 7 | 8 | 9 | describe("svelte", () => { 10 | 11 | it("should match attribute names via regex", () => { 12 | lint(sortClasses, TEST_SYNTAXES, { 13 | invalid: [ 14 | { 15 | svelte: ``, 16 | svelteOutput: ``, 17 | 18 | errors: 1, 19 | options: [{ attributes: [".*Attribute"], order: "asc" }] 20 | } 21 | ] 22 | }); 23 | }); 24 | 25 | // #42 26 | it("should work with shorthand attributes", () => { 27 | lint(sortClasses, TEST_SYNTAXES, { 28 | invalid: [ 29 | { 30 | svelte: ``, 31 | svelteOutput: ``, 32 | 33 | errors: 1, 34 | options: [{ order: "asc" }] 35 | } 36 | ] 37 | }); 38 | }); 39 | 40 | it("should change the quotes in expressions to backticks", () => { 41 | const singleLine = "a b c d e f"; 42 | const multiLine = dedent` 43 | a b c 44 | d e f 45 | `; 46 | 47 | lint(multiline, TEST_SYNTAXES, { 48 | invalid: [ 49 | { 50 | svelte: ``, 51 | svelteOutput: ``, 52 | 53 | errors: 2, 54 | options: [{ classesPerLine: 3 }] 55 | } 56 | ] 57 | }); 58 | }); 59 | 60 | }); 61 | -------------------------------------------------------------------------------- /src/parsers/vue.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { multiline } from "better-tailwindcss:rules/multiline.js"; 4 | import { sortClasses } from "better-tailwindcss:rules/sort-classes.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | import { dedent } from "better-tailwindcss:tests/utils/template.js"; 7 | import { MatcherType } from "better-tailwindcss:types/rule.js"; 8 | 9 | 10 | describe("vue", () => { 11 | 12 | it("should match attribute names via regex", () => { 13 | lint(sortClasses, TEST_SYNTAXES, { 14 | invalid: [ 15 | { 16 | vue: ``, 17 | vueOutput: ``, 18 | 19 | errors: 1, 20 | options: [{ attributes: [".*Attribute"], order: "asc" }] 21 | } 22 | ] 23 | }); 24 | }); 25 | 26 | it("should work in objects in bound classes", () => { 27 | lint(sortClasses, TEST_SYNTAXES, { 28 | invalid: [ 29 | { 30 | vue: ``, 31 | vueOutput: ``, 32 | 33 | errors: 1, 34 | options: [{ order: "asc" }] 35 | }, 36 | { 37 | vue: ``, 38 | vueOutput: ``, 39 | 40 | errors: 1, 41 | options: [{ order: "asc" }] 42 | } 43 | ] 44 | }); 45 | }); 46 | 47 | it("should work in arrays in bound classes", () => { 48 | lint(sortClasses, TEST_SYNTAXES, { 49 | invalid: [ 50 | { 51 | vue: ``, 52 | vueOutput: ``, 53 | 54 | errors: 2, 55 | options: [{ order: "asc" }] 56 | }, 57 | { 58 | vue: ``, 59 | vueOutput: ``, 60 | 61 | errors: 2, 62 | options: [{ order: "asc" }] 63 | } 64 | ] 65 | }); 66 | }); 67 | 68 | it("should evaluate bound classes", () => { 69 | lint(sortClasses, TEST_SYNTAXES, { 70 | invalid: [ 71 | { 72 | vue: ``, 73 | vueOutput: ``, 74 | 75 | errors: 1, 76 | options: [{ callees: ["defined"], order: "asc" }] 77 | }, 78 | { 79 | vue: ``, 80 | vueOutput: ``, 81 | 82 | errors: 1, 83 | options: [{ callees: ["defined"], order: "asc" }] 84 | } 85 | ] 86 | }); 87 | }); 88 | 89 | it("should automatically prefix bound classes", () => { 90 | lint(sortClasses, TEST_SYNTAXES, { 91 | invalid: [ 92 | { 93 | vue: ``, 94 | vueOutput: ``, 95 | 96 | errors: 1, 97 | options: [{ attributes: [[":custom-class", [{ match: MatcherType.String }]]], order: "asc" }] 98 | }, 99 | { 100 | vue: ``, 101 | vueOutput: ``, 102 | 103 | errors: 1, 104 | options: [{ attributes: [["v-bind:custom-class", [{ match: MatcherType.String }]]], order: "asc" }] 105 | } 106 | ] 107 | }); 108 | }); 109 | 110 | it("should match bound classes via regex", () => { 111 | lint(sortClasses, TEST_SYNTAXES, { 112 | invalid: [ 113 | { 114 | vue: ``, 115 | vueOutput: ``, 116 | 117 | errors: 1, 118 | options: [{ attributes: [[":.*Styles$", [{ match: MatcherType.String }]]], order: "asc" }] 119 | } 120 | ] 121 | }); 122 | }); 123 | 124 | // #95 125 | it("should change the quotes in expressions to backticks", () => { 126 | const singleLine = "a b c d e f"; 127 | const multiLine = dedent` 128 | a b c 129 | d e f 130 | `; 131 | 132 | lint(multiline, TEST_SYNTAXES, { 133 | invalid: [ 134 | { 135 | vue: ``, 136 | vueOutput: ``, 137 | 138 | errors: 2, 139 | options: [{ classesPerLine: 3 }] 140 | } 141 | ] 142 | }); 143 | }); 144 | 145 | }); 146 | -------------------------------------------------------------------------------- /src/rules/no-conflicting-classes.test.ts: -------------------------------------------------------------------------------- 1 | import { getTailwindcssVersion, TailwindcssVersion } from "src/tailwind/utils/version.js"; 2 | import { describe, it } from "vitest"; 3 | 4 | import { noConflictingClasses } from "better-tailwindcss:rules/no-conflicting-classes.js"; 5 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 6 | 7 | 8 | describe.skipIf(getTailwindcssVersion().major <= TailwindcssVersion.V3)(noConflictingClasses.name, () => { 9 | 10 | it("should not report on non-conflicting tailwind classes", () => { 11 | lint( 12 | noConflictingClasses, 13 | TEST_SYNTAXES, 14 | { 15 | valid: [ 16 | { 17 | angular: ``, 18 | html: ``, 19 | jsx: `() => `, 20 | svelte: ``, 21 | vue: `` 22 | } 23 | ] 24 | } 25 | ); 26 | }); 27 | 28 | it("should report on conflicting tailwind classes", () => { 29 | lint( 30 | noConflictingClasses, 31 | TEST_SYNTAXES, 32 | { 33 | invalid: [ 34 | { 35 | angular: `
`, 36 | html: `
`, 37 | jsx: `() =>
`, 38 | svelte: `
`, 39 | vue: ``, 40 | 41 | errors: 2 42 | } 43 | ] 44 | } 45 | ); 46 | }); 47 | 48 | it("should not report on different variants", () => { 49 | lint( 50 | noConflictingClasses, 51 | TEST_SYNTAXES, 52 | { 53 | valid: [ 54 | { 55 | angular: `
`, 56 | html: `
`, 57 | jsx: `() =>
`, 58 | svelte: `
`, 59 | vue: `` 60 | } 61 | ] 62 | } 63 | ); 64 | }); 65 | 66 | it("should not report on the variants itself", () => { 67 | lint( 68 | noConflictingClasses, 69 | TEST_SYNTAXES, 70 | { 71 | valid: [ 72 | { 73 | angular: `
`, 74 | html: `
`, 75 | jsx: `() =>
`, 76 | svelte: `
`, 77 | vue: `` 78 | } 79 | ] 80 | } 81 | ); 82 | }); 83 | 84 | it("should report on the same variants", () => { 85 | lint( 86 | noConflictingClasses, 87 | TEST_SYNTAXES, 88 | { 89 | invalid: [ 90 | { 91 | angular: `
`, 92 | html: `
`, 93 | jsx: `() =>
`, 94 | svelte: `
`, 95 | vue: ``, 96 | 97 | errors: 2 98 | } 99 | ] 100 | } 101 | ); 102 | }); 103 | 104 | it("should not report on classes if one of them has an important flag", () => { 105 | lint( 106 | noConflictingClasses, 107 | TEST_SYNTAXES, 108 | { 109 | valid: [ 110 | { 111 | angular: `
`, 112 | html: `
`, 113 | jsx: `() =>
`, 114 | svelte: `
`, 115 | vue: `` 116 | } 117 | ] 118 | } 119 | ); 120 | }); 121 | 122 | it("should not report for css properties with an `undefined` value", () => { 123 | lint( 124 | noConflictingClasses, 125 | TEST_SYNTAXES, 126 | { 127 | valid: [ 128 | { 129 | angular: `
`, 130 | html: `
`, 131 | jsx: `() =>
`, 132 | svelte: `
`, 133 | vue: `` 134 | } 135 | ] 136 | } 137 | ); 138 | }); 139 | 140 | }); 141 | -------------------------------------------------------------------------------- /src/rules/no-conflicting-classes.ts: -------------------------------------------------------------------------------- 1 | import { getConflictingClasses } from "better-tailwindcss:async/conflicting-classes.sync.js"; 2 | import { 3 | DEFAULT_ATTRIBUTE_NAMES, 4 | DEFAULT_CALLEE_NAMES, 5 | DEFAULT_TAG_NAMES, 6 | DEFAULT_VARIABLE_NAMES 7 | } from "better-tailwindcss:options/default-options.js"; 8 | import { 9 | ATTRIBUTE_SCHEMA, 10 | CALLEE_SCHEMA, 11 | ENTRYPOINT_SCHEMA, 12 | TAG_SCHEMA, 13 | TAILWIND_CONFIG_SCHEMA, 14 | VARIABLE_SCHEMA 15 | } from "better-tailwindcss:options/descriptions.js"; 16 | import { getCommonOptions } from "better-tailwindcss:utils/options.js"; 17 | import { createRuleListener } from "better-tailwindcss:utils/rule.js"; 18 | import { 19 | augmentMessageWithWarnings, 20 | display, 21 | getExactClassLocation, 22 | splitClasses 23 | } from "better-tailwindcss:utils/utils.js"; 24 | 25 | import type { Rule } from "eslint"; 26 | 27 | import type { Literal } from "better-tailwindcss:types/ast.js"; 28 | import type { 29 | AttributeOption, 30 | CalleeOption, 31 | ESLintRule, 32 | TagOption, 33 | VariableOption 34 | } from "better-tailwindcss:types/rule.js"; 35 | 36 | 37 | export type Options = [ 38 | Partial< 39 | AttributeOption & 40 | CalleeOption & 41 | TagOption & 42 | VariableOption & 43 | { 44 | entryPoint?: string; 45 | tailwindConfig?: string; 46 | } 47 | > 48 | ]; 49 | 50 | 51 | const defaultOptions = { 52 | attributes: DEFAULT_ATTRIBUTE_NAMES, 53 | callees: DEFAULT_CALLEE_NAMES, 54 | tags: DEFAULT_TAG_NAMES, 55 | variables: DEFAULT_VARIABLE_NAMES 56 | } as const satisfies Options[0]; 57 | 58 | const DOCUMENTATION_URL = "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-conflicting-classes.md"; 59 | 60 | export const noConflictingClasses: ESLintRule = { 61 | name: "no-conflicting-classes" as const, 62 | rule: { 63 | create: ctx => createRuleListener(ctx, getOptions(ctx), lintLiterals), 64 | meta: { 65 | docs: { 66 | description: "Disallow classes that produce conflicting styles.", 67 | recommended: false, 68 | url: DOCUMENTATION_URL 69 | }, 70 | fixable: "code", 71 | schema: [ 72 | { 73 | additionalProperties: false, 74 | properties: { 75 | ...CALLEE_SCHEMA, 76 | ...ATTRIBUTE_SCHEMA, 77 | ...VARIABLE_SCHEMA, 78 | ...TAG_SCHEMA, 79 | ...ENTRYPOINT_SCHEMA, 80 | ...TAILWIND_CONFIG_SCHEMA 81 | }, 82 | type: "object" 83 | } 84 | ], 85 | type: "problem" 86 | } 87 | } 88 | }; 89 | 90 | function lintLiterals(ctx: Rule.RuleContext, literals: Literal[]) { 91 | 92 | for(const literal of literals){ 93 | 94 | const { tailwindConfig } = getOptions(ctx); 95 | 96 | const classes = splitClasses(literal.content); 97 | 98 | const [conflictingClasses, warnings] = getConflictingClasses({ classes, configPath: tailwindConfig, cwd: ctx.cwd }); 99 | 100 | const conflictingClassesWarnings = warnings.map(warning => ({ ...warning, url: DOCUMENTATION_URL })); 101 | 102 | if(Object.keys(conflictingClasses).length === 0){ 103 | continue; 104 | } 105 | 106 | for(const conflictingClass in conflictingClasses){ 107 | const conflicts = conflictingClasses[conflictingClass]; 108 | 109 | const otherConflicts = conflicts.filter(conflict => conflict.tailwindClassName !== conflictingClass); 110 | const conflict = conflicts.find(conflict => conflict.tailwindClassName === conflictingClass); 111 | 112 | if(!conflict || otherConflicts.length === 0){ 113 | continue; 114 | } 115 | 116 | ctx.report({ 117 | data: { 118 | conflicting: display(conflict.tailwindClassName), 119 | other: otherConflicts.reduce((otherConflicts, otherConflict) => { 120 | if(otherConflict.tailwindClassName !== conflict.tailwindClassName){ 121 | otherConflicts.push(`${otherConflict.tailwindClassName} -> (${otherConflict.cssPropertyName}: ${otherConflict.cssPropertyValue})`); 122 | } 123 | return otherConflicts; 124 | }, []).join(", "), 125 | property: conflict.cssPropertyName, 126 | value: conflict.cssPropertyValue ?? "" 127 | }, 128 | loc: getExactClassLocation(literal, conflict.tailwindClassName), 129 | message: augmentMessageWithWarnings("Conflicting class detected: {{ conflicting }} -> ({{property}}: {{value}}) applies the same css property as {{ other }}", conflictingClassesWarnings) 130 | }); 131 | } 132 | 133 | } 134 | } 135 | 136 | export function getOptions(ctx: Rule.RuleContext) { 137 | return getCommonOptions(ctx); 138 | } 139 | -------------------------------------------------------------------------------- /src/rules/no-restricted-classes.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | 3 | import { noRestrictedClasses } from "better-tailwindcss:rules/no-restricted-classes.js"; 4 | import { lint, TEST_SYNTAXES } from "better-tailwindcss:tests/utils/lint.js"; 5 | 6 | 7 | describe(noRestrictedClasses.name, () => { 8 | 9 | it("should not report on unrestricted classes", () => { 10 | lint(noRestrictedClasses, TEST_SYNTAXES, { 11 | valid: [ 12 | { 13 | angular: ``, 14 | html: ``, 15 | jsx: `() => `, 16 | svelte: ``, 17 | vue: `` 18 | } 19 | ] 20 | }); 21 | }); 22 | 23 | it("should report restricted classes", () => { 24 | lint(noRestrictedClasses, TEST_SYNTAXES, { 25 | invalid: [ 26 | { 27 | angular: ``, 28 | html: ``, 29 | jsx: `() => `, 30 | svelte: ``, 31 | vue: ``, 32 | 33 | errors: 1, 34 | options: [{ restrict: ["container"] }] 35 | } 36 | ] 37 | }); 38 | }); 39 | 40 | it("should report restricted classes matching a regex", () => { 41 | lint(noRestrictedClasses, TEST_SYNTAXES, { 42 | invalid: [ 43 | { 44 | angular: ``, 45 | html: ``, 46 | jsx: `() => `, 47 | svelte: ``, 48 | vue: ``, 49 | 50 | errors: 1, 51 | options: [{ restrict: ["^container$"] }] 52 | } 53 | ] 54 | }); 55 | }); 56 | 57 | it("should report restricted classes with variants", () => { 58 | lint(noRestrictedClasses, TEST_SYNTAXES, { 59 | invalid: [ 60 | { 61 | angular: ``, 62 | html: ``, 63 | jsx: `() => `, 64 | svelte: ``, 65 | vue: ``, 66 | 67 | errors: 2, 68 | options: [{ restrict: ["^lg:.*"] }] 69 | } 70 | ] 71 | }); 72 | }); 73 | 74 | it("should report restricted classes containing reserved regex characters", () => { 75 | lint(noRestrictedClasses, TEST_SYNTAXES, { 76 | invalid: [ 77 | { 78 | angular: ``, 79 | html: ``, 80 | jsx: `() => `, 81 | svelte: ``, 82 | vue: ``, 83 | 84 | errors: 2, 85 | options: [{ restrict: ["^\\*+:.*"] }] 86 | } 87 | ] 88 | }); 89 | }); 90 | 91 | it("should be possible to disallow the important modifier", () => { 92 | lint(noRestrictedClasses, TEST_SYNTAXES, { 93 | invalid: [ 94 | { 95 | angular: ``, 96 | html: ``, 97 | jsx: `() => `, 98 | svelte: ``, 99 | vue: ``, 100 | 101 | errors: 1, 102 | options: [{ restrict: ["^.*!$"] }] 103 | } 104 | ] 105 | }); 106 | }); 107 | 108 | }); 109 | -------------------------------------------------------------------------------- /src/rules/no-restricted-classes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_ATTRIBUTE_NAMES, 3 | DEFAULT_CALLEE_NAMES, 4 | DEFAULT_TAG_NAMES, 5 | DEFAULT_VARIABLE_NAMES 6 | } from "better-tailwindcss:options/default-options.js"; 7 | import { 8 | ATTRIBUTE_SCHEMA, 9 | CALLEE_SCHEMA, 10 | TAG_SCHEMA, 11 | VARIABLE_SCHEMA 12 | } from "better-tailwindcss:options/descriptions.js"; 13 | import { getCommonOptions } from "better-tailwindcss:utils/options.js"; 14 | import { createRuleListener } from "better-tailwindcss:utils/rule.js"; 15 | import { getExactClassLocation, splitClasses } from "better-tailwindcss:utils/utils.js"; 16 | 17 | import type { Rule } from "eslint"; 18 | 19 | import type { Literal } from "better-tailwindcss:types/ast.js"; 20 | import type { 21 | AttributeOption, 22 | CalleeOption, 23 | ESLintRule, 24 | TagOption, 25 | VariableOption 26 | } from "better-tailwindcss:types/rule.js"; 27 | 28 | 29 | export type Options = [ 30 | Partial< 31 | AttributeOption & 32 | CalleeOption & 33 | TagOption & 34 | VariableOption & 35 | { 36 | restrict?: string[]; 37 | } 38 | > 39 | ]; 40 | 41 | const defaultOptions = { 42 | attributes: DEFAULT_ATTRIBUTE_NAMES, 43 | callees: DEFAULT_CALLEE_NAMES, 44 | restrict: [], 45 | tags: DEFAULT_TAG_NAMES, 46 | variables: DEFAULT_VARIABLE_NAMES 47 | } as const satisfies Options[0]; 48 | 49 | const DOCUMENTATION_URL = "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-restricted-classes.md"; 50 | 51 | export const noRestrictedClasses: ESLintRule = { 52 | name: "no-restricted-classes" as const, 53 | rule: { 54 | create: ctx => createRuleListener(ctx, getOptions(ctx), lintLiterals), 55 | meta: { 56 | docs: { 57 | description: "Disallow restricted classes.", 58 | recommended: false, 59 | url: DOCUMENTATION_URL 60 | }, 61 | schema: [ 62 | { 63 | additionalProperties: false, 64 | properties: { 65 | ...CALLEE_SCHEMA, 66 | ...ATTRIBUTE_SCHEMA, 67 | ...VARIABLE_SCHEMA, 68 | ...TAG_SCHEMA, 69 | restrict: { 70 | items: { 71 | type: "string" 72 | }, 73 | type: "array" 74 | } 75 | }, 76 | type: "object" 77 | } 78 | ], 79 | type: "problem" 80 | } 81 | } 82 | }; 83 | 84 | 85 | function lintLiterals(ctx: Rule.RuleContext, literals: Literal[]) { 86 | 87 | const { restrict: restrictions } = getOptions(ctx); 88 | 89 | for(const literal of literals){ 90 | const classes = literal.content; 91 | 92 | const classNames = splitClasses(classes); 93 | const restrict = classNames.filter(className => { 94 | return restrictions.some(restriction => className.match(restriction)); 95 | }); 96 | 97 | for(const restrictedClass of restrict){ 98 | ctx.report({ 99 | data: { 100 | restrictedClass 101 | }, 102 | loc: getExactClassLocation(literal, restrictedClass), 103 | message: "Restricted class: \"{{ restrictedClass }}\"." 104 | }); 105 | } 106 | 107 | } 108 | 109 | } 110 | 111 | export function getOptions(ctx: Rule.RuleContext) { 112 | 113 | const options: Options[0] = ctx.options[0] ?? {}; 114 | 115 | const common = getCommonOptions(ctx); 116 | 117 | const restrict = options.restrict ?? defaultOptions.restrict; 118 | 119 | return { 120 | ...common, 121 | restrict 122 | }; 123 | 124 | } 125 | -------------------------------------------------------------------------------- /src/rules/no-unregistered-classes.ts: -------------------------------------------------------------------------------- 1 | import { getUnregisteredClasses } from "better-tailwindcss:async/unregistered-classes.sync.js"; 2 | import { 3 | DEFAULT_ATTRIBUTE_NAMES, 4 | DEFAULT_CALLEE_NAMES, 5 | DEFAULT_TAG_NAMES, 6 | DEFAULT_VARIABLE_NAMES 7 | } from "better-tailwindcss:options/default-options.js"; 8 | import { 9 | ATTRIBUTE_SCHEMA, 10 | CALLEE_SCHEMA, 11 | ENTRYPOINT_SCHEMA, 12 | TAG_SCHEMA, 13 | TAILWIND_CONFIG_SCHEMA, 14 | VARIABLE_SCHEMA 15 | } from "better-tailwindcss:options/descriptions.js"; 16 | import { getCommonOptions } from "better-tailwindcss:utils/options.js"; 17 | import { createRuleListener } from "better-tailwindcss:utils/rule.js"; 18 | import { 19 | augmentMessageWithWarnings, 20 | display, 21 | getExactClassLocation, 22 | splitClasses 23 | } from "better-tailwindcss:utils/utils.js"; 24 | 25 | import type { Rule } from "eslint"; 26 | 27 | import type { Literal } from "better-tailwindcss:types/ast.js"; 28 | import type { 29 | AttributeOption, 30 | CalleeOption, 31 | ESLintRule, 32 | TagOption, 33 | VariableOption 34 | } from "better-tailwindcss:types/rule.js"; 35 | 36 | 37 | export type Options = [ 38 | Partial< 39 | AttributeOption & 40 | CalleeOption & 41 | TagOption & 42 | VariableOption & 43 | { 44 | entryPoint?: string; 45 | ignore?: string[]; 46 | tailwindConfig?: string; 47 | } 48 | > 49 | ]; 50 | 51 | export const DEFAULT_IGNORED_UNREGISTERED_CLASSES = [ 52 | "^group(?:\\/(\\S*))?$", 53 | "^peer(?:\\/(\\S*))?$" 54 | ]; 55 | 56 | const defaultOptions = { 57 | attributes: DEFAULT_ATTRIBUTE_NAMES, 58 | callees: DEFAULT_CALLEE_NAMES, 59 | ignore: DEFAULT_IGNORED_UNREGISTERED_CLASSES, 60 | tags: DEFAULT_TAG_NAMES, 61 | variables: DEFAULT_VARIABLE_NAMES 62 | } as const satisfies Options[0]; 63 | 64 | const DOCUMENTATION_URL = "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-unregistered-classes.md"; 65 | 66 | export const noUnregisteredClasses: ESLintRule = { 67 | name: "no-unregistered-classes" as const, 68 | rule: { 69 | create: ctx => createRuleListener(ctx, getOptions(ctx), lintLiterals), 70 | meta: { 71 | docs: { 72 | description: "Disallow any css classes that are not registered in tailwindcss.", 73 | recommended: false, 74 | url: DOCUMENTATION_URL 75 | }, 76 | fixable: "code", 77 | schema: [ 78 | { 79 | additionalProperties: false, 80 | properties: { 81 | ...CALLEE_SCHEMA, 82 | ...ATTRIBUTE_SCHEMA, 83 | ...VARIABLE_SCHEMA, 84 | ...TAG_SCHEMA, 85 | ...ENTRYPOINT_SCHEMA, 86 | ...TAILWIND_CONFIG_SCHEMA, 87 | ignore: { 88 | description: "A list of classes that should be ignored by the rule.", 89 | items: { 90 | type: "string" 91 | }, 92 | type: "array" 93 | } 94 | }, 95 | type: "object" 96 | } 97 | ], 98 | type: "problem" 99 | } 100 | } 101 | }; 102 | 103 | function lintLiterals(ctx: Rule.RuleContext, literals: Literal[]) { 104 | 105 | for(const literal of literals){ 106 | 107 | const { ignore, tailwindConfig } = getOptions(ctx); 108 | 109 | const classes = splitClasses(literal.content); 110 | 111 | const [unregisteredClasses, warnings] = getUnregisteredClasses({ classes, configPath: tailwindConfig, cwd: ctx.cwd }); 112 | 113 | const unregisteredClassesWarnings = warnings.map(warning => ({ ...warning, url: DOCUMENTATION_URL })); 114 | 115 | if(unregisteredClasses.length === 0){ 116 | continue; 117 | } 118 | 119 | for(const unregisteredClass of unregisteredClasses){ 120 | if(ignore.some(ignoredClass => unregisteredClass.match(ignoredClass))){ 121 | continue; 122 | } 123 | 124 | ctx.report({ 125 | data: { 126 | unregistered: display(unregisteredClass) 127 | }, 128 | loc: getExactClassLocation(literal, unregisteredClass), 129 | message: augmentMessageWithWarnings("Unregistered class detected: {{ unregistered }}", unregisteredClassesWarnings) 130 | }); 131 | } 132 | 133 | } 134 | } 135 | 136 | export function getOptions(ctx: Rule.RuleContext) { 137 | 138 | const options: Options[0] = ctx.options[0] ?? {}; 139 | 140 | const common = getCommonOptions(ctx); 141 | 142 | const ignore = options.ignore ?? defaultOptions.ignore; 143 | 144 | return { 145 | ...common, 146 | ignore 147 | }; 148 | 149 | } 150 | -------------------------------------------------------------------------------- /src/tailwind/api/interface.ts: -------------------------------------------------------------------------------- 1 | import type { Warning } from "better-tailwindcss:utils/utils.js"; 2 | 3 | 4 | export type ConfigWarning = Omit & Partial>; 5 | 6 | 7 | export interface GetClassOrderRequest { 8 | classes: string[]; 9 | cwd: string; 10 | configPath?: string; 11 | } 12 | export type GetClassOrderResponse = [classOrder: [className: string, order: bigint | null][], warnings: ConfigWarning[]]; 13 | 14 | 15 | export interface GetUnregisteredClassesRequest { 16 | classes: string[]; 17 | cwd: string; 18 | configPath?: string; 19 | } 20 | export type GetUnregisteredClassesResponse = [unregisteredClasses: string[], warnings: ConfigWarning[]]; 21 | 22 | 23 | export interface GetConflictingClassesRequest { 24 | classes: string[]; 25 | cwd: string; 26 | configPath?: string; 27 | } 28 | 29 | export type ConflictingClasses = { 30 | [className: string]: { 31 | cssPropertyName: string; 32 | important: boolean; 33 | tailwindClassName: string; 34 | cssPropertyValue?: string; 35 | }[]; 36 | }; 37 | 38 | export type GetConflictingClassesResponse = [conflictingClasses: ConflictingClasses, warnings: ConfigWarning[]]; 39 | -------------------------------------------------------------------------------- /src/tailwind/async/class-order.async.ts: -------------------------------------------------------------------------------- 1 | import { runAsWorker } from "synckit"; 2 | 3 | import type { GetClassOrderRequest } from "../api/interface.js"; 4 | import type { SupportedTailwindVersion } from "../utils/version.js"; 5 | 6 | 7 | let getClassOrderModule: typeof import("../v3/class-order.js") | typeof import("../v4/class-order.js"); 8 | 9 | runAsWorker(async (version: SupportedTailwindVersion, request: GetClassOrderRequest) => { 10 | getClassOrderModule ??= await import(`../v${version}/class-order.js`); 11 | return getClassOrderModule.getClassOrder(request); 12 | }); 13 | -------------------------------------------------------------------------------- /src/tailwind/async/class-order.sync.ts: -------------------------------------------------------------------------------- 1 | // runner.js 2 | import { resolve } from "node:path"; 3 | import { env } from "node:process"; 4 | 5 | import { createSyncFn, TsRunner } from "synckit"; 6 | 7 | import { getTailwindcssVersion, isSupportedVersion } from "../utils/version.js"; 8 | 9 | import type { GetClassOrderRequest, GetClassOrderResponse } from "../api/interface.js"; 10 | import type { SupportedTailwindVersion } from "../utils/version.js"; 11 | 12 | 13 | const workerPath = getWorkerPath(); 14 | const version = getTailwindcssVersion(); 15 | const workerOptions = getWorkerOptions(); 16 | 17 | const getClassOrderSync = createSyncFn<(version: SupportedTailwindVersion, request: GetClassOrderRequest) => any>(workerPath, workerOptions); 18 | 19 | 20 | export function getClassOrder(request: GetClassOrderRequest): GetClassOrderResponse { 21 | if(!isSupportedVersion(version.major)){ 22 | throw new Error(`Unsupported Tailwind CSS version: ${version.major}`); 23 | } 24 | 25 | return getClassOrderSync(version.major, request) as GetClassOrderResponse; 26 | } 27 | 28 | 29 | function getWorkerPath() { 30 | return resolve(getCurrentDirectory(), "./class-order.async.js"); 31 | } 32 | 33 | function getWorkerOptions() { 34 | if(env.NODE_ENV === "test"){ 35 | return { execArgv: ["--import", TsRunner.TSX] }; 36 | } 37 | } 38 | 39 | function getCurrentDirectory() { 40 | // eslint-disable-next-line eslint-plugin-typescript/prefer-ts-expect-error 41 | // @ts-ignore - `import.meta` doesn't exist in CommonJS -> will be transformed in build step 42 | return import.meta.dirname; 43 | } 44 | -------------------------------------------------------------------------------- /src/tailwind/async/conflicting-classes.async.ts: -------------------------------------------------------------------------------- 1 | import { runAsWorker } from "synckit"; 2 | 3 | import type { GetConflictingClassesRequest } from "../api/interface.js"; 4 | import type { TailwindcssVersion } from "../utils/version.js"; 5 | 6 | 7 | let getConflictingClassesModule: typeof import("../v4/conflicting-classes.js"); 8 | 9 | runAsWorker(async (version: TailwindcssVersion.V4, request: GetConflictingClassesRequest) => { 10 | getConflictingClassesModule ??= await import(`../v${version}/conflicting-classes.js`); 11 | return getConflictingClassesModule.getConflictingClasses(request); 12 | }); 13 | -------------------------------------------------------------------------------- /src/tailwind/async/conflicting-classes.sync.ts: -------------------------------------------------------------------------------- 1 | // runner.js 2 | import { resolve } from "node:path"; 3 | import { env } from "node:process"; 4 | 5 | import { createSyncFn, TsRunner } from "synckit"; 6 | 7 | import { getTailwindcssVersion, isTailwindcssVersion4 } from "../utils/version.js"; 8 | 9 | import type { GetConflictingClassesRequest, GetConflictingClassesResponse } from "../api/interface.js"; 10 | import type { TailwindcssVersion } from "../utils/version.js"; 11 | 12 | 13 | const workerPath = getWorkerPath(); 14 | const version = getTailwindcssVersion(); 15 | const workerOptions = getWorkerOptions(); 16 | 17 | const getConflictingClassesSync = createSyncFn<(version: TailwindcssVersion.V4, request: GetConflictingClassesRequest) => any>(workerPath, workerOptions); 18 | 19 | 20 | export function getConflictingClasses(request: GetConflictingClassesRequest): GetConflictingClassesResponse { 21 | if(!isTailwindcssVersion4(version.major)){ 22 | throw new Error(`Unsupported Tailwind CSS version: ${version.major}`); 23 | } 24 | 25 | return getConflictingClassesSync(version.major, request) as GetConflictingClassesResponse; 26 | } 27 | 28 | function getWorkerPath() { 29 | return resolve(getCurrentDirectory(), "./conflicting-classes.async.js"); 30 | } 31 | 32 | function getWorkerOptions() { 33 | if(env.NODE_ENV === "test"){ 34 | return { execArgv: ["--import", TsRunner.TSX] }; 35 | } 36 | } 37 | 38 | function getCurrentDirectory() { 39 | // eslint-disable-next-line eslint-plugin-typescript/prefer-ts-expect-error 40 | // @ts-ignore - `import.meta` doesn't exist in CommonJS -> will be transformed in build step 41 | return import.meta.dirname; 42 | } 43 | -------------------------------------------------------------------------------- /src/tailwind/async/unregistered-classes.async.ts: -------------------------------------------------------------------------------- 1 | import { runAsWorker } from "synckit"; 2 | 3 | import type { GetUnregisteredClassesRequest } from "../api/interface.js"; 4 | import type { SupportedTailwindVersion } from "../utils/version.js"; 5 | 6 | 7 | let getUnregisteredClassesModule: typeof import("../v3/unregistered-classes.js") | typeof import("../v4/unregistered-classes.js"); 8 | 9 | runAsWorker(async (version: SupportedTailwindVersion, request: GetUnregisteredClassesRequest) => { 10 | getUnregisteredClassesModule ??= await import(`../v${version}/unregistered-classes.js`); 11 | return getUnregisteredClassesModule.getUnregisteredClasses(request); 12 | }); 13 | -------------------------------------------------------------------------------- /src/tailwind/async/unregistered-classes.sync.ts: -------------------------------------------------------------------------------- 1 | // runner.js 2 | import { resolve } from "node:path"; 3 | import { env } from "node:process"; 4 | 5 | import { createSyncFn, TsRunner } from "synckit"; 6 | 7 | import { getTailwindcssVersion, isSupportedVersion } from "../utils/version.js"; 8 | 9 | import type { GetUnregisteredClassesRequest, GetUnregisteredClassesResponse } from "../api/interface.js"; 10 | import type { SupportedTailwindVersion } from "../utils/version.js"; 11 | 12 | 13 | const workerPath = getWorkerPath(); 14 | const version = getTailwindcssVersion(); 15 | const workerOptions = getWorkerOptions(); 16 | 17 | const getUnregisteredClassesSync = createSyncFn<(version: SupportedTailwindVersion, request: GetUnregisteredClassesRequest) => any>(workerPath, workerOptions); 18 | 19 | 20 | export function getUnregisteredClasses(request: GetUnregisteredClassesRequest): GetUnregisteredClassesResponse { 21 | if(!isSupportedVersion(version.major)){ 22 | throw new Error(`Unsupported Tailwind CSS version: ${version.major}`); 23 | } 24 | 25 | return getUnregisteredClassesSync(version.major, request) as GetUnregisteredClassesResponse; 26 | } 27 | 28 | 29 | function getWorkerPath() { 30 | return resolve(getCurrentDirectory(), "./unregistered-classes.async.js"); 31 | } 32 | 33 | function getWorkerOptions() { 34 | if(env.NODE_ENV === "test"){ 35 | return { execArgv: ["--import", TsRunner.TSX] }; 36 | } 37 | } 38 | 39 | function getCurrentDirectory() { 40 | // eslint-disable-next-line eslint-plugin-typescript/prefer-ts-expect-error 41 | // @ts-ignore - `import.meta` doesn't exist in CommonJS -> will be transformed in build step 42 | return import.meta.dirname; 43 | } 44 | -------------------------------------------------------------------------------- /src/tailwind/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import { getModifiedDate } from "../utils/fs.js"; 2 | 3 | 4 | const CACHE = new Map(); 5 | 6 | export async function withCache(key: string, callback: () => Promise): Promise { 7 | const modified = getModifiedDate(key); 8 | const cached = CACHE.get(key); 9 | 10 | if(cached && cached.date > modified){ 11 | return cached.value; 12 | } 13 | 14 | const value = await callback(); 15 | CACHE.set(key, { date: new Date(), value }); 16 | 17 | return value; 18 | } 19 | -------------------------------------------------------------------------------- /src/tailwind/utils/fs.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, statSync } from "node:fs"; 2 | import { basename, dirname, resolve } from "node:path"; 3 | 4 | 5 | export function findFileRecursive(cwd: string, paths: string[]): string | undefined { 6 | const resolvedPaths = paths.map(p => resolve(cwd, p)); 7 | 8 | for(let resolvedPath = resolvedPaths.shift(); resolvedPath !== undefined; resolvedPath = resolvedPaths.shift()){ 9 | 10 | if(existsSync(resolvedPath)){ 11 | const stat = statSync(resolvedPath); 12 | 13 | if(!stat.isFile()){ 14 | continue; 15 | } 16 | 17 | return resolvedPath; 18 | } 19 | 20 | const fileName = basename(resolvedPath); 21 | const directory = dirname(resolvedPath); 22 | 23 | const parentDirectory = resolve(directory, ".."); 24 | const parentPath = resolve(parentDirectory, fileName); 25 | 26 | if(parentDirectory === directory || directory === cwd){ 27 | continue; 28 | } 29 | 30 | resolvedPaths.push(parentPath); 31 | } 32 | } 33 | 34 | export function getModifiedDate(filePath: string): Date { 35 | try { 36 | const stats = statSync(filePath); 37 | return stats.mtime; 38 | } catch { 39 | return new Date(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/tailwind/utils/module.ts: -------------------------------------------------------------------------------- 1 | export function isCommonJSModule() { 2 | return typeof module !== "undefined" && typeof module.exports !== "undefined"; 3 | } 4 | -------------------------------------------------------------------------------- /src/tailwind/utils/platform.ts: -------------------------------------------------------------------------------- 1 | export function isWindows() { 2 | return process.platform === "win32"; 3 | } 4 | -------------------------------------------------------------------------------- /src/tailwind/utils/resolvers.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | 3 | import enhancedResolve from "enhanced-resolve"; 4 | 5 | 6 | const fileSystem = new enhancedResolve.CachedInputFileSystem(fs, 30_000); 7 | 8 | export const esmResolver = enhancedResolve.ResolverFactory.createResolver({ 9 | conditionNames: ["node", "import"], 10 | extensions: [".mjs", ".js"], 11 | fileSystem, 12 | mainFields: ["module"], 13 | useSyncFileSystemCalls: true 14 | }); 15 | 16 | export const cjsResolver = enhancedResolve.ResolverFactory.createResolver({ 17 | conditionNames: ["node", "require"], 18 | extensions: [".js", ".cjs"], 19 | fileSystem, 20 | mainFields: ["main"], 21 | useSyncFileSystemCalls: true 22 | }); 23 | 24 | export const cssResolver = enhancedResolve.ResolverFactory.createResolver({ 25 | conditionNames: ["style"], 26 | extensions: [".css"], 27 | fileSystem, 28 | mainFields: ["style"], 29 | useSyncFileSystemCalls: true 30 | }); 31 | 32 | export const jsonResolver = enhancedResolve.ResolverFactory.createResolver({ 33 | conditionNames: ["json"], 34 | extensions: [".json"], 35 | fileSystem, 36 | useSyncFileSystemCalls: true 37 | }); 38 | -------------------------------------------------------------------------------- /src/tailwind/utils/version.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "node:fs"; 2 | 3 | import { jsonResolver } from "../utils/resolvers.js"; 4 | 5 | 6 | export const enum TailwindcssVersion { 7 | V3 = 3, 8 | V4 = 4 9 | } 10 | 11 | export type SupportedTailwindVersion = TailwindcssVersion.V3 | TailwindcssVersion.V4; 12 | 13 | export function isSupportedVersion(version: number): version is SupportedTailwindVersion { 14 | return version === TailwindcssVersion.V3 || version === TailwindcssVersion.V4; 15 | } 16 | 17 | export function isTailwindcssVersion3(version: number): version is TailwindcssVersion.V3 { 18 | return version === TailwindcssVersion.V3; 19 | } 20 | 21 | export function isTailwindcssVersion4(version: number): version is TailwindcssVersion.V4 { 22 | return version === TailwindcssVersion.V4; 23 | } 24 | 25 | export function getTailwindcssVersion() { 26 | const packageJsonPath = jsonResolver.resolveSync({}, process.cwd(), "tailwindcss/package.json"); 27 | const packageJson = packageJsonPath && JSON.parse(readFileSync(packageJsonPath, "utf-8")); 28 | 29 | if(!packageJson){ 30 | throw new Error("Could not find a Tailwind CSS package.json"); 31 | } 32 | 33 | return parseSemanticVersion(packageJson.version); 34 | } 35 | 36 | function parseSemanticVersion(version: string): { major: number; minor: number; patch: number; identifier?: string; } { 37 | const [major, minor, patchString] = version.split("."); 38 | const [patch, identifier] = patchString.split("-"); 39 | 40 | return { identifier, major: +major, minor: +minor, patch: +patch }; 41 | } 42 | -------------------------------------------------------------------------------- /src/tailwind/v3/class-order.ts: -------------------------------------------------------------------------------- 1 | import { findTailwindConfig } from "./config.js"; 2 | import { createTailwindContextFromConfigFile } from "./context.js"; 3 | 4 | import type { ConfigWarning, GetClassOrderRequest, GetClassOrderResponse } from "../api/interface.js"; 5 | 6 | 7 | export async function getClassOrder({ classes, configPath, cwd }: GetClassOrderRequest): Promise { 8 | const warnings: ConfigWarning[] = []; 9 | 10 | const config = findTailwindConfig(cwd, configPath); 11 | 12 | if(!config){ 13 | warnings.push({ 14 | option: "entryPoint", 15 | title: `No tailwind css config found at \`${configPath}\`` 16 | }); 17 | } 18 | 19 | const context = await createTailwindContextFromConfigFile(config); 20 | 21 | return [context.getClassOrder(classes), warnings]; 22 | } 23 | -------------------------------------------------------------------------------- /src/tailwind/v3/config.ts: -------------------------------------------------------------------------------- 1 | import { findFileRecursive } from "../utils/fs.js"; 2 | 3 | 4 | export function findTailwindConfig(cwd: string, configPath?: string) { 5 | const potentialPaths = [ 6 | ...configPath ? [configPath] : [], 7 | "tailwind.config.js", 8 | "tailwind.config.cjs", 9 | "tailwind.config.mjs", 10 | "tailwind.config.ts" 11 | ]; 12 | 13 | return findFileRecursive(cwd, potentialPaths); 14 | } 15 | -------------------------------------------------------------------------------- /src/tailwind/v3/context.ts: -------------------------------------------------------------------------------- 1 | import defaultConfig from "tailwindcss3/defaultConfig.js"; 2 | import * as setupContextUtils from "tailwindcss3/lib/lib/setupContextUtils.js"; 3 | import loadConfig from "tailwindcss3/loadConfig.js"; 4 | import resolveConfig from "tailwindcss3/resolveConfig.js"; 5 | 6 | import { withCache } from "../utils/cache.js"; 7 | 8 | 9 | export function loadTailwindConfig(path: string) { 10 | const config = path === "default" 11 | ? defaultConfig 12 | : loadConfig(path); 13 | 14 | return resolveConfig(config); 15 | } 16 | 17 | export const createTailwindContextFromConfigFile = async (path: string = "default") => withCache(path, async () => { 18 | const tailwindConfig = loadTailwindConfig(path); 19 | 20 | return setupContextUtils.createContext?.(tailwindConfig) ?? setupContextUtils.default?.createContext?.(tailwindConfig); 21 | }); 22 | -------------------------------------------------------------------------------- /src/tailwind/v3/unregistered-classes.ts: -------------------------------------------------------------------------------- 1 | import * as rules from "tailwindcss3/lib/lib/generateRules.js"; 2 | 3 | import { findTailwindConfig } from "./config.js"; 4 | import { createTailwindContextFromConfigFile } from "./context.js"; 5 | 6 | import type { 7 | ConfigWarning, 8 | GetUnregisteredClassesRequest, 9 | GetUnregisteredClassesResponse 10 | } from "../api/interface.js"; 11 | 12 | 13 | export async function getUnregisteredClasses({ classes, configPath, cwd }: GetUnregisteredClassesRequest): Promise { 14 | const warnings: ConfigWarning[] = []; 15 | 16 | const config = findTailwindConfig(cwd, configPath); 17 | 18 | if(!config){ 19 | warnings.push({ 20 | option: "entryPoint", 21 | title: `No tailwind css config found at \`${configPath}\`` 22 | }); 23 | } 24 | 25 | const context = await createTailwindContextFromConfigFile(config); 26 | 27 | const invalidClasses = classes 28 | .filter(className => { 29 | return (rules.generateRules?.([className], context) ?? rules.default?.generateRules?.([className], context)).length === 0; 30 | }); 31 | 32 | return [invalidClasses, warnings]; 33 | } 34 | -------------------------------------------------------------------------------- /src/tailwind/v4/class-order.ts: -------------------------------------------------------------------------------- 1 | import { findDefaultConfig, findTailwindConfigPath } from "./config.js"; 2 | import { createTailwindContextFromEntryPoint } from "./context.js"; 3 | 4 | import type { ConfigWarning, GetClassOrderRequest, GetClassOrderResponse } from "../api/interface.js"; 5 | 6 | 7 | export async function getClassOrder({ classes, configPath, cwd }: GetClassOrderRequest): Promise { 8 | const warnings: ConfigWarning[] = []; 9 | 10 | const config = findTailwindConfigPath(cwd, configPath); 11 | const defaultConfig = findDefaultConfig(cwd); 12 | 13 | if(!config){ 14 | warnings.push({ 15 | option: "entryPoint", 16 | title: configPath 17 | ? `No tailwind css config found at \`${configPath}\`` 18 | : "No tailwind css entry point configured" 19 | }); 20 | } 21 | 22 | const path = config ?? defaultConfig; 23 | 24 | if(!path){ 25 | throw new Error("Could not find a valid Tailwind CSS configuration"); 26 | } 27 | 28 | const context = await createTailwindContextFromEntryPoint(path); 29 | 30 | return [context.getClassOrder(classes), warnings]; 31 | } 32 | -------------------------------------------------------------------------------- /src/tailwind/v4/config.ts: -------------------------------------------------------------------------------- 1 | import { findFileRecursive } from "../utils/fs.js"; 2 | import { cssResolver } from "../utils/resolvers.js"; 3 | 4 | 5 | export function findTailwindConfigPath(cwd: string, configPath?: string) { 6 | const potentialStylesheetPaths = [ 7 | ...configPath ? [configPath] : [] 8 | ]; 9 | 10 | return findFileRecursive(cwd, potentialStylesheetPaths); 11 | } 12 | 13 | export function findDefaultConfig(cwd: string) { 14 | return cssResolver.resolveSync({}, cwd, "tailwindcss/theme.css"); 15 | } 16 | -------------------------------------------------------------------------------- /src/tailwind/v4/context.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import path, { dirname } from "node:path"; 3 | import { pathToFileURL } from "node:url"; 4 | 5 | import { createJiti } from "jiti"; 6 | import postcss from "postcss"; 7 | import postcssImport from "postcss-import"; 8 | 9 | import { withCache } from "../utils/cache.js"; 10 | import { isCommonJSModule } from "../utils/module.js"; 11 | import { isWindows } from "../utils/platform.js"; 12 | import { cjsResolver, cssResolver, esmResolver } from "../utils/resolvers.js"; 13 | 14 | 15 | function resolveJsFrom(base: string, id: string): string { 16 | try { 17 | return esmResolver.resolveSync({}, base, id) || id; 18 | } catch (err){ 19 | return cjsResolver.resolveSync({}, base, id) || id; 20 | } 21 | } 22 | 23 | function resolveCssFrom(base: string, id: string) { 24 | return cssResolver.resolveSync({}, base, id) || id; 25 | } 26 | 27 | function createLoader({ 28 | filepath, 29 | jiti, 30 | legacy, 31 | onError 32 | }: { 33 | filepath: string; 34 | jiti: ReturnType; 35 | legacy: boolean; 36 | onError: (id: string, error: unknown, resourceType: string) => T; 37 | }) { 38 | const cacheKey = `${+Date.now()}`; 39 | 40 | async function loadFile(id: string, base: string, resourceType: string) { 41 | try { 42 | const resolved = resolveJsFrom(base, id); 43 | 44 | const url = pathToFileURL(resolved); 45 | url.searchParams.append("t", cacheKey); 46 | 47 | return await jiti.import(url.href, { default: true }); 48 | } catch (err){ 49 | return onError(id, err, resourceType); 50 | } 51 | } 52 | 53 | if(legacy){ 54 | const baseDir = path.dirname(filepath); 55 | return async (id: string) => loadFile(id, baseDir, "module"); 56 | } 57 | 58 | return async (id: string, base: string, resourceType: string) => { 59 | return { 60 | base, 61 | module: await loadFile(id, base, resourceType) 62 | }; 63 | }; 64 | } 65 | 66 | 67 | export const createTailwindContextFromEntryPoint = async (entryPoint: string) => withCache(entryPoint, async () => { 68 | 69 | // Create a Jiti instance that can be used to load plugins and config files 70 | const jiti = createJiti(getCurrentFilename(), { 71 | fsCache: false, 72 | moduleCache: false 73 | }); 74 | 75 | const importBasePath = dirname(entryPoint); 76 | 77 | const tailwindPath = isCommonJSModule() 78 | ? cjsResolver.resolveSync({}, importBasePath, "tailwindcss") 79 | : esmResolver.resolveSync({}, importBasePath, "tailwindcss"); 80 | 81 | if(!tailwindPath){ 82 | throw new Error("Could not find Tailwind CSS"); 83 | } 84 | 85 | const tailwindUrl = isWindows() ? pathToFileURL(tailwindPath).toString() : tailwindPath; 86 | 87 | // eslint-disable-next-line eslint-plugin-typescript/naming-convention 88 | const { __unstable__loadDesignSystem } = await import(tailwindUrl); 89 | 90 | let css = await readFile(entryPoint, "utf-8"); 91 | 92 | // Determine if the v4 API supports resolving `@import` 93 | let supportsImports = false; 94 | try { 95 | await __unstable__loadDesignSystem('@import "./empty";', { 96 | loadStylesheet: async () => { 97 | supportsImports = true; 98 | return { 99 | base: importBasePath, 100 | content: "" 101 | }; 102 | } 103 | }); 104 | } catch {} 105 | 106 | if(!supportsImports){ 107 | const resolveImports = postcss([postcssImport()]); 108 | const result = await resolveImports.process(css, { from: entryPoint }); 109 | css = result.css; 110 | } 111 | 112 | // Load the design system and set up a compatible context object that is 113 | // usable by the rest of the plugin 114 | const design = await __unstable__loadDesignSystem(css, { 115 | base: importBasePath, 116 | loadModule: createLoader({ 117 | filepath: entryPoint, 118 | jiti, 119 | legacy: false, 120 | onError: (id, err, resourceType) => { 121 | console.error(`Unable to load ${resourceType}: ${id}`, err); 122 | 123 | if(resourceType === "config"){ 124 | return {}; 125 | } else if(resourceType === "plugin"){ 126 | return () => {}; 127 | } 128 | } 129 | }), 130 | 131 | loadStylesheet: async (id: string, base: string) => { 132 | const resolved = resolveCssFrom(base, id); 133 | 134 | return { 135 | base: path.dirname(resolved), 136 | content: await readFile(resolved, "utf-8") 137 | }; 138 | } 139 | }); 140 | 141 | return design; 142 | }); 143 | 144 | function getCurrentFilename() { 145 | // eslint-disable-next-line eslint-plugin-typescript/prefer-ts-expect-error 146 | // @ts-ignore - `import.meta` doesn't exist in CommonJS -> will be transformed in build step 147 | return import.meta.url; 148 | } 149 | -------------------------------------------------------------------------------- /src/tailwind/v4/unregistered-classes.ts: -------------------------------------------------------------------------------- 1 | import { findDefaultConfig, findTailwindConfigPath } from "./config.js"; 2 | import { createTailwindContextFromEntryPoint } from "./context.js"; 3 | 4 | import type { 5 | ConfigWarning, 6 | GetUnregisteredClassesRequest, 7 | GetUnregisteredClassesResponse 8 | } from "../api/interface.js"; 9 | 10 | 11 | export async function getUnregisteredClasses({ classes, configPath, cwd }: GetUnregisteredClassesRequest): Promise { 12 | const warnings: ConfigWarning[] = []; 13 | 14 | const config = findTailwindConfigPath(cwd, configPath); 15 | const defaultConfig = findDefaultConfig(cwd); 16 | 17 | if(!config){ 18 | warnings.push({ 19 | option: "entryPoint", 20 | title: configPath 21 | ? `No tailwind css config found at \`${configPath}\`` 22 | : "No tailwind css entry point configured" 23 | }); 24 | } 25 | 26 | const path = config ?? defaultConfig; 27 | 28 | if(!path){ 29 | throw new Error("Could not find a valid Tailwind CSS configuration"); 30 | } 31 | 32 | const context = await createTailwindContextFromEntryPoint(path); 33 | 34 | const css = context.candidatesToCss(classes); 35 | const invalidClasses = classes.filter((_, index) => css.at(index) === null); 36 | 37 | return [invalidClasses, warnings]; 38 | } 39 | -------------------------------------------------------------------------------- /src/types/ast.ts: -------------------------------------------------------------------------------- 1 | export type LiteralValueQuotes = "'" | "\"" | "\\`" | "`"; 2 | 3 | export interface Range { 4 | range: [number, number]; 5 | } 6 | 7 | export interface Loc { 8 | loc: { 9 | end: { 10 | column: number; 11 | line: number; 12 | }; 13 | start: { 14 | column: number; 15 | line: number; 16 | }; 17 | }; 18 | } 19 | 20 | export interface MultilineMeta { 21 | multilineQuotes?: LiteralValueQuotes[]; 22 | supportsMultiline?: boolean; 23 | surroundingBraces?: boolean; 24 | } 25 | 26 | export interface WhitespaceMeta { 27 | leadingWhitespace?: string; 28 | trailingWhitespace?: string; 29 | } 30 | 31 | export interface QuoteMeta { 32 | closingQuote?: LiteralValueQuotes; 33 | openingQuote?: LiteralValueQuotes; 34 | } 35 | export interface BracesMeta { 36 | closingBraces?: string; 37 | openingBraces?: string; 38 | } 39 | 40 | export interface Indentation { 41 | indentation: number; 42 | } 43 | 44 | interface NodeBase extends Range, Loc { 45 | [key: PropertyKey]: unknown; 46 | type: string; 47 | } 48 | 49 | interface LiteralBase extends NodeBase, MultilineMeta, QuoteMeta, BracesMeta, WhitespaceMeta, Indentation, Range, Loc { 50 | content: string; 51 | raw: string; 52 | priorLiterals?: Literal[]; 53 | } 54 | 55 | export interface TemplateLiteral extends LiteralBase { 56 | type: "TemplateLiteral"; 57 | } 58 | 59 | export interface StringLiteral extends LiteralBase { 60 | type: "StringLiteral"; 61 | } 62 | 63 | export type Literal = StringLiteral | TemplateLiteral; 64 | -------------------------------------------------------------------------------- /src/types/rule.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from "eslint"; 2 | 3 | 4 | export enum MatcherType { 5 | /** Matches all object keys that are strings. */ 6 | ObjectKey = "objectKeys", 7 | /** Matches all object values that are strings. */ 8 | ObjectValue = "objectValues", 9 | /** Matches all strings that are not matched by another matcher. */ 10 | String = "strings" 11 | } 12 | 13 | export type StringMatcher = { 14 | match: MatcherType.String; 15 | }; 16 | 17 | export type ObjectKeyMatcher = { 18 | match: MatcherType.ObjectKey; 19 | pathPattern?: Regex; 20 | }; 21 | 22 | export type ObjectValueMatcher = { 23 | match: MatcherType.ObjectValue; 24 | pathPattern?: Regex; 25 | }; 26 | 27 | export type MatcherFunction = (node: unknown) => node is Node; 28 | export type MatcherFunctions = MatcherFunction[]; 29 | 30 | export type Matcher = ObjectKeyMatcher | ObjectValueMatcher | StringMatcher; 31 | 32 | export type Regex = string; 33 | 34 | export type CalleeName = string; 35 | export type CalleeMatchers = [callee: CalleeName, matchers: Matcher[]]; 36 | export type CalleeRegex = [containerRegex: Regex, literalRegex: Regex]; 37 | export type Callees = (CalleeMatchers | CalleeName | CalleeRegex)[]; 38 | export type CalleeOption = { 39 | callees: Callees; 40 | }; 41 | 42 | export type VariableName = string; 43 | export type VariableMatchers = [variable: VariableName, matchers: Matcher[]]; 44 | export type VariableRegex = [variableNameRegex: Regex, literalRegex: Regex]; 45 | export type Variables = (VariableMatchers | VariableName | VariableRegex)[]; 46 | export type VariableOption = { 47 | variables: Variables; 48 | }; 49 | 50 | export type TagName = string; 51 | export type TagMatchers = [tag: TagName, matchers: Matcher[]]; 52 | export type TagRegex = [tagRegex: Regex, literalRegex: Regex]; 53 | export type Tags = (TagMatchers | TagName | TagRegex)[]; 54 | export type TagOption = { 55 | tags: Tags; 56 | }; 57 | 58 | export type AttributeName = string; 59 | export type AttributeMatchers = [attribute: AttributeName, matchers: Matcher[]]; 60 | export type AttributeRegex = [attributeRegex: Regex, literalRegex: Regex]; 61 | export type Attributes = (AttributeMatchers | AttributeName | AttributeRegex)[]; 62 | export type AttributeOption = { 63 | attributes: Attributes; 64 | }; 65 | 66 | export type NameConfig = AttributeName | CalleeName | VariableName; 67 | export type RegexConfig = AttributeRegex | CalleeRegex | VariableRegex; 68 | export type MatchersConfig = AttributeMatchers | CalleeMatchers | VariableMatchers; 69 | 70 | export interface ESLintRule { 71 | name: string; 72 | rule: Rule.RuleModule; 73 | options?: Options; 74 | settings?: Rule.RuleContext["settings"]; 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/options.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_ATTRIBUTE_NAMES, 3 | DEFAULT_CALLEE_NAMES, 4 | DEFAULT_TAG_NAMES, 5 | DEFAULT_VARIABLE_NAMES 6 | } from "better-tailwindcss:options/default-options.js"; 7 | import { isAttributesRegex, isCalleeRegex, isVariableRegex } from "better-tailwindcss:utils/matchers.js"; 8 | 9 | import type { Rule } from "eslint"; 10 | 11 | 12 | export function getCommonOptions(ctx: Rule.RuleContext) { 13 | 14 | const attributes = getOption(ctx, "attributes") ?? DEFAULT_ATTRIBUTE_NAMES; 15 | const callees = getOption(ctx, "callees") ?? DEFAULT_CALLEE_NAMES; 16 | const variables = getOption(ctx, "variables") ?? DEFAULT_VARIABLE_NAMES; 17 | const tags = getOption(ctx, "tags") ?? DEFAULT_TAG_NAMES; 18 | const tailwindConfig = getOption(ctx, "entryPoint") ?? getOption(ctx, "tailwindConfig"); 19 | 20 | if(isAttributesRegex(attributes) || isCalleeRegex(callees) || isVariableRegex(variables)){ 21 | console.warn("⚠️ Warning: Regex matching is deprecated and will be removed in the next major version. Please use matchers instead."); 22 | } 23 | 24 | return { 25 | attributes, 26 | callees, 27 | tags, 28 | tailwindConfig, 29 | variables 30 | }; 31 | } 32 | function getOption(ctx: Rule.RuleContext, key: string) { 33 | return ctx.options[0]?.[key] ?? ctx.settings["eslint-plugin-better-tailwindcss"]?.[key] ?? 34 | ctx.settings["better-tailwindcss"]?.[key]; 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/quotes.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { escapeNestedQuotes } from "better-tailwindcss:utils/quotes.js"; 4 | 5 | 6 | describe("escapeNestedQuotes", () => { 7 | it("should escape all nested quotes", () => { 8 | expect(escapeNestedQuotes('content-[""]', "\"")).toBe('content-[\\"\\"]'); 9 | expect(escapeNestedQuotes("content-['']", "'")).toBe("content-[\\'\\']"); 10 | }); 11 | 12 | it("should not escape quotes that are not nested", () => { 13 | expect(escapeNestedQuotes('content-[""]', "'")).toBe('content-[""]'); 14 | expect(escapeNestedQuotes("content-['']", "\"")).toBe("content-['']"); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/utils/quotes.ts: -------------------------------------------------------------------------------- 1 | import type { LiteralValueQuotes } from "better-tailwindcss:types/ast.js"; 2 | 3 | 4 | export function escapeNestedQuotes(content: string, surroundingQuotes: LiteralValueQuotes): string { 5 | const regex = surroundingQuotes === "'" 6 | ? /(?( 9 | ctx: Rule.RuleContext, 10 | node: unknown, 11 | regex: RegexConfig, 12 | { 13 | getLiteralsByMatchingNode, 14 | getNodeByRangeStart, 15 | getNodeRange, 16 | getNodeSourceCode 17 | }: { 18 | getLiteralsByMatchingNode: (node: unknown) => LiteralType[] | undefined; 19 | getNodeByRangeStart: (start: number) => unknown; 20 | getNodeRange: (node: unknown) => undefined | [number | undefined, number | undefined]; 21 | getNodeSourceCode: (node: unknown) => string | undefined; 22 | } 23 | ): LiteralType[] { 24 | const [containerRegexString, stringLiteralRegexString] = regex; 25 | 26 | const sourceCode = getNodeSourceCode(node); 27 | 28 | if(!sourceCode){ return []; } 29 | 30 | const containerRegex = new RegExp(containerRegexString, "gdm"); 31 | const stringLiteralRegex = new RegExp(stringLiteralRegexString, "gdm"); 32 | const containers = sourceCode.matchAll(containerRegex); 33 | 34 | const matchedLiterals: LiteralType[] = []; 35 | 36 | for(const container of containers){ 37 | if(!container.indices || container.indices.length < 2){ continue; } 38 | 39 | for(const [containerStartIndex] of container.indices.slice(1)){ 40 | 41 | const range = getNodeRange(node); 42 | const containerNode = getNodeByRangeStart((range?.[0] ?? 0) + containerStartIndex); 43 | 44 | if(!containerNode){ continue; } 45 | 46 | const literalNodes = getLiteralNodesByRegex(ctx, containerNode, stringLiteralRegex); 47 | 48 | for(const literalNode of literalNodes){ 49 | const literals = getLiteralsByMatchingNode(literalNode); 50 | if(!literals){ continue; } 51 | 52 | matchedLiterals.push(...literals); 53 | } 54 | } 55 | 56 | } 57 | 58 | return matchedLiterals; 59 | 60 | } 61 | 62 | function getLiteralNodesByRegex( 63 | ctx: Rule.RuleContext, 64 | node: unknown, 65 | regex: RegExp, 66 | { 67 | getNodeByRangeStart, 68 | getNodeRange, 69 | getNodeSourceCode 70 | }: { 71 | getNodeByRangeStart: (start: number) => unknown; 72 | getNodeRange: (node: unknown) => undefined | [number | undefined, number | undefined]; 73 | getNodeSourceCode: (node: unknown) => string | undefined; 74 | } = { 75 | getNodeByRangeStart: (start: number) => ctx.sourceCode.getNodeByRangeIndex(start), 76 | getNodeRange: node => isESNode(node) ? [node.range?.[0], node.range?.[1]] : undefined, 77 | getNodeSourceCode: node => isESNode(node) ? ctx.sourceCode.getText(node) : undefined 78 | } 79 | ): unknown[] { 80 | 81 | const sourceCode = getNodeSourceCode(node); 82 | 83 | if(!sourceCode){ return []; } 84 | 85 | const matchedNodes: unknown[] = []; 86 | 87 | const matches = sourceCode.matchAll(regex); 88 | 89 | for(const groups of matches){ 90 | if(!groups.indices || groups.indices.length < 2){ continue; } 91 | 92 | for(const [startIndex] of groups.indices.slice(1)){ 93 | 94 | const range = getNodeRange(node); 95 | 96 | if(!range){ continue; } 97 | 98 | const literalNode = getNodeByRangeStart((range?.[0] ?? 0) + startIndex); 99 | 100 | if(!literalNode){ continue; } 101 | 102 | matchedNodes.push(literalNode); 103 | 104 | } 105 | } 106 | 107 | return matchedNodes; 108 | 109 | } 110 | -------------------------------------------------------------------------------- /src/utils/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { escapeForRegex, matchesName } from "better-tailwindcss:utils/utils.js"; 4 | 5 | 6 | describe("matchesName", () => { 7 | it("should match name", () => { 8 | expect(matchesName("class", "class")).toBe(true); 9 | expect(matchesName("data-attribute", "data-attribute")).toBe(true); 10 | expect(matchesName("custom_variable", "custom_variable")).toBe(true); 11 | }); 12 | 13 | it("should not match partial matches", () => { 14 | expect(matchesName("class", "className")).toBe(false); 15 | }); 16 | 17 | it("should match by regex", () => { 18 | expect(matchesName("class.*", "className")).toBe(true); 19 | expect(matchesName("class$", "class$")).toBe(false); 20 | expect(matchesName("class\\$", "class$")).toBe(true); 21 | }); 22 | }); 23 | 24 | describe("escapeForRegex", () => { 25 | it("should escape an user provided string to be used in a regular expression", () => { 26 | expect(escapeForRegex(".*")).toBe("\\.\\*"); 27 | expect(escapeForRegex("hello?")).toBe("hello\\?"); 28 | expect(escapeForRegex("[abc]")).toBe("\\[abc\\]"); 29 | expect(escapeForRegex("a+b*c")).toBe("a\\+b\\*c"); 30 | expect(escapeForRegex("class$")).toBe("class\\$"); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import type { BracesMeta, Literal, QuoteMeta } from "better-tailwindcss:types/ast.js"; 2 | 3 | 4 | export function getWhitespace(classes: string) { 5 | const leadingWhitespace = classes.match(/^\s*/)?.[0]; 6 | const trailingWhitespace = classes.match(/\s*$/)?.[0]; 7 | 8 | return { leadingWhitespace, trailingWhitespace }; 9 | } 10 | 11 | export function getQuotes(raw: string): QuoteMeta { 12 | const openingQuote = raw.at(0); 13 | const closingQuote = raw.at(-1); 14 | 15 | return { 16 | closingQuote: closingQuote === "'" || closingQuote === '"' || closingQuote === "`" ? closingQuote : undefined, 17 | openingQuote: openingQuote === "'" || openingQuote === '"' || openingQuote === "`" ? openingQuote : undefined 18 | }; 19 | } 20 | 21 | export function getContent(raw: string, quotes?: QuoteMeta, braces?: BracesMeta) { 22 | return raw.substring( 23 | (quotes?.openingQuote?.length ?? 0) + (braces?.closingBraces?.length ?? 0), 24 | raw.length - (quotes?.closingQuote?.length ?? 0) - (braces?.openingBraces?.length ?? 0) 25 | ); 26 | } 27 | 28 | export function splitClasses(classes: string): string[] { 29 | if(classes.trim() === ""){ 30 | return []; 31 | } 32 | 33 | return classes 34 | .trim() 35 | .split(/\s+/); 36 | } 37 | 38 | export function display(classes: string): string { 39 | return classes 40 | .replaceAll(" ", "·") 41 | .replaceAll("\n", "↵\n") 42 | .replaceAll("\r", "↩\r") 43 | .replaceAll("\t", "→"); 44 | } 45 | 46 | export interface Warning = Record> { 47 | option: keyof Options; 48 | title: string; 49 | url: string; 50 | } 51 | 52 | export function augmentMessageWithWarnings(message: string, warnings?: Warning[]) { 53 | if(!warnings || warnings.length === 0){ 54 | return message; 55 | } 56 | 57 | return [ 58 | warnings.flatMap(({ option, title, url }) => [ 59 | `⚠️ Warning: ${title}. Option \`${option}\` may be misconfigured.`, 60 | `Check documentation at ${url}` 61 | ]).join("\n"), 62 | message 63 | ].join("\n\n"); 64 | } 65 | 66 | export function splitWhitespaces(classes: string): string[] { 67 | return classes.split(/\S+/); 68 | } 69 | 70 | export function getIndentation(line: string): number { 71 | return line.match(/^[\t ]*/)?.[0].length ?? 0; 72 | } 73 | 74 | export function escapeForRegex(word: string) { 75 | return word.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&"); 76 | } 77 | 78 | export function getExactClassLocation(literal: Literal, className: string, partial: boolean = false, lastIndex: boolean = false) { 79 | const escapedClass = escapeForRegex(className); 80 | 81 | const regex = partial 82 | ? new RegExp(`(${escapedClass})`, "g") 83 | : new RegExp(`(?:^|\\s+)(${escapedClass})(?=\\s+|$)`, "g"); 84 | 85 | const [...matches] = literal.content.matchAll(regex); 86 | 87 | const match = lastIndex ? matches.at(-1) : matches.at(0); 88 | 89 | if(match?.index === undefined){ 90 | return literal.loc; 91 | } 92 | 93 | const fullMatchIndex = match.index; 94 | const word = match?.[1]; 95 | const indexOfClass = fullMatchIndex + match[0].indexOf(word); 96 | 97 | const linesUpToStartIndex = literal.content.slice(0, indexOfClass).split("\n"); 98 | const isOnFirstLine = linesUpToStartIndex.length === 1; 99 | const containingLine = linesUpToStartIndex.at(-1); 100 | 101 | const line = literal.loc.start.line + linesUpToStartIndex.length - 1; 102 | const column = ( 103 | isOnFirstLine 104 | ? literal.loc.start.column + (literal.openingQuote?.length ?? 0) 105 | : 0 106 | ) + (containingLine?.length ?? 0); 107 | 108 | return { 109 | end: { 110 | column: column + className.length, 111 | line 112 | }, 113 | start: { 114 | column, 115 | line 116 | } 117 | }; 118 | } 119 | 120 | export function matchesName(pattern: string, name: string | undefined): boolean { 121 | if(!name){ return false; } 122 | 123 | const match = name.match(pattern); 124 | return !!match && match[0] === name; 125 | } 126 | 127 | export function deduplicateLiterals(literals: Literal[]): Literal[] { 128 | return literals.filter((l1, index) => { 129 | return literals.findIndex(l2 => { 130 | return l1.content === l2.content && 131 | l1.range[0] === l2.range[0] && 132 | l1.range[1] === l2.range[1]; 133 | }) === index; 134 | }); 135 | } 136 | 137 | export interface GenericNodeWithParent { 138 | parent: GenericNodeWithParent; 139 | } 140 | 141 | export function isGenericNodeWithParent(node: unknown): node is GenericNodeWithParent { 142 | return ( 143 | typeof node === "object" && 144 | node !== null && 145 | "parent" in node && 146 | node.parent !== null && 147 | typeof node.parent === "object" 148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /tests/e2e/commonjs/eslint.config.js: -------------------------------------------------------------------------------- 1 | const eslintParserHTML = require("@html-eslint/parser"); 2 | const eslintPluginBetterTailwindcss = require("eslint-plugin-better-tailwindcss"); 3 | 4 | 5 | module.exports = { 6 | files: ["**/*.html"], 7 | languageOptions: { 8 | parser: eslintParserHTML 9 | }, 10 | plugins: { 11 | "better-tailwindcss": eslintPluginBetterTailwindcss 12 | }, 13 | rules: eslintPluginBetterTailwindcss.configs["stylistic-warn"].rules 14 | }; 15 | -------------------------------------------------------------------------------- /tests/e2e/commonjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /tests/e2e/commonjs/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | -------------------------------------------------------------------------------- /tests/e2e/commonjs/test.test.ts: -------------------------------------------------------------------------------- 1 | import { loadESLint } from "eslint"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | 5 | describe("e2e/commonjs", async () => { 6 | it("should report all errors", async () => { 7 | const ESLint = await loadESLint({ useFlatConfig: true }); 8 | 9 | const eslint = new ESLint({ 10 | cwd: import.meta.dirname, 11 | overrideConfigFile: "./eslint.config.js" 12 | }); 13 | 14 | const [json] = await eslint.lintFiles("./test.html"); 15 | 16 | expect(json.errorCount).toBe(0); 17 | expect(json.fatalErrorCount).toBe(0); 18 | expect(json.fixableErrorCount).toBe(0); 19 | expect(json.fixableWarningCount).toBe(4); 20 | expect(json.warningCount).toBe(4); 21 | 22 | expect(json.messages.map(({ ruleId }) => ruleId)).toEqual([ 23 | "better-tailwindcss/multiline", 24 | "better-tailwindcss/no-unnecessary-whitespace", 25 | "better-tailwindcss/sort-classes", 26 | "better-tailwindcss/no-duplicate-classes" 27 | ]); 28 | 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/e2e/esm/eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslintParserHTML from "@html-eslint/parser"; 2 | import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; 3 | 4 | 5 | export default { 6 | files: ["**/*.html"], 7 | languageOptions: { 8 | parser: eslintParserHTML 9 | }, 10 | plugins: { 11 | "better-tailwindcss": eslintPluginBetterTailwindcss 12 | }, 13 | rules: eslintPluginBetterTailwindcss.configs["stylistic-warn"].rules 14 | }; 15 | -------------------------------------------------------------------------------- /tests/e2e/esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /tests/e2e/esm/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | -------------------------------------------------------------------------------- /tests/e2e/esm/test.test.ts: -------------------------------------------------------------------------------- 1 | import { loadESLint } from "eslint"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | 5 | describe("e2e/esm", async () => { 6 | it("should report all errors", async () => { 7 | const ESLint = await loadESLint({ useFlatConfig: true }); 8 | 9 | const eslint = new ESLint({ 10 | cwd: import.meta.dirname, 11 | overrideConfigFile: "./eslint.config.js" 12 | }); 13 | 14 | const [json] = await eslint.lintFiles("./test.html"); 15 | 16 | expect(json.errorCount).toBe(0); 17 | expect(json.fatalErrorCount).toBe(0); 18 | expect(json.fixableErrorCount).toBe(0); 19 | expect(json.fixableWarningCount).toBe(4); 20 | expect(json.warningCount).toBe(4); 21 | 22 | expect(json.messages.map(({ ruleId }) => ruleId)).toEqual([ 23 | "better-tailwindcss/multiline", 24 | "better-tailwindcss/no-unnecessary-whitespace", 25 | "better-tailwindcss/sort-classes", 26 | "better-tailwindcss/no-duplicate-classes" 27 | ]); 28 | 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/utils/lint.ts: -------------------------------------------------------------------------------- 1 | import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; 2 | import { dirname, normalize } from "node:path"; 3 | import { chdir } from "node:process"; 4 | 5 | import eslintParserAngular from "@angular-eslint/template-parser"; 6 | import eslintParserHTML from "@html-eslint/parser"; 7 | import eslintParserAstro from "astro-eslint-parser"; 8 | import { RuleTester } from "eslint"; 9 | import eslintParserSvelte from "svelte-eslint-parser"; 10 | import eslintParserVue from "vue-eslint-parser"; 11 | 12 | import type { Linter } from "eslint"; 13 | import type { Node as ESNode } from "estree"; 14 | 15 | import type { ESLintRule } from "better-tailwindcss:types/rule.js"; 16 | 17 | 18 | export const TEST_SYNTAXES = { 19 | angular: { 20 | languageOptions: { parser: eslintParserAngular } 21 | }, 22 | astro: { 23 | languageOptions: { parser: eslintParserAstro } 24 | }, 25 | html: { 26 | languageOptions: { parser: eslintParserHTML } 27 | }, 28 | jsx: { 29 | languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } 30 | }, 31 | svelte: { 32 | languageOptions: { parser: eslintParserSvelte } 33 | }, 34 | vue: { 35 | languageOptions: { parser: eslintParserVue } 36 | } 37 | } as const; 38 | 39 | export function lint>( 40 | eslintRule: Rule, 41 | syntaxes: Syntaxes, 42 | tests: { 43 | invalid?: ( 44 | { 45 | [Key in keyof Syntaxes]?: string; 46 | } & { 47 | [Key in keyof Syntaxes as `${Key & string}Output`]?: string; 48 | } & { 49 | errors: number; 50 | } & { 51 | files?: Record; 52 | options?: Rule["options"]; 53 | settings?: Rule["settings"]; 54 | } 55 | )[]; 56 | valid?: ( 57 | { 58 | [Key in keyof Syntaxes]?: string; 59 | } & { 60 | files?: Record; 61 | options?: Rule["options"]; 62 | settings?: Rule["settings"]; 63 | } 64 | )[]; 65 | } 66 | ) { 67 | 68 | mkdirSync("tmp", { recursive: true }); 69 | chdir("tmp"); 70 | 71 | for(const invalid of tests.invalid ?? []){ 72 | 73 | for(const file in invalid.files ?? {}){ 74 | invalid.settings ??= { "better-tailwindcss": {} }; 75 | 76 | mkdirSync(dirname(file), { recursive: true }); 77 | writeFileSync(file, invalid.files![file]); 78 | } 79 | 80 | for(const syntax of Object.keys(syntaxes)){ 81 | 82 | const ruleTester = new RuleTester(syntaxes[syntax]); 83 | 84 | if(!invalid[syntax]){ 85 | continue; 86 | } 87 | 88 | ruleTester.run(eslintRule.name, eslintRule.rule, { 89 | invalid: [{ 90 | code: invalid[syntax], 91 | errors: invalid.errors, 92 | options: invalid.options ?? [], 93 | output: invalid[`${syntax}Output`] ?? null, 94 | settings: invalid.settings ?? {} 95 | }], 96 | valid: [] 97 | }); 98 | } 99 | } 100 | 101 | for(const valid of tests.valid ?? []){ 102 | 103 | for(const file in valid.files ?? {}){ 104 | valid.settings ??= { "better-tailwindcss": {} }; 105 | mkdirSync(dirname(file), { recursive: true }); 106 | writeFileSync(file, valid.files![file]); 107 | } 108 | 109 | for(const syntax of Object.keys(syntaxes)){ 110 | 111 | const ruleTester = new RuleTester(syntaxes[syntax]); 112 | 113 | if(!valid[syntax]){ 114 | continue; 115 | } 116 | 117 | ruleTester.run(eslintRule.name, eslintRule.rule, { 118 | invalid: [], 119 | valid: [{ 120 | code: valid[syntax], 121 | options: valid.options ?? [], 122 | settings: valid.settings ?? {} 123 | }] 124 | }); 125 | 126 | } 127 | } 128 | 129 | } 130 | 131 | type GuardedType = Type extends (value: any) => value is infer ResultType ? ResultType : never; 132 | 133 | export function findNode node is any>(node: unknown, matcherFunction: Matcher): GuardedType | undefined { 134 | if(!node || typeof node !== "object"){ 135 | return; 136 | } 137 | 138 | for(const key in node){ 139 | const value = node[key]; 140 | 141 | if(!value || typeof value !== "object" || key === "parent"){ 142 | continue; 143 | } 144 | 145 | if(matcherFunction(value)){ 146 | return value; 147 | } 148 | 149 | const foundNode = findNode(value, matcherFunction); 150 | 151 | if(foundNode){ 152 | return foundNode; 153 | } 154 | } 155 | } 156 | 157 | export function withParentNodeExtension(node: ESNode, parent: ESNode = node) { 158 | for(const key in node){ 159 | if(typeof node[key] === "object" && key !== "parent"){ 160 | if(Array.isArray(node[key])){ 161 | for(const element of node[key]){ 162 | element.parent = parent; 163 | withParentNodeExtension(element); 164 | } 165 | } else { 166 | node[key].parent = parent; 167 | withParentNodeExtension(node[key]); 168 | } 169 | } 170 | } 171 | return node; 172 | } 173 | 174 | export function getFilesInDirectory(importURL: string) { 175 | const path = normalize(importURL); 176 | const files = readdirSync(path); 177 | 178 | return files.filter(file => !file.includes(".test.ts")); 179 | } 180 | -------------------------------------------------------------------------------- /tests/utils/template.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { ts } from "./template"; 4 | 5 | 6 | describe("template utils", () => { 7 | 8 | it("should inject variables correctly", () => { 9 | const vars = "test"; 10 | const test = ts`const test = "${vars}";`; 11 | expect(test).toBe("const test = \"test\";"); 12 | }); 13 | 14 | it("should remove common white spaces from tagged template literals", () => { 15 | const test = ts` 16 | const test = "test"; 17 | `; 18 | expect(test).toBe("const test = \"test\";"); 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /tests/utils/template.ts: -------------------------------------------------------------------------------- 1 | const EOL = "\n"; 2 | const TABS_IN_SPACES = 4; 3 | 4 | 5 | export const ts = createTemplateTag(undefined, true); 6 | export const css = createTemplateTag(undefined, true); 7 | export const vue = createTemplateTag(undefined, true); 8 | export const html = createTemplateTag(undefined, true); 9 | export const svelte = createTemplateTag(undefined, true); 10 | export const angular = createTemplateTag(undefined, true); 11 | export const jsx = createTemplateTag(undefined, true); 12 | 13 | export const dedent = createTemplateTag(4, false); 14 | 15 | function createTemplateTag(indentation?: number, removeNewLines: boolean = false) { 16 | return (templateStrings: TemplateStringsArray, ...values: (boolean | number | string)[]) => { 17 | const assembledTemplateString = assembleTemplateString(templateStrings, ...values); 18 | 19 | const contentWithoutSurroundingNewLines = removeNewLines 20 | ? removeSurroundingNewLines(assembledTemplateString) 21 | : assembledTemplateString; 22 | 23 | const minIndentation = indentation ?? findCommonIndentation(contentWithoutSurroundingNewLines); 24 | 25 | return removeCommonIndentation(contentWithoutSurroundingNewLines, minIndentation); 26 | }; 27 | } 28 | 29 | function findCommonIndentation(content: string, eol: string = EOL) { 30 | 31 | const lines = content.split(eol).filter(line => line.match(/\S/) !== null); 32 | 33 | for(const line of lines){ 34 | if(line.match(/^\S+/)){ 35 | return 0; 36 | } 37 | } 38 | 39 | const spaces = lines.map( 40 | line => line.match(/^[^\S\t\n\r]+\S/) 41 | ? line.match(/^[^\S\t\n\r]*/)?.[0].length ?? 0 42 | : undefined 43 | ).filter(space => space !== undefined); 44 | 45 | const tabs = lines.map( 46 | line => line.match(/^\t+\S/) 47 | ? line.match(/^\t*/)?.[0].length ?? 0 48 | : undefined 49 | ).filter(tab => tab !== undefined); 50 | 51 | 52 | const tabsInSpaces = tabs.map(tab => tab * TABS_IN_SPACES); 53 | const indentations = [...spaces, ...tabsInSpaces] as number[]; 54 | 55 | if(indentations.length <= 0){ 56 | return 0; 57 | } 58 | 59 | const minIndentation = Math.min(...indentations); 60 | 61 | return minIndentation - minIndentation % 2; 62 | 63 | } 64 | 65 | function removeCommonIndentation(content: string, minIndentation: number, eol: string = EOL) { 66 | 67 | if(minIndentation <= 0){ 68 | return content; 69 | } 70 | 71 | const lines = content.split(eol); 72 | 73 | return lines.map(line => { 74 | 75 | let spacesLeft = minIndentation; 76 | 77 | for(let i = 0; i < line.length; i++){ 78 | if(line[i] === " "){ 79 | spacesLeft--; 80 | } else if(line[i] === "\t"){ 81 | spacesLeft -= TABS_IN_SPACES; 82 | } else { 83 | break; 84 | } 85 | if(spacesLeft <= 0){ 86 | line = line.slice(i + 1); 87 | break; 88 | } 89 | } 90 | 91 | return line; 92 | 93 | }).join(eol); 94 | 95 | } 96 | 97 | function removeSurroundingNewLines(content: string) { 98 | return content.replace(/^\n|\n[\t ]*$/g, ""); 99 | } 100 | 101 | function assembleTemplateString(templateString: TemplateStringsArray, ...values: (boolean | number | string)[]) { 102 | return templateString.reduce((acc, str, i) => `${acc}${str}${values[i] ?? ""}`, ""); 103 | } 104 | -------------------------------------------------------------------------------- /tsconfig.build.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "verbatimModuleSyntax": false, 7 | "declaration": true, 8 | "declarationMap": true, 9 | "noEmit": false, 10 | "skipLibCheck": true, 11 | "sourceMap": true, 12 | "target": "ES2020" 13 | }, 14 | "include": [ 15 | "src/tailwind/v3/**/*.ts", 16 | "src/tailwind/v4/**/*.ts", 17 | "src/tailwind/async/**/*.ts", 18 | "src/configs/cjs.ts", 19 | "src/api/defaults.ts", 20 | "src/api/types.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.build.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "nodenext", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "noEmit": false, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "target": "ES2020" 12 | }, 13 | "include": [ 14 | "src/tailwind/v3/**/*.ts", 15 | "src/tailwind/v4/**/*.ts", 16 | "src/tailwind/async/**/*.ts", 17 | "src/configs/esm.ts", 18 | "src/api/defaults.ts", 19 | "src/api/types.ts" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@schoero/configs/tsconfig", 3 | "compilerOptions": { 4 | 5 | // paths 6 | "baseUrl": ".", 7 | "lib": ["ESNext"], 8 | "module": "Preserve", 9 | 10 | // general 11 | "noEmit": true, 12 | "target": "ES2020", 13 | 14 | // strictness 15 | "noImplicitAny": false, 16 | "paths": { 17 | "better-tailwindcss:build/*": ["build/*"], 18 | "better-tailwindcss:async/*": ["src/tailwind/async/*"], 19 | "better-tailwindcss:options/*": ["src/options/*"], 20 | "better-tailwindcss:configs/*": ["src/configs/*"], 21 | "better-tailwindcss:parsers/*": ["src/parsers/*"], 22 | "better-tailwindcss:rules/*": ["src/rules/*"], 23 | "better-tailwindcss:tests/*": ["tests/*"], 24 | "better-tailwindcss:types/*": ["src/types/*"], 25 | "better-tailwindcss:utils/*": ["src/utils/*"] 26 | }, 27 | 28 | // imports 29 | "verbatimModuleSyntax": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | export { config as default } from "@schoero/configs/vite"; 2 | --------------------------------------------------------------------------------