Repository: schoero/eslint-plugin-readable-tailwind Branch: main Commit: cc05c4eeee71 Files: 223 Total size: 937.5 KB Directory structure: gitextract_rhr3h2e2/ ├── .cspell.json ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ ├── documentation.yml │ │ ├── feature_request.yml │ │ └── question.yml │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .markdownlint.jsonc ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── LICENSE ├── README.md ├── build/ │ ├── index.ts │ └── utils.ts ├── changelog.config.js ├── docs/ │ ├── api/ │ │ └── defaults.md │ ├── configuration/ │ │ └── advanced.md │ ├── parsers/ │ │ ├── angular.md │ │ ├── astro.md │ │ ├── css.md │ │ ├── html.md │ │ ├── javascript.md │ │ ├── jsx.md │ │ ├── svelte.md │ │ ├── tsx.md │ │ ├── typescript.md │ │ └── vue.md │ ├── rules/ │ │ ├── enforce-canonical-classes.md │ │ ├── enforce-consistent-class-order.md │ │ ├── enforce-consistent-important-position.md │ │ ├── enforce-consistent-line-wrapping.md │ │ ├── enforce-consistent-variable-syntax.md │ │ ├── enforce-consistent-variant-order.md │ │ ├── enforce-logical-properties.md │ │ ├── enforce-shorthand-classes.md │ │ ├── no-conflicting-classes.md │ │ ├── no-deprecated-classes.md │ │ ├── no-duplicate-classes.md │ │ ├── no-restricted-classes.md │ │ ├── no-unknown-classes.md │ │ └── no-unnecessary-whitespace.md │ └── settings/ │ └── settings.md ├── eslint.config.ts ├── package.json ├── src/ │ ├── api/ │ │ ├── defaults.ts │ │ └── types.ts │ ├── async-utils/ │ │ ├── cache.ts │ │ ├── escape.ts │ │ ├── fs.ts │ │ ├── module.ts │ │ ├── operations.ts │ │ ├── order.ts │ │ ├── path.ts │ │ ├── platform.ts │ │ ├── regex.ts │ │ ├── resolvers.ts │ │ ├── tsconfig.ts │ │ └── worker.ts │ ├── configs/ │ │ ├── config.test.ts │ │ └── config.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 │ │ ├── migrate.test.ts │ │ ├── migrate.ts │ │ ├── schemas/ │ │ │ ├── attributes.ts │ │ │ ├── callees.ts │ │ │ ├── common.ts │ │ │ ├── matchers.ts │ │ │ ├── selectors.ts │ │ │ ├── tags.ts │ │ │ └── variables.ts │ │ └── tags/ │ │ ├── twc.test.ts │ │ ├── twc.ts │ │ ├── twx.test.ts │ │ └── twx.ts │ ├── parsers/ │ │ ├── angular.test.ts │ │ ├── angular.ts │ │ ├── css.test.ts │ │ ├── css.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-canonical-classes.test.ts │ │ ├── enforce-canonical-classes.ts │ │ ├── enforce-consistent-class-order.test.ts │ │ ├── enforce-consistent-class-order.ts │ │ ├── enforce-consistent-important-position.test.ts │ │ ├── enforce-consistent-important-position.ts │ │ ├── enforce-consistent-line-wrapping.test.ts │ │ ├── enforce-consistent-line-wrapping.ts │ │ ├── enforce-consistent-variable-syntax.test.ts │ │ ├── enforce-consistent-variable-syntax.ts │ │ ├── enforce-consistent-variant-order.test.ts │ │ ├── enforce-consistent-variant-order.ts │ │ ├── enforce-logical-properties.test.ts │ │ ├── enforce-logical-properties.ts │ │ ├── enforce-shorthand-classes.test.ts │ │ ├── enforce-shorthand-classes.ts │ │ ├── no-conflicting-classes.test.ts │ │ ├── no-conflicting-classes.ts │ │ ├── no-deprecated-classes.test.ts │ │ ├── no-deprecated-classes.ts │ │ ├── no-duplicate-classes.test.ts │ │ ├── no-duplicate-classes.ts │ │ ├── no-restricted-classes.test.ts │ │ ├── no-restricted-classes.ts │ │ ├── no-unknown-classes.test.ts │ │ ├── no-unknown-classes.ts │ │ ├── no-unnecessary-whitespace.test.ts │ │ └── no-unnecessary-whitespace.ts │ ├── tailwindcss/ │ │ ├── canonical-classes.async.v4.ts │ │ ├── canonical-classes.ts │ │ ├── class-order.async.v3.ts │ │ ├── class-order.async.v4.ts │ │ ├── class-order.ts │ │ ├── conflicting-classes.async.v4.ts │ │ ├── conflicting-classes.ts │ │ ├── context.async.v3.ts │ │ ├── context.async.v4.ts │ │ ├── custom-component-classes.async.v3.ts │ │ ├── custom-component-classes.async.v4.ts │ │ ├── custom-component-classes.ts │ │ ├── dissect-classes.async.v3.ts │ │ ├── dissect-classes.async.v4.ts │ │ ├── dissect-classes.test.ts │ │ ├── dissect-classes.ts │ │ ├── prefix.async.v3.ts │ │ ├── prefix.async.v4.ts │ │ ├── prefix.ts │ │ ├── tailwind.async.worker.v3.ts │ │ ├── tailwind.async.worker.v4.ts │ │ ├── unknown-classes.async.v3.ts │ │ ├── unknown-classes.async.v4.ts │ │ ├── unknown-classes.ts │ │ ├── variant-order.async.v3.ts │ │ ├── variant-order.async.v4.ts │ │ └── variant-order.ts │ ├── types/ │ │ ├── ast.ts │ │ ├── async.ts │ │ ├── estree.ts │ │ └── rule.ts │ └── utils/ │ ├── ast.ts │ ├── class.ts │ ├── context.ts │ ├── lint.ts │ ├── matchers.test.ts │ ├── matchers.ts │ ├── quotes.test.ts │ ├── quotes.ts │ ├── rule.ts │ ├── selectors.ts │ ├── utils.test.ts │ ├── utils.ts │ ├── valibot.ts │ ├── version.ts │ └── warn.ts ├── tests/ │ ├── e2e/ │ │ ├── commonjs/ │ │ │ ├── eslint.config.js │ │ │ ├── package.json │ │ │ ├── test.html │ │ │ └── test.test.ts │ │ ├── eslintrc/ │ │ │ ├── .eslintrc.json │ │ │ ├── package.json │ │ │ ├── test.html │ │ │ └── test.test.ts │ │ └── esm/ │ │ ├── eslint.config.js │ │ ├── package.json │ │ ├── test.html │ │ └── test.test.ts │ ├── unit/ │ │ ├── monorepo-cwd-resolution.test.ts │ │ └── options.test.ts │ └── utils/ │ ├── context.ts │ ├── eslint.ts │ ├── lint.ts │ ├── prettier.ts │ ├── setup.ts │ ├── template.test.ts │ ├── template.ts │ ├── tmp.ts │ ├── values.ts │ └── version.ts ├── tsconfig.build.json ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cspell.json ================================================ { "ignorePaths": [ "node_modules/**", ".vscode/**", "lib/**" ], "import": [ "@schoero/configs/cspell" ], "words": [ "astro", "Atrule", "autofix", "callees", "classcat", "classnames", "clsx", "cnbuilder", "csstree", "daisyui", "dcnb", "DCNB", "Declarators", "ecma", "eslintrc", "espree", "estree", "jiti", "linebreak", "linebreakstyle", "longhands", "memfs", "objstr", "OBJSTR", "oxfmt", "quasis", "shadcn", "synckit", "Tmpl" ] } ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Create a report labels: ["bug"] body: - attributes: description: A clear and concise description of what the bug is. label: Description placeholder: Describe the bug here... id: description type: textarea validations: required: true - attributes: description: Which framework/flavor are you using? label: Flavor options: - JSX - TSX - Svelte - Vue - Astro - Angular - HTML - CSS - JavaScript - TypeScript id: flavor type: dropdown validations: required: true - attributes: description: The code that triggers the bug. label: Code Input placeholder: | // Code snippet render: tsx id: code-input type: textarea validations: required: true - attributes: description: What you expected to happen. label: Expected Behavior placeholder: | // Expected output render: tsx id: expected-behavior type: textarea validations: required: true - attributes: description: What actually happened. label: Actual Behavior placeholder: | // Actual output render: tsx id: actual-behavior type: textarea validations: required: true - attributes: description: Link to a reproduction (e.g. StackBlitz, CodeSandbox, GitHub repo). label: Reproduction URL placeholder: https://... id: reproduction-url type: input validations: required: false - attributes: description: Please paste the output of `npx eslint ./path/to/file` here. label: ESLint Log placeholder: | // ESLint log output render: shell id: eslint-log type: textarea validations: required: true - attributes: description: The relevant parts of your ESLint config label: ESLint Config render: typescript id: eslint-config type: textarea validations: required: true - attributes: description: Please list the versions of the tools you are using. label: Versions value: | - ESLint: - Parser: - Plugin: - Node: id: versions type: textarea validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/documentation.yml ================================================ name: Documentation description: Improvements or additions to documentation labels: ["documentation"] body: - attributes: description: Describe the documentation issue or improvement. label: Documentation Issue id: documentation type: textarea validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: Suggest an idea for this project labels: ["feature request"] body: - attributes: description: A clear and concise description of what the feature is. label: Description placeholder: Describe the feature here... id: description type: textarea validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/question.yml ================================================ name: Question description: Ask a question labels: ["question"] body: - attributes: description: What is your question? label: Question id: question type: textarea validations: required: true ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: branches: - main push: jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: cache: npm node-version: 24 - name: Install dependencies run: npm ci - name: Lint run: npm run lint:ci - name: Typecheck run: npm run typecheck - name: Spellcheck run: npm run spellcheck:ci test: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: cache: npm node-version: ${{ matrix.node }} - name: Install dependencies run: npm ci - name: Run build run: npm run build:ci - name: Install tailwindcss v3 run: npm run install:v3 - name: Run tests with tailwindcss v3 run: npm run test:v3 - name: Run e2e tests with tailwindcss v3 run: npm run test:e2e - name: Install tailwindcss v4 run: npm run install:v4 - name: Run tests with tailwindcss v4 run: npm run test:v4 - name: Run e2e tests with tailwindcss v4 run: npm run test:e2e strategy: fail-fast: true matrix: node: - 20 - 22 - 24 os: - ubuntu-latest - windows-latest - macos-latest ================================================ FILE: .gitignore ================================================ node_modules tmp lib local .DS_Store .env ================================================ FILE: .markdownlint.jsonc ================================================ { "extends": "@schoero/configs/markdownlint" } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "dbaeumer.vscode-eslint", "streetsidesoftware.code-spell-checker", "davidanson.vscode-markdownlint" ] } ================================================ FILE: .vscode/launch.json ================================================ { "configurations": [ { "args": [ "run", "${relativeFileDirname}/${fileBasenameNoExtension}" ], "autoAttachChildProcesses": true, "console": "integratedTerminal", "name": "debug current test file", "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", "request": "launch", "skipFiles": ["/**", "**/node_modules/**"], "smartStep": true, "type": "node" }, { "args": [ "run", "${relativeFileDirname}/${fileBasenameNoExtension}" ], "autoAttachChildProcesses": true, "console": "integratedTerminal", "name": "debug current test file with node internals", "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", "request": "launch", "skipFiles": [], "smartStep": true, "type": "node" } ], "version": "0.2.0" } ================================================ FILE: .vscode/settings.json ================================================ { // ESLint "[javascript][typescript][json][json5][jsonc][yaml]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "eslint.nodePath": "node_modules/eslint", "eslint.useFlatConfig": true, "eslint.validate": ["javascript", "typescript", "json", "jsonc", "json5", "yaml"], "eslint.rules.customizations": [ { "rule": "better-tailwindcss/*", "severity": "off" } ], "eslint.codeActionsOnSave.rules": [ "!better-tailwindcss/*", "*" ], // tailwindcss "tailwindCSS.lint.cssConflict": "ignore", "tailwindCSS.lint.suggestCanonicalClasses": "ignore", "editor.formatOnSave": false, // Prettier "prettier.enable": false, // File nesting "explorer.fileNesting.enabled": true, "explorer.fileNesting.expand": false, "explorer.fileNesting.patterns": { "*.ts": "$(capture).ts,$(capture).test.ts,$(capture).cts,$(capture).mts,$(capture).test.snap,$(capture).test-d.ts,$(capture).v4.ts,$(capture).async.ts,$(capture).v3.ts,$(capture).async.v4.ts,$(capture).async.v3.ts,$(capture).async.worker.ts,$(capture).async.worker.v4.ts,$(capture).async.worker.v3.ts", "*.v3.ts": "$(capture).v4.ts", "*.js": "$(capture).test.js,$(capture).cjs,$(capture).mjs,$(capture).d.ts,$(capture).d.ts.map,$(capture).js.map" }, // ES module import "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifierEnding": "js", "typescript.preferences.useAliasesForRenames": true, "typescript.preferences.autoImportFileExcludePatterns": [ "@types/node/test.d.ts" ], // Markdown "[markdown]": { "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" }, // VSCode "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", "source.fixAll.markdownlint": "explicit", "source.organizeImports": "never" }, "editor.rulers": [ 119 ], "typescript.preferences.autoImportSpecifierExcludeRegexes": ["lib"], "search.exclude": { "lib": true }, "typescript.tsdk": "node_modules/typescript/lib" } ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## v4.5.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.4.1...v4.5.0) ### Features - Add `ignore` option to `enforce-canonical-classes` ([#371](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/371)) - Add `tabWidth` option to `enforce-consistent-line-wrapping` ([#367](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/367)) ### Fixes - Add missing logical classes ([#368](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/368)) - Warning when tailwind css installation can't be found ([#373](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/373)) - Only sort variants that are safe ([#370](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/370)) ## v4.4.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.4.0...v4.4.1) ### Fixes - Remove auto detection of project root to set `cwd` ([#364](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/364)) If you're in a monorepo setup, you may need to [configure the `cwd`](https://github.com/schoero/eslint-plugin-better-tailwindcss?tab=readme-ov-file#monorepo-setup) manually. ## v4.4.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.3.2...v4.4.0) ### Features - Project root based cwd in monorepos ([#345](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/345)) - Target specific arguments of callees ([#347](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/347)) - New Anonymous functions matcher ([#348](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/348)) - Add support for tag paths ([#354](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/354)) - Reintroduce line ending and indentation misconfiguration warnings ([#351](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/351)) - **worker:** Use SYNCKIT_TIMEOUT env var for timeout configuration ([#352](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/352)) - Match default exports ([#346](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/346)) - React twc preset ([#355](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/355)) - Lint Template literal based on prefixed comments ([#356](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/356)) - New rule `enforce-logical-properties` ([#358](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/358)) - New rule `enforce-consistent-variant-order` ([#359](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/359)) ### Performance - Cache regex, early return ([#336](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/336)) ### Documentation - Add example to restrict unnamed groups ([#357](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/357)) ### ❤️ Contributors - Mickaël Depardon ([@squelix](https://github.com/squelix)) - Mike Schutte ([@tmikeschu](https://github.com/tmikeschu)) - Stephen Zhou ([@hyoban](https://github.com/hyoban)) ## v4.3.2 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.3.1...v4.3.2) ### Fixes - **no-unnecessary-whitespace:** Preserve whitespaces in concatenated strings ([#339](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/339)) - **enforce-consistent-class-order:** Non localized alphabetical sorting order ([#340](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/340)) ### Refactors - Lint concatenated strings ([#338](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/338)) ## v4.3.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.3.0...v4.3.1) ### Fixes - Variable matchers leaking into function expressions ([#333](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/333)) ### Documentation - Add oxlint documentation ([#331](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/331)) ## v4.3.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.2.0...v4.3.0) ### Features - Support curried calls ([#325](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/325)) - Support callee paths ([#326](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/326)) ### Refactors - Simplify matcher config ([#324](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/324)) The matcher config has been simplified from a nested tuple structure to a simple array of objects. This makes it easier to understand while also allowing better flexibility to support the new features. The old structure is still supported for now, but will be removed in the next major version. Check the updated [configuration documentation](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/configuration/advanced.md#selectors) for more information. ## v4.2.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.1.1...v4.2.0) ### Features - Add support for ESLint 10 ([#323](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/323)) ### Performance - Use shared worker to handle async calls ([#319](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/319)) ### ❤️ Contributors - Stephen Zhou ([@hyoban](https://github.com/hyoban)) - Bjorn Antonissen ([@Bjornftw](https://github.com/Bjornftw)) ## v4.1.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.1.0...v4.1.1) ### Fixes - Filter unrecommended rules ([#317](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/317)) ## v4.1.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.0.2...v4.1.0) ### Features - Experimental css linting ([#314](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/314)) - Add solid `classList` matcher ([#315](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/315)) ### Fixes - Type errors ([c3c9c40](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/c3c9c40)) - Prevent linting when no literals are found ([51333c6](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/51333c6)) - Add `exactOptionalPropertyTypes` to `tsconfig` ([#311](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/311)) ### ❤️ Contributors - Alexander Kachkaev ([@kachkaev](https://github.com/kachkaev)) ## v4.0.2 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.0.1...v4.0.2) ### Fixes - `enforce-canonical-classes`: removal of unrelated classes ([#309](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/309)) - `enforce-consistent-variable-syntax`: Support custom css functions ([#308](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/308)) - Config types ([#310](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/310)) ## v4.0.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.0.0...v4.0.1) ### Fixes - Disallow extra properties in rule options (valibot schemas) ([#295](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/295)) - Configuration warnings getting lost ([#297](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/297)) ### ❤️ Contributors - Andrew Kazakov ([@andreww2012](https://github.com/andreww2012)) ## v4.0.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.8.0...v4.0.0) This version includes a major rewrite of the internal architecture, improving performance and maintainability, resolving long-standing issues, and preparing the codebase for the future and for oxlint. ### New Features - New rule: `enforce-canonical-classes` ([#232](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/232)) - New options for `enforce-consistent-class-order` to sort "component classes" and "unknown classes" ([#263](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/263)) - `detectComponentClasses`: `boolean` - `componentClassOrder`: `"asc" | "desc" | "preserve"` - `componentClassPosition`: `"start" | "end"` - `unknownClassOrder`: `"asc" | "desc" | "preserve"` - `unknownClassPosition`: `"start" | "end"` - Added `strictness: "loose"` option to `enforce-consistent-line-wrapping` to improve interoperability with prettier ([#260](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/260)) - Better Performance - Oxlint support
### ⚠️ Breaking Changes First of all, the minimum required Node.js version is has changed to support v23.0.0, v22.12.0, v20.19.0 to support `require(esm)` - This made it possible to remove the `CommonJS` build ([#264](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/264))
Some rules have been renamed to better reflect their intentions: - Renamed rule `no-unregistered-classes` to `no-unknown-classes` - Renamed rule `sort-classes` to `enforce-consistent-class-order` - Renamed rule `multiline` to `enforce-consistent-line-wrapping` The rule recommendations have been updated to enable new rules by default. Check the updated [rule recommendations](https://github.com/schoero/eslint-plugin-better-tailwindcss?tab=readme-ov-file#stylistic-rules) for more information.
For some rules, the options have been renamed or changed: - Options for `better-tailwindcss/enforce-consistent-variable-syntax` have been renamed to `shorthand` and `variable`. - The default for `enforce-consistent-important-position` is now always `recommended`. - Renamed the `improved` sorting order for `enforce-consistent-class-order` to `strict` ([#245](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/245)) - `improved` is no longer the default option as most people expect the order to match the official order from tailwind. - the `improved` order got renamed to `strict` to better describe its intentions. - the logic of the `strict` order has changed: - Classes that share the same base variants get grouped together. - Classes with less variants come before classes with more variants. - Classes with arbitrary variants come last. - The `enforce-consistent-line-wrapping` rule now groups variants more strictly. Previously it only grouped classes by their first variant. Now all variants are ordered correctly.
The configs have been renamed and updated to match the recommended shape of ESLint. - Renamed configs ([#244](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/244)) - The following configs are now exposed: - `recommended` - `recommended-warn` - `recommended-error` - `stylistic` - `stylistic-warn` - `stylistic-error` - `correctness` - `correctness-warn` - `correctness-error` - `legacy-recommended` - `legacy-recommended-warn` - `legacy-recommended-error` - `legacy-stylistic` - `legacy-stylistic-warn` - `legacy-stylistic-error` - `legacy-correctness` - `legacy-correctness-warn` - `legacy-correctness-error` - Please check the updated [Parser Documentation](https://github.com/schoero/eslint-plugin-better-tailwindcss?tab=readme-ov-file#quick-start) to see the recommended way to set up the plugin with your parser.
Other changes: - Function `getDefaultIgnoredUnregisteredClasses()` has been removed. - Removed rule regex matchers - Preserve normal quotes whenever possible ([#246](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/246)) Here is the full list of changes in this version: ### Features - New rule: `enforce-canonical-classes` ([#232](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/232)) - Oxlint support ([#284](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/284) - Add `strictness: "loose"` option to `enforce-consistent-line-wrapping` ([#260](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/260)) - Add settings option to configure `messageStyle` ([#276](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/276)) - **angular:** Support bound attribute classes ([#277](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/277)) - **svelte:** Support class directive ([#278](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/278)) ### Fixes - Don't match attribute values for bound attribute names ([#291](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/291)) - Correctly override shared settings with rule options ([#289](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/289)) - Invalid variant grouping order ([#282](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/282)) - Ignore variants in custom component classes ([#258](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/258)) - Angular line wrapping ([#259](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/259)) ### Refactors - Deprecate `/api/` path for imports ([#281](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/281)) - Update rule recommendations ([#280](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/280)) ### Documentation - Add `detectComponentClasses` to settings ([388103e](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/388103e)) - Add attribute matcher example ([#272](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/272)) - Improve configuration guide ([bd873ea](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/bd873ea)) #### ⚠️ Breaking Changes - ⚠️ Ignore indexed access keys ([#292](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/292)) - ⚠️ Update rule recommendations ([#280](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/280)) - ⚠️ Remove separate `CommonJS` build ([#264](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/264)) Minimum Node.js version to v23.0.0, v22.12.0, v20.19.0 to support `require(esm)` - ⚠️ Preserve normal quotes whenever possible ([#246](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/246)) - ⚠️ Renamed the `improved` sorting order to `strict` ([#245](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/245)) - ⚠️ Rename configs ([#244](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/244)) - ⚠️ Renamed rule `no-unregistered-classes` to `no-unknown-classes` - ⚠️ Renamed rule `sort-classes` to `enforce-consistent-class-order` - ⚠️ Renamed rule `multiline` to `enforce-consistent-line-wrapping` - ⚠️ Options for `better-tailwindcss/enforce-consistent-variable-syntax` have been renamed to `shorthand` and `variable`. - ⚠️ Function `getDefaultIgnoredUnregisteredClasses()` has been removed. - ⚠️ The default for `enforce-consistent-important-position` is now always `recommended`. If you are on tailwindcss v3 need to manually set it to `legacy` to keep it working for tailwindcss v3. - ⚠️ Removed rule regex matchers ### ❤️ Contributors - V-iktor ([@V-iktor](https://github.com/V-iktor)) ## v3.8.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.11...v3.8.0) ### Features - **no-unregistered-classes:** Support `@import layer(components)` ([#257](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/257)) ### Fixes - Wrong documentation url ([#255](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/255)) - Ignore variants in custom component classes ([#258](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/258)) - Angular line wrapping ([#259](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/259)) ### ❤️ Contributors - Carlos Marques ## v3.7.11 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v4.0.0-beta.3...v3.7.11) ### Fixes - Convert missing flex shrink and grow utilities ([#236](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/236)) - Ignore literals in binary expressions ([#238](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/238)) - Allow interpolations in normal svelte string literals ([#239](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/239)) - Only show config warning when config is set and not found ([#240](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/240)) ### ❤️ Contributors - Akameco ([@akameco](https://github.com/akameco)) ## v3.7.10 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.9...v3.7.10) ### Fixes - `enforce-shorthand-classes` to include horizontal and vertical cases for `rounded` classes ([#231](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/231)) ### Chore - Correct recommended rules to match implementation ([#229](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/229)) ### ❤️ Contributors - Andrew Kodkod ([@akodkod](https://github.com/akodkod)) - 2754 ([@2754github](https://github.com/2754github)) ## v3.7.9 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.8...v3.7.9) ### Fixes - Don't match index accessed object keys ([#227](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/227)) ## v3.7.8 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.7...v3.7.8) ### Fixes - Improved angular support ([#182](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/182)) - Fixes object key detection for intersecting classes - Adds support for `pathPattern` in angular ## v3.7.7 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.6...v3.7.7) ### Fixes - Compound variants with slots class string not being detected ([#219](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/219)) ### ❤️ Contributors - tim-spitzer-syzygy ([@tim-spitzer-syzygy](https://github.com/tim-spitzer-syzygy)) ## v3.7.6 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.5...v3.7.6) ### Fixes - Check for tailwindcss before running rules ([#217](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/217)) - Angular: Prevent crash when objectContent is undefined in createLiteralByLiteralMapKey ([#215](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/215)) ### Tests - Add no-unregistered-classes test for DaisyUI classes ([#186](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/186)) ### ❤️ Contributors - Paul Parker ([@pauldesmondparker](https://github.com/pauldesmondparker)) - Yossi Yedid ([@yossiyedid](https://github.com/yossiyedid)) ## v3.7.5 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.4...v3.7.5) ### Fixes - Matching object values with immediate indexed access ([#212](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/212)) ## v3.7.4 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.3...v3.7.4) ### Fixes - Error in no-conflicting-classes when used in tailwindcss 3 ([#205](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/205)) - Invalid config warning when config was actually found ([#206](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/206)) - Differentiate shorthands for the same classes with different variants ([#207](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/207)) ## v3.7.3 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.2...v3.7.3) ### Fixes - Invalid fix for multiple vars in `enforce-consistent-variable-syntax` ([#200](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/200)) ## v3.7.2 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.1...v3.7.2) ### Fixes - Error when no tsconfig is available ([#195](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/195)) ### Refactors - Refine cache ([#196](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/196)) ## v3.7.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.7.0...v3.7.1) ### Fixes - `no-unnecessary-whitespace` false positive on empty string ([#191](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/191)) - Don't convert variable definitions ([#192](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/192)) ### Chore - Update dependencies ([#193](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/193)) ## v3.7.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.6.3...v3.7.0) ### Features - Support tsconfig paths ([#185](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/185)) ### Refactors - Exact unnecessary whitespace fixes ([#184](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/184)) ## v3.6.3 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.6.2...v3.6.3) ### Fixes - Error position ([7b699ee](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/7b699ee)) ### Refactors - Add missing deprecations ([#181](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/181)) - Variable syntax tailwindcss3 shorthand ([#183](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/183)) ## v3.6.2 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.6.1...v3.6.2) ### Fixes - Fixes crash when importing css files via tsconfig path alias and [`detectComponentClasses`](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-unregistered-classes.md#detectcomponentclasses) enabled ([#178](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/178)) - Fixes component classes not getting updated when inside an imported file ([#178](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/178)) - Disallow extra properties in rule options ([#180](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/180)) ### ❤️ Contributors - Andrew Kazakov ([@andreww2012](https://github.com/andreww2012)) ## v3.6.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.6.0...v3.6.1) ### Fixes - Recursively reading imports ([#175](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/175)) ## v3.6.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.5.2...v3.6.0) ### Features - New rule `enforce-consistent-important-position` ([#167](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/167)) - New rule `no-deprecated-classes` ([#169](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/169)) ### Fixes - Support starting important in `enforce-shorthand-classes` ([#164](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/164)) - Error position ([a55a6cc](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/a55a6cc)) ## v3.5.2 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.5.1...v3.5.2) ### Fixes - Tailwind 3 shorthand classes with important modifier ([#162](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/162)) ## v3.5.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.5.0...v3.5.1) ### Fixes - False reports of shorthand classes ([c5f14ab](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/c5f14ab)) ## v3.5.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.4.4...v3.5.0) ### Features - New Rule: Enforce shorthand classes ([#153](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/153)) ### Fixes - Bump tailwindcss peer dependency ([#157](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/157)) - Regex deprecation warning ([#161](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/161)) ## v3.4.4 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.4.3...v3.4.4) ### Fixes - Altering variant order in tailwindcss cache ([#151](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/151)) ### Documentation - Add example for arbitrary values ([ef6faa2](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/ef6faa2)) ## v3.4.3 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.4.2...v3.4.3) ### Fixes - Prevent removal of whitespace between template literals ([#147](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/147)) - Extract class variants via tailwind ([#146](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/146)) ## v3.4.2 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.4.1...v3.4.2) ### Fixes - Template literals resulting in `undefined` path in getESObjectPath causing false positives ([#142](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/142)) ### ❤️ Contributors - Long Zheng ([@longzheng](https://github.com/longzheng)) ## v3.4.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.4.0...v3.4.1) ### Fixes - Detect conflicts with multiple properties ([#137](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/137)) ## v3.4.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.3.1...v3.4.0) ### Features - Add customizable autofix option to `no-restricted-classes` ([#133](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/133)) ### Refactors - Rename rules for better consistency ([#134](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/134)) - better-tailwindcss/multiline -> better-tailwindcss/enforce-consistent-line-wrapping - better-tailwindcss/sort-classes -> better-tailwindcss/enforce-consistent-class-order The old names will still work for now, but will be removed in the next major version. ## v3.3.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.3.0...v3.3.1) ### Fixes - Prevent variable matchers from crossing arrow function boundaries ([#131](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/131)) - Sorting order with unregistered class with variant ([#132](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/132)) ## v3.3.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.2.1...v3.3.0) ### Features - No-restricted-classes rule to support custom error messages ([#129](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/129)) ### Fixes - Node version range ([b50df13](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/b50df13)) ## v3.2.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.2.0...v3.2.1) ### Fixes - Don't report inside member expressions ([#120](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/120)) ## v3.2.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.1.0...v3.2.0) ### Features - Auto detect custom component layer classes ([#111](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/111)) - Ignore prefix in groups ([#110](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/110)) - Support prefixed groups and tags ([#115](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/115)) ### Fixes - Add additional tailwind variants matchers ([#116](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/116)) ## v3.1.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v3.0.0...v3.1.0) ### Features - Add support for astro syntactic sugar ([#103](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/103)) - New rule `enforce consistent variable syntax` ([#101](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/101)) ### Fixes - Remove `name` property ([#105](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/105)) ## v3.0.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v2.1.2...v3.0.0) This version adds 3 new correctness rules to the plugin. To better reflect the new scope of the plugin it was renamed from `eslint-plugin-readable-tailwind` to `eslint-plugin-better-tailwindcss`. The predefined configs also have been renamed to better reflect their scope. ### Features - [no-unregistered-classes](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-unregistered-classes.md): Report classes not registered with tailwindcss. ([#89](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/89)) - [no-conflicting-classes](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-conflicting-classes.md): Report classes that produce conflicting styles. ([#90](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/90)) - [no-restricted-classes](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-restricted-classes.md): Disallow restricted classes. ([#92](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/92)) #### ⚠️ Breaking changes - Plugin renamed to `eslint-plugin-better-tailwindcss` - Deprecate [Regex matchers](https://github.com/schoero/eslint-plugin-readable-tailwind/blob/v2.1.2/docs/concepts/concepts.md#regular-expressions) to simplify the configuration. ([#98](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/98)) [Regex matchers](https://github.com/schoero/eslint-plugin-readable-tailwind/blob/v2.1.2/docs/concepts/concepts.md#regular-expressions) were an early attempt to make the plugin more flexible. However, they were quickly replaced with [Matchers](https://github.com/schoero/eslint-plugin-readable-tailwind/blob/v2.1.2/docs/concepts/concepts.md#matchers) which work on the Abstract Syntax Tree and are far more powerful. Support for [Regex matchers](https://github.com/schoero/eslint-plugin-readable-tailwind/blob/v2.1.2/docs/concepts/concepts.md#regular-expressions) will be removed in the next major version. - `warning` and `error` configs have been removed. Use `recommended-warn` or `recommended-error` instead. ([#99](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/99)) ### Migration 1. Replace `eslint-plugin-readable-tailwind` with `eslint-plugin-better-tailwindcss`: ```sh npm uninstall eslint-plugin-readable-tailwind ``` ```sh npm i -D eslint-plugin-better-tailwindcss ``` 1. Update the imports in your config: ```diff - import eslintPluginReadableTailwind from "eslint-plugin-readable-tailwind"; + import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; ``` 1. Migrate to the new configs ```diff rules: { // enable all recommended rules to warn - ...eslintPluginReadableTailwind.configs.warning.rules, + ...eslintPluginBetterTailwindcss.configs["recommended-warn"].rules, // enable all recommended rules to error - ...eslintPluginReadableTailwind.configs.error.rules, + ...eslintPluginBetterTailwindcss.configs["recommended-error"].rules, // or configure rules individually - "readable-tailwind/multiline": ["warn", { printWidth: 100 }] + "better-tailwindcss/multiline": ["warn", { printWidth: 100 }] } ``` ## v2.1.2 [compare changes](https://github.com/schoero/eslint-plugin-readable-tailwind/compare/v2.1.1...v2.1.2) ### Fixes - Multiline quotes ([#96](https://github.com/schoero/eslint-plugin-readable-tailwind/pull/96)) ### Refactors - Report error for each duplicate class instead of the whole class string ([#91](https://github.com/schoero/eslint-plugin-readable-tailwind/pull/91)) ## v2.1.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v2.1.0...v2.1.1) ### Fixes - Unnecessarily escaped quotes in autofixed classes ([#88](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/88)) ## v2.1.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v2.0.1...v2.1.0) ### Features - Experimental angular support. ([#85](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/85)) ### Fixes - Keep carriage return in es literals when used with vue parser ([#84](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/84)) ## v2.0.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v2.0.0...v2.0.1) ### Fixes - Keep original newline characters ([a564783](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/a564783)) ### Refactors - Display warning if plugin is misconfigured ([7c532cd](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/7c532cd)) ### Documentation - Update quick start guide ([e570981](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/e570981)) ## v2.0.0 Adds tailwindcss v4 support while keeping support for tailwindcss v3. ([#78](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/78)) This version contains breaking changes. Most notably support for Node.js < 20 had to be dropped. The other breaking changes are mostly just changes of the default config, that may cause linting errors. ### Migration - If you use tailwindcss v4, you should specify the [`entryPoint`](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/sort-classes.md#entrypoint) of the css based tailwind configuration file for the [sort-classes](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/sort-classes.md) rule or in the [settings](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/settings/settings.md#entrypoint). - If you have customized the `classAttributes` option for any of the rules or via the [settings](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/settings/settings.md#attributes), rename the option to [`attributes`](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/settings/settings.md#attributes) - If you have customized `attributes`, `callees`, `variables`, or `tags`, escape any reserved characters for regular expressions in the name as the name is now evaluated as a regular expression. For example: ```diff { variables: [ - "$MyVariable" + "\\$MyVariable" ] } ``` ### Changes - Reload tailwind config automatically if a change is detected. - Options now correctly override settings ([#66](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/66)) #### ⚠️ Breaking Changes - ⚠️ Drop support for Node.js < 20 due to incompatibility of worker threads. - ⚠️ Add support for tailwindcss v4 ([#25](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/25)) - The official class ordering seems to have changed slightly. - The `improved` sorting order will no longer sort variants alphabetically, instead it just makes sure that identical variants are grouped together. - ⚠️ Regex names ([#63](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/63)) - ["Names"](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/configuration/advanced.md#name-based-matching) can now be regular expressions. This is a breaking change, if you have names configured that contain reserved characters in regular expressions like `$`. - ⚠️ Enable `no-duplicate-classes` by default ([#67](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/67)) - ⚠️ Change default `multiline` grouping to `newLine` ([#68](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/68)) - ⚠️ Rename `classAttributes` to `attributes` ([#69](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/69)) ## v1.9.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v2.0.0-beta.2...v1.9.1) ### Fixes - Lint `className` in render functions inside object ([#75](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/75)) ## v1.9.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.8.2...v1.9.0) ### Features - Template literal tags ([#65](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/65)) ## v1.8.2 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.8.1...v1.8.2) ### Fixes - Fixing loop when lines wrap on two lines immediately but was theoretically short enough to not wrap ([#61](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/61)) ## v1.8.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.8.0...v1.8.1) ### Refactors - Improve display of linting errors ([#60](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/60)) ## v1.8.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.7.0...v1.8.0) ### Features - Add support to globally configure shared options across all rules via the settings object ([#56](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/56)) ## v1.7.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.6.1...v1.7.0) ### Features - New option `preferSingleLine` ([#54](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/54)) ## v1.6.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.6.0...v1.6.1) ### Fixes - Group type `never` not working with expressions ([#53](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/53)) ## v1.6.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.5.3...v1.6.0) ### Features - New rule `no-duplicate-classes` ([#49](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/49)) This rule will be enabled by default in v2.0.0. If you want to enable it now, please refer to the [rule documentation](https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-duplicate-classes.md). You can suggest additional rules in the [discussions](https://github.com/schoero/eslint-plugin-better-tailwindcss/discussions/categories/new-rules-or-options?discussions_q=category%3A%22New+rules+or+options%22+). ### Refactors - Revert back to vitest ([38f6eab](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/38f6eab)) ## v1.5.3 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.5.2...v1.5.3) ### Refactors - Insertion of unnecessary escape characters ([#47](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/47)) ## v1.5.2 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.5.1...v1.5.2) ### Fixes - Remove unnecessary plugin import in shared config ([#44](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/44)) - Support svelte shorthand syntax ([#43](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/43)) ## v1.5.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.5.0...v1.5.1) ### Fixes - Commonjs build ([#39](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/39)) ## v1.5.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.4.0...v1.5.0) ### Features - Vue bound classes ([#31](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/31)) ### Fixes - Change quotes in multiline arrays ([#32](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/32)) - Escape nested quotes ([#33](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/33)) - Allow call expressions as object values ([#34](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/34)) - Attributes are no longer case sensitive ([#35](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/35)) - Warn in html matchers ([#36](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/36)) - Don't treat escape characters as whitespace ([6aa74f8](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/6aa74f8)) ### Refactors - Simplify build system ([#26](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/26), [#29](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/29)) ## v1.4.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.3.2...v1.4.0) ### Features - Matchers ([#28](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/28)) ## v1.3.2 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.3.1...v1.3.2) ### Fixes - Remove unnecessary newline after single sticky class ([#23](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/23)) - Prevent inserting new line if the first class is already too long ([#24](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/24)) ### Tests - Simplify testing ([#22](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/22)) ## v1.3.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.3.0...v1.3.1) ### Fixes - Accept tabs ([#21](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/21)) ## v1.3.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.2.5...v1.3.0) ### Features - Add eslint 9 support ([#19](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/19)) ### Chore - Update dependencies ([be69b11](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/be69b11)) ## v1.2.5 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.2.4...v1.2.5) ### Performance - Cache tailwind config and context ([#16](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/16)) ### Fixes - Resolving tailwind config ([#15](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/15)) ## v1.2.4 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.2.3...v1.2.4) ### Fixes - Sticky expressions ([#13](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/13)) ## v1.2.3 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.2.2...v1.2.3) ### Fixes - Remove unnecessary trailing spaces in multiline strings ([#12](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/12)) - False positives when using `crlf` ([#11](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/11)) ## v1.2.2 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.2.1...v1.2.2) ### Fixes - False positives of unnecessary whitespace around template literal elements ([#9](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/9)) ## v1.2.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.2.0...v1.2.1) ### Fixes - Don't wrap empty attributes ([#8](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/8)) ## v1.2.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.1.1...v1.2.0) ### Features - Lint variables ([#7](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/7)) ### Fixes - Apply nested regex only to container groups ([#6](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/6)) ## v1.1.1 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.1.0...v1.1.1) ### Fixes - Invalid collapsing with template literal expressions ([adfafbf](https://github.com/schoero/eslint-plugin-better-tailwindcss/commit/adfafbf)) ## v1.1.0 [compare changes](https://github.com/schoero/eslint-plugin-better-tailwindcss/compare/v1.0.0...v1.1.0) ### Features - Collapse unnecessary newlines ([#4](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/4)) - Regex as callees ([#3](https://github.com/schoero/eslint-plugin-better-tailwindcss/pull/3)) ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to eslint-plugin-better-tailwindcss First off, thank you for considering contributing to **eslint-plugin-better-tailwindcss**! Your help is essential to making this project better. This document provides guidelines and instructions for contributing.
## How Can I Contribute? ### Reporting Bugs If you find a bug, please create an issue on [GitHub Issues](https://github.com/schoero/eslint-plugin-better-tailwindcss/issues) with: - **Clear title and description** of the issue - **Steps to reproduce** the problem - **Expected behavior** vs **actual behavior** - **Your environment**: Node.js version, npm version, ESLint version, Tailwind CSS version - **Code examples** or screenshots if applicable
### Feature Requests Feature requests are tracked as GitHub issues. When creating a feature request, include: - **Clear title and description** - **Use case and benefits** of the proposed feature - **Possible implementation** approach (if you have ideas) - **Examples** of how the feature would be used
### Pull Requests Pull requests are appreciated! Here's the process: #### Prerequisites - Node.js `^20.11.0` or `>=21.2.0` - npm `>=8.0.0`
##### Installation ```bash git clone https://github.com/schoero/eslint-plugin-better-tailwindcss.git cd eslint-plugin-better-tailwindcss npm install ```
##### Fork the repository and create your branch from `main` ```bash git checkout -b feat/your-feature-name ```
##### Set up the development environment ```bash npm install ```
##### Make your changes - Follow the project's code style - Add tests for new features or bug fixes - Update documentation if needed - Keep commits atomic and write clear commit messages
##### Run the test suite ```bash npm test ``` If you use vscode, you can open a test file and press F5 to run all tests in the file in debug mode. The plugin supports both Tailwind CSS v3 and v4. Use the following commands to test against both versions: ```bash npm run install:v3 npm run test:v3 npm run install:v4 npm run test:v4 ```
##### Fix linting and formatting issues ```bash npm run lint:fix ```
##### Check type validity ```bash npm run typecheck ```
##### Build the project ```bash npm run build ```
##### Commit and push your changes ```bash git add . git commit -m "feat: add new feature" # or "fix: fix issue", "docs: update docs", etc. git push origin feat/your-feature-name ``` Use conventional commit messages: - `feat:` for new features - `fix:` for bug fixes - `docs:` for documentation changes - `test:` for test changes - `refactor:` for code refactoring - `chore:` for maintenance tasks - `ci:` for CI/CD changes Example: `feat: add new rule for enforcing class ordering`
##### Create a pull request on GitHub with - Clear title and description - Reference to related issues - Summary of changes
### Test Template The project includes a sophisticated testing abstraction that automatically tests rules across multiple parsers (Angular, Astro, HTML, JSX, Svelte, and Vue). Use the `lint()` helper from `tests/utils/lint.ts`: ```ts import { describe, it } from "vitest"; import { yourRule } from "better-tailwindcss:rules/your-rule.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe(yourRule.name, () => { it("should describe what it does", () => { lint(yourRule, { invalid: [ { angular: '', angularOutput: '', html: '', htmlOutput: '', jsx: '() => ', jsxOutput: '() => ', svelte: '', svelteOutput: '', vue: '', vueOutput: '', errors: 1, options: [{ /* rule options */ }] } ], valid: [ { angular: '', html: '', jsx: '() => ', svelte: '', vue: '', options: [{ /* rule options */ }] } ] }); }); }); ```
## Recognition Contributors will be recognized in: - Pull request acknowledgments - CHANGELOG entries - GitHub contributors list Thank you for your contributions! ================================================ FILE: FUNDING.yml ================================================ github: - schoero ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Roger Schönbächler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
eslint-plugin-better-tailwindcss logo

eslint-plugin-better-tailwindcss



eslint-plugin-better-tailwindcss logo eslint-plugin-better-tailwindcss logo eslint-plugin-better-tailwindcss logo eslint-plugin-better-tailwindcss logo eslint-plugin-better-tailwindcss logo eslint-plugin-better-tailwindcss logo


ESLint/Oxlint plugin with formatting and linting rules to help you write cleaner, more maintainable Tailwind CSS. The formatting rules focus on improving readability by automatically breaking up long Tailwind class strings into multiple lines and sorting/grouping them in a logical order. The linting rules enforce best practices and catch potential issues, ensuring that you're writing valid Tailwind CSS. This plugin supports a wide range of projects, including React, Solid.js, Qwik, Svelte, Vue, Astro, Angular, HTML or plain JavaScript or TypeScript.

eslint-plugin-better-tailwindcss example


eslint-plugin-better-tailwindcss logo

[Buy me a coffee](https://buymeacoffee.com/schoero) | [GitHub Sponsors](https://github.com/sponsors/schoero) Help support this project. If you or your company benefit from this project, please consider becoming a sponsor or making a one-time donation. Your contribution will help me to maintain and develop the project.


## Installation ```sh npm i -D eslint-plugin-better-tailwindcss ```
## Quick start Depending on the flavor you are using, you need to install and configure the corresponding parser: - React: [.jsx](docs/parsers/jsx.md) · [.tsx](docs/parsers/tsx.md) - SolidJS: [.jsx](docs/parsers/jsx.md) · [.tsx](docs/parsers/tsx.md) - Qwik: [.jsx](docs/parsers/jsx.md) · [.tsx](docs/parsers/tsx.md) - Svelte: [.svelte](docs/parsers/svelte.md) - Vue: [.vue](docs/parsers/vue.md) - Astro: [.astro](docs/parsers/astro.md) - Angular: [.html, .ts](docs/parsers/angular.md) - HTML: [.html](docs/parsers/html.md) - CSS: [.css](docs/parsers/css.md) - JavaScript: [.js](docs/parsers/javascript.md) - TypeScript: [.ts](docs/parsers/typescript.md)

### Rules The rules are categorized into two types: `stylistic` and `correctness`. #### Configs The plugin offers three recommended configurations to help you get started quickly: - `stylistic`: Enforces stylistic rules for tailwind classes. - `correctness`: Enforces correctness rules for tailwind classes. - `recommended`: Enforces both stylistic and correctness rules. By default: - `stylistic` rules are reported as warnings - `correctness` rules are reported as errors You can change the severity by adding a suffix to the config name: - Use `-error` to report all rules as errors - Use `-warn` to report all rules as warnings For example, `recommended-warn` will report every rule as a warning and `stylistic-error` will report the formatting rules as errors. If you still use the old .eslintrc configuration format, you can prefix the config names with `legacy-`. For example, `legacy-recommended` or `legacy-correctness-warn`. The table below lists all available rules, the Tailwind CSS versions they support, and whether they are enabled by default in each recommended configuration:

#### Stylistic rules | Name | Description | `tw3` | `tw4` | `recommended` | autofix | | :--- | :--- | :---: | :---: | :---: | :---: | | [enforce-consistent-line-wrapping](docs/rules/enforce-consistent-line-wrapping.md) | Enforce consistent line wrapping for tailwind classes. | ✔ | ✔ | ✔ | ✔ | | [enforce-consistent-class-order](docs/rules/enforce-consistent-class-order.md) | Enforce a consistent order for tailwind classes. | ✔ | ✔ | ✔ | ✔ | | [enforce-consistent-variant-order](docs/rules/enforce-consistent-variant-order.md) | Enforce a consistent variant order for tailwind classes. | | ✔ | | ✔ | | [enforce-consistent-variable-syntax](docs/rules/enforce-consistent-variable-syntax.md) | Enforce consistent variable syntax. | ✔ | ✔ | | ✔ | | [enforce-consistent-important-position](docs/rules/enforce-consistent-important-position.md) | Enforce consistent position of the important modifier. | ✔ | ✔ | | ✔ | | [enforce-shorthand-classes](docs/rules/enforce-shorthand-classes.md) | Enforce shorthand class names. | ✔ | ✔ | | ✔ | | [enforce-logical-properties](docs/rules/enforce-logical-properties.md) | Enforce logical property class names. | ✔ | ✔ | | ✔ | | [enforce-canonical-classes](docs/rules/enforce-canonical-classes.md) | Enforce canonical class names. | | ✔ | ✔ | ✔ | | [no-duplicate-classes](docs/rules/no-duplicate-classes.md) | Remove duplicate classes. | ✔ | ✔ | ✔ | ✔ | | [no-deprecated-classes](docs/rules/no-deprecated-classes.md) | Remove deprecated classes. | | ✔ | ✔ | ✔ | | [no-unnecessary-whitespace](docs/rules/no-unnecessary-whitespace.md) | Disallow unnecessary whitespace in tailwind classes. | ✔ | ✔ | ✔ | ✔ | #### Correctness rules | Name | Description | `tw3` | `tw4` | `recommended` | autofix | | :--- | :--- | :---: | :---: | :---: | :---: | | [no-unknown-classes](docs/rules/no-unknown-classes.md) | Report classes not registered with Tailwind CSS. | ✔ | ✔ | ✔ | | | [no-conflicting-classes](docs/rules/no-conflicting-classes.md) | Report classes that produce conflicting styles. | | ✔ | ✔ | | | [no-restricted-classes](docs/rules/no-restricted-classes.md) | Disallow restricted classes. | ✔ | ✔ | | ✔ |

### Utilities This plugin is pre-configured to lint tailwind classes for the most popular utilities: - [tailwind merge](https://github.com/dcastil/tailwind-merge): `twMerge` · `twJoin` - [class variance authority](https://github.com/joe-bell/cva): `cva` - [tailwind variants](https://github.com/nextui-org/tailwind-variants?tab=readme-ov-file): `tv` - [shadcn](https://ui.shadcn.com/docs/installation/manual): `cn` - [classcat](https://github.com/jorgebucaran/classcat): `cc` - [class list builder](https://github.com/crswll/clb): `clb` - [clsx](https://github.com/lukeed/clsx): `clsx` - [cnbuilder](https://github.com/xobotyi/cnbuilder): `cnb` - [classnames template literals](https://github.com/netlify/classnames-template-literals): `ctl` - [obj str](https://github.com/lukeed/obj-str): `objstr` - [react-twc](https://github.com/gregberge/twc): `twc` · `twx`

### Advanced configuration If an utility is not supported by default, or you want to customize the configuration, you can define which [attributes](./docs/configuration/advanced.md#attribute), [callees](./docs/configuration/advanced.md#callee), [variables](./docs/configuration/advanced.md#variable), and [tags](./docs/configuration/advanced.md#tag) should get linted. See the [Advanced configuration guide](./docs/configuration/advanced.md) to learn how to override or extend the default settings.

### Monorepo setup In monorepos, linting is often started from the repository root while each package has its own Tailwind setup. You can configure `settings["better-tailwindcss"].cwd` per file group so the plugin resolves `tailwindcss` and config files from the correct project directory. ```js // eslint.config.js export default [ { files: ["packages/website/**/*.{js,jsx,cjs,mjs,ts,tsx}"], settings: { "better-tailwindcss": { cwd: "./packages/website" } } }, { files: ["packages/app/**/*.{js,jsx,cjs,mjs,ts,tsx}"], settings: { "better-tailwindcss": { cwd: "./packages/app" } } } ]; ``` See [Settings](./docs/settings/settings.md#cwd) for more details.

### Editor configuration #### VSCode ##### Auto-fix on save Most rules are intended to automatically fix the tailwind classes using VSCode extensions. ###### ESLint For ESLint, you can install the [VSCode ESLint plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and configure it to automatically fix the classes on save by adding the following options to your `.vscode/settings.json`: ```jsonc { // enable VSCode to fix tailwind classes on save "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" } } ``` ###### Oxlint For Oxlint, you can install the [VSCode Oxc plugin](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode) and configure it to automatically fix the classes on save by adding the following options to your `.vscode/settings.json`: ```jsonc { // enable VSCode to fix tailwind classes on save "editor.codeActionsOnSave": { "source.fixAll.oxc": "explicit" } } ```

================================================ FILE: build/index.ts ================================================ import { $ } from "better-tailwindcss:build/utils.js"; async function build(){ const outDir = "lib" console.info("Building...") await $(`npx tsc --project tsconfig.build.json --outDir ${outDir}`) await $(`npx tsc-alias --outDir ${outDir}`) console.info("Build complete") } build().catch(console.error); ================================================ FILE: build/utils.ts ================================================ import { exec, type ExecOptions } from 'node:child_process' export async function $(command: string, options?: ExecOptions): Promise { return new Promise((resolve, reject) => { exec(command, options, (error, stdout, stderr) => { if (error) { reject(error || stderr) } resolve(stdout) }) }) } ================================================ FILE: changelog.config.js ================================================ export { default } from "@schoero/configs/changelogen"; ================================================ FILE: docs/api/defaults.md ================================================ # Defaults The plugin comes with a set of default [selectors](../configuration/advanced.md#selectors). These selectors are used to [determine how the rules should behave](../configuration/advanced.md#advanced-configuration) when checking your code. In order to extend the default configuration instead of overwriting it, you can import the default options from `eslint-plugin-better-tailwindcss/defaults` and merge them with your own options.

## Extending the config ```ts import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { getDefaultSelectors } from "eslint-plugin-better-tailwindcss/defaults"; import { MatcherType, SelectorKind } from "eslint-plugin-better-tailwindcss/types"; export default [ { plugins: { "better-tailwindcss": eslintPluginBetterTailwindcss }, rules: { "better-tailwindcss/enforce-consistent-class-order": ["warn", { selectors: [ ...getDefaultSelectors(), // custom tag { kind: SelectorKind.Tag, match: [ { type: MatcherType.String } ], name: "^myTag$" }, // custom callee { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^myFunction$" }, // custom attribute { kind: SelectorKind.Attribute, match: [ { type: MatcherType.String } ], name: "^myAttribute$" }, // custom variable { kind: SelectorKind.Variable, match: [ { type: MatcherType.String } ], name: "^myVariable$" } ] }] } } ]; ``` ================================================ FILE: docs/configuration/advanced.md ================================================ # Advanced Configuration The [rules](../../README.md#rules) in this plugin lint Tailwind classes inside string literals. To do that safely, the plugin must know **which strings are expected to contain Tailwind classes**. If it would lint every string literal in your codebase, it would produce many false positives and potentially unsafe fixes. To configure this, you can provide an array of [selectors](#selectors) that specify where the plugin should look for class strings and how to extract them. The plugin already ships with defaults that support [most popular tailwind utilities](../../README.md#utilities). You only need advanced configuration when: - you use custom utilities/APIs not covered by defaults, - you want to narrow down linting behavior, - or you want to lint additional locations. To extend defaults instead of replacing them, import and spread `getDefaultSelectors()` from `eslint-plugin-better-tailwindcss/defaults`. You can find the default selectors in the [defaults documentation](../api/defaults.md).

## Selectors Each selector targets one kind of source location and tells the plugin how to extract class strings from it. The plugin supports four selector types: `attribute`, `callee`, `variable`, and `tag`. Every selector can then match different types of string literals based on the provided `match` option. ### Type
### `attribute` - **kind**: `"attribute"`. - **name**: regular expression for attribute names. - **match** `optional`: [selector matcher](#selector-matcher-types) list. When omitted, only direct string literals are collected. ```ts type AttributeSelector = { kind: "attribute"; name: string; match?: SelectorMatcher[]; }; ```
### `callee` - **kind**: `"callee"`. - **name** `optional`: regular expression for callee names. - **path** `optional`: regular expression for callee member paths like `classes.push`. When `path` is provided, `name` is not required. - **targetCall** `optional`: curried call target for example for `fn()("my classes")`. If a non-negative number is provided, the zero-based call index is used. Negative numbers count from the end (`-1` is the last call). When omitted, the first call in a curried chain is used. - **targetArgument** `optional`: target specific call arguments. If a non-negative number is provided, the zero-based argument index is used. Negative numbers count from the end (`-1` is the last argument). When omitted, all arguments of the selected call are checked. - **match** `optional`: [selector matcher](#selector-matcher-types) list. When omitted, only direct string literals are collected. ```ts type CalleeSelector = { kind: "callee"; match?: SelectorMatcher[]; name?: string; path?: string; targetArgument?: "all" | "first" | "last" | number; targetCall?: "all" | "first" | "last" | number; }; ```
### `variable` - **kind**: `"variable"`. - **name**: regular expression for variable names. Tip: The name `default` targets the `export default ...` declaration. - **match** `optional`: [selector matcher](#selector-matcher-types) list. When omitted, only direct string literals are collected. ```ts type VariableSelector = { kind: "variable"; name: string; match?: SelectorMatcher[]; }; ```
### `tag` - **kind**: `"tag"`. - **name**: `optional` regular expression for tagged template names. - **path** `optional`: regular expression for tagged template member paths like `twc.class`. When `path` is provided, `name` is not required. - **match** `optional`: [selector matcher](#selector-matcher-types) list. When omitted, only direct string literals are collected. ```ts type TagSelector = { kind: "tag"; name: string; match?: SelectorMatcher[]; }; ```
### How selector matching works - Names are treated as regular expressions. - Reserved regex characters must be escaped. - The regex must match the whole name (not a substring). ```jsonc { "selectors": [ { "kind": "callee", "path": "^classes\\.push$", "match": [{ "type": "strings" }] } ] } ```

### Matchers #### Selector matcher types ##### `strings` Matches all string literals that are not object keys or object values. ```ts type SelectorStringMatcher = { type: "strings"; }; ``` ```json { "selectors": [ { "kind": "callee", "name": "^tw$", "match": [ { "type": "strings" } ] } ] } ``` Matches: ```tsx tw( "this will get linted", { className: "this will not get linted by this matcher" } ); ```
##### `objectKeys` Matches all object keys. - `path` `optional`: regular expression to narrow matching to specific object key paths See [Path option details](#path-option-details). ```ts type SelectorObjectKeyMatcher = { type: "objectKeys"; path?: string; }; ``` ```json { "selectors": [ { "kind": "callee", "name": "^tw$", "match": [ { "type": "objectKeys", "path": "^compoundVariants\\[\\d+\\]\\.(?:className|class)$" } ] } ] } ``` Matches: ```tsx tw({ compoundVariants: [ { className: "<- this key will get linted", myVariant: "but this key will not get linted" } ] }); ```
##### `objectValues` Matches all object values. - `path` `optional`: regular expression to narrow matching to specific object value paths See [Path option details](#path-option-details). ```ts type SelectorObjectValueMatcher = { type: "objectValues"; path?: string; }; ``` ```json { "selectors": [ { "kind": "callee", "name": "^tw$", "match": [ { "type": "objectValues", "path": "^compoundVariants\\[\\d+\\]\\.(?:className|class)$" } ] } ] } ``` Matches: ```tsx tw({ compoundVariants: [ { className: "this value will get linted", myVariant: "but this value will not get linted" } ] }); ```
##### `anonymousFunctionReturn` Matches values returned from anonymous functions and applies nested matchers to those return values. - `match` `required`: nested matcher array The nested `match` array can include `strings`, `objectKeys`, and `objectValues` matchers. ```ts type SelectorAnonymousFunctionReturnMatcher = { match: (SelectorObjectKeyMatcher | SelectorObjectValueMatcher | SelectorStringMatcher)[]; type: "anonymousFunctionReturn"; }; ``` ```json { "selectors": [ { "kind": "callee", "name": "^tw$", "match": [ { "type": "anonymousFunctionReturn", "match": [ { "type": "strings" }, { "type": "objectKeys" }, { "type": "objectValues" } ] } ] } ] } ``` Matches: ```tsx tw(() => "this will get linted with a nested string matcher"); tw(() => ({ className: "<- this key will get linted with a nested objectKeys matcher" })); tw(() => ({ className: "this will get linted with nested objectValues matcher" })); ```
##### Path option details The `path` option lets you narrow down `objectKeys` and `objectValues` matching to specific object paths. This is especially useful for libraries like [Class Variance Authority (cva)](https://cva.style/docs/getting-started/installation#intellisense), where class names appear in nested object structures. `path` is a regex matched against the object path. For example, the following matcher will only match object values for the `compoundVariants.class` key:
```json { "selectors": [ { "kind": "callee", "name": "^cva$", "match": [ { "type": "objectValues", "path": "^compoundVariants\\[\\d+\\]\\.(?:className|class)$" } ] } ] } ``` ```tsx ; ```
The path reflects how the string is nested in the object: - Dot notation for plain keys: `root.nested.values` - Square brackets for arrays: `values[0]` - Quoted brackets for special characters: `root["some-key"]` For example, the object path for `value` in the object below is `root["nested-key"].values[0].value`: ```json { "root": { "nested-key": { "values": [ { "value": "this will get linted" } ] } } } ```
### Examples #### Example: lint only the first argument of the last curried call ```jsonc { "selectors": [ { "kind": "callee", "name": "^tw$", "targetCall": "last", "targetArgument": "first" } ] } ``` ```tsx tw("keep", "ignore")("this will get linted", "this will not"); ``` #### Example: lint `cva` strings + specific nested values ```jsonc { "selectors": [ { "kind": "callee", "name": "^cva$", "match": [ { "type": "strings" }, { "type": "objectValues", "path": "^compoundVariants\\[\\d+\\]\\.(?:className|class)$" } ] } ] } ``` ```tsx ; ``` #### Full example: custom Algolia attribute selector You can match custom attributes by modifying your `selectors` configuration. Here is an example on how to match the values inside the Algolia `classNames` objects: ```tsx ; ```
```js // eslint.config.js import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { getDefaultSelectors } from "eslint-plugin-better-tailwindcss/defaults"; import { SelectorKind } from "eslint-plugin-better-tailwindcss/types"; import { defineConfig } from "eslint/config"; export default defineConfig({ plugins: { "better-tailwindcss": eslintPluginBetterTailwindcss }, settings: { "better-tailwindcss": { entryPoint: "app/globals.css", selectors: [ ...getDefaultSelectors(), // preserve default selectors { kind: SelectorKind.Attribute, match: [{ type: "objectValues" }], name: "^classNames$" } ] } } }); // ... ``` ================================================ FILE: docs/parsers/angular.md ================================================ # Angular - [ESLint](#eslint) - [Oxlint](#oxlint)
## ESLint To use ESLint with Angular, install [Angular ESLint](https://github.com/angular-eslint/angular-eslint?tab=readme-ov-file#quick-start) and [TypeScript ESLint](https://typescript-eslint.io/getting-started). You can follow the [flat config](https://github.com/angular-eslint/angular-eslint/blob/main/docs/CONFIGURING_FLAT_CONFIG.md) setup, which includes rules from the Angular ESLint package or you can add the parser directly by following the steps below. ```sh npm i -D angular-eslint typescript-eslint ``` To lint Tailwind CSS classes in Angular files, ensure that: - The `angular-eslint` package is installed and configured. - The `typescript-eslint` package is installed and configured. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
### Flat config Read more about the [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
```js // eslint.config.js import eslintParserAngular from "angular-eslint"; import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "eslint/config"; import { parser as eslintParserTypeScript } from "typescript-eslint"; export default defineConfig([ { // enable all recommended rules extends: [ eslintPluginBetterTailwindcss.configs.recommended ], // if needed, override rules to configure them individually // rules: { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] // }, settings: { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) entryPoint: "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) tailwindConfig: "tailwind.config.js" } } }, { files: ["**/*.ts"], languageOptions: { parser: eslintParserTypeScript, parserOptions: { project: true } }, processor: eslintParserAngular.processInlineTemplates }, { files: ["**/*.html"], languageOptions: { parser: eslintParserAngular.templateParser } } ]); ```

Legacy config


To use ESLint with Angular using the legacy config, install [Angular ESLint](https://github.com/angular-eslint/angular-eslint?tab=readme-ov-file#quick-start) and [@typescript-eslint/parser](https://typescript-eslint.io/getting-started/legacy-eslint-setup). You can follow the [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. ```sh npm i -D angular-eslint @typescript-eslint/parser ``` To lint Tailwind CSS classes in TypeScript files, ensure that: - The `angular-eslint` package is installed and configured. - The `@typescript-eslint/parser` is installed and configured. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
```jsonc // .eslintrc.json { // enable all recommended rules "extends": [ "plugin:better-tailwindcss/legacy-recommended" ], "settings": { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) "entryPoint": "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) "tailwindConfig": "tailwind.config.js" } }, // if needed, override rules to configure them individually // "rules": { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { "printWidth": 100 }] // }, "overrides": [ { "files": ["**/*.ts"], "parser": "@typescript-eslint/parser", "extends": [ "plugin:@angular-eslint/template/process-inline-templates" ] }, { "files": ["**/*.html"], "parser": "@angular-eslint/template-parser" } ] } ```

## Oxlint Oxlint currently does **not** support Angular templates and inline template processing. Framework-specific parsers/processors like Angular are not supported in Oxlint yet, so `eslint-plugin-better-tailwindcss` cannot currently lint Angular templates through Oxlint. You can continue using ESLint for Angular files until Oxlint adds framework parser support. ================================================ FILE: docs/parsers/astro.md ================================================ # Astro - [ESLint](#eslint) - [Oxlint](#oxlint)
## ESLint To use ESLint with Astro files, first install the [astro-eslint-parser](https://github.com/ota-meshi/astro-eslint-parser) and optionally [TypeScript ESLint](https://typescript-eslint.io/getting-started). Then, configure ESLint to use this parser for Astro files. ```sh npm i -D astro-eslint-parser typescript-eslint ``` To lint Tailwind CSS classes in Astro files, ensure that: - The `astro-eslint-parser` is installed and configured. - The `typescript-eslint` package is installed if you want to lint TypeScript within Astro files. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
### Flat config Read more about the [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
```js // eslint.config.js import eslintParserAstro from "astro-eslint-parser"; import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "eslint/config"; import { parser as eslintParserTypeScript } from "typescript-eslint"; export default defineConfig({ // enable all recommended rules extends: [ eslintPluginBetterTailwindcss.configs.recommended ], // if needed, override rules to configure them individually // rules: { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] // }, settings: { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) entryPoint: "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) tailwindConfig: "tailwind.config.js" } }, files: ["**/*.astro"], languageOptions: { parser: eslintParserAstro, parserOptions: { // optionally use TypeScript parser within for Astro files parser: eslintParserTypeScript } } }); ```

Legacy config


To use ESLint with Astro files using the legacy config, first install the [astro-eslint-parser](https://github.com/ota-meshi/astro-eslint-parser) and optionally [@typescript-eslint/parser](https://typescript-eslint.io/getting-started/legacy-eslint-setup). Then, configure ESLint to use this parser for Astro files. ```sh npm i -D astro-eslint-parser @typescript-eslint/parser ``` To lint Tailwind CSS classes in TypeScript files, ensure that: - The `astro-eslint-parser` is installed and configured. - The `@typescript-eslint/parser` is installed and configured if you want to lint TypeScript within Astro files. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
```jsonc // .eslintrc.json { // enable all recommended rules "extends": [ "plugin:better-tailwindcss/legacy-recommended" ], // if needed, override rules to configure them individually // "rules": { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { "printWidth": 100 }] // }, "settings": { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) "entryPoint": "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) "tailwindConfig": "tailwind.config.js" } }, "parser": "astro-eslint-parser", "parserOptions": { // optionally use TypeScript parser within for Astro files "parser": "@typescript-eslint/parser" } } ```

## Oxlint Oxlint currently does **not** support Astro files (`.astro`). Framework-specific parsers like Astro are not supported in Oxlint yet, so `eslint-plugin-better-tailwindcss` cannot currently lint Astro templates through Oxlint. You can continue using ESLint for Astro files until Oxlint adds framework parser support. ================================================ FILE: docs/parsers/css.md ================================================ # CSS - [ESLint](#eslint) - [Oxlint](#oxlint)
## ESLint To use ESLint with CSS files containing Tailwind CSS `@apply` directives, first install the [@eslint/css](https://github.com/eslint/css) plugin and the [tailwind-csstree](https://www.npmjs.com/package/tailwind-csstree) custom syntax. ```sh npm i -D @eslint/css tailwind-csstree ``` To lint Tailwind CSS classes in CSS files, ensure that: - The `@eslint/css` plugin is installed and configured. - The `tailwind-csstree` custom syntax is installed and configured. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
### Flat config Read more about the [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
```js // eslint.config.js import css from "@eslint/css"; import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "eslint/config"; import { tailwind4 } from "tailwind-csstree"; export default defineConfig({ // enable all recommended rules extends: [ eslintPluginBetterTailwindcss.configs.recommended ], // if needed, override rules to configure them individually // rules: { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] // }, settings: { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) entryPoint: "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) tailwindConfig: "tailwind.config.js" } }, files: ["**/*.css"], language: "css/css", languageOptions: { customSyntax: tailwind4, tolerant: true }, plugins: { css } }); ```
> **Note:** Legacy config is not supported for CSS files as the `@eslint/css` plugin requires the ESLint flat config format.
## Oxlint Oxlint currently does **not** support CSS parser integration for this use case. Because Oxlint currently only supports JavaScript-like files, `eslint-plugin-better-tailwindcss` cannot currently lint CSS `@apply` directives through Oxlint. You can continue using ESLint for CSS files until broader parser support is available in Oxlint. ================================================ FILE: docs/parsers/html.md ================================================ # HTML - [ESLint](#eslint) - [Oxlint](#oxlint)
## ESLint To use ESLint with HTML files, first install the [@html-eslint/parser](https://github.com/yeonjuan/html-eslint/tree/main/packages/parser). ```sh npm i -D @html-eslint/parser ``` To lint Tailwind CSS classes in HTML files, ensure that: - The `@html-eslint/parser` is installed and configured. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
### Flat config Read more about the [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
```js // eslint.config.js import eslintParserHTML from "@html-eslint/parser"; import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "eslint/config"; export default defineConfig({ // enable all recommended rules extends: [ eslintPluginBetterTailwindcss.configs.recommended ], // if needed, override rules to configure them individually // rules: { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] // }, settings: { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) entryPoint: "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) tailwindConfig: "tailwind.config.js" } }, files: ["**/*.html"], languageOptions: { parser: eslintParserHTML } }); ```

Legacy config


```jsonc // .eslintrc.json { // enable all recommended rules "extends": [ "plugin:better-tailwindcss/legacy-recommended" ], // if needed, override rules to configure them individually // "rules": { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { "printWidth": 100 }] // }, "settings": { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) "entryPoint": "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) "tailwindConfig": "tailwind.config.js" } }, "parser": "@html-eslint/parser" } ```

## Oxlint Oxlint currently does **not** support HTML parser integration for this use case. Because Oxlint currently only supports JavaScript-like files, `eslint-plugin-better-tailwindcss` cannot currently lint standalone HTML files through Oxlint. You can continue using ESLint for HTML files until broader parser support is available in Oxlint. ================================================ FILE: docs/parsers/javascript.md ================================================ # JavaScript - [ESLint](#eslint) - [Oxlint](#oxlint)
## ESLint To lint Tailwind CSS classes in JavaScript files, ensure that: - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
### Flat config Read more about the [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
```js // eslint.config.js import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "eslint/config"; export default defineConfig({ // enable all recommended rules extends: [ eslintPluginBetterTailwindcss.configs.recommended ], // if needed, override rules to configure them individually // rules: { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] // }, settings: { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) entryPoint: "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) tailwindConfig: "tailwind.config.js" } } }); ```

Legacy config


```jsonc // .eslintrc.json { // enable all recommended rules "extends": [ "plugin:better-tailwindcss/legacy-recommended" ], // if needed, override rules to configure them individually // "rules": { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { "printWidth": 100 }] // }, "settings": { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) "entryPoint": "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) "tailwindConfig": "tailwind.config.js" } } } ```

## Oxlint More info about the Oxlint configuration format can be found in the [Oxlint documentation](https://oxc.rs/docs/guide/usage/linter/config.html). To lint Tailwind CSS classes in JavaScript files, ensure that: - The plugin is added to the `jsPlugins` array. - The `settings` object contains the correct Tailwind CSS configuration paths. - All relevant rules are added to the `rules` object.
```js // oxlint.config.js import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "oxlint"; export default defineConfig({ overrides: [{ files: ["**/*.{js,cjs,mjs}"], jsPlugins: [ "eslint-plugin-better-tailwindcss" ], rules: { // enable all recommended rules ...eslintPluginBetterTailwindcss.configs.recommended.rules, // if needed, override rules to configure them individually "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] } }], settings: { "better-tailwindcss": { entryPoint: "src/global.css" } } }); ``` ================================================ FILE: docs/parsers/jsx.md ================================================ # JSX - [ESLint](#eslint) - [Oxlint](#oxlint)
## ESLint To lint Tailwind CSS classes in JSX files, ensure that: - `jsx` parsing is enabled in language options. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
### Flat config Read more about the [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
```js // eslint.config.js import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "eslint/config"; export default defineConfig({ // enable all recommended rules extends: [ eslintPluginBetterTailwindcss.configs.recommended ], // if needed, override rules to configure them individually // rules: { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] // }, settings: { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) entryPoint: "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) tailwindConfig: "tailwind.config.js" } }, languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } }); ```

Legacy config


```jsonc // .eslintrc.json { // enable all recommended rules "extends": [ "plugin:better-tailwindcss/legacy-recommended" ], // if needed, override rules to configure them individually // "rules": { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { "printWidth": 100 }] // }, "settings": { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) "entryPoint": "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) "tailwindConfig": "tailwind.config.js" } }, "parserOptions": { "ecmaFeatures": { "jsx": true }, "ecmaVersion": "latest" } } ```

## Oxlint More info about the Oxlint configuration format can be found in the [Oxlint documentation](https://oxc.rs/docs/guide/usage/linter/config.html). To lint Tailwind CSS classes in JSX files, ensure that: - The plugin is added to the `jsPlugins` array. - The `settings` object contains the correct Tailwind CSS configuration paths. - All relevant rules are added to the `rules` object.
```js // oxlint.config.js import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "oxlint"; export default defineConfig({ overrides: [{ files: ["**/*.{js,jsx,mjs,cjs}"], jsPlugins: [ "eslint-plugin-better-tailwindcss" ], rules: { // enable all recommended rules ...eslintPluginBetterTailwindcss.configs.recommended.rules, // if needed, override rules to configure them individually "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] } }], settings: { "better-tailwindcss": { entryPoint: "src/global.css" } } }); ``` ================================================ FILE: docs/parsers/svelte.md ================================================ # Svelte - [ESLint](#eslint) - [Oxlint](#oxlint)
## ESLint To use ESLint with Svelte files, first install the [svelte-eslint-parser](https://github.com/sveltejs/svelte-eslint-parser). ```sh npm i -D svelte-eslint-parser ``` To lint Tailwind CSS classes in Svelte files, ensure that: - The `svelte-eslint-parser` is installed and configured. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
### Flat config Read more about the [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
```js // eslint.config.js import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "eslint/config"; import eslintParserSvelte from "svelte-eslint-parser"; export default defineConfig({ // enable all recommended rules extends: [ eslintPluginBetterTailwindcss.configs.recommended ], // if needed, override rules to configure them individually // rules: { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] // }, settings: { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) entryPoint: "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) tailwindConfig: "tailwind.config.js" } }, files: ["**/*.svelte"], languageOptions: { parser: eslintParserSvelte } }); ```

Legacy config


```jsonc // .eslintrc.json { // enable all recommended rules "extends": [ "plugin:better-tailwindcss/legacy-recommended" ], // if needed, override rules to configure them individually // "rules": { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { "printWidth": 100 }] // }, "settings": { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) "entryPoint": "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) "tailwindConfig": "tailwind.config.js" } }, "parser": "svelte-eslint-parser" } ```

## Oxlint Oxlint currently does **not** support Svelte files (`.svelte`). Framework-specific parsers like Svelte are not supported in Oxlint yet, so `eslint-plugin-better-tailwindcss` cannot currently lint Svelte templates through Oxlint. You can continue using ESLint for Svelte files until Oxlint adds framework parser support. ================================================ FILE: docs/parsers/tsx.md ================================================ # TSX - [ESLint](#eslint) - [Oxlint](#oxlint)
## ESLint To use ESLint with TSX files, first install the [typescript-eslint](https://typescript-eslint.io/getting-started) package. ```sh npm i -D typescript-eslint ``` To lint Tailwind CSS classes in TSX files, ensure that: - The `typescript-eslint` package is installed and configured. - `jsx` parsing is enabled in language options. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
### Flat config Read more about the [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
```js // eslint.config.js import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "eslint/config"; import { parser as eslintParserTypeScript } from "typescript-eslint"; export default defineConfig([ { // enable all recommended rules extends: [ eslintPluginBetterTailwindcss.configs.recommended ], // if needed, override rules to configure them individually // rules: { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] // }, settings: { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) entryPoint: "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) tailwindConfig: "tailwind.config.js" } } }, { files: ["**/*.{ts,tsx,cts,mts}"], languageOptions: { parser: eslintParserTypeScript, parserOptions: { project: true } } }, { files: ["**/*.{jsx,tsx}"], languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } } ]); ```

Legacy config


To use ESLint with TypeScript files using the legacy config, first install the [@typescript-eslint/parser](https://typescript-eslint.io/getting-started/legacy-eslint-setup). ```sh npm i -D @typescript-eslint/parser ``` To lint Tailwind CSS classes in TypeScript files, ensure that: - The `@typescript-eslint/parser` is installed and configured. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
```jsonc // .eslintrc.json { // enable all recommended rules "extends": [ "plugin:better-tailwindcss/legacy-recommended" ], // if needed, override rules to configure them individually // "rules": { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { "printWidth": 100 }] // }, "settings": { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) "entryPoint": "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) "tailwindConfig": "tailwind.config.js" } }, "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaFeatures": { "jsx": true }, "ecmaVersion": "latest" } } ```

## Oxlint More info about the Oxlint configuration format can be found in the [Oxlint documentation](https://oxc.rs/docs/guide/usage/linter/config.html). To lint Tailwind CSS classes in TSX files, ensure that: - The plugin is added to the `jsPlugins` array. - The `settings` object contains the correct Tailwind CSS configuration paths. - All relevant rules are added to the `rules` object.
```ts // oxlint.config.ts import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "oxlint"; export default defineConfig({ overrides: [{ files: ["**/*.{js,cjs,mjs,ts,tsx,cts,mts}"], jsPlugins: [ "eslint-plugin-better-tailwindcss" ], rules: { // enable all recommended rules ...eslintPluginBetterTailwindcss.configs.recommended.rules, // if needed, override rules to configure them individually "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] } }], settings: { "better-tailwindcss": { entryPoint: "src/global.css" } } }); ``` ================================================ FILE: docs/parsers/typescript.md ================================================ # TypeScript - [ESLint](#eslint) - [Oxlint](#oxlint)
## ESLint To use ESLint with TypeScript files, first install the [typescript-eslint](https://typescript-eslint.io/getting-started) package. ```sh npm i -D typescript-eslint ``` To lint Tailwind CSS classes in TypeScript files, ensure that: - The `typescript-eslint` package is installed and configured. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
### Flat config Read more about the [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
```js // eslint.config.js import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "eslint/config"; import { parser as eslintParserTypeScript } from "typescript-eslint"; export default defineConfig({ // enable all recommended rules extends: [ eslintPluginBetterTailwindcss.configs.recommended ], // if needed, override rules to configure them individually // rules: { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] // }, settings: { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) entryPoint: "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) tailwindConfig: "tailwind.config.js" } }, files: ["**/*.{ts,tsx,cts,mts}"], languageOptions: { parser: eslintParserTypeScript, parserOptions: { project: true } } }); ```

Legacy config


To use ESLint with TypeScript files using the legacy config, first install the [@typescript-eslint/parser](https://typescript-eslint.io/getting-started/legacy-eslint-setup). ```sh npm i -D @typescript-eslint/parser ``` To lint Tailwind CSS classes in TypeScript files, ensure that: - The `@typescript-eslint/parser` is installed and configured. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
```jsonc // .eslintrc.json { // enable all recommended rules "extends": [ "plugin:better-tailwindcss/legacy-recommended" ], // if needed, override rules to configure them individually // "rules": { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { "printWidth": 100 }] // }, "settings": { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) "entryPoint": "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) "tailwindConfig": "tailwind.config.js" } }, "parser": "@typescript-eslint/parser" } ```

## Oxlint More info about the Oxlint configuration format can be found in the [Oxlint documentation](https://oxc.rs/docs/guide/usage/linter/config.html). To lint Tailwind CSS classes in TypeScript files, ensure that: - The plugin is added to the `jsPlugins` array. - The `settings` object contains the correct Tailwind CSS configuration paths. - All relevant rules are added to the `rules` object.
```ts // oxlint.config.ts import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "oxlint"; export default defineConfig({ overrides: [{ files: ["**/*.{js,cjs,mjs,ts,tsx,cts,mts}"], jsPlugins: [ "eslint-plugin-better-tailwindcss" ], rules: { // enable all recommended rules ...eslintPluginBetterTailwindcss.configs.recommended.rules, // if needed, override rules to configure them individually "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] } }], settings: { "better-tailwindcss": { entryPoint: "src/global.css" } } }); ``` ================================================ FILE: docs/parsers/vue.md ================================================ # Vue - [ESLint](#eslint) - [Oxlint](#oxlint)
## ESLint To use ESLint with Vue files, first install the [vue-eslint-parser](https://github.com/vuejs/vue-eslint-parser). ```sh npm i -D vue-eslint-parser ``` To lint Tailwind CSS classes in Vue files, ensure that: - The `vue-eslint-parser` is installed and configured. - The plugin is added to your configuration. - The `settings` object contains the correct Tailwind CSS configuration paths.
### Flat config Read more about the [ESLint flat config format](https://eslint.org/docs/latest/use/configure/configuration-files-new)
```js // eslint.config.js import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss"; import { defineConfig } from "eslint/config"; import eslintParserVue from "vue-eslint-parser"; export default defineConfig({ // enable all recommended rules extends: [ eslintPluginBetterTailwindcss.configs.recommended ], // if needed, override rules to configure them individually // rules: { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 100 }] // }, settings: { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) entryPoint: "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) tailwindConfig: "tailwind.config.js" } }, files: ["**/*.vue"], languageOptions: { parser: eslintParserVue } }); ```

Legacy config


```jsonc // .eslintrc.json { // enable all recommended rules "extends": [ "plugin:better-tailwindcss/legacy-recommended" ], // if needed, override rules to configure them individually // "rules": { // "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { "printWidth": 100 }] // }, "settings": { "better-tailwindcss": { // tailwindcss 4: the path to the entry file of the css based tailwind config (eg: `src/global.css`) "entryPoint": "src/global.css", // tailwindcss 3: the path to the tailwind config file (eg: `tailwind.config.js`) "tailwindConfig": "tailwind.config.js" } }, "parser": "vue-eslint-parser" } ```

## Oxlint Oxlint currently does **not** support Vue files (`.vue`). Framework-specific parsers like Vue are not supported in Oxlint yet, so `eslint-plugin-better-tailwindcss` cannot currently lint Vue templates through Oxlint. You can continue using ESLint for Vue files until Oxlint adds framework parser support. ================================================ FILE: docs/rules/enforce-canonical-classes.md ================================================ # better-tailwindcss/enforce-canonical-classes Implements the [canonical suggestions](https://github.com/tailwindlabs/tailwindcss/pull/19059) from Tailwind CSS `^4.1.15`. A canonical class is a simpler representation of a less optimal way of writing the same class. This can be the case when arbitrary values or variants are used while a predefined value exists for example. > [!NOTE] > > - This rule is identical to `suggestCanonicalClasses` from the [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) VSCode extension. > It is recommended to disable `suggestCanonicalClasses` in your projects `.vscode/settings.json` to avoid confusion: > > ```jsonc > { > "tailwindCSS.lint.suggestCanonicalClasses": "ignore" > } > ``` > > - The rule covers multiple other rules. > It is recommended to disable the following rules to avoid duplicate reports: > - [`better-tailwindcss/enforce-shorthand-classes`](./enforce-shorthand-classes.md) > - [`better-tailwindcss/enforce-consistent-important-position`](./enforce-consistent-important-position.md) > - [`better-tailwindcss/enforce-consistent-variable-syntax`](./enforce-consistent-variable-syntax.md) > > - The canonical suggestions are based on the internal logic of Tailwind CSS and it is possible that the suggestions can change in future versions of Tailwind CSS. > - Configurability is also limited to what Tailwind CSS exposes via their API. > - The rule comes with a [startup cost of around ~1s](https://github.com/tailwindlabs/tailwindcss/pull/19059#:~:text=performance).
## Options
### `rootFontSize` The font size of the `` element in pixels. By default, the root font size is `16px` unless it is changed with CSS. If provided, this will be used to determine if arbitrary values can be replaced with predefined sizing scales. This can also be configured via the [`settings` object](../settings/settings.md). **Type**: `number | undefined` **Default**: `undefined`
### `collapse` Whether to collapse multiple utilities into a single utility if possible. If set to `true`, it is recommended to disable the [`better-tailwindcss/enforce-shorthand-classes`](./enforce-shorthand-classes.md) rule to avoid duplicate reports. **Type**: `boolean` **Default**: `true`
### `logical` Whether to convert between logical and physical properties when collapsing utilities. **Type**: `boolean` **Default**: `true`
### `ignore` List of List of regex patterns for classes that should not report a canonical suggestion. This can be useful for cases where a non-canonical class representation is intentional. **Type**: `string[]` **Default**: `[]`
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)

## Examples ```tsx // ❌ BAD: using unnecessary arbitrary value
; ``` ```tsx // ✅ GOOD: using canonical class for display flex
; ``` ```tsx // ❌ BAD: using unnecessary arbitrary data attribute variant
; ``` ```tsx // ✅ GOOD: using canonical data attribute variant
; ``` ```tsx // ❌ BAD: using arbitrary value for spacing
; ``` ```tsx // ✅ GOOD: using canonical class for spacing (with rootFontSize: 16)
; ``` ```tsx // ❌ BAD: using multiple utilities for margin
; ``` ```tsx // ✅ GOOD: using collapsed utility for margin (with collapse: true)
; ``` ```tsx // ❌ BAD: using physical properties for margin
; ``` ```tsx // ✅ GOOD: using logical properties for margin (with logicalToPhysical: true)
; ``` ================================================ FILE: docs/rules/enforce-consistent-class-order.md ================================================ # better-tailwindcss/enforce-consistent-class-order Enforce the order of tailwind classes. It is possible to sort classes alphabetically or logically.
## Options ### `order` - `asc`: Sort classes alphabetically in ascending order. - `desc`: Sort classes alphabetically in descending order. - `official`: Sort classes according to the official sorting order from Tailwind CSS based on semantics. - `strict`: Same as `official` but sorts variants more strictly: - Classes that share the same base variants get grouped together. - Classes with less variants come before classes with more variants. - Classes with arbitrary variants come last. **Type**: `"asc" | "desc" | "official" | "strict"` **Default**: `"official"`
### `detectComponentClasses` Tailwind CSS v4 allows you to define custom [component classes](https://tailwindcss.com/docs/adding-custom-styles#adding-component-classes) like `card`, `btn`, `badge` etc. If you want to create such classes, you can set this option to `true` to allow the rule to detect those classes and not report them as unknown classes. This can also be configured via the [`settings` object](../settings/settings.md). **Type**: `boolean` **Default**: `false`
### `componentClassOrder` Defines how component classes should be ordered among themselves. - `asc`: Sort component classes alphabetically in ascending order. - `desc`: Sort component classes alphabetically in descending order. - `preserve`: Keep component classes in their original order. **Type**: `"asc" | "desc" | "preserve"` **Default**: `"preserve"`
### `componentClassPosition` Defines where component classes should be placed in relation to the whole string literal. - `start`: Place component classes at the beginning. - `end`: Place component classes at the end. **Type**: `"start" | "end"` **Default**: `"start"`
### `unknownClassOrder` Defines how unknown classes should be ordered among themselves. - `asc`: Sort unknown classes alphabetically in ascending order. - `desc`: Sort unknown classes alphabetically in descending order. - `preserve`: Keep unknown classes in their original order. **Type**: `"asc" | "desc" | "preserve"` **Default**: `"preserve"`
### `unknownClassPosition` Defines where unknown classes should be placed in relation to the whole string literal. - `start`: Place unknown classes at the beginning. - `end`: Place unknown classes at the end. **Type**: `"start" | "end"` **Default**: `"start"`
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)
### `entryPoint` 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. **Type**: `string` **Default**: `undefined`
### `tailwindConfig` 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. This can also be set globally via the [`settings` object](../settings/settings.md#tailwindConfig). For Tailwind CSS v4 and the css based config, use the [`entryPoint`](#entrypoint) option instead. **Type**: `string` **Default**: `undefined`
### `tsconfig` The path to the `tsconfig.json` file. If not specified, the plugin will try to find it automatically. This can also be set globally via the [`settings` object](../settings/settings.md#tsconfig). The tsconfig is used to resolve tsconfig [`path`](https://www.typescriptlang.org/tsconfig/#paths) aliases. **Type**: `string` **Default**: `undefined`

## Examples ```tsx // ❌ BAD: all classes are in random order
; ``` ```tsx // ✅ GOOD: with option { order: 'asc' }
; ``` ```tsx // ✅ GOOD: with option { order: 'desc' }
; ``` ```tsx // ✅ GOOD: with option { order: 'official' }
; ``` ```tsx // ✅ GOOD: with option { componentClassPosition: 'start' } // 'btn' and 'card' are defined as component classes in the tailwind config
; ``` ```tsx // ✅ GOOD: with option { componentClassPosition: 'end' } // 'btn' and 'card' are defined as component classes in the tailwind config
; ``` ```tsx // ✅ GOOD: with option { unknownClassPosition: 'start' } // 'unknown-class' is not defined in the tailwind config
; ``` ```tsx // ✅ GOOD: with option { unknownClassPosition: 'end' } // 'unknown-class' is not defined in the tailwind config
; ``` ================================================ FILE: docs/rules/enforce-consistent-important-position.md ================================================ # better-tailwindcss/enforce-consistent-important-position Enforce consistent important position for Tailwind CSS classes. This rule ensures that the important modifier (`!`) is placed consistently either at the beginning (legacy style) or at the end (recommended style) of class names. Tailwind CSS v4 introduces the "recommended" position as the new standard. This rule helps you migrate to the new syntax or maintain consistency with the legacy format.
> [!NOTE] > This rule might interfere with [`better-tailwindcss/enforce-canonical-classes`](./enforce-canonical-classes.md) if both rules are enabled. It is recommended to use only one of them to avoid conflicting fixes.
## Options ### `position` Controls where the important modifier (`!`) should be placed in class names. - `legacy`: Places the important modifier at the beginning of the class name. - `recommended`: Places the important modifier at the end of the class name. **Type**: `"legacy" | "recommended"` **Default**: `"recommended"` in Tailwind CSS v4, `"legacy"` in Tailwind CSS v3 and earlier.
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)
### `entryPoint` 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. **Type**: `string` **Default**: `undefined`
### `tailwindConfig` 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. This can also be set globally via the [`settings` object](../settings/settings.md#tailwindConfig). For Tailwind CSS v4 and the css based config, use the [`entryPoint`](#entrypoint) option instead. **Type**: `string` **Default**: `undefined`
### `tsconfig` The path to the `tsconfig.json` file. If not specified, the plugin will try to find it automatically. This can also be set globally via the [`settings` object](../settings/settings.md#tsconfig). The tsconfig is used to resolve tsconfig [`path`](https://www.typescriptlang.org/tsconfig/#paths) aliases. **Type**: `string` **Default**: `undefined`

## Examples ### Recommended Position ```tsx // ❌ BAD: with option { "position": "recommended" }
; ``` ```tsx // ✅ GOOD: with option { "position": "recommended" }
; ``` ### Legacy Position ```tsx // ❌ BAD: with option { "position": "legacy" }
; ``` ```tsx // ✅ GOOD: with option { "position": "legacy" }
; ``` ================================================ FILE: docs/rules/enforce-consistent-line-wrapping.md ================================================ # better-tailwindcss/enforce-consistent-line-wrapping Enforce tailwind classes to be broken up into multiple lines. It is possible to break at a certain print width or a certain number of classes per line.
## Options ### `printWidth` The maximum line length. Lines are wrapped appropriately to stay within this limit. The value `0` disables line wrapping by `printWidth`. Tabs count according to [`tabWidth`](#tabwidth) when evaluating this limit. **Type**: `number` **Default**: `80`
### `classesPerLine` The maximum amount of classes per line. Lines are wrapped appropriately to stay within this limit . The value `0` disables line wrapping by `classesPerLine`. **Type**: `number` **Default**: `0`
### `group` Defines how different groups of classes should be separated. A group is a set of classes that share the same variant. **Type**: `"emptyLine" | "never" | "newLine"` **Default**: `"newLine"`
### `preferSingleLine` Prefer a single line for different variants. When set to `true`, the rule will keep all variants on a single line until the line exceeds the `printWidth` or `classesPerLine` limit. **Type**: `boolean` **Default**: `false`
### `indent` Determines how the code should be indented. A number defines the amount of space characters, and the string `"tab"` will use a single tab character. **Type**: `number | "tab"` **Default**: `2`
### `tabWidth` Determines how many columns a tab character contributes when checking `printWidth`. This option only affects width calculations and does not change emitted indentation characters. **Type**: `number` **Default**: `1`
### `lineBreakStyle` The line break style. The style `windows` will use `\r\n` as line breaks and `unix` will use `\n`. **Type**: `"windows" | "unix"` **Default**: `"unix"`
### `strictness` When used in combination with formatters like prettier, biome or oxfmt, the line wrapping might interfere with the line wrapping of those formatters in some [edge cases](https://github.com/schoero/eslint-plugin-better-tailwindcss/issues/243). If you experience such issues, you can set the `strictness` option to `"loose"` to make the rule less strict about line wrapping. This will allow the lines to slightly exceed the `printWidth` if the plugin detects that the line wrapping would likely cause conflicts with a formatter. **Type**: `"strict" | "loose"` **Default**: `"strict"`
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)
### `entryPoint` 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. **Type**: `string` **Default**: `undefined`
### `tailwindConfig` 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. This can also be set globally via the [`settings` object](../settings/settings.md#tailwindConfig). For Tailwind CSS v4 and the css based config, use the [`entryPoint`](#entrypoint) option instead. **Type**: `string` **Default**: `undefined`
### `tsconfig` The path to the `tsconfig.json` file. If not specified, the plugin will try to find it automatically. This can also be set globally via the [`settings` object](../settings/settings.md#tsconfig). The tsconfig is used to resolve tsconfig [`path`](https://www.typescriptlang.org/tsconfig/#paths) aliases. **Type**: `string` **Default**: `undefined`

## Examples With the default options, a class name will be broken up into multiple lines and grouped by their variants. Groups are separated by an empty line. The following examples show how the rule behaves with different options: ```tsx // ❌ BAD
; ``` ```tsx // ✅ GOOD: with option { group: 'emptyLine' }
; ``` ```tsx // ✅ GOOD: with option { group: 'newLine' }
; ``` ```tsx // ✅ GOOD: with option { group: 'never', printWidth: 80 }
; ``` ```tsx // ✅ GOOD: with { classesPerLine: 1, group: 'emptyLine' }
; ``` ```tsx // ✅ GOOD: with { group: "newLine", preferSingleLine: true, printWidth: 120 }
; ``` ```tsx // ✅ GOOD: with { group: "newLine", preferSingleLine: true, printWidth: 80 }
; ``` ================================================ FILE: docs/rules/enforce-consistent-variable-syntax.md ================================================ # better-tailwindcss/enforce-consistent-variable-syntax Enforce consistent css variable syntax in Tailwind CSS class strings.
> [!NOTE] > This rule might interfere with [`better-tailwindcss/enforce-canonical-classes`](./enforce-canonical-classes.md) if both rules are enabled. It is recommended to use only one of them to avoid conflicting fixes.
## Options ### `syntax` The syntax to enforce for css variables in Tailwind CSS class strings. The `shorthand` syntax uses the `(--variable)` syntax in Tailwind CSS v4 and `[--variable]` syntax in Tailwind CSS v3. **Type**: `"variable"` | `"shorthand"` **Default**: `"shorthand"`
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)

## Examples ```tsx // ❌ BAD: Incorrect css variable syntax with option `syntax: "shorthand"`
; ``` ```tsx // ✅ GOOD: With option `syntax: "shorthand"` in Tailwind CSS v4
; ``` ```tsx // ✅ GOOD: With option `syntax: "shorthand"` in Tailwind CSS v3
; ``` ```tsx // ❌ BAD: Incorrect css variable syntax with option `syntax: "variable"` in Tailwind CSS v4
; ``` ```tsx // ❌ BAD: Incorrect css variable syntax with option `syntax: "variable"` in Tailwind CSS v3
; ``` ```tsx // ✅ GOOD: With option `syntax: "variable"`
; ``` ================================================ FILE: docs/rules/enforce-consistent-variant-order.md ================================================ # better-tailwindcss/enforce-consistent-variant-order Enforce Tailwind CSS variant order for class names.
## Options This rule has no custom options.
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)

## Examples ```tsx // ❌ BAD: variants are not in Tailwind's recommended order
; ``` ```tsx // ✅ GOOD: variants follow Tailwind's recommended order
; ```
> [!NOTE] > This rule only enforces variant order for Tailwind CSS v4 projects. ================================================ FILE: docs/rules/enforce-logical-properties.md ================================================ # better-tailwindcss/enforce-logical-properties Enforce logical utility class names over physical directions for better RTL and LTR support. The rule reports physical classes and auto-fixes them to their logical equivalent when a direct Tailwind replacement exists. ## Physical to Logical Mappings | **Physical** | **Logical** | | :--- | :--- | | `pl-*` | `ps-*` | | `pr-*` | `pe-*` | | `pt-*` | `pbs-*` | | `pb-*` | `pbe-*` | | `ml-*` | `ms-*` | | `mr-*` | `me-*` | | `mt-*` | `mbs-*` | | `mb-*` | `mbe-*` | | `scroll-pl-*` | `scroll-ps-*` | | `scroll-pr-*` | `scroll-pe-*` | | `scroll-pt-*` | `scroll-pbs-*` | | `scroll-pb-*` | `scroll-pbe-*` | | `scroll-ml-*` | `scroll-ms-*` | | `scroll-mr-*` | `scroll-me-*` | | `scroll-mt-*` | `scroll-mbs-*` | | `scroll-mb-*` | `scroll-mbe-*` | | `left-*` | `inset-s-*` | | `right-*` | `inset-e-*` | | `top-*` | `inset-bs-*` | | `bottom-*` | `inset-be-*` | | `border-l` / `border-l-*` | `border-s` / `border-s-*` | | `border-r` / `border-r-*` | `border-e` / `border-e-*` | | `border-t` / `border-t-*` | `border-bs` / `border-bs-*` | | `border-b` / `border-b-*` | `border-be` / `border-be-*` | | `rounded-l` / `rounded-l-*` | `rounded-s` / `rounded-s-*` | | `rounded-r` / `rounded-r-*` | `rounded-e` / `rounded-e-*` | | `rounded-tl` / `rounded-tl-*` | `rounded-ss` / `rounded-ss-*` | | `rounded-tr` / `rounded-tr-*` | `rounded-se` / `rounded-se-*` | | `rounded-br` / `rounded-br-*` | `rounded-ee` / `rounded-ee-*` | | `rounded-bl` / `rounded-bl-*` | `rounded-es` / `rounded-es-*` | | `text-left` | `text-start` | | `text-right` | `text-end` | | `float-left` | `float-start` | | `float-right` | `float-end` | | `clear-left` | `clear-start` | | `clear-right` | `clear-end` | | `h-*` | `block-*` | | `w-*` | `inline-*` | | `min-h-*` | `min-block-*` | | `min-w-*` | `min-inline-*` | | `max-h-*` | `max-block-*` | | `max-w-*` | `max-inline-*` | | `size-*` | `block-* inline-*` |
## Options
Common options
These options are common to all rules and can also be set globally via the [settings object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)
### `entryPoint` 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. **Type**: `string` **Default**: `undefined`
### `tailwindConfig` Tailwind config file path. **Type**: string **Default**: Tailwind's default config resolution
### `tsconfig` The path to the `tsconfig.json` file. If not specified, the plugin will try to find it automatically. This can also be set globally via the [settings object](../settings/settings.md#tsconfig). The tsconfig is used to resolve tsconfig [path](https://www.typescriptlang.org/tsconfig/#paths) aliases. **Type**: `string` **Default**: `undefined`

## Examples ```tsx // ❌ BAD: physical direction classes
; ``` ```tsx // ✅ GOOD: logical direction classes
; ``` ================================================ FILE: docs/rules/enforce-shorthand-classes.md ================================================ # better-tailwindcss/enforce-shorthand-classes This rule identifies when multiple longhand Tailwind CSS classes can be replaced with a single shorthand class, improving code readability and reducing bundle size.
> [!NOTE] > This rule might interfere with [`better-tailwindcss/enforce-canonical-classes`](./enforce-canonical-classes.md) if both rules are enabled. It is recommended to use only one of them to avoid conflicting fixes.
## Options
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)

## Examples ```tsx // ❌ BAD: using separate padding classes
; ``` ```tsx // ✅ GOOD: using shorthand padding class
; ``` ```tsx // ❌ BAD: using separate width and height classes
; ``` ```tsx // ✅ GOOD: using shorthand size class
; ``` ================================================ FILE: docs/rules/no-conflicting-classes.md ================================================ # better-tailwindcss/no-conflicting-classes Disallow conflicting classes in Tailwind CSS 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.
> [!NOTE] > This rule is similar to `cssConflict` from the [Tailwind CSS 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: > > ```jsonc > { > "tailwindCSS.lint.cssConflict": "ignore" > } > ```
## Options
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)
### `entryPoint` 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. **Type**: `string` **Default**: `undefined`
### `tailwindConfig` 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. This can also be set globally via the [`settings` object](../settings/settings.md#tailwindConfig). For Tailwind CSS v4 and the css based config, use the [`entryPoint`](#entrypoint) option instead. **Type**: `string` **Default**: `undefined`
### `tsconfig` The path to the `tsconfig.json` file. If not specified, the plugin will try to find it automatically. This can also be set globally via the [`settings` object](../settings/settings.md#tsconfig). The tsconfig is used to resolve tsconfig [`path`](https://www.typescriptlang.org/tsconfig/#paths) aliases. **Type**: `string` **Default**: `undefined`

## Examples ```tsx // ❌ BAD: Conflicting class detected: "flex" and "grid" apply the same css properties: "display"
; ``` ```tsx // ✅ GOOD: no conflicting classes
; ``` ================================================ FILE: docs/rules/no-deprecated-classes.md ================================================ # better-tailwindcss/no-deprecated-classes Disallow the use of [deprecated Tailwind CSS classes](https://tailwindcss.com/docs/upgrade-guide#removed-deprecated-utilities) in Tailwind CSS v4. The following classes will be reported as deprecated: ## Deprecated Utilities | **Deprecated** | **Replacement** | |---------------------------|-----------------------------------------------| | `bg-opacity-*` | Use opacity modifiers like `bg-black/50` | | `text-opacity-*` | Use opacity modifiers like `text-black/50` | | `border-opacity-*` | Use opacity modifiers like `border-black/50` | | `divide-opacity-*` | Use opacity modifiers like `divide-black/50` | | `ring-opacity-*` | Use opacity modifiers like `ring-black/50` | | `placeholder-opacity-*` | Use opacity modifiers like `placeholder-black/50` | | `flex-shrink` | `shrink` | | `flex-shrink-*` | `shrink-*` | | `flex-grow` | `grow` | | `flex-grow-*` | `grow-*` | | `overflow-ellipsis` | `text-ellipsis` | | `decoration-slice` | `box-decoration-slice` | | `decoration-clone` | `box-decoration-clone` | ## Renamed Utilities (v3 → v4) | **v3** | **v4** | |--------------------------|--------------------------| | `shadow` | `shadow-sm` | | `drop-shadow` | `drop-shadow-sm` | | `blur` | `blur-sm` | | `backdrop-blur` | `backdrop-blur-sm` | | `rounded` | `rounded-sm` |
## Options
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)
### `entryPoint` 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. **Type**: `string` **Default**: `undefined`
### `tailwindConfig` Tailwind config file path. **Type**: string **Default**: Tailwind's default config resolution
### `tsconfig` The path to the `tsconfig.json` file. If not specified, the plugin will try to find it automatically. This can also be set globally via the [`settings` object](../settings/settings.md#tsconfig). The tsconfig is used to resolve tsconfig [`path`](https://www.typescriptlang.org/tsconfig/#paths) aliases. **Type**: `string` **Default**: `undefined`

## Examples ```tsx // ❌ BAD: using deprecated shadow class
; ``` ```tsx // ✅ GOOD: using updated shadow class
; ``` ```tsx // ❌ BAD: using deprecated flex-shrink class
; ``` ```tsx // ✅ GOOD: using updated shrink class
; ``` ================================================ FILE: docs/rules/no-duplicate-classes.md ================================================ # better-tailwindcss/no-duplicate-classes Disallow duplicate classes in Tailwind CSS class strings.
## Options
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)

## Examples ```tsx // ❌ BAD: duplicate classes
; ``` ```tsx // ✅ GOOD: no duplicate classes
; ```
> [!NOTE] > This rule is smart. It is able to detect duplicates across template literal boundaries. ```tsx // ❌ BAD: duplicate classes in conditional template literal classes and around template elements
; ``` ```tsx // ✅ GOOD: no duplicate classes
; ``` ================================================ FILE: docs/rules/no-restricted-classes.md ================================================ # better-tailwindcss/no-restricted-classes 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 enforce the use of semantic color names or disallow features like arbitrary values (text-[#fff]), child variants (`*:`) or the `!important` modifier (`!`) in your project. It is also possible to provide a custom error message and a fix for the disallowed class. The fix can be used to automatically replace the disallowed class with a recommended one.
## Options ### `restrict` The classes that should be disallowed. The patterns in this list are treated as regular expressions. Matched groups of the regular expression can be used in the error message or fix by using the `$1`, `$2`, etc. syntax. **Type**: `string[] | { pattern: string, message?: string, fix?: string }[]` **Default**: `[]` Make sure to match possible variants and modifiers of the class names as well: ```json { "restrict": [{ "fix": "$1$2-success$3", "message": "Restricted class: Use '$1$2-success$3' instead.", "pattern": "^([a-zA-Z0-9:/_-]*:)?(text|bg)-green-500(\\/[0-9]{1,3})?$" }] } ```
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)

## Examples ```tsx // ❌ BAD: disallow the use of the `text-green-500` class with option `{ restrict: [{ pattern: "^(.*)-green-500$", message: "Restricted class: Use '$1-success' instead." }] }`
; // ~~~~~~~~~~~~~~ Restricted class: Use 'text-success' instead. ``` ```tsx // ❌ BAD: disallow the use of the arbitrary values with option `{ restrict: ["\\[([^\\[\\]]*?)\\](?!:)"] }`
; // ~~~~~~ ``` ```tsx // ❌ BAD: disallow the use of the child variants with option `{ restrict: ["^\\*+:.*"] }`
; // ~~~~~~ ``` ```tsx // ❌ BAD: disallow the use of the important modifier with option `{ restrict: ["^.*!$"] }`
; // ~~~~ ``` ```tsx // ❌ BAD: disallow the use of unnamed groups with option `{ restrict: ["^group$"] }`
; // ~~~~~ ``` ```tsx // ❌ BAD: disallow the use of unnamed group variants with option `{ restrict: ["^group-(hover|focus|active|visited|disabled):"] }`
; // ~~~~~~~~~~~ ``` ================================================ FILE: docs/rules/no-unknown-classes.md ================================================ # better-tailwindcss/no-unknown-classes Disallow unknown classes in Tailwind CSS class strings. Unknown classes are classes that are not defined in your Tailwind CSS config file and therefore not recognized by Tailwind CSS.
## Options ### `ignore` List of List of regex patterns for classes that should not report an error. **Type**: `string[]` **Default**: `[]`
### `detectComponentClasses` Tailwind CSS v4 allows you to define custom [component classes](https://tailwindcss.com/docs/adding-custom-styles#adding-component-classes) like `card`, `btn`, `badge` etc. If you want to create such classes, you can set this option to `true` to allow the rule to detect those classes and not report them as unknown classes. This can also be configured via the [`settings` object](../settings/settings.md). **Type**: `boolean` **Default**: `false`
Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)
### `entryPoint` 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. **Type**: `string` **Default**: `undefined`
### `tailwindConfig` 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. This can also be set globally via the [`settings` object](../settings/settings.md#tailwindConfig). For Tailwind CSS v4 and the css based config, use the [`entryPoint`](#entrypoint) option instead. **Type**: `string` **Default**: `undefined`
### `tsconfig` The path to the `tsconfig.json` file. If not specified, the plugin will try to find it automatically. This can also be set globally via the [`settings` object](../settings/settings.md#tsconfig). The tsconfig is used to resolve tsconfig [`path`](https://www.typescriptlang.org/tsconfig/#paths) aliases. **Type**: `string` **Default**: `undefined`

## Examples ```tsx // ❌ BAD: unknown class
; ``` ```tsx // ✅ GOOD: only valid tailwindcss classes
; ``` ================================================ FILE: docs/rules/no-unnecessary-whitespace.md ================================================ # better-tailwindcss/no-unnecessary-whitespace Disallow unnecessary whitespace in between and around tailwind classes.
## Options ### `allowMultiline` Allow multi-line class declarations. 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/enforce-consistent-line-wrapping](./enforce-consistent-line-wrapping.md). **Type**: `boolean` **Default**: `true`

Common options
These options are common to all rules and can also be set globally via the [`settings` object](../settings/settings.md).
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md)

## Examples ```tsx // ❌ BAD: random unnecessary whitespace
; ``` ```tsx // ✅ GOOD: only necessary whitespace is remaining
; ``` ================================================ FILE: docs/settings/settings.md ================================================ # Settings ## Table of Contents - [entryPoint](#entrypoint) - [tailwindConfig](#tailwindconfig) - [tsconfig](#tsconfig) - [cwd](#cwd) - [detectComponentClasses](#detectcomponentclasses) - [rootFontSize](#rootfontsize) - [messageStyle](#messagestyle) - [selectors](#selectors)

The settings object can be used to globally configure shared options across all rules. Global options will always be overridden by rule-specific options. To set the settings object, add a `settings` key to the eslint config.

```jsonc // eslint.config.js { // "plugins": {... }, // "rules": { ... }, "settings": { "better-tailwindcss": { // ... } } } ```

### `entryPoint` 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. Relative to the [current working directory](#cwd). The tailwind config is used for various rules. **Type**: `string`
### `tailwindConfig` 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. Relative to the [current working directory](#cwd). The tailwind config is used for various rules. For Tailwind CSS v4 and the css based config, use the [`entryPoint`](#entrypoint) option instead. **Type**: `string`
### `tsconfig` The path to the `tsconfig.json` file. If not specified, the plugin will try to find it automatically. Relative to the [current working directory](#cwd). The tsconfig is used to resolve tsconfig [`path`](https://www.typescriptlang.org/tsconfig/#paths) aliases. **Type**: `string` **Default**: `undefined`
### `cwd` The working directory used to resolve `tailwindcss` and related config files. This is useful for monorepos where linting runs from the repository root but each project has its own `node_modules` and Tailwind setup. This path is resolved relative to the current working directory of the ESLint process. If not specified, it falls back to the current working directory of the ESLint process. **Type**: `string` **Default**: `undefined`
### `detectComponentClasses` Tailwind CSS v4 allows you to define custom [component classes](https://tailwindcss.com/docs/adding-custom-styles#adding-component-classes) like `card`, `btn`, `badge` etc. If you want to create such classes, you can set this option to `true` to allow the rule to detect those classes and not report them as unknown classes. **Type**: `boolean` **Default**: `false`
### `rootFontSize` The font size of the `` element in pixels. By default, the root font size is `16px` unless it is changed with CSS. If provided, this will be used to determine if arbitrary values can be replaced with predefined sizing scales. **Type**: `number | undefined` **Default**: `undefined`
### `messageStyle` Customize how linting messages are displayed. `"visual"` visualizes whitespaces and line breaks for better readability. `"compact"` displays visual message on a single line, better suitable for CI environments. `"raw"` shows only the raw information without whitespace or line break visualization. **Type**: `"visual" | "compact" | "raw"` **Default**: `"visual"`, `"compact"` in CI environments
### `selectors` Flat list of selectors that determines where Tailwind class strings are linted. This controls what gets linted globally: only string literals matched by these selectors are treated as Tailwind class candidates. **Type**: Array of [Selectors](../configuration/advanced.md#selectors) **Default**: See [defaults API](../api/defaults.md) ================================================ FILE: eslint.config.ts ================================================ import config from "@schoero/configs/eslint"; import { defineConfig } from "eslint/config"; export default defineConfig([ ...config, { files: ["**/*.test.{js,jsx,cjs,mjs,ts,tsx}", "**/*.test-d.{ts,tsx}"], rules: { "eslint-plugin-stylistic/quotes": ["warn", "double", { allowTemplateLiterals: "always", avoidEscape: true }], "eslint-plugin-typescript/no-unnecessary-condition": "off", "eslint-plugin-typescript/no-useless-template-literals": "off", "eslint-plugin-vitest/expect-expect": "off" } }, { files: ["**/*.test.ts"], rules: { "eslint-plugin-perfectionist/sort-objects": [ "warn", { customGroups: [ { elementNamePattern: "^(astro|angular|jsx|svelte|vue|html)(Output)?$", groupName: "markup", selector: "property" } ], groups: ["markup", { newlinesBetween: "always" }, "unknown"], ignoreCase: true, partitionByComment: false, type: "alphabetical" } ], "eslint-plugin-typescript/naming-convention": "off", "eslint-plugin-typescript/no-floating-promises": "off" } }, { files: ["tests/utils/lint.ts"], rules: { "eslint-plugin-typescript/naming-convention": "off" } } ]); ================================================ FILE: package.json ================================================ { "version": "4.5.0", "type": "module", "name": "eslint-plugin-better-tailwindcss", "description": "auto-wraps tailwind classes after a certain print width or class count into multiple lines to improve readability.", "license": "MIT", "author": "Roger Schönbächler", "repository": { "type": "git", "url": "git+https://github.com/schoero/eslint-plugin-better-tailwindcss.git" }, "bugs": { "url": "https://github.com/schoero/eslint-plugin-better-tailwindcss/issues" }, "exports": { ".": "./lib/configs/config.js", "./api/defaults": "./lib/api/defaults.js", "./api/types": "./lib/api/types.js", "./defaults": "./lib/api/defaults.js", "./types": "./lib/api/types.js" }, "main": "./lib/configs/config.js", "scripts": { "build": "vite-node build", "build:ci": "vite-node build", "eslint": "eslint .", "eslint:ci": "npm run eslint -- --max-warnings 0", "eslint:fix": "npm run eslint -- --fix", "install:v3": "npm i tailwindcss@^3 --no-save", "install:v4": "npm i tailwindcss@^4 --no-save", "lint": "npm run eslint && npm run markdownlint", "lint:ci": "npm run eslint:ci && npm run markdownlint:ci", "lint:fix": "npm run eslint:fix && npm run markdownlint:fix", "markdownlint": "markdownlint-cli2 '**/*.md' '#**/node_modules'", "markdownlint:ci": "npm run markdownlint", "markdownlint:fix": "npm run markdownlint -- --fix", "postrelease:alpha": "eslint --fix package.json && markdownlint-cli2 --fix 'CHANGELOG.md'", "postrelease:beta": "eslint --fix package.json && markdownlint-cli2 --fix 'CHANGELOG.md'", "postrelease:latest": "eslint --fix package.json && markdownlint-cli2 --fix 'CHANGELOG.md'", "prebuild": "npm run typecheck && npm run lint && npm run spellcheck", "prerelease:alpha": "npm run test -- --run && npm run build", "prerelease:beta": "npm run test -- --run && npm run build", "prerelease:latest": "npm run test -- --run && npm run build", "pretest:v3": "npm run install:v3", "pretest:v4": "npm run install:v4", "publish:alpha": "changelogen gh release && changelogen --publish --publishTag alpha", "publish:beta": "changelogen gh release && changelogen --publish --publishTag beta", "publish:latest": "changelogen gh release && changelogen --publish", "release:alpha": "changelogen --bump --output --prerelease alpha", "release:beta": "changelogen --bump --output --prerelease beta", "release:latest": "changelogen --bump --output --no-tag", "spellcheck": "cspell lint", "spellcheck:ci": "npm run spellcheck -- --no-progress", "test": "vitest -c ./vite.config.ts --exclude tests/e2e", "test:all": "npm run test:v3 && npm run test:v4", "test:e2e": "vitest -c ./vite.config.ts tests/e2e --run", "test:v3": "npm run test -- --run", "test:v4": "npm run test -- --run", "typecheck": "tsc --noEmit" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=23.0.0" }, "files": [ "lib" ], "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", "oxlint": "^1.35.0", "tailwindcss": "^3.3.0 || ^4.1.17" }, "peerDependenciesMeta": { "eslint": { "optional": true }, "oxlint": { "optional": true } }, "dependencies": { "@eslint/css-tree": "^4.0.1", "@valibot/to-json-schema": "^1.6.0", "enhanced-resolve": "^5.20.1", "jiti": "^2.6.1", "synckit": "^0.11.12", "tailwind-csstree": "^0.3.0", "tsconfig-paths-webpack-plugin": "^4.2.0", "valibot": "^1.3.1" }, "devDependencies": { "@angular/compiler": "^21.2.6", "@angular-eslint/template-parser": "^21.3.1", "@eslint/css": "^1.1.0", "@html-eslint/parser": "0.58.1", "@oxc-node/core": "^0.0.35", "@schoero/configs": "^1.5.32", "@types/estree-jsx": "^1.0.5", "@types/node": "^25.5.0", "@typescript-eslint/parser": "^8.58.0", "astro-eslint-parser": "^1.4.0", "changelogen": "^0.6.2", "cspell": "^10.0.0", "daisyui": "^5.5.19", "es-html-parser": "^0.3.1", "eslint": "^9.39.4", "eslint-plugin-better-tailwindcss": "file:./", "json-schema": "^0.4.0", "markdownlint": "^0.40.0", "oxlint": "^1.58.0", "prettier": "^3.8.1", "svelte": "^5.55.1", "svelte-eslint-parser": "^1.6.0", "tailwindcss": "^4.2.2", "tsc-alias": "^1.8.16", "typescript": "^5.9.3", "vite-node": "^6.0.0", "vitest": "^4.1.2", "vue-eslint-parser": "^10.4.0" }, "keywords": [ "eslint", "eslint-plugin", "stylistic", "formatting", "tailwind", "readable", "readability", "horizontal", "scrolling", "multiline", "multi", "newline", "line", "break", "linebreak", "wrap", "template", "literal", "jsx", "html", "astro", "svelte", "vue", "react", "qwik", "solid", "template-literal", "template-literals", "tailwindcss", "tailwind-css", "tailwind-classes" ], "volta": { "node": "25.2.1" } } ================================================ FILE: src/api/defaults.ts ================================================ /* eslint-disable eslint-plugin-jsdoc/require-returns */ /* eslint-disable eslint-plugin-jsdoc/require-description */ import { DEFAULT_SELECTORS } from "better-tailwindcss:options/default-options.js"; import { migrateFlatSelectorsToLegacySelectors } from "better-tailwindcss:options/migrate.js"; import { SelectorKind } from "better-tailwindcss:types/rule.js"; import { isSelectorKind } from "better-tailwindcss:utils/selectors.js"; import type { Attributes } from "better-tailwindcss:options/schemas/attributes.js"; import type { Callees } from "better-tailwindcss:options/schemas/callees.js"; import type { Tags } from "better-tailwindcss:options/schemas/tags.js"; import type { Variables } from "better-tailwindcss:options/schemas/variables.js"; /** * @deprecated Migrate to selectors instead. */ export function getDefaultCallees() { return migrateFlatSelectorsToLegacySelectors( DEFAULT_SELECTORS.filter(isSelectorKind(SelectorKind.Callee)) ).callees ?? [] satisfies Callees; } /** * @deprecated Migrate to selectors instead. */ export function getDefaultAttributes() { return migrateFlatSelectorsToLegacySelectors( DEFAULT_SELECTORS.filter(isSelectorKind(SelectorKind.Attribute)) ).attributes ?? [] satisfies Attributes; } /** * @deprecated Migrate to selectors instead. */ export function getDefaultVariables() { return migrateFlatSelectorsToLegacySelectors( DEFAULT_SELECTORS.filter(isSelectorKind(SelectorKind.Variable)) ).variables ?? [] satisfies Variables; } /** * @deprecated Migrate to selectors instead. */ export function getDefaultTags() { return migrateFlatSelectorsToLegacySelectors( DEFAULT_SELECTORS.filter(isSelectorKind(SelectorKind.Tag)) ).tags ?? [] satisfies Tags; } export function getDefaultSelectors() { return DEFAULT_SELECTORS; } ================================================ FILE: src/api/types.ts ================================================ /* Targets for arguments and calls */ export type { ArgumentTarget, CallTarget } from "better-tailwindcss:types/rule.js"; /* Legacy Matchers */ export { MatcherType } from "better-tailwindcss:types/rule.js"; export type { Matcher, ObjectKeyMatcher, ObjectValueMatcher, StringMatcher } from "better-tailwindcss:types/rule.js"; /* Selector */ export { SelectorKind } from "better-tailwindcss:types/rule.js"; export type { AttributeSelector, CalleeSelector, Selector, TagSelector, VariableSelector } from "better-tailwindcss:types/rule.js"; export type { SelectorAnonymousFunctionReturnMatcher, SelectorMatcher, SelectorObjectKeyMatcher, SelectorObjectValueMatcher, SelectorStringMatcher } from "better-tailwindcss:types/rule.js"; /* Other types */ export type { Regex, Selectors } from "better-tailwindcss:types/rule.js"; ================================================ FILE: src/async-utils/cache.ts ================================================ import { getModifiedDate } from "./fs.js"; interface CacheItem { date: Date; value: any; } const CACHE = new Map(); export function invalidateByModifiedDate(cache: CacheItem, path: string | undefined): boolean { if(!path){ return true; } const modified = getModifiedDate(path); return modified > cache.date; } export function withCache(key: string, path: string | undefined, callback: () => Result, invalidate?: (cache: CacheItem, path: string | undefined) => boolean): Result; export function withCache(key: string, path: string | undefined, callback: () => Promise, invalidate?: (cache: CacheItem, path: string | undefined) => boolean): Promise; export function withCache(key: string, path: string | undefined, callback: () => Promise | Result, invalidate: (cache: CacheItem, path: string | undefined) => boolean = invalidateByModifiedDate): Promise | Result { const cacheKey = `${key}-${path}`; const cached = CACHE.get(cacheKey); if(cached && !invalidate(cached, path)){ return cached.value; } const value = callback(); if(value instanceof Promise){ return value.then(resolvedValue => { CACHE.set(cacheKey, { date: new Date(), value: resolvedValue }); return resolvedValue; }); } else { CACHE.set(cacheKey, { date: new Date(), value }); return value; } } export function clearCache() { CACHE.clear(); } ================================================ FILE: src/async-utils/escape.ts ================================================ import { getCachedRegex } from "./regex.js"; export function escapeForRegex(word: string) { return word.replace(getCachedRegex(/[$()*+./?[\\\]^{|}-]/g), "\\$&"); } ================================================ FILE: src/async-utils/fs.ts ================================================ import { existsSync, statSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; export function findPathRecursive(cwd: string, startDirectory: string, entries: string[]): string | undefined { const resolvedPaths = entries.map(p => resolve(startDirectory, p)); for(let resolvedPath = resolvedPaths.shift(); resolvedPath !== undefined; resolvedPath = resolvedPaths.shift()){ if(existsSync(resolvedPath)){ return resolvedPath; } const fileName = basename(resolvedPath); const directory = dirname(resolvedPath); const parentDirectory = resolve(directory, ".."); const parentPath = resolve(parentDirectory, fileName); if(parentDirectory === directory || directory === cwd){ continue; } resolvedPaths.push(parentPath); } } export function getModifiedDate(filePath: string): Date { try { const stats = statSync(filePath); return stats.mtime; } catch { return new Date(); } } ================================================ FILE: src/async-utils/module.ts ================================================ export function isCommonJSModule() { return typeof module !== "undefined" && typeof module.exports !== "undefined"; } export function isESModule() { return !isCommonJSModule(); } ================================================ FILE: src/async-utils/operations.ts ================================================ import type { GetCanonicalClasses } from "../tailwindcss/canonical-classes.js"; import type { GetClassOrder } from "../tailwindcss/class-order.js"; import type { GetConflictingClasses } from "../tailwindcss/conflicting-classes.js"; import type { GetCustomComponentClasses } from "../tailwindcss/custom-component-classes.js"; import type { GetDissectedClasses } from "../tailwindcss/dissect-classes.js"; import type { GetPrefix } from "../tailwindcss/prefix.js"; import type { GetUnknownClasses } from "../tailwindcss/unknown-classes.js"; import type { GetVariantOrder } from "../tailwindcss/variant-order.js"; import type { Async } from "../types/async.js"; export interface Operations { getCanonicalClasses: Async; getClassOrder: Async; getConflictingClasses: Async; getCustomComponentClasses: Async; getDissectedClasses: Async; getPrefix: Async; getUnknownClasses: Async; getVariantOrder: Async; } // mapped type variant that enables correlated generic dispatch https://github.com/microsoft/TypeScript/issues/30581 export type OperationHandlers = { [Operation in keyof Operations]: (...args: Parameters) => ReturnType; }; ================================================ FILE: src/async-utils/order.ts ================================================ export enum VARIANT_ORDER_FLAGS { GLOBAL = 1 << 16 } ================================================ FILE: src/async-utils/path.ts ================================================ import { pathToFileURL } from "node:url"; import { isESModule } from "./module.js"; import { isWindows } from "./platform.js"; export function normalize(path: string): string { return isWindows() && isESModule() ? pathToFileURL(path).toString() : path; } ================================================ FILE: src/async-utils/platform.ts ================================================ export function isWindows() { return process.platform === "win32"; } ================================================ FILE: src/async-utils/regex.ts ================================================ const REGEX_CACHE = new Map(); const MAX_CACHE_SIZE = 500; function getRegexCacheKey(pattern: string, flags: string): string { return `${flags}\u0000${pattern}`; } export function getCachedRegex(regex: RegExp): RegExp; export function getCachedRegex(pattern: string, flags?: string): RegExp; export function getCachedRegex(patternOrRegex: RegExp | string, flags?: string): RegExp { const regexFlags = typeof patternOrRegex === "string" ? flags ?? "" : patternOrRegex.flags; const regexPattern = typeof patternOrRegex === "string" ? patternOrRegex : patternOrRegex.source; const cacheKey = getRegexCacheKey(regexPattern, regexFlags); let regex = REGEX_CACHE.get(cacheKey); if(!regex){ if(REGEX_CACHE.size >= MAX_CACHE_SIZE){ const firstKey = REGEX_CACHE.keys().next().value; if(firstKey !== undefined){ REGEX_CACHE.delete(firstKey); } } regex = typeof patternOrRegex === "string" ? new RegExp(patternOrRegex, flags) : patternOrRegex; REGEX_CACHE.set(cacheKey, regex); } if(regex.global || regex.sticky){ regex.lastIndex = 0; } return regex; } ================================================ FILE: src/async-utils/resolvers.ts ================================================ import fs from "node:fs"; import enhancedResolve from "enhanced-resolve"; import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin"; import { withCache } from "../async-utils/cache.js"; import type { AsyncContext } from "../utils/context.js"; const fileSystem = new enhancedResolve.CachedInputFileSystem(fs, 30_000); const getESMResolver = (ctx: AsyncContext | undefined) => withCache("esm-resolver", ctx?.tsconfigPath, () => enhancedResolve.ResolverFactory.createResolver({ conditionNames: ["node", "import"], extensions: [".mjs", ".js"], fileSystem, mainFields: ["module"], plugins: ctx?.tsconfigPath ? [new TsconfigPathsPlugin({ configFile: ctx.tsconfigPath, mainFields: ["module"] })] : [], useSyncFileSystemCalls: true })); const getCJSResolver = (ctx: AsyncContext | undefined) => withCache("cjs-resolver", ctx?.tsconfigPath, () => enhancedResolve.ResolverFactory.createResolver({ conditionNames: ["node", "require"], extensions: [".js", ".cjs"], fileSystem, mainFields: ["main"], plugins: ctx?.tsconfigPath ? [new TsconfigPathsPlugin({ configFile: ctx.tsconfigPath, mainFields: ["main"] })] : [], useSyncFileSystemCalls: true })); const getCSSResolver = (ctx: AsyncContext | undefined) => withCache("css-resolver", ctx?.tsconfigPath, () => enhancedResolve.ResolverFactory.createResolver({ conditionNames: ["style"], extensions: [".css"], fileSystem, mainFields: ["style"], plugins: ctx?.tsconfigPath ? [new TsconfigPathsPlugin({ configFile: ctx.tsconfigPath, mainFields: ["style"] })] : [], useSyncFileSystemCalls: true })); const jsonResolver = enhancedResolve.ResolverFactory.createResolver({ conditionNames: ["json"], extensions: [".json"], fileSystem, useSyncFileSystemCalls: true }); export function resolveJs(path: string, cwd: string): string; export function resolveJs(ctx: AsyncContext, path: string, cwd?: string): string; export function resolveJs(ctxOrPath: AsyncContext | string | undefined, pathOrCwd: string, cwdOrUndefined?: string): string { const ctx = typeof ctxOrPath === "object" ? ctxOrPath : undefined; const path = typeof ctxOrPath === "string" ? ctxOrPath : pathOrCwd; const cwd = (typeof ctxOrPath === "object" ? cwdOrUndefined : pathOrCwd)!; try { return getESMResolver(ctx).resolveSync({}, cwd, path) || path; } catch { return getCJSResolver(ctx).resolveSync({}, cwd, path) || path; } } export function resolveCss(path: string, cwd: string): string; export function resolveCss(ctx: AsyncContext, path: string, cwd?: string): string; export function resolveCss(ctxOrPath: AsyncContext | string | undefined, pathOrCwd: string, cwdOrUndefined?: string): string { const ctx = typeof ctxOrPath === "object" ? ctxOrPath : undefined; const path = typeof ctxOrPath === "string" ? ctxOrPath : pathOrCwd; const cwd = (typeof ctxOrPath === "object" ? cwdOrUndefined : pathOrCwd)!; try { return getCSSResolver(ctx).resolveSync({}, cwd, path) || path; } catch { return path; } } export function resolveJson(path: string, cwd: string): string | undefined { try { return jsonResolver.resolveSync({}, cwd, path) || undefined; } catch {} } ================================================ FILE: src/async-utils/tsconfig.ts ================================================ import { resolve } from "node:path"; import { withCache } from "./cache.js"; import { findPathRecursive } from "./fs.js"; import type { Warning } from "../types/async.js"; export interface GetTSConfigRequest { configPath: string | undefined; cwd: string; } export interface GetTSConfigResponse { path: string | undefined; warnings: (Warning | undefined)[]; } export const getTSConfigPath = ({ configPath, cwd }: GetTSConfigRequest): GetTSConfigResponse => withCache("tsconfig-path", configPath, () => { const potentialPaths = [ ...configPath ? [configPath] : [], "tsconfig.json", "jsconfig.json" ]; const foundConfigPath = findPathRecursive(cwd, cwd, potentialPaths); const warning = getConfigPathWarning(configPath, foundConfigPath); return { path: foundConfigPath, warnings: [warning] }; }); function getConfigPathWarning(configPath: string | undefined, foundConfigPath: string | undefined): Warning | undefined { if(!configPath){ return; } if(foundConfigPath && resolve(configPath) === resolve(foundConfigPath)){ return; } return { option: "tsconfig", title: `No tsconfig found at \`${configPath}\`` }; } ================================================ FILE: src/async-utils/worker.ts ================================================ import { env } from "node:process"; import { TsRunner } from "synckit"; import type { SynckitOptions } from "synckit"; const defaultTimeout = 30_000; export function getWorkerOptions(): SynckitOptions | undefined { if(env.NODE_ENV === "test"){ return { timeout: Number(env.SYNCKIT_TIMEOUT) || defaultTimeout, tsRunner: TsRunner.OXC }; } else { return { timeout: Number(env.SYNCKIT_TIMEOUT) || defaultTimeout }; } } ================================================ FILE: src/configs/config.test.ts ================================================ import { describe, expect, it } from "vitest"; import config from "better-tailwindcss:configs/config.js"; describe("configs", () => { const stylisticRules = Object.entries(config.rules).reduce((acc, [name, rule]) => { if(rule.meta.docs.recommended && rule.meta.type === "layout"){ acc.push(`${config.meta.name}/${name}`); } return acc; }, []); const correctnessRules = Object.entries(config.rules).reduce((acc, [name, rule]) => { if(rule.meta.docs.recommended && rule.meta.type === "problem"){ acc.push(`${config.meta.name}/${name}`); } return acc; }, []); describe("stylistic", () => { it("should only contain recommended stylistic rules", () => { expect(Object.keys(config.configs.stylistic.rules)).toEqual(stylisticRules); }); }); describe("correctness", () => { it("should only contain recommended correctness rules", () => { expect(Object.keys(config.configs.correctness.rules)).toEqual(correctnessRules); }); }); describe("recommended", () => { it("should contain all recommended rules", () => { expect(Object.keys(config.configs.recommended.rules)).toEqual([ ...stylisticRules, ...correctnessRules ]); }); }); }); ================================================ FILE: src/configs/config.ts ================================================ import { enforceCanonicalClasses } from "better-tailwindcss:rules/enforce-canonical-classes.js"; import { enforceConsistentClassOrder } from "better-tailwindcss:rules/enforce-consistent-class-order.js"; import { enforceConsistentImportantPosition } from "better-tailwindcss:rules/enforce-consistent-important-position.js"; import { enforceConsistentLineWrapping } from "better-tailwindcss:rules/enforce-consistent-line-wrapping.js"; import { enforceConsistentVariableSyntax } from "better-tailwindcss:rules/enforce-consistent-variable-syntax.js"; import { enforceConsistentVariantOrder } from "better-tailwindcss:rules/enforce-consistent-variant-order.js"; import { enforceLogicalProperties } from "better-tailwindcss:rules/enforce-logical-properties.js"; import { enforceShorthandClasses } from "better-tailwindcss:rules/enforce-shorthand-classes.js"; import { noConflictingClasses } from "better-tailwindcss:rules/no-conflicting-classes.js"; import { noDeprecatedClasses } from "better-tailwindcss:rules/no-deprecated-classes.js"; import { noDuplicateClasses } from "better-tailwindcss:rules/no-duplicate-classes.js"; import { noRestrictedClasses } from "better-tailwindcss:rules/no-restricted-classes.js"; import { noUnknownClasses } from "better-tailwindcss:rules/no-unknown-classes.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import type { ESLint, Linter } from "eslint"; import type { RuleCategory } from "better-tailwindcss:types/rule.js"; type ConfigName = "recommended" | RuleCategory; type Severity = "error" | "warn"; type PluginRules = typeof rules[number]; type PluginName = typeof plugin.meta.name; type RuleObject = { [Rule in PluginRules as Rule extends any ? Rule["name"] : never]: Rule["rule"] }; type GetRules = Extract< PluginRules, { category: Category; recommended: true; } >; const rules = [ enforceConsistentClassOrder, enforceConsistentImportantPosition, enforceConsistentLineWrapping, enforceConsistentVariantOrder, enforceConsistentVariableSyntax, enforceLogicalProperties, enforceShorthandClasses, noConflictingClasses, noDeprecatedClasses, noDuplicateClasses, noRestrictedClasses, noUnnecessaryWhitespace, noUnknownClasses, enforceCanonicalClasses ] as const; const plugin = { meta: { name: "better-tailwindcss" }, rules: rules.reduce( (acc, { name, rule }) => { acc[name] = rule; return acc; }, {} ) as RuleObject } as const satisfies ESLint.Plugin; const getStylisticRules = (severity: SeverityLevel = "warn" as SeverityLevel) => { return rules.reduce((acc, { category, name, recommended }) => { if(category !== "stylistic" || !recommended){ return acc; } acc[`${plugin.meta.name}/${name}`] = severity; return acc; }, {} as Record<`${PluginName}/${GetRules<"stylistic">["name"]}`, SeverityLevel>); }; const getCorrectnessRules = (severity: SeverityLevel = "error" as SeverityLevel) => { return rules.reduce((acc, { category, name, recommended }) => { if(category !== "correctness" || !recommended){ return acc; } acc[`${plugin.meta.name}/${name}`] = severity; return acc; }, {} as Record<`${PluginName}/${GetRules<"correctness">["name"]}`, SeverityLevel>); }; const getRecommendedRules = (severity?: SeverityLevel) => ({ ...getStylisticRules(severity), ...getCorrectnessRules(severity) }); const createLegacyConfig = (rules: Rules) => ({ plugins: [plugin.meta.name], rules } satisfies Linter.LegacyConfig); const createFlatConfig = (rules: Rules) => ({ plugins: { [plugin.meta.name]: plugin }, rules } satisfies Linter.Config); const configEntry = (key: ConfigName, value: Config) => { return { [key]: value } as { [Name in ConfigName]: Config }; }; const createConfig = (name: Name, getRules: (severity?: SeverityLevel) => Record) => { return { ...configEntry(`legacy-${name}` as const, createLegacyConfig(getRules())), ...configEntry( `legacy-${name}-error` as const, createLegacyConfig(getRules("error")) ), ...configEntry( `legacy-${name}-warn` as const, createLegacyConfig(getRules("warn")) ), ...configEntry(name, createFlatConfig(getRules())), ...configEntry( `${name}-error` as const, createFlatConfig(getRules("error")) ), ...configEntry(`${name}-warn` as const, createFlatConfig(getRules("warn"))) }; }; const config = { ...plugin, configs: { ...createConfig("stylistic", getStylisticRules), ...createConfig("correctness", getCorrectnessRules), ...createConfig("recommended", getRecommendedRules) } } satisfies ESLint.Plugin; export default config; export { config as "module.exports" }; ================================================ FILE: src/options/callees/cc.test.ts ================================================ import { describe, it } from "vitest"; import { CC_OBJECT_KEYS, CC_STRINGS } from "better-tailwindcss:options/callees/cc.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("cc", () => { it("should lint strings and strings in arrays", () => { const dirty = `cc(" lint ", [" lint ", " lint "])`; const clean = `cc("lint", ["lint", "lint"])`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [CC_STRINGS] }] } ] }); }); it("should lint object keys", () => { const dirty = ` cc(" ignore ", { " lint ": { " lint ": " ignore " }, } ) `; const clean = ` cc(" ignore ", { "lint": { "lint": " ignore " }, } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [CC_OBJECT_KEYS] }] } ] }); }); }); ================================================ FILE: src/options/callees/cc.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const CC_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^cc$" } satisfies CalleeSelector; export const CC_OBJECT_KEYS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.ObjectKey } ], name: "^cc$" } satisfies CalleeSelector; /** @see https://github.com/jorgebucaran/classcat */ export const CC = [ CC_STRINGS, CC_OBJECT_KEYS ] satisfies Selectors; ================================================ FILE: src/options/callees/clb.test.ts ================================================ import { describe, it } from "vitest"; import { CLB_BASE_VALUES, CLB_COMPOUND_VARIANTS_CLASSES, CLB_VARIANT_VALUES } from "better-tailwindcss:options/callees/clb.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("clb", () => { it("should lint object values inside the `base` property", () => { const dirty = ` clb({ base: " lint ", " ignore ": " ignore " } ) `; const clean = ` clb({ base: "lint", " ignore ": " ignore " } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [CLB_BASE_VALUES] }] } ] }); }); it("should lint object values inside the `variants` property", () => { const dirty = ` clb({ variants: { " ignore ": " lint " }, compoundVariants: { " ignore ": " ignore " } } ) `; const clean = ` clb({ variants: { " ignore ": "lint" }, compoundVariants: { " ignore ": " ignore " } } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [CLB_VARIANT_VALUES] }] } ] }); }); it("should lint only object values inside the `compoundVariants.classes` property", () => { const dirty = ` clb({ variants: { " ignore ": " ignore " }, compoundVariants: [{ " ignore ": " ignore ", "classes": " lint " }] } ) `; const clean = ` clb({ variants: { " ignore ": " ignore " }, compoundVariants: [{ " ignore ": " ignore ", "classes": "lint" }] } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [CLB_COMPOUND_VARIANTS_CLASSES] }] } ] }); }); it("should lint all `clb` variations in combination by default", () => { const dirty = ` clb({ base: " lint ", compoundVariants: [ { " ignore ": " ignore ", "classes": " lint " } ], defaultVariants: { " ignore ": " ignore " }, variants: { " ignore ": { " ignore array ": [ " lint ", " lint " ], " ignore string ": " lint " } } }); `; const clean = ` clb({ base: "lint", compoundVariants: [ { " ignore ": " ignore ", "classes": "lint" } ], defaultVariants: { " ignore ": " ignore " }, variants: { " ignore ": { " ignore array ": [ "lint", "lint" ], " ignore string ": "lint" } } }); `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 10 } ] }); }); }); ================================================ FILE: src/options/callees/clb.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const CLB_BASE_VALUES = { kind: SelectorKind.Callee, match: [ { path: "^base$", type: MatcherType.ObjectValue } ], name: "^clb$" } satisfies CalleeSelector; export const CLB_VARIANT_VALUES = { kind: SelectorKind.Callee, match: [ { path: "^variants.*$", type: MatcherType.ObjectValue } ], name: "^clb$" } satisfies CalleeSelector; export const CLB_COMPOUND_VARIANTS_CLASSES = { kind: SelectorKind.Callee, match: [ { path: "^compoundVariants\\[\\d+\\]\\.classes$", type: MatcherType.ObjectValue } ], name: "^clb$" } satisfies CalleeSelector; /** @see https://github.com/crswll/clb */ export const CLB = [ CLB_BASE_VALUES, CLB_VARIANT_VALUES, CLB_COMPOUND_VARIANTS_CLASSES // TODO: add object key matcher: classes ] satisfies Selectors; ================================================ FILE: src/options/callees/clsx.test.ts ================================================ import { describe, it } from "vitest"; import { CLSX_OBJECT_KEYS, CLSX_STRINGS } from "better-tailwindcss:options/callees/clsx.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("clsx", () => { it("should lint strings and strings in arrays", () => { const dirty = `clsx(" lint ", [" lint ", " lint "])`; const clean = `clsx("lint", ["lint", "lint"])`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [CLSX_STRINGS] }] } ] }); }); it("should lint object keys", () => { const dirty = ` clsx(" ignore ", { " lint ": { " lint ": " ignore " }, } ) `; const clean = ` clsx(" ignore ", { "lint": { "lint": " ignore " }, } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [CLSX_OBJECT_KEYS] }] } ] }); }); }); ================================================ FILE: src/options/callees/clsx.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const CLSX_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^clsx$" } satisfies CalleeSelector; export const CLSX_OBJECT_KEYS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.ObjectKey } ], name: "^clsx$" } satisfies CalleeSelector; /** @see https://github.com/lukeed/clsx */ export const CLSX = [ CLSX_STRINGS, CLSX_OBJECT_KEYS ] satisfies Selectors; ================================================ FILE: src/options/callees/cn.test.ts ================================================ import { describe, it } from "vitest"; import { CN_OBJECT_KEYS, CN_STRINGS } from "better-tailwindcss:options/callees/cn.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("cn", () => { it("should lint strings and strings in arrays", () => { const dirty = `cn(" lint ", [" lint ", " lint "])`; const clean = `cn("lint", ["lint", "lint"])`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [CN_STRINGS] }] } ] }); }); it("should lint object keys", () => { const dirty = ` cn(" ignore ", { " lint ": { " lint ": " ignore " }, } ) `; const clean = ` cn(" ignore ", { "lint": { "lint": " ignore " }, } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [CN_OBJECT_KEYS] }] } ] }); }); }); ================================================ FILE: src/options/callees/cn.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const CN_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^cn$" } satisfies CalleeSelector; export const CN_OBJECT_KEYS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.ObjectKey } ], name: "^cn$" } satisfies CalleeSelector; /** @see https://ui.shadcn.com/docs/installation/manual */ export const CN = [ CN_STRINGS, CN_OBJECT_KEYS ] satisfies Selectors; ================================================ FILE: src/options/callees/cnb.test.ts ================================================ import { describe, it } from "vitest"; import { CNB_OBJECT_KEYS, CNB_STRINGS } from "better-tailwindcss:options/callees/cnb.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("cnb", () => { it("should lint strings and strings in arrays", () => { const dirty = `cnb(" lint ", [" lint ", " lint "])`; const clean = `cnb("lint", ["lint", "lint"])`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [CNB_STRINGS] }] } ] }); }); it("should lint object keys", () => { const dirty = ` cnb(" ignore ", { " lint ": { " lint ": " ignore " }, } ) `; const clean = ` cnb(" ignore ", { "lint": { "lint": " ignore " }, } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [CNB_OBJECT_KEYS] }] } ] }); }); }); ================================================ FILE: src/options/callees/cnb.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const CNB_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^cnb$" } satisfies CalleeSelector; export const CNB_OBJECT_KEYS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.ObjectKey } ], name: "^cnb$" } satisfies CalleeSelector; /** @see https://github.com/xobotyi/cnbuilder */ export const CNB = [ CNB_STRINGS, CNB_OBJECT_KEYS ] satisfies Selectors; ================================================ FILE: src/options/callees/ctl.test.ts ================================================ import { describe, it } from "vitest"; import { CTL_STRINGS } from "better-tailwindcss:options/callees/ctl.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("ctl", () => { it("should lint strings", () => { const dirty = `ctl(" lint ")`; const clean = `ctl("lint")`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [CTL_STRINGS] }] } ] }); }); }); ================================================ FILE: src/options/callees/ctl.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const CTL_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^ctl$" } satisfies CalleeSelector; /** @see https://github.com/netlify/classnames-template-literals */ export const CTL = [ CTL_STRINGS ] satisfies Selectors; ================================================ FILE: src/options/callees/cva.test.ts ================================================ import { describe, it } from "vitest"; import { CVA_COMPOUND_VARIANTS_CLASS, CVA_STRINGS, CVA_VARIANT_VALUES } from "better-tailwindcss:options/callees/cva.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("cva", () => { it("should lint strings in arrays", () => { const dirty = `cva(" lint ", [" lint ", " lint "])`; const clean = `cva("lint", ["lint", "lint"])`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [CVA_STRINGS] }] } ] }); }); it("should lint object values inside the `variants` property", () => { const dirty = ` cva(" ignore ", { variants: { " ignore ": " lint " }, compoundVariants: { " ignore ": " ignore " } } ) `; const clean = ` cva(" ignore ", { variants: { " ignore ": "lint" }, compoundVariants: { " ignore ": " ignore " } } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [CVA_VARIANT_VALUES] }] } ] }); }); it("should lint only object values inside the `compoundVariants.class` and `compoundVariants.className` property", () => { const dirty = ` cva(" ignore ", { variants: { " ignore ": " ignore " }, compoundVariants: [{ " ignore ": " ignore ", "class": " lint ", "className": " lint " }] } ) `; const clean = ` cva(" ignore ", { variants: { " ignore ": " ignore " }, compoundVariants: [{ " ignore ": " ignore ", "class": "lint", "className": "lint" }] } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [CVA_COMPOUND_VARIANTS_CLASS] }] } ] }); }); it("should lint all `cva` variations in combination by default", () => { const dirty = ` cva([" lint ", " lint "], " lint ", { compoundVariants: [ { " ignore ": " ignore ", "class": " lint " } ], defaultVariants: { " ignore ": " ignore " }, variants: { " ignore ": { " ignore array ": [ " lint ", " lint " ], " ignore string ": " lint " } } }); `; const clean = ` cva(["lint", "lint"], "lint", { compoundVariants: [ { " ignore ": " ignore ", "class": "lint" } ], defaultVariants: { " ignore ": " ignore " }, variants: { " ignore ": { " ignore array ": [ "lint", "lint" ], " ignore string ": "lint" } } }); `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 14 } ] }); }); }); ================================================ FILE: src/options/callees/cva.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const CVA_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^cva$" } satisfies CalleeSelector; export const CVA_VARIANT_VALUES = { kind: SelectorKind.Callee, match: [ { path: "^variants.*$", type: MatcherType.ObjectValue } ], name: "^cva$" } satisfies CalleeSelector; export const CVA_COMPOUND_VARIANTS_CLASS = { kind: SelectorKind.Callee, match: [ { path: "^compoundVariants\\[\\d+\\]\\.(?:className|class)$", type: MatcherType.ObjectValue } ], name: "^cva$" } satisfies CalleeSelector; /** @see https://github.com/joe-bell/cva */ export const CVA = [ CVA_STRINGS, CVA_VARIANT_VALUES, CVA_COMPOUND_VARIANTS_CLASS ] satisfies Selectors; ================================================ FILE: src/options/callees/cx.test.ts ================================================ import { describe, it } from "vitest"; import { CX_OBJECT_KEYS, CX_STRINGS } from "better-tailwindcss:options/callees/cx.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("cx", () => { it("should lint strings and strings in arrays", () => { const dirty = `cx(" lint ", [" lint ", " lint "])`; const clean = `cx("lint", ["lint", "lint"])`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [CX_STRINGS] }] } ] }); }); it("should lint object keys", () => { const dirty = ` cx(" ignore ", { " lint ": { " lint ": " ignore " }, } ) `; const clean = ` cx(" ignore ", { "lint": { "lint": " ignore " }, } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [CX_OBJECT_KEYS] }] } ] }); }); }); ================================================ FILE: src/options/callees/cx.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const CX_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^cx$" } satisfies CalleeSelector; export const CX_OBJECT_KEYS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.ObjectKey } ], name: "^cx$" } satisfies CalleeSelector; /** @see https://cva.style/docs/api-reference#cx */ export const CX = [ CX_STRINGS, CX_OBJECT_KEYS ] satisfies Selectors; ================================================ FILE: src/options/callees/dcnb.test.ts ================================================ import { describe, it } from "vitest"; import { DCNB_OBJECT_KEYS, DCNB_STRINGS } from "better-tailwindcss:options/callees/dcnb.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("dcnb", () => { it("should lint strings and strings in arrays", () => { const dirty = `dcnb(" lint ", [" lint ", " lint "])`; const clean = `dcnb("lint", ["lint", "lint"])`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [DCNB_STRINGS] }] } ] }); }); it("should lint object keys", () => { const dirty = ` dcnb(" ignore ", { " lint ": { " lint ": " ignore " }, } ) `; const clean = ` dcnb(" ignore ", { "lint": { "lint": " ignore " }, } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [DCNB_OBJECT_KEYS] }] } ] }); }); }); ================================================ FILE: src/options/callees/dcnb.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const DCNB_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^dcnb$" } satisfies CalleeSelector; export const DCNB_OBJECT_KEYS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.ObjectKey } ], name: "^dcnb$" } satisfies CalleeSelector; /** @see https://github.com/xobotyi/cnbuilder */ export const DCNB = [ DCNB_STRINGS, DCNB_OBJECT_KEYS ] satisfies Selectors; ================================================ FILE: src/options/callees/objstr.test.ts ================================================ import { describe, it } from "vitest"; import { OBJSTR_OBJECT_KEYS } from "better-tailwindcss:options/callees/objstr.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("objstr", () => { it("should lint object keys", () => { const dirty = ` objstr(" ignore ", { " lint ": { " lint ": " ignore " }, } ) `; const clean = ` objstr(" ignore ", { "lint": { "lint": " ignore " }, } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [OBJSTR_OBJECT_KEYS] }] } ] }); }); }); ================================================ FILE: src/options/callees/objstr.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const OBJSTR_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^objstr$" } satisfies CalleeSelector; export const OBJSTR_OBJECT_KEYS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.ObjectKey } ], name: "^objstr$" } satisfies CalleeSelector; /** @see https://github.com/lukeed/obj-str */ export const OBJSTR = [ OBJSTR_OBJECT_KEYS ] satisfies Selectors; ================================================ FILE: src/options/callees/tv.test.ts ================================================ import { describe, it } from "vitest"; import { TV_BASE_VALUES, TV_COMPOUND_SLOTS_CLASS, TV_COMPOUND_VARIANTS_CLASS, TV_SLOTS_VALUES, TV_STRINGS, TV_VARIANT_VALUES } from "better-tailwindcss:options/callees/tv.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("tv", () => { it("should lint strings in arrays", () => { const dirty = `tv(" lint ", [" lint ", " lint "])`; const clean = `tv("lint", ["lint", "lint"])`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [TV_STRINGS] }] } ] }); }); it("should lint object values inside the `variants` property", () => { const dirty = ` tv(" ignore ", { variants: { " ignore ": " lint " }, compoundVariants: { " ignore ": " ignore " } } ) `; const clean = ` tv(" ignore ", { variants: { " ignore ": "lint" }, compoundVariants: { " ignore ": " ignore " } } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [TV_VARIANT_VALUES] }] } ] }); }); it("should lint only object values inside the `compoundVariants.class` and `compoundVariants.className` property", () => { const dirty = ` tv(" ignore ", { variants: { " ignore ": " ignore " }, compoundVariants: [{ " ignore ": " ignore ", "class": " lint ", "className": " lint " }] } ) `; const clean = ` tv(" ignore ", { variants: { " ignore ": " ignore " }, compoundVariants: [{ " ignore ": " ignore ", "class": "lint", "className": "lint" }] } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [TV_COMPOUND_VARIANTS_CLASS] }] } ] }); }); it("should lint all `tv` variations in combination by default", () => { const dirty = ` tv([" lint ", " lint "], " lint ", { base: " lint ", slots: { " ignore ": " lint " }, compoundVariants: [ { " ignore ": " ignore ", "class": " lint " } ], compoundSlots: [ { slots: [" ignore ", " ignore "], " ignore ": " ignore ", "class": " lint " } ], defaultVariants: { " ignore ": " ignore " }, variants: { " ignore ": { " ignore array ": [ " lint ", " lint " ], " ignore string ": " lint " } } }); `; const clean = ` tv(["lint", "lint"], "lint", { base: "lint", slots: { " ignore ": "lint" }, compoundVariants: [ { " ignore ": " ignore ", "class": "lint" } ], compoundSlots: [ { slots: [" ignore ", " ignore "], " ignore ": " ignore ", "class": "lint" } ], defaultVariants: { " ignore ": " ignore " }, variants: { " ignore ": { " ignore array ": [ "lint", "lint" ], " ignore string ": "lint" } } }); `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 20 } ] }); }); it("should lint object values inside the `base` property", () => { const dirty = ` tv(" ignore ", { base: " lint ", variants: { " ignore ": " ignore " }, compoundVariants: { " ignore ": " ignore " } } ) `; const clean = ` tv(" ignore ", { base: "lint", variants: { " ignore ": " ignore " }, compoundVariants: { " ignore ": " ignore " } } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [TV_BASE_VALUES] }] } ] }); }); it("should lint object values inside the `slots` property", () => { const dirty = ` tv(" ignore ", { slots: { slotName: " lint ", anotherSlot: [" lint ", " lint "] }, variants: { " ignore ": " ignore " } } ) `; const clean = ` tv(" ignore ", { slots: { slotName: "lint", anotherSlot: ["lint", "lint"] }, variants: { " ignore ": " ignore " } } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [TV_SLOTS_VALUES] }] } ] }); }); it("should lint only object values inside the `compoundSlots.class` and `compoundSlots.className` property", () => { const dirty = ` tv(" ignore ", { slots: { " ignore ": " ignore " }, compoundSlots: [{ slots: [" ignore ", " ignore "], " ignore ": " ignore ", "class": " lint ", "className": " lint " }] } ) `; const clean = ` tv(" ignore ", { slots: { " ignore ": " ignore " }, compoundSlots: [{ slots: [" ignore ", " ignore "], " ignore ": " ignore ", "class": "lint", "className": "lint" }] } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [TV_COMPOUND_SLOTS_CLASS] }] } ] }); }); it("should lint string arrays inside the `compoundVariants.class` and `compoundVariants.className` property", () => { const dirty = ` tv(" ignore ", { slots: { " ignore ": " ignore " }, compoundVariants: [{ " ignore ": " ignore ", "class": [" lint ", " lint "], "className": [" lint ", " lint ", " lint "] }] } ) `; const clean = ` tv(" ignore ", { slots: { " ignore ": " ignore " }, compoundVariants: [{ " ignore ": " ignore ", "class": ["lint", "lint"], "className": ["lint", "lint", "lint"] }] } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 10, options: [{ selectors: [TV_COMPOUND_VARIANTS_CLASS] }] } ] }); }); it("should lint string arrays inside the `compoundSlots.class` and `compoundSlots.className` property", () => { const dirty = ` tv(" ignore ", { slots: { " ignore ": " ignore " }, compoundSlots: [{ slots: [" ignore ", " ignore "], " ignore ": " ignore ", "class": [" lint ", " lint "], "className": [" lint ", " lint ", " lint "] }] } ) `; const clean = ` tv(" ignore ", { slots: { " ignore ": " ignore " }, compoundSlots: [{ slots: [" ignore ", " ignore "], " ignore ": " ignore ", "class": ["lint", "lint"], "className": ["lint", "lint", "lint"] }] } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 10, options: [{ selectors: [TV_COMPOUND_SLOTS_CLASS] }] } ] }); }); it("should lint string arrays inside the `base` property", () => { const dirty = ` tv(" ignore ", { base: [" lint ", " lint ", " lint "], variants: { " ignore ": " ignore " }, compoundVariants: { " ignore ": " ignore " } } ) `; const clean = ` tv(" ignore ", { base: ["lint", "lint", "lint"], variants: { " ignore ": " ignore " }, compoundVariants: { " ignore ": " ignore " } } ) `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [TV_BASE_VALUES] }] } ] }); }); }); ================================================ FILE: src/options/callees/tv.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const TV_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^tv$" } satisfies CalleeSelector; export const TV_VARIANT_VALUES = { kind: SelectorKind.Callee, match: [ { path: "^variants.*$", type: MatcherType.ObjectValue } ], name: "^tv$" } satisfies CalleeSelector; export const TV_BASE_VALUES = { kind: SelectorKind.Callee, match: [ { path: "^base$", type: MatcherType.ObjectValue } ], name: "^tv$" } satisfies CalleeSelector; export const TV_SLOTS_VALUES = { kind: SelectorKind.Callee, match: [ { path: "^slots.*$", type: MatcherType.ObjectValue } ], name: "^tv$" } satisfies CalleeSelector; export const TV_COMPOUND_VARIANTS_CLASS = { kind: SelectorKind.Callee, match: [ { path: "^compoundVariants\\[\\d+\\]\\.(?:className|class).*$", type: MatcherType.ObjectValue } ], name: "^tv$" } satisfies CalleeSelector; export const TV_COMPOUND_SLOTS_CLASS = { kind: SelectorKind.Callee, match: [ { path: "^compoundSlots\\[\\d+\\]\\.(?:className|class).*$", type: MatcherType.ObjectValue } ], name: "^tv$" } satisfies CalleeSelector; /** @see https://github.com/nextui-org/tailwind-variants?tab=readme-ov-file */ export const TV = [ TV_STRINGS, TV_VARIANT_VALUES, TV_COMPOUND_VARIANTS_CLASS, TV_BASE_VALUES, TV_SLOTS_VALUES, TV_COMPOUND_SLOTS_CLASS ] satisfies Selectors; ================================================ FILE: src/options/callees/twJoin.test.ts ================================================ import { describe, it } from "vitest"; import { TW_JOIN_STRINGS } from "better-tailwindcss:options/callees/twJoin.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("twJoin", () => { it("should lint strings and strings in arrays", () => { const dirty = `twJoin(" lint ", [" lint ", " lint "])`; const clean = `twJoin("lint", ["lint", "lint"])`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [TW_JOIN_STRINGS] }] } ] }); }); }); ================================================ FILE: src/options/callees/twJoin.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const TW_JOIN_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^twJoin$" } satisfies CalleeSelector; /** @see https://github.com/dcastil/tailwind-merge */ export const TW_JOIN = [ TW_JOIN_STRINGS ] satisfies Selectors; ================================================ FILE: src/options/callees/twMerge.test.ts ================================================ import { describe, it } from "vitest"; import { TW_MERGE_STRINGS } from "better-tailwindcss:options/callees/twMerge.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("twMerge", () => { it("should lint strings and strings in arrays", () => { const dirty = `twMerge(" lint ", [" lint ", " lint "])`; const clean = `twMerge("lint", ["lint", "lint"])`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirty, jsxOutput: clean, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6, options: [{ selectors: [TW_MERGE_STRINGS] }] } ] }); }); }); ================================================ FILE: src/options/callees/twMerge.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors } from "better-tailwindcss:types/rule.js"; export const TW_MERGE_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String } ], name: "^twMerge$" } satisfies CalleeSelector; /** @see https://github.com/dcastil/tailwind-merge */ export const TW_MERGE = [ TW_MERGE_STRINGS ] satisfies Selectors; ================================================ FILE: src/options/default-options.test.ts ================================================ import { describe, expect, it } from "vitest"; import { DEFAULT_CALLEE_SELECTORS, DEFAULT_TAG_SELECTORS } from "better-tailwindcss:options/default-options.js"; import { getFilesInDirectory } from "better-tailwindcss:tests/utils/lint.js"; import { SelectorKind } from "better-tailwindcss:types/rule.js"; describe("default options", () => { it("should include all callees by default", () => { const callees = DEFAULT_CALLEE_SELECTORS .filter(selector => selector.kind === SelectorKind.Callee) .map(selector => selector.name) .filter((callee, index, arr) => arr.indexOf(callee) === index); const exportedFiles = getFilesInDirectory("./src/options/callees/"); const fileNames = exportedFiles.map(file => file.replace(".ts", "")); expect(callees.sort()).toStrictEqual(fileNames.sort().map(name => `^${name}$`)); }); it("should include all tags by default", () => { const tags = DEFAULT_TAG_SELECTORS .filter(selector => selector.kind === SelectorKind.Tag) .map(selector => selector.path) .filter((tag, index, arr) => arr.indexOf(tag) === index); const exportedFiles = getFilesInDirectory("./src/options/tags/"); const fileNames = exportedFiles.map(file => file.replace(".ts", "")); expect(tags.sort()).toStrictEqual(fileNames.sort().map(name => `${name}(\\.\\w+)?`)); }); }); ================================================ FILE: src/options/default-options.ts ================================================ import { CC } from "better-tailwindcss:options/callees/cc.js"; import { CLB } from "better-tailwindcss:options/callees/clb.js"; import { CLSX } from "better-tailwindcss:options/callees/clsx.js"; import { CN } from "better-tailwindcss:options/callees/cn.js"; import { CNB } from "better-tailwindcss:options/callees/cnb.js"; import { CTL } from "better-tailwindcss:options/callees/ctl.js"; import { CVA } from "better-tailwindcss:options/callees/cva.js"; import { CX } from "better-tailwindcss:options/callees/cx.js"; import { DCNB } from "better-tailwindcss:options/callees/dcnb.js"; import { OBJSTR } from "better-tailwindcss:options/callees/objstr.js"; import { TV } from "better-tailwindcss:options/callees/tv.js"; import { TW_JOIN } from "better-tailwindcss:options/callees/twJoin.js"; import { TW_MERGE } from "better-tailwindcss:options/callees/twMerge.js"; import { TWC } from "better-tailwindcss:options/tags/twc.js"; import { TWX } from "better-tailwindcss:options/tags/twx.js"; import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { Variables } from "better-tailwindcss:options/schemas/variables.js"; import type { Selectors } from "better-tailwindcss:types/rule.js"; export const DEFAULT_CALLEE_SELECTORS = [ ...CC, ...CLB, ...CLSX, ...CN, ...CNB, ...CTL, ...CVA, ...CX, ...DCNB, ...OBJSTR, ...TV, ...TW_JOIN, ...TW_MERGE ] satisfies Selectors; export const DEFAULT_ATTRIBUTE_SELECTORS = [ { kind: SelectorKind.Attribute, name: "^class(?:Name)?$" }, { kind: SelectorKind.Attribute, match: [ { type: MatcherType.String } ], name: "^class(?:Name)?$" }, { kind: SelectorKind.Attribute, match: [ { type: MatcherType.String } ], name: "^class:.*$" }, { kind: SelectorKind.Attribute, name: "(?:^\\[class\\]$)|(?:^\\[ngClass\\]$)" }, { kind: SelectorKind.Attribute, match: [ { type: MatcherType.String } ], name: "(?:^\\[class\\..*\\]$)" }, { kind: SelectorKind.Attribute, match: [ { type: MatcherType.String }, { type: MatcherType.ObjectKey } ], name: "(?:^\\[class\\]$)|(?:^\\[ngClass\\]$)" }, { kind: SelectorKind.Attribute, match: [ { type: MatcherType.String }, { type: MatcherType.ObjectKey } ], name: "^v-bind:class$" }, { kind: SelectorKind.Attribute, match: [ { type: MatcherType.String }, { type: MatcherType.ObjectKey } ], name: "^class:list$" }, { kind: SelectorKind.Attribute, match: [ { type: MatcherType.ObjectKey } ], name: "^classList$" } ] satisfies Selectors; export const DEFAULT_VARIABLE_NAMES = [ [ "^classNames?$", [ { match: MatcherType.String } ] ], [ "^classes$", [ { match: MatcherType.String } ] ], [ "^styles?$", [ { match: MatcherType.String } ] ] ] satisfies Variables; export const DEFAULT_VARIABLE_SELECTORS = [ { kind: SelectorKind.Variable, match: [ { type: MatcherType.String } ], name: "^classNames?$" }, { kind: SelectorKind.Variable, match: [ { type: MatcherType.String } ], name: "^classes$" }, { kind: SelectorKind.Variable, match: [ { type: MatcherType.String } ], name: "^styles?$" } ] satisfies Selectors; export const DEFAULT_TAG_SELECTORS = [ ...TWC, ...TWX ] satisfies Selectors; export const DEFAULT_SELECTORS = [ ...DEFAULT_ATTRIBUTE_SELECTORS, ...DEFAULT_CALLEE_SELECTORS, ...DEFAULT_VARIABLE_SELECTORS, ...DEFAULT_TAG_SELECTORS ] satisfies Selectors; ================================================ FILE: src/options/descriptions.test.ts ================================================ import { toJsonSchema } from "@valibot/to-json-schema"; import { validate } from "json-schema"; import { describe, expect, test } from "vitest"; import { COMMON_OPTIONS } from "better-tailwindcss:options/descriptions.js"; import { ATTRIBUTES_OPTION_SCHEMA } from "better-tailwindcss:options/schemas/attributes.js"; import { CALLEES_OPTION_SCHEMA } from "better-tailwindcss:options/schemas/callees.js"; import { VARIABLES_OPTION_SCHEMA } from "better-tailwindcss:options/schemas/variables.js"; import { MatcherType } from "better-tailwindcss:types/rule.js"; import type { AttributesOptions } from "better-tailwindcss:options/schemas/attributes.js"; import type { CalleesOptions } from "better-tailwindcss:options/schemas/callees.js"; import type { VariablesOptions } from "better-tailwindcss:options/schemas/variables.js"; describe("descriptions", () => { test("name config", () => { const attributes = { attributes: [ "class", "className" ] } satisfies AttributesOptions; expect( validate(attributes, toJsonSchema(ATTRIBUTES_OPTION_SCHEMA)) ).toStrictEqual( { errors: [], valid: true } ); const callees = { callees: [ "callee" ] } satisfies CalleesOptions; expect( validate(callees, toJsonSchema(CALLEES_OPTION_SCHEMA)) ).toStrictEqual( { errors: [], valid: true } ); const variable = { variables: [ "classes", "styles" ] } satisfies VariablesOptions; expect( validate(variable, toJsonSchema(VARIABLES_OPTION_SCHEMA)) ).toStrictEqual( { errors: [], valid: true } ); }); test("regex config", () => { const attributes = { attributes: [ "(class|className)", "(.*)" ] } satisfies AttributesOptions; expect( validate(attributes, toJsonSchema(ATTRIBUTES_OPTION_SCHEMA)) ).toStrictEqual( { errors: [], valid: true } ); const callees = { callees: [ "callee(.*)", "(.*)" ] } satisfies CalleesOptions; expect( validate(callees, toJsonSchema(CALLEES_OPTION_SCHEMA)) ).toStrictEqual( { errors: [], valid: true } ); const variable = { variables: [ "variable = (.*)", "(.*)" ] } satisfies VariablesOptions; expect( validate(variable, toJsonSchema(VARIABLES_OPTION_SCHEMA)) ).toStrictEqual( { errors: [], valid: true } ); }); test("matcher config", () => { const attributes: AttributesOptions = { attributes: [ [ "class", [ { match: MatcherType.String }, { match: MatcherType.ObjectKey, pathPattern: "^.*" }, { match: MatcherType.ObjectValue } ] ] ] }; expect( validate(attributes, toJsonSchema(ATTRIBUTES_OPTION_SCHEMA)) ).toStrictEqual( { errors: [], valid: true } ); const callees: CalleesOptions = { callees: [ [ "callee", [ { match: MatcherType.String }, { match: MatcherType.ObjectKey, pathPattern: "^.*" }, { match: MatcherType.ObjectValue } ] ] ] }; expect( validate(callees, toJsonSchema(CALLEES_OPTION_SCHEMA)) ).toStrictEqual( { errors: [], valid: true } ); const variable: VariablesOptions = { variables: [ [ "variable", [ { match: MatcherType.String }, { match: MatcherType.ObjectKey, pathPattern: "^.*" }, { match: MatcherType.ObjectValue } ] ] ] }; expect( validate(variable, toJsonSchema(VARIABLES_OPTION_SCHEMA)) ).toStrictEqual( { errors: [], valid: true } ); }); test("selectors config", () => { const selectors = { selectors: [ { callTarget: -1, kind: "callee", match: [ { type: MatcherType.String } ], path: "^classes\\.push$" }, { kind: "tag", name: "^tw$" }, { kind: "attribute", match: [ { pathPattern: "^compoundVariants\\[\\d+\\]\\.(?:className|class)$", type: MatcherType.ObjectValue } ], name: "^class(?:Name)?$" } ] }; expect( validate(selectors, toJsonSchema(COMMON_OPTIONS)) ).toStrictEqual( { errors: [], valid: true } ); }); test("selectors targetCall and targetArgument config", () => { const selectors = { selectors: [ { kind: "callee", name: "^cn$", targetArgument: "first", targetCall: "last" }, { callTarget: -1, kind: "callee", name: "^legacy$", targetArgument: 0 } ] }; expect( validate(selectors, toJsonSchema(COMMON_OPTIONS)) ).toStrictEqual( { errors: [], valid: true } ); }); test("selectors anonymousFunctionReturn matcher config", () => { const selectors = { selectors: [ { kind: "callee", match: [{ match: [ { type: MatcherType.String }, { type: MatcherType.ObjectKey }, { path: "^compoundVariants\\[\\d+\\]\\.(?:className|class)$", type: MatcherType.ObjectValue } ], type: MatcherType.AnonymousFunctionReturn }], name: "^classFactory$" } ] }; expect( validate(selectors, toJsonSchema(COMMON_OPTIONS)) ).toStrictEqual( { errors: [], valid: true } ); }); }); ================================================ FILE: src/options/descriptions.ts ================================================ import { strictObject } from "valibot"; import { ATTRIBUTES_OPTION_SCHEMA } from "better-tailwindcss:options/schemas/attributes.js"; import { CALLEES_OPTION_SCHEMA } from "better-tailwindcss:options/schemas/callees.js"; import { CWD_OPTION_SCHEMA, DETECT_COMPONENT_CLASSES_OPTION_SCHEMA, ENTRYPOINT_OPTION_SCHEMA, MESSAGE_STYLE_OPTION_SCHEMA, ROOT_FONT_SIZE_OPTION_SCHEMA, TAILWIND_OPTION_SCHEMA, TSCONFIG_OPTION_SCHEMA } from "better-tailwindcss:options/schemas/common.js"; import { SELECTORS_OPTION_SCHEMA } from "better-tailwindcss:options/schemas/selectors.js"; import { TAGS_OPTIONS_SCHEMA } from "better-tailwindcss:options/schemas/tags.js"; import { VARIABLES_OPTION_SCHEMA } from "better-tailwindcss:options/schemas/variables.js"; import type { InferOutput } from "valibot"; export const COMMON_OPTIONS = strictObject({ ...SELECTORS_OPTION_SCHEMA.entries, ...CALLEES_OPTION_SCHEMA.entries, ...ATTRIBUTES_OPTION_SCHEMA.entries, ...VARIABLES_OPTION_SCHEMA.entries, ...TAGS_OPTIONS_SCHEMA.entries, ...ENTRYPOINT_OPTION_SCHEMA.entries, ...MESSAGE_STYLE_OPTION_SCHEMA.entries, ...TAILWIND_OPTION_SCHEMA.entries, ...TSCONFIG_OPTION_SCHEMA.entries, ...DETECT_COMPONENT_CLASSES_OPTION_SCHEMA.entries, ...ROOT_FONT_SIZE_OPTION_SCHEMA.entries, ...CWD_OPTION_SCHEMA.entries }); export type CommonOptions = InferOutput; ================================================ FILE: src/options/migrate.test.ts ================================================ import { describe, expect, test } from "vitest"; import { hasLegacySelectorConfig, migrateFlatSelectorsToLegacySelectors, migrateLegacySelectorsToFlatSelectors } from "better-tailwindcss:options/migrate.js"; import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; describe("migrate", () => { test("should migrate legacy selectors to flat selectors", () => { const selectors = migrateLegacySelectorsToFlatSelectors({ attributes: ["^class(?:Name)?$"], callees: [["^cva$", [{ match: MatcherType.String }]]] }); expect(selectors).toStrictEqual([ { kind: SelectorKind.Attribute, name: "^class(?:Name)?$" }, { kind: SelectorKind.Callee, match: [{ type: MatcherType.String }], name: "^cva$", path: "^cva$" } ]); }); test("should detect legacy selector config", () => { expect(hasLegacySelectorConfig({})).toBe(false); expect(hasLegacySelectorConfig({ attributes: [] })).toBe(true); expect(hasLegacySelectorConfig({ callees: [] })).toBe(true); expect(hasLegacySelectorConfig({ tags: [] })).toBe(true); expect(hasLegacySelectorConfig({ variables: [] })).toBe(true); }); test("should migrate flat selectors to legacy selectors", () => { const selectors = migrateFlatSelectorsToLegacySelectors([ { kind: SelectorKind.Attribute, name: "^class$" }, { kind: SelectorKind.Callee, match: [{ type: MatcherType.String }], name: "^cva$" }, { kind: SelectorKind.Variable, match: [{ path: "^foo$", type: MatcherType.ObjectKey }], name: "^classes$" } ]); expect(selectors).toStrictEqual({ attributes: ["^class$"], callees: [["^cva$", [{ match: MatcherType.String }]]], variables: [["^classes$", [{ match: MatcherType.ObjectKey, pathPattern: "^foo$" }]]] }); }); test("should skip selectors with anonymousFunctionReturn matcher when migrating to legacy", () => { const selectors = migrateFlatSelectorsToLegacySelectors([ { kind: SelectorKind.Callee, match: [{ type: MatcherType.String }], name: "^cva$" }, { kind: SelectorKind.Callee, match: [{ match: [{ type: MatcherType.String }], type: MatcherType.AnonymousFunctionReturn }], name: "^classFactory$" } ]); expect(selectors).toStrictEqual({ callees: [["^cva$", [{ match: MatcherType.String }]]] }); }); }); ================================================ FILE: src/options/migrate.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { Attributes } from "better-tailwindcss:options/schemas/attributes.js"; import type { Callees } from "better-tailwindcss:options/schemas/callees.js"; import type { Tags } from "better-tailwindcss:options/schemas/tags.js"; import type { Variables } from "better-tailwindcss:options/schemas/variables.js"; import type { Matcher, Selector, SelectorMatcher, Selectors } from "better-tailwindcss:types/rule.js"; type LegacySelector = Attributes[number] | Callees[number] | Tags[number] | Variables[number]; type LegacySelectorsByKind = { attributes?: Attributes | undefined; callees?: Callees | undefined; tags?: Tags | undefined; variables?: Variables | undefined; }; export function migrateLegacySelectorsToFlatSelectors(legacy: LegacySelectorsByKind): Selectors { const selectors: Selectors = []; if(legacy.attributes){ for(const attributeSelector of legacy.attributes){ selectors.push(migrateLegacySelector(attributeSelector, SelectorKind.Attribute)); } } if(legacy.callees){ for(const calleeSelector of legacy.callees){ selectors.push(migrateLegacySelector(calleeSelector, SelectorKind.Callee)); } } if(legacy.tags){ for(const tagSelector of legacy.tags){ selectors.push(migrateLegacySelector(tagSelector, SelectorKind.Tag)); } } if(legacy.variables){ for(const variableSelector of legacy.variables){ selectors.push(migrateLegacySelector(variableSelector, SelectorKind.Variable)); } } return selectors; } export function migrateFlatSelectorsToLegacySelectors(selectors: Selectors): LegacySelectorsByKind { return selectors.reduce((legacy, selector) => { const migratedSelector = migrateFlatSelector(selector); if(migratedSelector === undefined){ return legacy; } switch (selector.kind){ case SelectorKind.Attribute: (legacy.attributes ??= []).push(migratedSelector); break; case SelectorKind.Callee: (legacy.callees ??= []).push(migratedSelector); break; case SelectorKind.Tag: (legacy.tags ??= []).push(migratedSelector); break; case SelectorKind.Variable: (legacy.variables ??= []).push(migratedSelector); break; } return legacy; }, {}); } export function hasLegacySelectorConfig(options: LegacySelectorsByKind): boolean { return ( options.attributes !== undefined || options.callees !== undefined || options.tags !== undefined || options.variables !== undefined ); } function toSelectorMatcher(matcher: Matcher): SelectorMatcher { if(matcher.match === MatcherType.String){ return { type: matcher.match }; } return { ...matcher.pathPattern !== undefined && { path: matcher.pathPattern }, type: matcher.match }; } function toLegacyMatcher(matcher: SelectorMatcher): Matcher | undefined { if(matcher.type === MatcherType.AnonymousFunctionReturn){ return; } if(matcher.type === MatcherType.String){ return { match: matcher.type }; } return { ...matcher.path !== undefined && { pathPattern: matcher.path }, match: matcher.type }; } function migrateLegacySelector(selector: LegacySelector, kind: SelectorKind) { const name = typeof selector === "string" ? selector : selector[0]; const path = kind === SelectorKind.Callee || kind === SelectorKind.Tag ? name : undefined; const matchers = typeof selector === "string" ? undefined : selector[1].map(toSelectorMatcher); if(matchers === undefined){ return { kind, name, ...path ? { path } : {} }; } return { kind, match: matchers, name, ...path ? { path } : {} }; } function migrateFlatSelector(selector: Selector): LegacySelector | undefined { if(selector.kind === SelectorKind.Callee || selector.kind === SelectorKind.Tag){ if(selector.match === undefined){ return selector.name ?? selector.path!; } const legacyMatchers = selector.match .map(toLegacyMatcher) .filter((matcher): matcher is Matcher => matcher !== undefined); if(legacyMatchers.length !== selector.match.length){ return; } return [ selector.name ?? selector.path!, legacyMatchers ]; } if(selector.match === undefined){ return selector.name; } const legacyMatchers = selector.match .map(toLegacyMatcher) .filter((matcher): matcher is Matcher => matcher !== undefined); if(legacyMatchers.length !== selector.match.length){ return; } return [ selector.name, legacyMatchers ]; } ================================================ FILE: src/options/schemas/attributes.ts ================================================ import { array, description, optional, pipe, strictObject, string, tuple, union } from "valibot"; import { OBJECT_KEY_MATCHER_SCHEMA, OBJECT_VALUE_MATCHER_SCHEMA, STRING_MATCHER_SCHEMA } from "better-tailwindcss:options/schemas/matchers.js"; import type { InferOutput } from "valibot"; export const ATTRIBUTE_MATCHER_CONFIG = pipe( tuple([ pipe( string(), description("Attribute name for which children get linted if matched.") ), pipe( array( union([ STRING_MATCHER_SCHEMA, OBJECT_KEY_MATCHER_SCHEMA, OBJECT_VALUE_MATCHER_SCHEMA ]) ), description("List of matchers that will be applied.") ) ]), description("List of matchers that will automatically be matched.") ); export type AttributeMatchers = InferOutput; export const ATTRIBUTE_NAME_CONFIG = pipe( string(), description("Attribute name that for which children get linted.") ); export type AttributeName = InferOutput; export const ATTRIBUTES_SCHEMA = pipe( array( union([ ATTRIBUTE_NAME_CONFIG, ATTRIBUTE_MATCHER_CONFIG ]) ), description("List of attribute names that should get linted.") ); export type Attributes = InferOutput; export const ATTRIBUTES_OPTION_SCHEMA = strictObject({ attributes: optional(ATTRIBUTES_SCHEMA) }); export type AttributesOptions = InferOutput; ================================================ FILE: src/options/schemas/callees.ts ================================================ import { array, description, optional, pipe, strictObject, string, tuple, union } from "valibot"; import { OBJECT_KEY_MATCHER_SCHEMA, OBJECT_VALUE_MATCHER_SCHEMA, STRING_MATCHER_SCHEMA } from "better-tailwindcss:options/schemas/matchers.js"; import type { InferOutput } from "valibot"; const CALLEE_MATCHER_SCHEMA = pipe( tuple([ pipe( string(), description("Callee name for which children get linted if matched.") ), pipe( array( union([ STRING_MATCHER_SCHEMA, OBJECT_KEY_MATCHER_SCHEMA, OBJECT_VALUE_MATCHER_SCHEMA ]) ), description("List of matchers that will be applied.") ) ]), description("List of matchers that will automatically be matched.") ); export type CalleeMatchers = InferOutput; const CALLEE_NAME_SCHEMA = pipe( string(), description("Callee name for which children get linted.") ); export type CalleeName = InferOutput; export const CALLEES_SCHEMA = pipe( array( union([ CALLEE_MATCHER_SCHEMA, CALLEE_NAME_SCHEMA ]) ), description("List of function names which arguments should get linted.") ); export type Callees = InferOutput; export const CALLEES_OPTION_SCHEMA = strictObject({ callees: optional(CALLEES_SCHEMA) }); export type CalleesOptions = InferOutput; ================================================ FILE: src/options/schemas/common.ts ================================================ import { env } from "node:process"; import { boolean, description, literal, number, optional, pipe, strictObject, string, union } from "valibot"; import type { InferOutput } from "valibot"; export const ENTRYPOINT_OPTION_SCHEMA = strictObject({ entryPoint: optional( pipe( string(), description("The path to the css entry point of the project. If not specified, the plugin will fall back to the default tailwind classes.") ), undefined ) }); export type EntryPointOption = InferOutput; export const TAILWIND_OPTION_SCHEMA = strictObject({ tailwindConfig: optional( pipe( string(), description("The path to the tailwind config file. If not specified, the plugin will try to find it automatically or falls back to the default configuration.") ), undefined ) }); export type TailwindConfigOption = InferOutput; export const TSCONFIG_OPTION_SCHEMA = strictObject({ tsconfig: optional( pipe( string(), description("The path to the tsconfig file. Is used to resolve path aliases in the tsconfig.") ), undefined ) }); export type TSConfigOption = InferOutput; export const MESSAGE_STYLE_OPTION_SCHEMA = strictObject({ messageStyle: optional( pipe( union([ literal("visual"), literal("compact"), literal("raw") ]), description("How linting messages are displayed.") ), env.CI === "true" || env.CI === "1" ? "compact" : "visual" ) }); export type MessageStyleOption = InferOutput; export const DETECT_COMPONENT_CLASSES_OPTION_SCHEMA = strictObject({ detectComponentClasses: optional( pipe( boolean(), description("Whether to automatically detect custom component classes from the tailwindcss config.") ), false ) }); export type DetectComponentClassesOption = InferOutput; export const ROOT_FONT_SIZE_OPTION_SCHEMA = strictObject({ rootFontSize: optional( pipe( number(), description("The root font size in pixels.") ), undefined ) }); export type RootFontSizeOption = InferOutput; export const CWD_OPTION_SCHEMA = strictObject({ cwd: optional( pipe( string(), description("The working directory to resolve tailwindcss and the config from. Useful in monorepo setups.") ), undefined ) }); export type CwdOption = InferOutput; ================================================ FILE: src/options/schemas/matchers.ts ================================================ import { description, literal, optional, pipe, strictObject, string } from "valibot"; import { MatcherType } from "better-tailwindcss:types/rule.js"; export const STRING_MATCHER_SCHEMA = strictObject({ match: pipe( literal(MatcherType.String), description("Matcher type that will be applied.") ) }); export const OBJECT_KEY_MATCHER_SCHEMA = strictObject({ match: pipe( literal(MatcherType.ObjectKey), description("Matcher type that will be applied.") ), pathPattern: optional(pipe( string(), description("Regular expression that filters the object key and matches the content for further processing in a group.") )) }); export const OBJECT_VALUE_MATCHER_SCHEMA = strictObject({ match: pipe( literal(MatcherType.ObjectValue), description("Matcher type that will be applied.") ), pathPattern: optional(pipe( string(), description("Regular expression that filters the object value and matches the content for further processing in a group.") )) }); ================================================ FILE: src/options/schemas/selectors.ts ================================================ import { array, description, literal, number, optional, pipe, strictObject, string, union } from "valibot"; import { DEFAULT_SELECTORS } from "better-tailwindcss:options/default-options.js"; import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { InferOutput } from "valibot"; const STRING_SELECTOR_MATCHER_SCHEMA = strictObject({ type: pipe( literal(MatcherType.String), description("Matcher type that will be applied.") ) }); const OBJECT_KEY_SELECTOR_MATCHER_SCHEMA = strictObject({ path: optional(pipe( string(), description("Regular expression that filters the object key and matches the content for further processing in a group.") )), type: pipe( literal(MatcherType.ObjectKey), description("Matcher type that will be applied.") ) }); const OBJECT_VALUE_SELECTOR_MATCHER_SCHEMA = strictObject({ path: optional(pipe( string(), description("Regular expression that filters the object value and matches the content for further processing in a group.") )), type: pipe( literal(MatcherType.ObjectValue), description("Matcher type that will be applied.") ) }); const ANONYMOUS_FUNCTION_RETURN_SELECTOR_MATCHER_SCHEMA = strictObject({ match: pipe( array(union([ STRING_SELECTOR_MATCHER_SCHEMA, OBJECT_KEY_SELECTOR_MATCHER_SCHEMA, OBJECT_VALUE_SELECTOR_MATCHER_SCHEMA ])), description("List of nested matchers that target the return value of anonymous functions.") ), type: pipe( literal(MatcherType.AnonymousFunctionReturn), description("Matcher type that will be applied.") ) }); const SELECTOR_MATCH_SCHEMA = pipe( optional( array( union([ STRING_SELECTOR_MATCHER_SCHEMA, OBJECT_KEY_SELECTOR_MATCHER_SCHEMA, OBJECT_VALUE_SELECTOR_MATCHER_SCHEMA, ANONYMOUS_FUNCTION_RETURN_SELECTOR_MATCHER_SCHEMA ]) ) ), description("Optional list of matchers that will be applied.") ); const SELECTOR_NAME_SCHEMA = pipe( string(), description("Regular expression for names that should be linted.") ); const CALLEE_SELECTOR_PATH_SCHEMA = pipe( string(), description("Regular expression for callee paths that should be linted.") ); const TAG_SELECTOR_PATH_SCHEMA = pipe( string(), description("Regular expression for tag paths that should be linted.") ); const CALLEE_SELECTOR_TARGET_VALUE_SCHEMA = optional( union([ literal("all"), literal("first"), literal("last"), number() ]) ); const CALLEE_SELECTOR_TARGET_ARGUMENT_SCHEMA = pipe( CALLEE_SELECTOR_TARGET_VALUE_SCHEMA, description("Optional argument target for call arguments: index, first, last, or all.") ); const CALLEE_SELECTOR_TARGET_CALL_SCHEMA = pipe( CALLEE_SELECTOR_TARGET_VALUE_SCHEMA, description("Optional call target for curried callees: index, first, last, or all.") ); const CALLEE_SELECTOR_LEGACY_CALL_TARGET_SCHEMA = pipe( optional( union([ literal("all"), literal("first"), literal("last"), number() ]) ), description("Optional call target for curried callees: index, first, last, or all.") ); const ATTRIBUTE_SELECTOR_SCHEMA = strictObject({ kind: pipe( literal(SelectorKind.Attribute), description("Selector kind that determines where matching is applied.") ), match: SELECTOR_MATCH_SCHEMA, name: SELECTOR_NAME_SCHEMA }); const CALLEE_SELECTOR_SCHEMA = union([ strictObject({ callTarget: CALLEE_SELECTOR_LEGACY_CALL_TARGET_SCHEMA, kind: pipe( literal(SelectorKind.Callee), description("Selector kind that determines where matching is applied.") ), match: SELECTOR_MATCH_SCHEMA, name: SELECTOR_NAME_SCHEMA, path: optional(CALLEE_SELECTOR_PATH_SCHEMA), targetArgument: CALLEE_SELECTOR_TARGET_ARGUMENT_SCHEMA, targetCall: CALLEE_SELECTOR_TARGET_CALL_SCHEMA }), strictObject({ callTarget: CALLEE_SELECTOR_LEGACY_CALL_TARGET_SCHEMA, kind: pipe( literal(SelectorKind.Callee), description("Selector kind that determines where matching is applied.") ), match: SELECTOR_MATCH_SCHEMA, name: optional(SELECTOR_NAME_SCHEMA), path: CALLEE_SELECTOR_PATH_SCHEMA, targetArgument: CALLEE_SELECTOR_TARGET_ARGUMENT_SCHEMA, targetCall: CALLEE_SELECTOR_TARGET_CALL_SCHEMA }) ]); const TAG_SELECTOR_SCHEMA = union([ strictObject({ kind: pipe( literal(SelectorKind.Tag), description("Selector kind that determines where matching is applied.") ), match: SELECTOR_MATCH_SCHEMA, name: SELECTOR_NAME_SCHEMA, path: optional(TAG_SELECTOR_PATH_SCHEMA) }), strictObject({ kind: pipe( literal(SelectorKind.Tag), description("Selector kind that determines where matching is applied.") ), match: SELECTOR_MATCH_SCHEMA, name: optional(SELECTOR_NAME_SCHEMA), path: TAG_SELECTOR_PATH_SCHEMA }) ]); const VARIABLE_SELECTOR_SCHEMA = strictObject({ kind: pipe( literal(SelectorKind.Variable), description("Selector kind that determines where matching is applied.") ), match: SELECTOR_MATCH_SCHEMA, name: SELECTOR_NAME_SCHEMA }); export const SELECTOR_SCHEMA = union([ ATTRIBUTE_SELECTOR_SCHEMA, CALLEE_SELECTOR_SCHEMA, TAG_SELECTOR_SCHEMA, VARIABLE_SELECTOR_SCHEMA ]); export type Selector = InferOutput; export const SELECTORS_SCHEMA = pipe( array(SELECTOR_SCHEMA), description("Flat list of selectors that should get linted.") ); export type Selectors = InferOutput; export const SELECTORS_OPTION_SCHEMA = strictObject({ selectors: optional(SELECTORS_SCHEMA, DEFAULT_SELECTORS) }); export type SelectorsOptions = InferOutput; ================================================ FILE: src/options/schemas/tags.ts ================================================ import { array, description, optional, pipe, strictObject, string, tuple, union } from "valibot"; import { OBJECT_KEY_MATCHER_SCHEMA, OBJECT_VALUE_MATCHER_SCHEMA, STRING_MATCHER_SCHEMA } from "better-tailwindcss:options/schemas/matchers.js"; import type { InferOutput } from "valibot"; const TAG_MATCHER_CONFIG = pipe( tuple([ pipe( string(), description("Template literal tag for which children get linted if matched.") ), pipe( array( union([ STRING_MATCHER_SCHEMA, OBJECT_KEY_MATCHER_SCHEMA, OBJECT_VALUE_MATCHER_SCHEMA ]) ), description("List of matchers that will be applied.") ) ]), description("List of matchers that will automatically be matched.") ); export type TagMatchers = InferOutput; const TAG_NAME_CONFIG = pipe( string(), description("Template literal tag that should get linted.") ); export type TagName = InferOutput; export const TAGS_SCHEMA = pipe( array( union([ TAG_MATCHER_CONFIG, TAG_NAME_CONFIG ]) ), description("List of template literal tags that should get linted.") ); export type Tags = InferOutput; export const TAGS_OPTIONS_SCHEMA = strictObject({ tags: optional(TAGS_SCHEMA) }); export type TagsOptions = InferOutput; ================================================ FILE: src/options/schemas/variables.ts ================================================ import { array, description, optional, pipe, strictObject, string, tuple, union } from "valibot"; import { OBJECT_KEY_MATCHER_SCHEMA, OBJECT_VALUE_MATCHER_SCHEMA, STRING_MATCHER_SCHEMA } from "better-tailwindcss:options/schemas/matchers.js"; import type { InferOutput } from "valibot"; export const VARIABLE_MATCHER_CONFIG = pipe( tuple([ pipe( string(), description("Variable name for which children get linted if matched.") ), pipe( array( union([ STRING_MATCHER_SCHEMA, OBJECT_KEY_MATCHER_SCHEMA, OBJECT_VALUE_MATCHER_SCHEMA ]) ), description("List of matchers that will be applied.") ) ]), description("List of matchers that will automatically be matched.") ); export type VariableMatchers = InferOutput; export const VARIABLE_NAME_CONFIG = pipe( string(), description("Variable name for which children get linted.") ); export type VariableName = InferOutput; export const VARIABLES_SCHEMA = pipe( array( union([ VARIABLE_MATCHER_CONFIG, VARIABLE_NAME_CONFIG ]) ), description("List of variable names which values should get linted.") ); export type Variables = InferOutput; export const VARIABLES_OPTION_SCHEMA = strictObject({ variables: optional(VARIABLES_SCHEMA) }); export type VariablesOptions = InferOutput; ================================================ FILE: src/options/tags/twc.test.ts ================================================ import { describe, it } from "vitest"; import { TWC_CALLEE_STRINGS, TWC_TAG } from "better-tailwindcss:options/tags/twc.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("twc", () => { it("should lint tagged template literals on member expressions", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: "twc.div` lint `;", jsxOutput: "twc.div`lint`;", svelte: "", svelteOutput: "", vue: "", vueOutput: "", errors: 2, options: [{ selectors: [TWC_TAG] }] } ] }); }); it("should lint tagged template literals on call expressions", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: "twc(Card)` lint `;", jsxOutput: "twc(Card)`lint`;", svelte: "", svelteOutput: "", vue: "", vueOutput: "", errors: 2, options: [{ selectors: [TWC_TAG] }] } ] }); }); it("should lint strings inside arrow function callbacks", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `twc.div(({ $active }) => [" lint ", $active && " lint "]);`, jsxOutput: `twc.div(({ $active }) => ["lint", $active && "lint"]);`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [TWC_CALLEE_STRINGS] }] } ] }); }); it("should lint strings inside arrow function callbacks with block body", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `twc.div(({ $active }) => { return [" lint ", $active && " lint "]; });`, jsxOutput: `twc.div(({ $active }) => { return ["lint", $active && "lint"]; });`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [TWC_CALLEE_STRINGS] }] } ] }); }); it("should lint strings in conditional arrow function returns", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `twc.div(({ $active }) => $active ? " lint " : " lint2 ");`, jsxOutput: `twc.div(({ $active }) => $active ? "lint" : "lint2");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [TWC_CALLEE_STRINGS] }] } ] }); }); it("should lint strings with multiple return paths in block body", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `twc.div(({ $active }) => { if($active) { return " lint "; } return " lint2 "; });`, jsxOutput: `twc.div(({ $active }) => { if($active) { return "lint"; } return "lint2"; });`, errors: 4, options: [{ selectors: [TWC_CALLEE_STRINGS] }] } ] }); }); it("should not lint strings that are not inside twc member expressions", () => { lint(noUnnecessaryWhitespace, { valid: [ { jsx: `other.div(({ $active }) => [" ignore ", $active && " ignore "]);`, svelte: ``, vue: ``, options: [{ selectors: [TWC_CALLEE_STRINGS] }] } ] }); }); }); ================================================ FILE: src/options/tags/twc.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors, TagSelector } from "better-tailwindcss:types/rule.js"; export const TWC_TAG = { kind: SelectorKind.Tag, path: "twc(\\.\\w+)?" } satisfies TagSelector; export const TWC_CALLEE_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String }, { match: [{ type: MatcherType.String }], type: MatcherType.AnonymousFunctionReturn } ], path: "^twc\\.\\w+" } satisfies CalleeSelector; /** @see https://github.com/gregberge/twc */ export const TWC = [ TWC_TAG, TWC_CALLEE_STRINGS ] satisfies Selectors; ================================================ FILE: src/options/tags/twx.test.ts ================================================ import { describe, it } from "vitest"; import { TWX_CALLEE_STRINGS, TWX_TAG } from "better-tailwindcss:options/tags/twx.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("twx", () => { it("should lint tagged template literals on member expressions", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: "twx.div` lint `;", jsxOutput: "twx.div`lint`;", svelte: "", svelteOutput: "", vue: "", vueOutput: "", errors: 2, options: [{ selectors: [TWX_TAG] }] } ] }); }); it("should lint tagged template literals on call expressions", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: "twx(Card)` lint `;", jsxOutput: "twx(Card)`lint`;", svelte: "", svelteOutput: "", vue: "", vueOutput: "", errors: 2, options: [{ selectors: [TWX_TAG] }] } ] }); }); it("should lint strings inside arrow function callbacks", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `twx.div(({ $active }) => [" lint ", $active && " lint "]);`, jsxOutput: `twx.div(({ $active }) => ["lint", $active && "lint"]);`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [TWX_CALLEE_STRINGS] }] } ] }); }); it("should lint strings inside arrow function callbacks with block body", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `twx.span(({ $active }) => { return [" lint ", $active && " lint "]; });`, jsxOutput: `twx.span(({ $active }) => { return ["lint", $active && "lint"]; });`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [TWX_CALLEE_STRINGS] }] } ] }); }); it("should not lint strings that are not inside twx member expressions", () => { lint(noUnnecessaryWhitespace, { valid: [ { jsx: `other.div(({ $active }) => [" ignore ", $active && " ignore "]);`, svelte: ``, vue: ``, options: [{ selectors: [TWX_CALLEE_STRINGS] }] } ] }); }); }); ================================================ FILE: src/options/tags/twx.ts ================================================ import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; import type { CalleeSelector, Selectors, TagSelector } from "better-tailwindcss:types/rule.js"; export const TWX_TAG = { kind: SelectorKind.Tag, path: "twx(\\.\\w+)?" } satisfies TagSelector; export const TWX_CALLEE_STRINGS = { kind: SelectorKind.Callee, match: [ { type: MatcherType.String }, { match: [{ type: MatcherType.String }], type: MatcherType.AnonymousFunctionReturn } ], path: "^twx\\.\\w+" } satisfies CalleeSelector; /** @see https://github.com/gregberge/twc */ export const TWX = [ TWX_TAG, TWX_CALLEE_STRINGS ] satisfies Selectors; ================================================ FILE: src/parsers/angular.test.ts ================================================ import { describe, it } from "vitest"; import { enforceConsistentClassOrder } from "better-tailwindcss:rules/enforce-consistent-class-order.js"; import { noRestrictedClasses } from "better-tailwindcss:rules/no-restricted-classes.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { dedent } from "better-tailwindcss:tests/utils/template.js"; import { MatcherType } from "better-tailwindcss:types/rule.js"; describe("angular", () => { describe("defaults", () => { it("should support normal classes", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 1, options: [{ order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 1, options: [{ order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 1, options: [{ order: "asc" }] } ] }); }); it("should support array binding in [class] and [ngClass]", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 2, options: [{ order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 2, options: [{ order: "asc" }] } ] }); }); it("should support expressions in literal arrays", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 3, options: [{ order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 3, options: [{ order: "asc" }] } ] }); }); it("should support object keys in [class] and [ngClass]", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 2, options: [{ order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 2, options: [{ order: "asc" }] } ] }); }); }); describe("names", () => { it("should match attribute names via names regex", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 1, options: [{ attributes: [".*Attribute"], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 1, options: [{ attributes: ["class"], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 1, options: [{ attributes: ["\\[class\\]"], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 1, options: [{ attributes: ["\\[ngClass\\]"], order: "asc" }] } ] }); }); }); describe("matchers", () => { describe("string", () => { it("should match attribute names via matchers", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 1, options: [{ attributes: [["class", [{ match: MatcherType.String }]]], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 1, options: [{ attributes: [["\\[class\\]", [{ match: MatcherType.String }]]], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 1, options: [{ attributes: [["\\[ngClass\\]", [{ match: MatcherType.String }]]], order: "asc" }] } ] }); }); }); describe("object keys", () => { it("should match object keys", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 2, options: [{ attributes: [ [ "\\[class\\]", [ { match: MatcherType.ObjectKey } ] ] ], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 2, options: [{ attributes: [ [ "\\[ngClass\\]", [ { match: MatcherType.ObjectKey } ] ] ], order: "asc" }] } ] }); }); it("should still match the object key when there is a value with the same content", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 2, options: [{ attributes: [ [ "\\[class\\]", [ { match: MatcherType.ObjectKey } ] ] ], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 2, options: [{ attributes: [ [ "\\[ngClass\\]", [ { match: MatcherType.ObjectKey } ] ] ], order: "asc" }] } ] }); }); it("should not lint literals in binary comparisons", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 1, options: [{ order: "asc" }] } ] }); }); }); describe("object values", () => { // this is not used by angular, but matchers should still be able to handle it it("should support object values", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 2, options: [{ attributes: [["\\[ngClass\\]", [ { match: MatcherType.ObjectValue } ]]], order: "asc" }] } ] }); }); }); describe("arrays", () => { it("should support arrays", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 2, options: [{ attributes: [["\\[class\\]", [{ match: MatcherType.String }]]], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 2, options: [{ attributes: [["\\[ngClass\\]", [{ match: MatcherType.String }]]], order: "asc" }] } ] }); }); it("should support expressions in arrays", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 3, options: [{ attributes: [["\\[class\\]", [{ match: MatcherType.String }]]], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 3, options: [{ attributes: [["\\[ngClass\\]", [{ match: MatcherType.String }]]], order: "asc" }] } ] }); }); }); describe("expressions", () => { it("should lint classes returned from expressions", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 2, options: [{ attributes: [["class", [{ match: MatcherType.String }]]], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 2, options: [{ attributes: [["\\[class\\]", [{ match: MatcherType.String }]]], order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 2, options: [{ attributes: [["\\[ngClass\\]", [{ match: MatcherType.String }]]], order: "asc" }] } ] }); }); }); describe("template literals", () => { it("should support template literals in interpolated class", () => { lint(enforceConsistentClassOrder, { invalid: [ // 1st pass of multi pass fix { angular: "", angularOutput: "", errors: 3, options: [{ order: "asc" }] }, // 2nd pass of multi pass fix { angular: "", angularOutput: "", errors: 1, options: [{ order: "asc" }] } ] }); }); it("should support short circuiting", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: "", angularOutput: "", errors: 1, options: [{ attributes: [["\\[class\\]", [{ match: MatcherType.String }]]], order: "asc" }] }, { angular: "", angularOutput: "", errors: 1, options: [{ attributes: [["\\[ngClass\\]", [{ match: MatcherType.String }]]], order: "asc" }] } ] }); }); it("should lint classes around expressions", () => { lint(enforceConsistentClassOrder, { invalid: [ // 1st pass of multi pass fix { angular: "", angularOutput: "", errors: 3, options: [{ attributes: [["\\[class\\]", [{ match: MatcherType.String }]]], order: "asc" }] }, // 2nd pass of multi pass fix { angular: "", angularOutput: "", errors: 1, options: [{ attributes: [["\\[class\\]", [{ match: MatcherType.String }]]], order: "asc" }] }, // 1st pass of multi pass fix { angular: "", angularOutput: "", errors: 3, options: [{ attributes: [["\\[ngClass\\]", [{ match: MatcherType.String }]]], order: "asc" }] }, // 2nd pass of multi pass fix { angular: "", angularOutput: "", errors: 1, options: [{ attributes: [["\\[ngClass\\]", [{ match: MatcherType.String }]]], order: "asc" }] } ] }); }); it("should support multiline template literal types", () => { const dirty = dedent` b a d c `; const clean = dedent` a b c d `; lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 1, options: [{ order: "asc" }] }, { angular: ``, angularOutput: ``, errors: 1, options: [{ order: "asc" }] } ] }); }); it("should not crash on dynamic [class] expression (no literal)", () => { lint(enforceConsistentClassOrder, { valid: [ { // Dynamic computed class expression – previously could crash parser // when parent.source was unavailable. angular: `` } ] }); }); it("should not crash on dynamic [ngClass] expression (no literal)", () => { lint(enforceConsistentClassOrder, { valid: [ { angular: `` } ] }); }); it("should continue handling literal map keys without crashing", () => { lint(enforceConsistentClassOrder, { invalid: [ { // Sanity check: object-literal keys are still parsed and reordered angular: ``, angularOutput: ``, errors: 2, options: [{ order: "asc" }] } ] }); }); }); }); // #177 it("should be able to differentiate between overlapping object keys", () => { lint(noUnnecessaryWhitespace, { invalid: [ { angular: ``, angularOutput: ``, errors: 2 } ] }); }); it("should correctly create object paths", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, errors: 1, options: [{ attributes: [ ["\\[class\\]", [ { match: MatcherType.ObjectValue, pathPattern: `root.nested\\["level-2"\\]\\[\\d+\\].matched` } ]] ], order: "asc" }] } ] }); }); // #274 it("should support bound attribute names", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, angularOutput: ``, errors: 1, options: [{ restrict: [ { fix: "allowed", pattern: "restricted" } ] }] } ] }); }); }); ================================================ FILE: src/parsers/angular.ts ================================================ import { MATCHER_RESULT, MatcherType } from "better-tailwindcss:types/rule.js"; import { getLocByRange } from "better-tailwindcss:utils/ast.js"; import { getLiteralNodesByMatchers, matchesPathPattern } from "better-tailwindcss:utils/matchers.js"; import { addAttribute, createObjectPathElement, deduplicateLiterals, getIndentation, getQuotes, getWhitespace, matchesName } from "better-tailwindcss:utils/utils.js"; import type { AST, ASTWithSource, Binary, Call, Conditional, Interpolation, LiteralArray, LiteralMap, LiteralMapPropertyKey, LiteralPrimitive, ParseSourceSpan, TemplateLiteral, TemplateLiteralElement, TmplAstBoundAttribute, TmplAstElement, TmplAstNode, TmplAstTextAttribute } from "@angular/compiler"; import type { Rule } from "eslint"; import type { SourceLocation } from "estree"; import type { BracesMeta, Literal } from "better-tailwindcss:types/ast.js"; import type { AttributeSelector, MatcherFunctions, SelectorMatcher } from "better-tailwindcss:types/rule.js"; // https://angular.dev/api/common/NgClass // https://angular.dev/guide/templates/binding#css-class-and-style-property-bindings export function getAttributesByAngularElement(ctx: Rule.RuleContext, node: TmplAstElement): (TmplAstBoundAttribute | TmplAstTextAttribute)[] { return [ ...node.attributes, ...node.inputs ]; } export function getLiteralsByAngularAttribute(ctx: Rule.RuleContext, attribute: TmplAstBoundAttribute | TmplAstTextAttribute, selectors: AttributeSelector[]): Literal[] { const name = getAttributeName(attribute); const literals = selectors.reduce((literals, selector) => { if(!matchesName(selector.name.toLowerCase(), name.toLowerCase())){ return literals; } if(!selector.match){ literals.push(...createLiteralsByAngularAttribute(ctx, attribute)); return literals; } if(isTextAttribute(attribute) && selector.match.some(matcher => matcher.type === MatcherType.String)){ literals.push(...createLiteralsByAngularTextAttribute(ctx, attribute)); } if(isBoundAttribute(attribute)){ if(isBoundAttributeName(attribute)){ literals.push(...getLiteralsByAngularMatchers(ctx, attribute, selector.match)); } else if(isASTWithSource(attribute.value)){ literals.push(...getLiteralsByAngularMatchers(ctx, attribute.value.ast, selector.match)); } } return literals; }, []); return literals .filter(deduplicateLiterals) .map(addAttribute(name)); } function createLiteralsByAngularAst(ctx: Rule.RuleContext, ast: AST): Literal[] { if(isInterpolation(ast)){ return ast.expressions.flatMap(expression => { return createLiteralsByAngularAst(ctx, expression); }); } if(isLiteralArray(ast)){ return ast.expressions.flatMap(expression => { return createLiteralsByAngularAst(ctx, expression); }); } if(isObjectKey(ast)){ return createLiteralByLiteralMapKey(ctx, ast); } if(isConditional(ast)){ return createLiteralsByAngularConditional(ctx, ast); } if(isLiteralPrimitive(ast)){ return createLiteralByAngularLiteralPrimitive(ctx, ast); } if(isTemplateLiteralElement(ast)){ return createLiteralByAngularTemplateLiteralElement(ctx, ast); } if(isBoundAttribute(ast) && isBoundAttributeName(ast)){ return createLiteralsByAngularBoundAttributeName(ctx, ast); } return []; } function createLiteralsByAngularConditional(ctx: Rule.RuleContext, conditional: Conditional): Literal[] { const literals: Literal[] = []; literals.push(...createLiteralsByAngularAst(ctx, conditional.trueExp)); literals.push(...createLiteralsByAngularAst(ctx, conditional.falseExp)); return literals; } function createLiteralsByAngularAttribute(ctx: Rule.RuleContext, attribute: TmplAstBoundAttribute | TmplAstTextAttribute): Literal[] { if(isTextAttribute(attribute)){ return createLiteralsByAngularTextAttribute(ctx, attribute); } if(isBoundAttribute(attribute) && isASTWithSource(attribute.value) && isLiteralPrimitive(attribute.value.ast)){ return createLiteralsByAngularAst(ctx, attribute.value.ast); } return []; } function getLiteralsByAngularMatchers(ctx: Rule.RuleContext, ast: AST | TmplAstBoundAttribute, matchers: SelectorMatcher[]): Literal[] { const matcherFunctions = getAngularMatcherFunctions(ctx, matchers); const matchingAstNodes = getLiteralNodesByMatchers(ctx, ast, matcherFunctions); const literals = matchingAstNodes.flatMap(ast => createLiteralsByAngularAst(ctx, ast)); return literals.filter(deduplicateLiterals); } function getAngularMatcherFunctions(ctx: Rule.RuleContext, matchers: SelectorMatcher[]): MatcherFunctions { return matchers.reduce((matcherFunctions, matcher) => { switch (matcher.type){ case MatcherType.String: { matcherFunctions.push(ast => { if( isAST(ast) && isCallExpression(ast) ){ return MATCHER_RESULT.UNCROSSABLE_BOUNDARY; } if( !isAST(ast) || isInsideConditionalExpressionCondition(ctx, ast) || isInsideLogicalExpressionLeft(ctx, ast) || isObjectKey(ast) || isInsideObjectValue(ctx, ast)){ return MATCHER_RESULT.NO_MATCH; } return isStringLike(ast) || isBoundAttributeName(ast); }); break; } case MatcherType.ObjectKey: { matcherFunctions.push(ast => { if(isAST(ast) && ( isCallExpression(ast) || isBoundAttributeName(ast) )){ return MATCHER_RESULT.UNCROSSABLE_BOUNDARY; } if( !isAST(ast) || !isObjectKey(ast) || isInsideConditionalExpressionCondition(ctx, ast) || isInsideLogicalExpressionLeft(ctx, ast)){ return MATCHER_RESULT.NO_MATCH; } const path = getAngularObjectPath(ctx, ast); if(!path || !matcher.path){ return MATCHER_RESULT.MATCH; } return matchesPathPattern(path, matcher.path); }); break; } case MatcherType.ObjectValue: { matcherFunctions.push(ast => { if(isAST(ast) && ( isCallExpression(ast) || isBoundAttributeName(ast) )){ return MATCHER_RESULT.UNCROSSABLE_BOUNDARY; } if( !isAST(ast) || !hasParent(ast) || !isInsideObjectValue(ctx, ast) || isInsideConditionalExpressionCondition(ctx, ast) || isInsideLogicalExpressionLeft(ctx, ast) || isObjectKey(ast) || !isStringLike(ast) ){ return MATCHER_RESULT.NO_MATCH; } const path = getAngularObjectPath(ctx, ast); if(!path || !matcher.path){ return MATCHER_RESULT.MATCH; } return matchesPathPattern(path, matcher.path); }); break; } } return matcherFunctions; }, []); } function getAngularObjectPath(ctx: Rule.RuleContext, ast: AST): string | undefined { const parent = findParent(ctx, ast); if(!parent){ return; } const paths: (string | undefined)[] = []; if(isObjectKey(ast)){ paths.unshift(createObjectPathElement(ast.key)); } if(isLiteralArray(parent)){ const index = parent.expressions.indexOf(ast); paths.unshift(`[${index}]`); } if(isLiteralMap(parent) && isInsideObjectValue(ctx, ast)){ const keyIndex = parent.values.indexOf(ast); const objectKey = parent.keys[keyIndex]; if(objectKey && isObjectKey(objectKey)){ paths.unshift(createObjectPathElement(objectKey.key)); } } paths.unshift(getAngularObjectPath(ctx, parent)); return paths.reduce((paths, currentPath) => { if(!currentPath){ return paths; } if(paths.length === 0){ return [currentPath]; } if(currentPath.startsWith("[") && currentPath.endsWith("]")){ return [...paths, currentPath]; } return [...paths, ".", currentPath]; }, []).join(""); } function createLiteralsByAngularBoundAttributeName(ctx: Rule.RuleContext, attribute: TmplAstBoundAttribute): Literal[] { if(!attribute.keySpan){ return []; } const content = attribute.name; const startOffset = attribute.keySpan.toString()?.indexOf(content) ?? 0; const start = attribute.keySpan.fullStart; const end = attribute.keySpan.end; const range = [start.offset + startOffset, end.offset] satisfies [number, number]; const raw = attribute.sourceSpan.start.file.content.slice(...range); const quotes = getQuotes(raw); const whitespaces = getWhitespace(content); const loc = convertParseSourceSpanToLoc(attribute.keySpan); loc.start.column += startOffset; loc.end.column = loc.start.column + content.length; const line = ctx.sourceCode.lines[loc.start.line - 1]; const indentation = getIndentation(line); const supportsMultiline = false; const concatenation = { isConcatenatedLeft: false, isConcatenatedRight: false }; return [{ ...quotes, ...whitespaces, ...concatenation, content, indentation, loc, range, raw, supportsMultiline, type: "StringLiteral" }]; } function createLiteralByLiteralMapKey(ctx: Rule.RuleContext, key: LiteralMapPropertyKey): Literal[] { // @ts-expect-error - angular types are faulty const literalMap = key?.parent as LiteralMap | undefined; // @ts-expect-error - angular types are faulty const objectContent = literalMap?.parent?.source; const keyContent = key?.key; const keyIndex = literalMap?.keys.indexOf(key); if(keyIndex === undefined || keyIndex === -1){ return []; } const previousValue = literalMap?.values[keyIndex - 1]; const value = literalMap?.values[keyIndex]; if(!literalMap?.sourceSpan || typeof objectContent !== "string" || typeof keyContent !== "string"){ return []; } const rangeStart = previousValue?.span?.end ?? 0; const rangeEnd = value?.span?.start ?? objectContent.length; const slice = objectContent.slice(rangeStart, rangeEnd); const start = rangeStart + slice.indexOf(keyContent) - (key.quoted ? 1 : 0); const end = start + keyContent.length + (key.quoted ? 1 : 0); const raw = objectContent.slice(start, end); const quotes = getQuotes(raw); const whitespaces = getWhitespace(keyContent); const range = [literalMap.sourceSpan.start + start, literalMap.sourceSpan.start + end] satisfies [number, number]; const loc = getLocByRange(ctx, range); const line = ctx.sourceCode.lines[loc.start.line - 1] ?? ""; const indentation = getIndentation(line); const concatenation = { isConcatenatedLeft: false, isConcatenatedRight: false }; return [{ ...quotes, ...whitespaces, ...concatenation, content: keyContent, indentation, loc, range, raw, supportsMultiline: false, type: "StringLiteral" }]; } function createLiteralsByAngularTextAttribute(ctx: Rule.RuleContext, attribute: TmplAstTextAttribute): Literal[] { const content = attribute.value; if(!attribute.valueSpan){ return []; } const start = attribute.valueSpan.fullStart; const end = attribute.valueSpan.end; const range = [start.offset - 1, end.offset + 1] satisfies [number, number]; const raw = attribute.sourceSpan.start.file.content.slice(...range); const quotes = getQuotes(raw); const whitespaces = getWhitespace(content); const loc = convertParseSourceSpanToLoc(attribute.valueSpan); const line = ctx.sourceCode.lines[loc.start.line - 1]; const indentation = getIndentation(line); const supportsMultiline = true; const concatenation = { isConcatenatedLeft: false, isConcatenatedRight: false }; return [{ ...quotes, ...whitespaces, ...concatenation, content, indentation, loc, range, raw, supportsMultiline, type: "StringLiteral" }]; } function createLiteralByAngularLiteralPrimitive(ctx: Rule.RuleContext, literal: LiteralPrimitive): Literal[] { const content = literal.value; if(!literal.sourceSpan || typeof content !== "string"){ return []; } const start = literal.sourceSpan.start; const end = literal.sourceSpan.end; const range = [start, end] satisfies [number, number]; const raw = ctx.sourceCode.text.slice(...range); const quotes = getQuotes(raw); const whitespaces = getWhitespace(content); const loc = getLocByRange(ctx, range); const line = ctx.sourceCode.lines[loc.start.line - 1]; const indentation = getIndentation(line); const concatenation = getStringConcatenationMeta(ctx, literal); const supportsMultiline = true; return [{ ...quotes, ...whitespaces, ...concatenation, content, indentation, loc, range, raw, supportsMultiline, type: "StringLiteral" }]; } function createLiteralByAngularTemplateLiteralElement(ctx: Rule.RuleContext, literal: TemplateLiteralElement): Literal[] { const content = literal.text; if(!literal.sourceSpan || !hasParent(literal)){ return []; } const braces = getBraces(literal); const isInterpolated = getIsInterpolated(literal); const start = literal.sourceSpan.start - (braces.closingBraces?.length ?? 0); const end = literal.sourceSpan.end + (braces.openingBraces?.length ?? 0); const range = [start, end] satisfies [number, number]; const raw = ctx.sourceCode.text.slice(...range); const quotes = getQuotes(raw); const whitespaces = getWhitespace(content); const loc = getLocByRange(ctx, range); const parent = literal.parent; const parentStart = parent.sourceSpan?.start; const parentEnd = parent.sourceSpan?.end; const parentRange = [parentStart, parentEnd] satisfies [number, number]; const parentLoc = getLocByRange(ctx, parentRange); const parentLine = ctx.sourceCode.lines[parentLoc.start.line - 1]; const indentation = getIndentation(parentLine); const supportsMultiline = true; const concatenation = getStringConcatenationMeta(ctx, literal); return [{ ...quotes, ...whitespaces, ...braces, ...concatenation, content, indentation, isInterpolated, loc, range, raw, supportsMultiline, type: "TemplateLiteral" }]; } function convertParseSourceSpanToLoc(sourceSpan: ParseSourceSpan): SourceLocation { return { end: { column: sourceSpan.end.col, line: sourceSpan.end.line + 1 }, start: { column: sourceSpan.fullStart.col, line: sourceSpan.fullStart.line + 1 } }; } function isInsideInlineTemplate(ctx: Rule.RuleContext) { return getInlineTemplateComponentIndex(ctx) !== undefined; } function getInlineTemplateComponentIndex(ctx: Rule.RuleContext) { const matches = ctx.filename.match(/^.*_inline-template-[\w.-]+-(\d+)\.component\.html$/); if(matches){ const [, index] = matches; return +index; } } function getBraces(literal: TemplateLiteralElement): BracesMeta { if(!hasParent(literal)){ return {}; } const parent = literal.parent as TemplateLiteral; const index = parent.elements.indexOf(literal); if(parent.elements.length === 1){ return {}; } return { closingBraces: index >= 1 ? "}" : undefined, openingBraces: index < parent.elements.length - 1 ? "${" : undefined }; } function getIsInterpolated(literal: TemplateLiteralElement): boolean { const braces = getBraces(literal); return !!braces.closingBraces || !!braces.openingBraces; } function getAttributeName(node: TmplAstBoundAttribute | TmplAstTextAttribute): string { if(!node.keySpan){ return node.name; } return node.sourceSpan.start.offset !== node.keySpan.start.offset ? node.sourceSpan.fullStart.file.content.slice(node.sourceSpan.start.offset, node.keySpan.end.offset + 1) : node.keySpan.toString() ?? node.name; } export type Parent = { parent: AST; }; function isInsideConditionalExpressionCondition(ctx: Rule.RuleContext, ast: AST): boolean { const parent = findParent(ctx, ast); if(!parent){ return false; } if(isConditional(parent) && parent.condition === ast){ return true; } return isInsideConditionalExpressionCondition(ctx, parent); } function isInsideLogicalExpressionLeft(ctx: Rule.RuleContext, ast: AST): boolean { const parent = findParent(ctx, ast); if(!parent){ return false; } if(isBinary(parent) && parent.operation === "&&" && parent.left === ast){ return true; } return isInsideConditionalExpressionCondition(ctx, parent); } function getStringConcatenationMeta(ctx: Rule.RuleContext, ast: AST, isConcatenatedLeft = false, isConcatenatedRight = false): { isConcatenatedLeft: boolean; isConcatenatedRight: boolean; } { const parent = findParent(ctx, ast); if(!parent){ return { isConcatenatedLeft, isConcatenatedRight }; } if(isBinary(parent) && parent.operation === "+"){ return getStringConcatenationMeta( ctx, parent, isConcatenatedLeft || parent.right === ast, isConcatenatedRight || parent.left === ast ); } return getStringConcatenationMeta(ctx, parent, isConcatenatedLeft, isConcatenatedRight); } function isInsideObjectValue(ctx: Rule.RuleContext, ast: AST): boolean { const parent = findParent(ctx, ast); if(!parent){ return false; } // #34 allow call expressions as object values if(isCallExpression(ast)){ return false; } if(isObjectValue(ast)){ return true; } if(isLiteralMap(parent) && parent.values.includes(ast)){ return true; } return isInsideObjectValue(ctx, parent); } function isStringLike(ast: AST): ast is LiteralPrimitive | TemplateLiteralElement { return isStringLiteral(ast) || isTemplateLiteralElement(ast); } function hasParent(ast: AST): ast is AST & Parent { return "parent" in ast && ast.parent !== undefined; } /** * The angular parser doesn't provide parent references for all nodes. This function traverses the entire AST * to find the parent node of the given AST reference. * * @param ctx The ESLint rule context. * @param astNode The AST node to find the parent for. * @returns The parent AST node, or undefined if not found. */ function findParent(ctx: Rule.RuleContext, astNode: AST): AST | undefined { if(hasParent(astNode)){ return astNode.parent; } const ast = ctx.sourceCode.ast; const visitChildNode = (childNode: unknown) => { if(!childNode || typeof childNode !== "object"){ return; } for(const key in childNode){ if(key === "parent"){ continue; } if(childNode[key] === astNode){ return childNode; } const result = visitChildNode(childNode[key]); if(result){ return result; } } }; return visitChildNode(ast); } function isBoundAttributeName(ast: AST | TmplAstBoundAttribute | TmplAstTextAttribute): boolean { return isBoundAttribute(ast) && getAttributeName(ast)?.startsWith("[class."); } function isObjectValue(ast: AST): ast is LiteralPrimitive { return isStringLiteral(ast) && hasParent(ast) && isLiteralMap(ast.parent); } function isObjectKey(ast: Record): ast is LiteralMapPropertyKey { return "type" in ast && ast.type === "Object" && "key" in ast && ast.key !== undefined; } function isStringLiteral(ast: AST): ast is LiteralPrimitive { return isLiteralPrimitive(ast) && typeof ast.value === "string"; } export function isAST(ast: unknown): ast is AST { return typeof ast === "object" && ast !== null && "type" in ast; } function is(ast: AST | TmplAstNode, type: string): ast is Type { return "type" in ast && typeof ast.type === "string" && ast.type === type; } const isCallExpression = (ast: AST) => is(ast, "Call"); const isASTWithSource = (ast: AST) => is(ast, "ASTWithSource"); const isInterpolation = (ast: AST) => is(ast, "Interpolation"); const isConditional = (ast: AST) => is(ast, "Conditional"); const isBinary = (ast: AST) => is(ast, "Binary"); const isLiteralArray = (ast: AST) => is(ast, "LiteralArray"); const isLiteralMap = (ast: AST) => is(ast, "LiteralMap"); const isTemplateLiteral = (ast: AST) => is(ast, "TemplateLiteral"); const isTemplateLiteralElement = (ast: AST) => is(ast, "TemplateLiteralElement"); const isLiteralPrimitive = (ast: AST) => is(ast, "LiteralPrimitive"); const isTextAttribute = (ast: AST | TmplAstBoundAttribute | TmplAstTextAttribute) => is(ast, "TextAttribute"); const isBoundAttribute = (ast: AST | TmplAstBoundAttribute | TmplAstTextAttribute) => is(ast, "BoundAttribute"); ================================================ FILE: src/parsers/css.test.ts ================================================ import { describe, it } from "vitest"; import { enforceConsistentLineWrapping } from "better-tailwindcss:rules/enforce-consistent-line-wrapping.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("css", () => { it("should lint single classes", () => { lint(noUnnecessaryWhitespace, { invalid: [ { css: `@utility test { @apply lint ; }`, cssOutput: `@utility test { @apply lint; }`, errors: 2 } ] }); }); it("should lint multiple classes", () => { lint(noUnnecessaryWhitespace, { invalid: [ { css: `@utility test { @apply lint lint ; }`, cssOutput: `@utility test { @apply lint lint; }`, errors: 3 } ] }); }); it("should lint multiline class lists", () => { lint(enforceConsistentLineWrapping, { invalid: [ { css: ` @utility test { @apply lint hover:lint ; } `, cssOutput: ` @utility test { @apply lint hover:lint ; } `, errors: 1 } ] }); }); it("should support raw prelude", () => { lint(noUnnecessaryWhitespace, { invalid: [ { css: `@utility test { @apply text-[red] ; }`, cssOutput: `@utility test { @apply text-[red]; }`, errors: 2 } ] }); }); it("should add a whitespace when collapsing to a single line", () => { lint(enforceConsistentLineWrapping, { invalid: [ { css: ` @utility test { @apply text-red-500 ; } `, cssOutput: ` @utility test { @apply text-red-500; } `, errors: 1 } ] }); }); }); ================================================ FILE: src/parsers/css.ts ================================================ import { getIndentation, getWhitespace } from "better-tailwindcss:utils/utils.js"; import type { Atrule } from "@eslint/css-tree"; import type { Rule } from "eslint"; import type { CSSClassListLiteral, Literal, Loc, Range } from "better-tailwindcss:types/ast.js"; export function getLiteralsByCSSAtRule(ctx: Rule.RuleContext, node: Atrule): Literal[] { const literals: Literal[] = []; if(node.name !== "apply"){ return []; } if(node.prelude?.type === "AtrulePrelude" || node.prelude?.type === "Raw"){ const literal = getLiteralsByAtrule(ctx, node); if(literal){ literals.push(literal); } } return literals; } function getLiteralsByAtrule(ctx: Rule.RuleContext, node: Atrule): CSSClassListLiteral | undefined { // @ts-expect-error - CSS Tree types are different const raw = ctx.sourceCode.getText(node); const match = raw.match(/^(?@apply[\t ](?!\r?\n)|@apply(?=\s))(?.+?)(?;?\s*)$/s); if(!match?.groups?.leadingApply || !match.groups.content || match.groups.trailingSemicolon === undefined){ return; } const { content, leadingApply, trailingSemicolon } = match.groups; const startOffset = leadingApply.length; const endOffset = trailingSemicolon.length; const loc = getLoc(ctx, node, startOffset, endOffset); const range = getRange(ctx, node, startOffset, endOffset); if(!loc){ return; } const line = ctx.sourceCode.lines[node.loc!.start.line - 1]; const indentation = getIndentation(line); const whitespaces = getWhitespace(content); const type = "CSSClassListLiteral"; return { ...whitespaces, content, indentation, isInterpolated: false, leadingApply, loc, range, raw: content, supportsMultiline: true, trailingSemicolon, type }; } function getLoc(ctx: Rule.RuleContext, node: Atrule, startOffset: number, endOffset: number): Loc["loc"] | undefined { if(!node.loc){ return; } return { end: { column: node.loc.end.column - endOffset, line: node.loc.end.line }, start: { column: node.loc.start.column + startOffset, line: node.loc.start.line } }; } function getRange(ctx: Rule.RuleContext, node: Atrule, startOffset: number, endOffset: number): Range["range"] { const range = ctx.sourceCode // @ts-expect-error - CSS Tree types are different .getRange(node); return [ range[0] + startOffset, range[1] - endOffset ]; } ================================================ FILE: src/parsers/es.test.ts ================================================ import { describe, it } from "vitest"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; describe("es", () => { it("should match callees names via regex", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(" lint ");`, jsxOutput: `testStyles("lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ callees: ["^.*Styles$"] }] } ] }); }); it("should support callee target last for curried calls", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(" keep ")(" lint ");`, jsxOutput: `testStyles(" keep ")("lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { callTarget: "last", kind: SelectorKind.Callee, name: "^testStyles$" } ] }] } ] }); }); it("should support callee target all for curried calls", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(" first ")(" second ");`, jsxOutput: `testStyles("first")("second");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [ { callTarget: "all", kind: SelectorKind.Callee, name: "^testStyles$" } ] }] } ] }); }); it("should support numeric and negative callee targets", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(" keep ")(" middle ")(" lint ");`, jsxOutput: `testStyles(" keep ")(" middle ")("lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { callTarget: -1, kind: SelectorKind.Callee, name: "^testStyles$" } ] }] } ], valid: [ { jsx: `testStyles(" keep ")(" middle ")(" lint ");`, svelte: ``, vue: ``, options: [{ selectors: [ { callTarget: 5, kind: SelectorKind.Callee, name: "^testStyles$" } ] }] } ] }); }); it("should support targetCall for curried calls", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(" keep ")(" lint ");`, jsxOutput: `testStyles(" keep ")("lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$", targetCall: "last" } ] }] } ] }); }); it("should lint all targetArguments by default", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(" lint ", " lint ");`, jsxOutput: `testStyles("lint", "lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$" } ] }] } ] }); }); it("should support targetArgument for direct callee arguments", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(" lint ", " keep ");`, jsxOutput: `testStyles("lint", " keep ");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$", targetArgument: "first" } ] }] }, { jsx: `testStyles(" keep ", " lint ");`, jsxOutput: `testStyles(" keep ", "lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$", targetArgument: 1 } ] }] }, { jsx: `testStyles(...foo, " lint ");`, jsxOutput: `testStyles(...foo, "lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$", targetArgument: 1 } ] }] } ], valid: [ { jsx: `testStyles(" keep ", " keep ");`, svelte: ``, vue: ``, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$", targetArgument: 5 } ] }] }, { jsx: `testStyles(...foo, " keep ");`, svelte: ``, vue: ``, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$", targetArgument: 0 } ] }] } ] }); }); it("should apply first and last targetArgument to raw argument positions", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(" lint ", ...foo);`, jsxOutput: `testStyles("lint", ...foo);`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$", targetArgument: "first" } ] }] } ], valid: [ { jsx: `testStyles(...foo, " keep ");`, svelte: ``, vue: ``, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$", targetArgument: "first" } ] }] }, { jsx: `testStyles(" keep ", ...foo);`, svelte: ``, vue: ``, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$", targetArgument: "last" } ] }] } ] }); }); it("should support combining targetCall and targetArgument", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(" keep ", " ignore ")(" lint ", " keep ");`, jsxOutput: `testStyles(" keep ", " ignore ")("lint", " keep ");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Callee, name: "^testStyles$", targetArgument: "first", targetCall: "last" } ] }] } ] }); }); it("should apply matchers only inside selected arguments", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(...[{ objectKey: " lint " }], " keep ");`, jsxOutput: `testStyles(...[{ objectKey: "lint" }], " keep ");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Callee, match: [{ type: MatcherType.ObjectValue }], name: "^testStyles$", targetArgument: 0 } ] }] } ], valid: [ { jsx: `testStyles({ objectKey: " keep " }, " keep ");`, svelte: ``, vue: ``, options: [{ selectors: [ { kind: SelectorKind.Callee, match: [{ type: MatcherType.ObjectValue }], name: "^testStyles$", targetArgument: "last" } ] }] } ] }); }); it("should match anonymous arrow function returns", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(() => " lint ", () => { return " lint "; });`, jsxOutput: `testStyles(() => "lint", () => { return "lint"; });`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [{ kind: SelectorKind.Callee, match: [{ match: [{ type: MatcherType.String }], type: MatcherType.AnonymousFunctionReturn }], name: "^testStyles$" }] }] } ] }); }); it("should only match concise arrow returned expression", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles((param = " keep ") => " lint ");`, jsxOutput: `testStyles((param = " keep ") => "lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [{ kind: SelectorKind.Callee, match: [{ match: [{ type: MatcherType.String }], type: MatcherType.AnonymousFunctionReturn }], name: "^testStyles$" }] }] } ] }); }); it("should not match non-return literals inside anonymous arrow function block bodies", () => { lint(noUnnecessaryWhitespace, { valid: [ { jsx: `testStyles(() => { const value = " keep "; return value; });`, svelte: ``, vue: ``, options: [{ selectors: [{ kind: SelectorKind.Callee, match: [{ match: [{ type: MatcherType.String }], type: MatcherType.AnonymousFunctionReturn }], name: "^testStyles$" }] }] } ] }); }); it("should match anonymous normal function returns", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(function() { return " lint "; });`, jsxOutput: `testStyles(function() { return "lint"; });`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [{ kind: SelectorKind.Callee, match: [{ match: [{ type: MatcherType.String }], type: MatcherType.AnonymousFunctionReturn }], name: "^testStyles$" }] }] } ] }); }); it("should support all other nested matcher types inside anonymousFunctionReturn", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(() => ({ " objectKey ": " objectValue " }));`, jsxOutput: `testStyles(() => ({ "objectKey": "objectValue" }));`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4, options: [{ selectors: [{ kind: SelectorKind.Callee, match: [{ match: [ { type: MatcherType.ObjectKey }, { type: MatcherType.ObjectValue } ], type: MatcherType.AnonymousFunctionReturn }], name: "^testStyles$" }] }] }, { jsx: `testStyles(() => " string ");`, jsxOutput: `testStyles(() => "string");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [{ kind: SelectorKind.Callee, match: [{ match: [ { type: MatcherType.String } ], type: MatcherType.AnonymousFunctionReturn }], name: "^testStyles$" }] }] } ] }); }); it("should not cross function boundary twice for anonymousFunctionReturn", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `testStyles(() => { setTimeout(() => { return " keep "; }); return " lint "; });`, jsxOutput: `testStyles(() => { setTimeout(() => { return " keep "; }); return "lint"; });`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [{ kind: SelectorKind.Callee, match: [{ match: [ { type: MatcherType.String } ], type: MatcherType.AnonymousFunctionReturn }], name: "^testStyles$" }] }] } ] }); }); it("should match member expression callee names", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `const classes = []; classes.push(" lint ");`, jsxOutput: `const classes = []; classes.push("lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ callees: [[ "^classes\\.push$", [{ match: MatcherType.String }] ]] }] } ] }); }); it("should match nested member expression callee names", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `const foo = { bar: { push: (value) => value } }; foo.bar.push(" lint ");`, jsxOutput: `const foo = { bar: { push: (value) => value } }; foo.bar.push("lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ callees: [[ "^foo\\.bar\\.push$", [{ match: MatcherType.String }] ]] }] } ] }); }); it("should match callee selectors via path", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `const classes = ["keep"]; classes.push(" lint ");`, jsxOutput: `const classes = ["keep"]; classes.push("lint");`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Callee, match: [{ type: MatcherType.String }], path: "^classes\\.push$" } ] }] } ] }); }); it("should match variable names via regex", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `const testStyles = " lint ";`, jsxOutput: `const testStyles = "lint";`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ variables: ["^.*Styles$"] }] } ] }); }); it("should match default exports via variable selectors", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `export default " lint ";`, jsxOutput: `export default "lint";`, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Variable, name: "^default$" } ] }] } ] }); }); it("should not dereference exported default identifiers for variable selectors", () => { lint(noUnnecessaryWhitespace, { valid: [ { jsx: `const classes = " lint "; export default classes;`, options: [{ selectors: [ { kind: SelectorKind.Variable, name: "^default$" } ] }] } ] }); }); it("should match default-exported objects", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `export default { slots: { root: " lint ", icon: " keep " }, title: " keep " };`, jsxOutput: `export default { slots: { root: "lint", icon: " keep " }, title: " keep " };`, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Variable, match: [ { path: "^slots\\.root$", type: MatcherType.ObjectValue } ], name: "^default$" } ] }] } ] }); }); it("should match attributes via regex", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: ``, jsxOutput: ``, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ attributes: ["^.*Styles$"] }] } ] }); }); // #234 it("should ignore literals in binary comparisons", () => { lint(noUnnecessaryWhitespace, { valid: [ { jsx: ``, svelte: ``, vue: ``, options: [{ attributes: [[ "^v-bind:class$", [{ match: MatcherType.ObjectValue }] ], [ "class", [{ match: MatcherType.ObjectValue }] ]] }] } ] }); }); // #332 it("should not leak variable selectors into callee selectors when assigned to a variable", () => { lint(noUnnecessaryWhitespace, { valid: [ { jsx: `const variable = function func({ classes = " sm " }) {}`, options: [{ selectors: [{ kind: SelectorKind.Variable, match: [ { type: MatcherType.String } ], name: "^variable$" }] }] } ] }); }); it("should lint bare template literals with matching marker comments", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: "/* tw */` lint `;", jsxOutput: "/* tw */`lint`;", svelte: "", svelteOutput: "", vue: "", vueOutput: "", errors: 2, options: [{ selectors: [ { kind: SelectorKind.Tag, name: "^tw$" } ] }] }, { jsx: "/* tw */ ` lint `;", jsxOutput: "/* tw */ `lint`;", svelte: "", svelteOutput: "", vue: "", vueOutput: "", errors: 2, options: [{ selectors: [ { kind: SelectorKind.Tag, name: "^tw$" } ] }] } ] }); }); it("should ignore bare template literals when marker comments do not match or are not leading", () => { lint(noUnnecessaryWhitespace, { valid: [ { jsx: "/* tw */ const keep = true; ` lint `;", svelte: "", vue: "", options: [{ selectors: [ { kind: SelectorKind.Tag, name: "^tw$" } ] }] }, { jsx: "/* not-tailwind */ ` lint `;", svelte: "", vue: "", options: [{ selectors: [ { kind: SelectorKind.Tag, name: "^tw$" } ] }] } ] }); }); it("should only use the closest leading marker comment for bare template literals", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: "/* not-tailwind */ /* tw */ ` lint `;", jsxOutput: "/* not-tailwind */ /* tw */ `lint`;", svelte: "", svelteOutput: "", vue: "", vueOutput: "", errors: 2, options: [{ selectors: [ { kind: SelectorKind.Tag, name: "^tw$" } ] }] } ], valid: [ { jsx: "/* tw */ /* not-tailwind */ ` lint `;", svelte: "", vue: "", options: [{ selectors: [ { kind: SelectorKind.Tag, name: "^tw$" } ] }] } ] }); }); }); ================================================ FILE: src/parsers/es.ts ================================================ import { MATCHER_RESULT, MatcherType } from "better-tailwindcss:types/rule.js"; import { getLiteralNodesByMatchers, isIndexedAccessLiteral, isInsideConditionalExpressionTest, isInsideDisallowedBinaryExpression, isInsideLogicalExpressionLeft, isInsideMemberExpression, matchesPathPattern } from "better-tailwindcss:utils/matchers.js"; import { createObjectPathElement, deduplicateLiterals, getContent, getIndentation, getQuotes, getWhitespace, isGenericNodeWithParent, matchesName } from "better-tailwindcss:utils/utils.js"; import type { Rule } from "eslint"; import type { ArrowFunctionExpression as ESArrowFunctionExpression, BaseNode as ESBaseNode, CallExpression as ESCallExpression, ExportDefaultDeclaration as ESExportDefaultDeclaration, Expression as ESExpression, FunctionDeclaration as ESFunctionDeclaration, FunctionExpression as ESFunctionExpression, Identifier as ESIdentifier, MemberExpression as ESMemberExpression, Node as ESNode, SimpleLiteral as ESSimpleLiteral, SpreadElement as ESSpreadElement, TaggedTemplateExpression as ESTaggedTemplateExpression, TemplateElement as ESTemplateElement, TemplateLiteral as ESTemplateLiteral, VariableDeclarator as ESVariableDeclarator } from "estree"; import type { BracesMeta, Literal, LiteralValueQuotes, MultilineMeta, StringLiteral, TemplateLiteral } from "better-tailwindcss:types/ast.js"; import type { WithParent } from "better-tailwindcss:types/estree.js"; import type { ArgumentTarget, CalleeSelector, CallTarget, MatcherFunctions, SelectorMatcher, TagSelector, VariableSelector } from "better-tailwindcss:types/rule.js"; import type { GenericNodeWithParent } from "better-tailwindcss:utils/utils.js"; export const ES_CONTAINER_TYPES_TO_REPLACE_QUOTES: string[] = [ "ArrayExpression", "Property", "CallExpression", "VariableDeclarator", "ConditionalExpression", "LogicalExpression" ]; export const ES_CONTAINER_TYPES_TO_INSERT_BRACES: string[] = [ ]; export function getLiteralsByESVariableDeclarator(ctx: Rule.RuleContext, node: ESVariableDeclarator, selectors: VariableSelector[]): Literal[] { const literals = selectors.reduce((literals, selector) => { if(!node.init){ return literals; } if(!isESVariableSymbol(node.id)){ return literals; } if(!matchesName(selector.name, node.id.name)){ return literals; } if(!selector.match){ literals.push(...getLiteralsByESExpression(ctx, [node.init])); return literals; } if(isESArrowFunctionExpression(node.init) || isESCallExpression(node.init) || isESFunctionExpression(node.init)){ return literals; } literals.push(...getLiteralsByESMatchers(ctx, node.init, selector.match)); return literals; }, []); return literals.filter(deduplicateLiterals); } export function getLiteralsByESExportDefaultDeclaration(ctx: Rule.RuleContext, node: ESExportDefaultDeclaration, selectors: VariableSelector[]): Literal[] { const literals = selectors.reduce((literals, selector) => { if(!matchesName(selector.name, "default")){ return literals; } if(!isESExportDefaultExpression(node.declaration)){ return literals; } if(!selector.match){ literals.push(...getLiteralsByESExpression(ctx, [node.declaration])); return literals; } if(isESArrowFunctionExpression(node.declaration) || isESCallExpression(node.declaration) || isESFunctionExpression(node.declaration)){ return literals; } literals.push(...getLiteralsByESMatchers(ctx, node.declaration, selector.match)); return literals; }, []); return literals.filter(deduplicateLiterals); } export function getLiteralsByESCallExpression(ctx: Rule.RuleContext, node: ESCallExpression, selectors: CalleeSelector[]): Literal[] { if(isNestedCurriedCall(node)){ return []; } const callChain = getCurriedCallChain(node); if(!callChain){ return []; } const calleePath = getESCalleeName(callChain[0].callee, "path"); const calleeName = getESCalleeName(callChain[0].callee, "name"); const literals = selectors.reduce((literals, selector) => { if( !selector.path && !selector.name || (!selector.path || !matchesName(selector.path, calleePath)) && (!selector.name || !matchesName(selector.name, calleeName)) ){ return literals; } const targetCall = selector.targetCall ?? selector.callTarget; const targetCalls = getTargetCalls(callChain, targetCall); for(const targetCall of targetCalls){ const targetArguments = getTargetArguments(targetCall.arguments, selector.targetArgument); if(!selector.match){ literals.push(...getLiteralsByESExpression(ctx, targetArguments)); continue; } for(const targetArgument of targetArguments){ literals.push(...getLiteralsByESMatchers(ctx, targetArgument, selector.match)); } } return literals; }, []); return literals.filter(deduplicateLiterals); } export function getLiteralsByTaggedTemplateExpression(ctx: Rule.RuleContext, node: ESTaggedTemplateExpression, selectors: TagSelector[]): Literal[] { const tagPath = getTaggedTemplateName(node.tag, "path"); const tagName = getTaggedTemplateName(node.tag, "name"); if(!tagPath && !tagName){ return []; } const literals = selectors.reduce((literals, selector) => { if( !selector.path && !selector.name || (!selector.path || !matchesName(selector.path, tagPath)) && (!selector.name || !matchesName(selector.name, tagName)) ){ return literals; } if(!selector.match){ literals.push(...getLiteralsByESTemplateLiteral(ctx, node.quasi)); return literals; } literals.push(...getLiteralsByESMatchers(ctx, node, selector.match)); return literals; }, []); return literals.filter(deduplicateLiterals); } export function getLiteralsByESBareTemplateLiteral(ctx: Rule.RuleContext, node: ESTemplateLiteral, selectors: TagSelector[]): Literal[] { const leadingComment = getLeadingComment(ctx, node); if(isTaggedTemplateLiteral(node) || !leadingComment){ return [];} const literals = selectors.reduce((literals, selector) => { if(!selector.name || !matchesName(selector.name, leadingComment)){ return literals; } if(!selector.match){ literals.push(...getLiteralsByESTemplateLiteral(ctx, node)); return literals; } literals.push(...getLiteralsByESMatchers(ctx, node, selector.match)); return literals; }, []); return literals.filter(deduplicateLiterals); } export function getLiteralsByESLiteralNode(ctx: Rule.RuleContext, node: ESBaseNode): Literal[] { if(isESSimpleStringLiteral(node)){ const literal = getStringLiteralByESStringLiteral(ctx, node); return literal ? [literal] : []; } if(isESTemplateLiteral(node)){ return getLiteralsByESTemplateLiteral(ctx, node); } if(isESTemplateElement(node) && hasESNodeParentExtension(node)){ const literal = getLiteralByESTemplateElement(ctx, node); return literal ? [literal] : []; } return []; } export function getLiteralsByESMatchers(ctx: Rule.RuleContext, node: ESBaseNode, matchers: SelectorMatcher[]): Literal[] { const matcherFunctions = getESMatcherFunctions(matchers); const literalNodes = getLiteralNodesByMatchers(ctx, node, matcherFunctions); const literals = literalNodes.flatMap(literalNode => getLiteralsByESLiteralNode(ctx, literalNode)); return literals.filter(deduplicateLiterals); } export function getStringLiteralByESStringLiteral(ctx: Rule.RuleContext, node: ESSimpleStringLiteral): StringLiteral | undefined { const raw = node.raw; if(!raw || !node.loc || !node.range || !node.parent.loc || !node.parent.range){ return; } const line = ctx.sourceCode.lines[node.loc.start.line - 1]; const quotes = getQuotes(raw); const priorLiterals = findPriorLiterals(ctx, node); const content = getContent(raw, quotes); const whitespaces = getWhitespace(content); const indentation = getIndentation(line); const multilineQuotes = getMultilineQuotes(node); const supportsMultiline = !isESObjectKey(node); const concatenation = getStringConcatenationMeta(node); return { ...quotes, ...whitespaces, ...multilineQuotes, ...concatenation, content, indentation, isInterpolated: false, loc: node.loc, priorLiterals, range: node.range, raw, supportsMultiline, type: "StringLiteral" }; } function getLiteralByESTemplateElement(ctx: Rule.RuleContext, node: ESTemplateElement & Rule.Node): TemplateLiteral | undefined { const raw = ctx.sourceCode.getText(node); if(!raw || !node.loc || !node.range || !node.parent.loc || !node.parent.range){ return; } const line = ctx.sourceCode.lines[node.parent.loc.start.line - 1]; const quotes = getQuotes(raw); const braces = getBracesByString(ctx, raw); const isInterpolated = getIsInterpolated(ctx, raw); const priorLiterals = findPriorLiterals(ctx, node); const content = getContent(raw, quotes, braces); const whitespaces = getWhitespace(content); const indentation = getIndentation(line); const multilineQuotes = getMultilineQuotes(node); const concatenation = getStringConcatenationMeta(node); return { ...whitespaces, ...quotes, ...braces, ...multilineQuotes, ...concatenation, content, indentation, isInterpolated, loc: node.loc, priorLiterals, range: node.range, raw, supportsMultiline: true, type: "TemplateLiteral" }; } function getMultilineQuotes(node: ESNode & Rule.NodeParentExtension): MultilineMeta { const surroundingBraces = ES_CONTAINER_TYPES_TO_INSERT_BRACES.includes(node.parent.type); const multilineQuotes: LiteralValueQuotes[] = ES_CONTAINER_TYPES_TO_REPLACE_QUOTES.includes(node.parent.type) ? ["`"] : []; return { multilineQuotes, surroundingBraces }; } function getLiteralsByESExpression(ctx: Rule.RuleContext, args: ESExpression[]): Literal[] { return args.reduce((acc, node) => { acc.push(...getLiteralsByESLiteralNode(ctx, node)); return acc; }, []); } export function getLiteralsByESTemplateLiteral(ctx: Rule.RuleContext, node: ESTemplateLiteral): Literal[] { return node.quasis.map(quasi => { if(!hasESNodeParentExtension(quasi)){ return; } return getLiteralByESTemplateElement(ctx, quasi); }).filter((literal): literal is TemplateLiteral => literal !== undefined); } export function findParentESTemplateLiteralByESTemplateElement(node: WithParent): ESTemplateLiteral | undefined { if(!hasESNodeParentExtension(node)){ return; } if(node.parent.type === "TemplateLiteral"){ return node.parent; } return findParentESTemplateLiteralByESTemplateElement(node.parent); } function findPriorLiterals(ctx: Rule.RuleContext, node: ESNode) { if(!hasESNodeParentExtension(node)){ return; } const priorLiterals: Literal[] = []; let currentNode: ESNode = node; while(hasESNodeParentExtension(currentNode)){ const parent = currentNode.parent; if(isESCallExpression(parent)){ break; } if(isESArrowFunctionExpression(parent)){ break; } if(isESFunctionExpression(parent)){ break; } if(isESVariableDeclarator(parent)){ break; } if(parent.type === "TemplateLiteral"){ for(const quasi of parent.quasis){ if(quasi.range === node.range){ break; } if(quasi.type === "TemplateElement" && hasESNodeParentExtension(quasi)){ const literal = getLiteralByESTemplateElement(ctx, quasi); if(!literal){ continue; } priorLiterals.push(literal); } } } if(parent.type === "TemplateElement"){ const literal = getLiteralByESTemplateElement(ctx, parent); if(!literal){ continue; } priorLiterals.push(literal); } if(parent.type === "Literal"){ const literal = getLiteralsByESLiteralNode(ctx, parent); if(!literal){ continue; } priorLiterals.push(...literal); } currentNode = parent; } return priorLiterals; } export function getESObjectPath(node: WithParent): string | undefined { if(!isGenericNodeWithParent(node)){ return; } if(!hasESNodeParentExtension(node)){ return; } if( node.type !== "Property" && node.type !== "ObjectExpression" && node.type !== "ArrayExpression" && node.type !== "Identifier" && node.type !== "Literal" && node.type !== "TemplateElement" ){ return; } const paths: (string | undefined)[] = []; if(node.type === "Property"){ if(node.key.type === "Identifier"){ paths.unshift(createObjectPathElement(node.key.name)); } else if(node.key.type === "Literal"){ paths.unshift(createObjectPathElement(node.key.value?.toString() ?? node.key.raw)); } else { return ""; } } if(isESStringLike(node) && isInsideObjectValue(node)){ const property = findMatchingParentNodes(node, (node): node is ESNode => { return isESNode(node) && node.type === "Property"; }); if(property){ return getESObjectPath(property); } } if(isESObjectKey(node)){ const property = node.parent; return getESObjectPath(property); } if(node.parent.type === "ArrayExpression" && node.type !== "Property" && node.type !== "TemplateElement"){ const index = node.parent.elements.indexOf(node); paths.unshift(`[${index}]`); } paths.unshift(getESObjectPath(node.parent)); return paths.reduce((paths, currentPath) => { if(!currentPath){ return paths; } if(paths.length === 0){ return [currentPath]; } if(currentPath.startsWith("[") && currentPath.endsWith("]")){ return [...paths, currentPath]; } return [...paths, ".", currentPath]; }, []).join(""); } export interface ESSimpleStringLiteral extends Rule.NodeParentExtension, ESSimpleLiteral { value: string; } export function isESObjectKey(node: ESBaseNode & Rule.NodeParentExtension) { return ( node.parent.type === "Property" && node.parent.parent.type === "ObjectExpression" && node.parent.key === node ); } export function isInsideObjectValue(node: WithParent) { if(!hasESNodeParentExtension(node)){ return false; } // #34 allow call expressions as object values if(isESCallExpression(node)){ return false; } if(isESArrowFunctionExpression(node)){ return false; } if(isESFunctionExpression(node)){ return false; } if( node.parent.type === "Property" && node.parent.parent.type === "ObjectExpression" && node.parent.value === node ){ return true; } return isInsideObjectValue(node.parent); } function findMatchingParentNodes(node: Partial, matchesNode: (node: unknown) => node is Node): Node | undefined { if(!isGenericNodeWithParent(node)){ return; } if(matchesNode(node.parent)){ return node.parent as Node; } return findMatchingParentNodes(node.parent, matchesNode); } export function isESSimpleStringLiteral(node: ESBaseNode): node is ESSimpleStringLiteral { return ( node.type === "Literal" && "value" in node && typeof node.value === "string" ); } export function isESStringLike(node: ESBaseNode): node is ESSimpleStringLiteral | ESTemplateElement { return isESSimpleStringLiteral(node) || isESTemplateElement(node); } export function isESTemplateLiteral(node: ESBaseNode): node is ESTemplateLiteral { return node.type === "TemplateLiteral"; } export function isESTemplateElement(node: ESBaseNode): node is ESTemplateElement { return node.type === "TemplateElement"; } export function isESNode(node: unknown): node is ESNode { return ( node !== null && typeof node === "object" && "type" in node ); } export function isESCallExpression(node: ESBaseNode): node is ESCallExpression { return node.type === "CallExpression"; } export function isESArrowFunctionExpression(node: ESBaseNode): node is ESArrowFunctionExpression { return node.type === "ArrowFunctionExpression"; } export function isESFunctionExpression(node: ESBaseNode): node is ESFunctionExpression { return node.type === "FunctionExpression"; } export function isESFunctionDeclaration(node: ESBaseNode): node is ESFunctionDeclaration { return node.type === "FunctionDeclaration"; } export function isESAnonymousFunction(node: ESBaseNode): boolean { if(isESArrowFunctionExpression(node)){ return true; } if(isESFunctionExpression(node) && node.id === null){ return true; } return false; } export function isESArrowFunctionWithoutBody(node: ESBaseNode): node is ESArrowFunctionExpression { return isESArrowFunctionExpression(node) && node.body.type !== "BlockStatement"; } export function isESReturnStatement(node: ESNode): boolean { return node.type === "ReturnStatement"; } function getESMemberExpressionPropertyName(node: ESMemberExpression): string | undefined { if(!node.computed && node.property.type === "Identifier"){ return node.property.name; } if(node.computed && isESSimpleStringLiteral(node.property)){ return node.property.value; } } function getESCalleeName(node: ESBaseNode, type: "name" | "path"): string | undefined { if(node.type === "Identifier" && "name" in node && typeof node.name === "string"){ return node.name; } if(node.type === "MemberExpression" && "object" in node){ const memberNode = node as ESMemberExpression; if(memberNode.object.type === "Super"){ return; } const object = getESCalleeName(memberNode.object as ESBaseNode, type); const property = getESMemberExpressionPropertyName(memberNode); if(!property){ return; } if(type === "name"){ return property; } if(!object){ return; } return `${object}.${property}`; } if(node.type === "ChainExpression" && "expression" in node){ return getESCalleeName(node.expression as ESBaseNode, type); } } function getTaggedTemplateName(node: ESBaseNode & Partial, type: "name" | "path"): string | undefined { if( node.type === "Identifier" && "name" in node && typeof node.name === "string" && hasESNodeParentExtension(node) && isTaggedTemplateExpression(node.parent) ){ return node.name; } if(node.type === "MemberExpression"){ return getESCalleeName(node, type); } if(node.type === "CallExpression"){ return getESCalleeName((node as ESCallExpression).callee as ESBaseNode, type); } if(node.type === "ChainExpression" && "expression" in node){ return getTaggedTemplateName(node.expression as ESBaseNode, type); } } function isNestedCurriedCall(node: ESCallExpression): boolean { return hasESNodeParentExtension(node) && isESCallExpression(node.parent) && node.parent.callee === node; } function getCurriedCallChain(node: ESCallExpression) { const calls: ESCallExpression[] = [node]; let currentCall: ESCallExpression = node; while(isESCallExpression(currentCall.callee)){ currentCall = currentCall.callee; calls.unshift(currentCall); } return calls; } function getTargetCalls(callChain: ESCallExpression[], callTarget: CallTarget | undefined): ESCallExpression[] { return getTargetItems(callChain, callTarget, "first"); } function getTargetArguments(args: (ESExpression | ESSpreadElement)[], argumentTarget: ArgumentTarget | undefined): ESExpression[] { const expressionArgs = args.map((arg): ESExpression => { return arg.type === "SpreadElement" ? arg.argument : arg; }); if(typeof argumentTarget !== "number"){ return getTargetItems(expressionArgs, argumentTarget, "all"); } if(args.length === 0){ return []; } const index = argumentTarget >= 0 ? argumentTarget : args.length + argumentTarget; if(index < 0 || index >= args.length){ return []; } const targetArg = args[index]; return [targetArg.type === "SpreadElement" ? targetArg.argument : targetArg]; } function getTargetItems(items: T[], target: CallTarget | undefined, defaultTarget: "all" | "first"): T[] { if(items.length === 0){ return []; } if(target === "all" || target === undefined && defaultTarget === "all"){ return items; } if(target === "last"){ return [items[items.length - 1]]; } if(target === undefined || target === "first"){ return [items[0]]; } const index = target >= 0 ? target : items.length + target; if(index < 0 || index >= items.length){ return []; } return [items[index]]; } function isTaggedTemplateExpression(node: ESBaseNode): node is ESTaggedTemplateExpression { return node.type === "TaggedTemplateExpression"; } function isTaggedTemplateLiteral(node: ESBaseNode): node is ESTemplateLiteral { return hasESNodeParentExtension(node) && isTaggedTemplateExpression(node.parent); } export function isESVariableDeclarator(node: ESBaseNode): node is ESVariableDeclarator { return node.type === "VariableDeclarator"; } function isESExportDefaultExpression(node: ESBaseNode): node is ESExpression { if(node.type === "FunctionDeclaration"){ return false; } if(node.type === "ClassDeclaration"){ return false; } return true; } function isESVariableSymbol(node: ESBaseNode & Partial): node is ESIdentifier { return node.type === "Identifier" && !!node.parent && isESVariableDeclarator(node.parent); } export function hasESNodeParentExtension(node: ESBaseNode): node is Rule.Node & Rule.NodeParentExtension { return "parent" in node && !!node.parent; } function getBracesByString(ctx: Rule.RuleContext, raw: string): BracesMeta { const closingBraces = raw.trim().startsWith("}") ? "}" : undefined; const openingBraces = raw.trim().endsWith("${") ? "${" : undefined; return { closingBraces, openingBraces }; } function getIsInterpolated(ctx: Rule.RuleContext, raw: string): boolean { const braces = getBracesByString(ctx, raw); return !!braces.closingBraces || !!braces.openingBraces; } function getLeadingComment(ctx: Rule.RuleContext, node: ESNode): string | undefined { try { const token = ctx.sourceCode.getTokenBefore(node, { includeComments: true }); if(token && isESTokenComment(token)){ return token.value.trim(); } } catch {} } type ESTokenWithOptionalComment = { type: string; value?: string; }; function isESTokenComment(token: ESTokenWithOptionalComment): token is ESTokenWithOptionalComment & { value: string; } { return (token.type === "Block" || token.type === "Line") && typeof token.value === "string"; } function getStringConcatenationMeta(node: ESNode, isConcatenatedLeft = false, isConcatenatedRight = false): { isConcatenatedLeft: boolean; isConcatenatedRight: boolean; } { if(!hasESNodeParentExtension(node)){ return { isConcatenatedLeft, isConcatenatedRight }; } const parent = node.parent; if(parent.type === "BinaryExpression" && parent.operator === "+"){ return getStringConcatenationMeta( parent, isConcatenatedLeft || parent.right === node, isConcatenatedRight || parent.left === node ); } return getStringConcatenationMeta(parent, isConcatenatedLeft, isConcatenatedRight); } export function getESMatcherFunctions( matchers: SelectorMatcher[], options?: { isStringLikeNode?: (node: ESBaseNode) => boolean; } ): MatcherFunctions { return matchers.reduce((matcherFunctions, matcher) => { switch (matcher.type){ case MatcherType.AnonymousFunctionReturn: { matcherFunctions.push(node => { if(isESNode(node) && ( isESCallExpression(node) || isESVariableDeclarator(node) )){ return MATCHER_RESULT.UNCROSSABLE_BOUNDARY; } if( !isESNode(node) || !hasESNodeParentExtension(node) || !isESAnonymousFunction(node) ){ return MATCHER_RESULT.NO_MATCH; } // return matchers directly if the arrow function immediately returns if(isESArrowFunctionWithoutBody(node)){ return [(node: unknown) => { if( !isESNode(node) || !hasESNodeParentExtension(node) || !isESArrowFunctionWithoutBody(node.parent) || node !== node.parent.body){ return MATCHER_RESULT.NO_MATCH; } return getESMatcherFunctions(matcher.match, options); }]; } // create a matcher function that first matches the return statement and then the final matchers return [(node: unknown) => { if(isESNode(node) && ( isESCallExpression(node) || isESArrowFunctionExpression(node) || isESVariableDeclarator(node) || isESFunctionExpression(node) || isESFunctionDeclaration(node) )){ return MATCHER_RESULT.UNCROSSABLE_BOUNDARY; } if( !isESNode(node) || !hasESNodeParentExtension(node) || !isESReturnStatement(node) ){ return MATCHER_RESULT.NO_MATCH; } return getESMatcherFunctions(matcher.match, options); }]; }); break; } case MatcherType.String: { matcherFunctions.push(node => { if(isESNode(node) && ( isESCallExpression(node) || isESArrowFunctionExpression(node) || isESVariableDeclarator(node) || isESFunctionExpression(node) )){ return MATCHER_RESULT.UNCROSSABLE_BOUNDARY; } if( !isESNode(node) || !hasESNodeParentExtension(node) || isInsideDisallowedBinaryExpression(node) || isInsideConditionalExpressionTest(node) || isInsideLogicalExpressionLeft(node) || isIndexedAccessLiteral(node) || isESObjectKey(node) || isInsideObjectValue(node)){ return MATCHER_RESULT.NO_MATCH; } return isESStringLike(node) || !!options?.isStringLikeNode?.(node); }); break; } case MatcherType.ObjectKey: { matcherFunctions.push(node => { if(isESNode(node) && ( isESCallExpression(node) || isESArrowFunctionExpression(node) || isESVariableDeclarator(node) || isESFunctionExpression(node) )){ return MATCHER_RESULT.UNCROSSABLE_BOUNDARY; } if( !isESNode(node) || !hasESNodeParentExtension(node) || !isESObjectKey(node) || isInsideDisallowedBinaryExpression(node) || isInsideConditionalExpressionTest(node) || isInsideLogicalExpressionLeft(node) || isInsideMemberExpression(node) || isIndexedAccessLiteral(node)){ return MATCHER_RESULT.NO_MATCH; } const path = getESObjectPath(node); if(!path || !matcher.path){ return MATCHER_RESULT.MATCH; } return matchesPathPattern(path, matcher.path); }); break; } case MatcherType.ObjectValue: { matcherFunctions.push(node => { if(isESNode(node) && ( isESCallExpression(node) || isESArrowFunctionExpression(node) || isESVariableDeclarator(node) || isESFunctionExpression(node) )){ return MATCHER_RESULT.UNCROSSABLE_BOUNDARY; } if( !isESNode(node) || !hasESNodeParentExtension(node) || !isInsideObjectValue(node) || isInsideDisallowedBinaryExpression(node) || isInsideConditionalExpressionTest(node) || isInsideLogicalExpressionLeft(node) || isESObjectKey(node) || isIndexedAccessLiteral(node) || !isESStringLike(node) && !options?.isStringLikeNode?.(node)){ return MATCHER_RESULT.NO_MATCH; } const path = getESObjectPath(node); if(!path || !matcher.path){ return MATCHER_RESULT.MATCH; } return matchesPathPattern(path, matcher.path); }); break; } } return matcherFunctions; }, []); } ================================================ FILE: src/parsers/html.test.ts ================================================ import { describe, it } from "vitest"; import { enforceConsistentClassOrder } from "better-tailwindcss:rules/enforce-consistent-class-order.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; describe("html", () => { it("should match attribute names via regex", () => { lint(enforceConsistentClassOrder, { invalid: [ { html: ``, htmlOutput: ``, errors: 1, options: [{ attributes: [".*Attribute"], order: "asc" }] } ] }); }); }); ================================================ FILE: src/parsers/html.ts ================================================ import { addAttribute, deduplicateLiterals, getContent, getIndentation, matchesName } from "better-tailwindcss:utils/utils.js"; import type { AttributeNode, TagNode } from "es-html-parser"; import type { Rule } from "eslint"; import type { Literal, QuoteMeta } from "better-tailwindcss:types/ast.js"; import type { AttributeSelector } from "better-tailwindcss:types/rule.js"; export function getLiteralsByHTMLAttribute(ctx: Rule.RuleContext, attribute: AttributeNode, selectors: AttributeSelector[]): Literal[] { const name = attribute.key.value; const literals = selectors.reduce((literals, selector) => { if(!matchesName(selector.name.toLowerCase(), name.toLowerCase())){ return literals; } if(!selector.match){ literals.push(...getLiteralsByHTMLAttributeNode(ctx, attribute)); return literals; } return literals; }, []); return literals .filter(deduplicateLiterals) .map(addAttribute(name)); } export function getAttributesByHTMLTag(ctx: Rule.RuleContext, node: TagNode): AttributeNode[] { return node.attributes; } export function getLiteralsByHTMLAttributeNode(ctx: Rule.RuleContext, attribute: AttributeNode): Literal[] { const value = attribute.value; if(!value){ return []; } const line = ctx.sourceCode.lines[attribute.loc.start.line - 1]; const raw = attribute.startWrapper?.value + value.value + attribute.endWrapper?.value; const quotes = getQuotesByHTMLAttribute(ctx, attribute); const indentation = getIndentation(line); const content = getContent(raw, quotes); return [{ ...quotes, content, indentation, isInterpolated: false, loc: value.loc, range: [value.range[0] - 1, value.range[1] + 1], // include quotes in range raw, supportsMultiline: true, type: "StringLiteral" }]; } function getQuotesByHTMLAttribute(ctx: Rule.RuleContext, attribute: AttributeNode): QuoteMeta { const openingQuote = attribute.startWrapper?.value; const closingQuote = attribute.endWrapper?.value; return { closingQuote: closingQuote === "'" || closingQuote === '"' ? closingQuote : undefined, openingQuote: openingQuote === "'" || openingQuote === '"' ? openingQuote : undefined }; } ================================================ FILE: src/parsers/jsx.test.ts ================================================ import { describe, it } from "vitest"; import { enforceConsistentClassOrder } from "better-tailwindcss:rules/enforce-consistent-class-order.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; describe("jsx", () => { it("should match attribute names via regex", () => { lint(enforceConsistentClassOrder, { invalid: [ { jsx: ``, jsxOutput: ``, errors: 1, options: [{ attributes: [".*Attribute"], order: "asc" }] } ] }); }); // #119 it("should not report inside member expressions", () => { lint(noUnnecessaryWhitespace, { valid: [ { jsx: `` } ] }); }); // #211 it("should still handle object values even when they are immediately index accessed", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: "", jsxOutput: "", errors: 2, options: [{ attributes: [["class", [{ match: MatcherType.ObjectValue }]]] }] } ] }); }); // #226 it("should not match index accessed object keys", () => { lint(noUnnecessaryWhitespace, { valid: [ { jsx: "", options: [{ attributes: [["class", [{ match: MatcherType.ObjectKey }]]] }] } ] }); }); // #286 it("should not match index access string literals", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: "", jsxOutput: "", errors: 2, options: [{ attributes: [["class", [{ match: MatcherType.ObjectValue }]]] }] }, { jsx: "", jsxOutput: "", errors: 2, options: [{ callees: [["cx", [{ match: MatcherType.String }]]] }] } ] }); }); it("should match default export via variable selector", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `export default " lint ";`, jsxOutput: `export default "lint";`, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Variable, name: "^default$" } ] }] } ] }); }); }); describe("astro (jsx)", () => { it("should match astro syntactic sugar", () => { lint(enforceConsistentClassOrder, { invalid: [ { astro: ``, astroOutput: ``, errors: 1, options: [{ order: "asc" }] } ] }); }); }); describe("solid (jsx)", () => { it("should match solid classList attribute", () => { lint(enforceConsistentClassOrder, { invalid: [ { jsx: `
`, jsxOutput: `
`, errors: 1, options: [{ order: "asc" }] } ] }); }); }); ================================================ FILE: src/parsers/jsx.ts ================================================ import { ES_CONTAINER_TYPES_TO_INSERT_BRACES, ES_CONTAINER_TYPES_TO_REPLACE_QUOTES, getLiteralsByESMatchers, getLiteralsByESTemplateLiteral, getStringLiteralByESStringLiteral, hasESNodeParentExtension, isESNode, isESSimpleStringLiteral, isESTemplateLiteral } from "better-tailwindcss:parsers/es.js"; import { addAttribute, deduplicateLiterals, matchesName } from "better-tailwindcss:utils/utils.js"; import type { Rule } from "eslint"; import type { BaseNode as ESBaseNode, TemplateLiteral as ESTemplateLiteral } from "estree"; import type { JSXAttribute, BaseNode as JSXBaseNode, JSXExpressionContainer, JSXOpeningElement } from "estree-jsx"; import type { ESSimpleStringLiteral } from "better-tailwindcss:parsers/es.js"; import type { Literal, LiteralValueQuotes, MultilineMeta } from "better-tailwindcss:types/ast.js"; import type { AttributeSelector } from "better-tailwindcss:types/rule.js"; export const JSX_CONTAINER_TYPES_TO_REPLACE_QUOTES = [ ...ES_CONTAINER_TYPES_TO_REPLACE_QUOTES, "JSXExpressionContainer" ]; export const JSX_CONTAINER_TYPES_TO_INSERT_BRACES = [ ...ES_CONTAINER_TYPES_TO_INSERT_BRACES ]; export function getLiteralsByJSXAttribute(ctx: Rule.RuleContext, attribute: JSXAttribute, selectors: AttributeSelector[]): Literal[] { const name = getAttributeName(attribute); const value = attribute.value; const literals = selectors.reduce((literals, selector) => { if(!value){ return literals; } if(typeof name !== "string"){ return literals; } if(!matchesName(selector.name.toLowerCase(), name.toLowerCase())){ return literals; } if(!selector.match){ literals.push(...getLiteralsByJSXAttributeValue(ctx, value)); return literals; } literals.push(...getLiteralsByESMatchers(ctx, value, selector.match)); return literals; }, []); return literals .filter(deduplicateLiterals) .map(addAttribute(name)); } export function getAttributesByJSXElement(ctx: Rule.RuleContext, node: JSXOpeningElement): JSXAttribute[] { return node.attributes.reduce((acc, attribute) => { if(isJSXAttribute(attribute)){ acc.push(attribute); } return acc; }, []); } function getAttributeName(attribute: JSXAttribute): string | undefined { if(attribute.name.type === "JSXIdentifier"){ return attribute.name.name; } if(attribute.name.type === "JSXNamespacedName"){ return `${attribute.name.namespace.name}:${attribute.name.name.name}`; } } function getLiteralsByJSXAttributeValue(ctx: Rule.RuleContext, value: JSXAttribute["value"]): Literal[] { if(!value){ return []; } if(isESSimpleStringLiteral(value)){ const stringLiteral = getStringLiteralByJSXStringLiteral(ctx, value); if(stringLiteral){ return [stringLiteral]; } } if(isJSXExpressionContainerWithESSimpleStringLiteral(value)){ const stringLiteral = getStringLiteralByJSXStringLiteral(ctx, value.expression); if(stringLiteral){ return [stringLiteral]; } } if(isJSXExpressionContainerWithESTemplateLiteral(value)){ return getLiteralsByJSXTemplateLiteral(ctx, value.expression); } return []; } function getStringLiteralByJSXStringLiteral(ctx: Rule.RuleContext, node: ESSimpleStringLiteral): Literal | undefined { const literal = getStringLiteralByESStringLiteral(ctx, node); const multilineQuotes = getMultilineQuotes(node); if(!literal){ return; } return { ...literal, ...multilineQuotes }; } function getLiteralsByJSXTemplateLiteral(ctx: Rule.RuleContext, node: ESTemplateLiteral): Literal[] { const literals = getLiteralsByESTemplateLiteral(ctx, node); return literals.map(literal => { if(!hasESNodeParentExtension(node)){ return literal; } const multilineQuotes = getMultilineQuotes(node); return { ...literal, ...multilineQuotes }; }); } function getMultilineQuotes(node: ESBaseNode & Rule.NodeParentExtension): MultilineMeta { const surroundingBraces = JSX_CONTAINER_TYPES_TO_INSERT_BRACES.includes(node.parent.type); const multilineQuotes: LiteralValueQuotes[] = JSX_CONTAINER_TYPES_TO_REPLACE_QUOTES.includes(node.parent.type) ? ["`"] : []; return { multilineQuotes, surroundingBraces }; } function isJSXExpressionContainerWithESSimpleStringLiteral(node: JSXBaseNode): node is JSXExpressionContainer & { expression: ESSimpleStringLiteral; } { return node.type === "JSXExpressionContainer" && "expression" in node && isESNode(node.expression) && isESSimpleStringLiteral(node.expression); } function isJSXExpressionContainerWithESTemplateLiteral(node: JSXBaseNode): node is JSXExpressionContainer & { expression: ESTemplateLiteral; } { return node.type === "JSXExpressionContainer" && "expression" in node && isESNode(node.expression) && isESTemplateLiteral(node.expression); } function isJSXAttribute(node: JSXBaseNode): node is JSXAttribute { return node.type === "JSXAttribute"; } ================================================ FILE: src/parsers/svelte.test.ts ================================================ import { describe, it } from "vitest"; import { enforceConsistentClassOrder } from "better-tailwindcss:rules/enforce-consistent-class-order.js"; import { enforceConsistentLineWrapping } from "better-tailwindcss:rules/enforce-consistent-line-wrapping.js"; import { noRestrictedClasses } from "better-tailwindcss:rules/no-restricted-classes.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { dedent } from "better-tailwindcss:tests/utils/template.js"; import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; describe("svelte", () => { it("should match attribute names via regex", () => { lint(enforceConsistentClassOrder, { invalid: [ { svelte: ``, svelteOutput: ``, errors: 1, options: [{ attributes: [".*Attribute"], order: "asc" }] } ] }); }); // #42 it("should work with shorthand attributes", () => { lint(enforceConsistentClassOrder, { invalid: [ { svelte: ``, svelteOutput: ``, errors: 1, options: [{ order: "asc" }] } ] }); }); it("should change the quotes in expressions to backticks", () => { const singleLine = "a b c d e f"; const multiLine = dedent` a b c d e f `; lint(enforceConsistentLineWrapping, { invalid: [ { svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 3 }] } ] }); }); // #119 it("should not report inside member expressions", () => { lint(noUnnecessaryWhitespace, { valid: [ { svelte: `` } ] }); }); // #211 it("should still handle object values even when they are immediately index accessed", () => { lint(noUnnecessaryWhitespace, { invalid: [ { svelte: "", svelteOutput: "", errors: 2, options: [{ attributes: [["class", [{ match: MatcherType.ObjectValue }]]] }] } ] }); }); // #226 it("should not match index accessed object keys", () => { lint(noUnnecessaryWhitespace, { valid: [ { svelte: "", options: [{ attributes: [["class", [{ match: MatcherType.ObjectKey }]]] }] } ] }); }); // #237 it("should keep interpolations in normal string literals", () => { lint(enforceConsistentLineWrapping, { invalid: [ { svelte: ``, svelteOutput: ``, errors: 2 } ], valid: [ { svelte: `` } ] }); }); // https://svelte.dev/docs/svelte/class#The-class:-directive it("should class name directive", () => { lint(noRestrictedClasses, { invalid: [ { svelte: ``, svelteOutput: ``, errors: 1, options: [{ restrict: [ { fix: "allowed", pattern: "restricted" } ] }] }, { svelte: ``, svelteOutput: ``, errors: 1, options: [{ restrict: [ { fix: "allowed", pattern: "restricted" } ] }] } ] }); }); it("should match default export via variable selector", () => { lint(noUnnecessaryWhitespace, { invalid: [ { svelte: ``, svelteOutput: ``, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Variable, name: "^default$" } ] }] } ] }); }); }); ================================================ FILE: src/parsers/svelte.ts ================================================ import { ES_CONTAINER_TYPES_TO_REPLACE_QUOTES, getESMatcherFunctions, getLiteralsByESLiteralNode, hasESNodeParentExtension, isESStringLike } from "better-tailwindcss:parsers/es.js"; import { getLiteralNodesByMatchers } from "better-tailwindcss:utils/matchers.js"; import { addAttribute, deduplicateLiterals, getContent, getIndentation, getQuotes, getWhitespace, matchesName } from "better-tailwindcss:utils/utils.js"; import type { Rule } from "eslint"; import type { BaseNode as ESBaseNode, Node as ESNode } from "estree"; import type { SvelteAttachTag, SvelteAttribute, SvelteDirective, SvelteGenericsDirective, SvelteLiteral, SvelteMustacheTagText, SvelteName, SvelteShorthandAttribute, SvelteSpecialDirective, SvelteSpreadAttribute, SvelteStartTag, SvelteStyleDirective } from "svelte-eslint-parser/lib/ast/index.js"; import type { BracesMeta, Literal, LiteralValueQuotes, MultilineMeta, StringLiteral } from "better-tailwindcss:types/ast.js"; import type { AttributeSelector, MatcherFunctions, SelectorMatcher } from "better-tailwindcss:types/rule.js"; export const SVELTE_CONTAINER_TYPES_TO_REPLACE_QUOTES = [ ...ES_CONTAINER_TYPES_TO_REPLACE_QUOTES, "SvelteMustacheTag" ]; export const SVELTE_CONTAINER_TYPES_TO_INSERT_BRACES: string[] = [ ]; export function getAttributesBySvelteTag(ctx: Rule.RuleContext, node: SvelteStartTag): SvelteAttribute[] { return node.attributes.reduce((acc, attribute) => { if(isSvelteAttribute(attribute)){ acc.push(attribute); } return acc; }, []); } export function getDirectivesBySvelteTag(ctx: Rule.RuleContext, node: SvelteStartTag): SvelteDirective[] { return node.attributes.reduce((acc, attribute) => { if(isSvelteDirective(attribute)){ acc.push(attribute); } return acc; }, []); } export function getLiteralsBySvelteAttribute(ctx: Rule.RuleContext, attribute: SvelteAttribute, selectors: AttributeSelector[]): Literal[] { // skip shorthand attributes #42 if(!Array.isArray(attribute.value)){ return []; } const name = attribute.key.name; const literals = selectors.reduce((literals, selector) => { for(const value of attribute.value){ if(!matchesName(selector.name.toLowerCase(), name.toLowerCase())){ continue; } if(!selector.match){ literals.push(...getLiteralsBySvelteLiteralNode(ctx, value)); continue; } literals.push(...getLiteralsBySvelteMatchers(ctx, value, selector.match)); } return literals; }, []); return literals .filter(deduplicateLiterals) .map(addAttribute(name)); } export function getLiteralsBySvelteDirective(ctx: Rule.RuleContext, directive: SvelteDirective, selectors: AttributeSelector[]): Literal[] { if(directive.kind !== "Class"){ return []; } const name = `class:${directive.key.name.name}`; const literals = selectors.reduce((literals, selector) => { if(!matchesName(selector.name.toLowerCase(), name.toLowerCase())){ return literals; } if(!selector.match){ return literals; } literals.push(...getLiteralsBySvelteMatchers(ctx, directive.key.name, selector.match)); return literals; }, []); return literals .filter(deduplicateLiterals) .map(addAttribute(name)); } function getLiteralsBySvelteMatchers(ctx: Rule.RuleContext, node: ESBaseNode, matchers: SelectorMatcher[]): Literal[] { const matcherFunctions = getSvelteMatcherFunctions(matchers); const literalNodes = getLiteralNodesByMatchers(ctx, node, matcherFunctions); const literals = literalNodes.flatMap(literalNode => getLiteralsBySvelteLiteralNode(ctx, literalNode)); return literals.filter(deduplicateLiterals); } function getLiteralsBySvelteLiteralNode(ctx: Rule.RuleContext, node: ESBaseNode): Literal[] { if(isSvelteStringLiteral(node)){ const stringLiteral = getStringLiteralBySvelteStringLiteral(ctx, node); if(stringLiteral){ return [stringLiteral]; } } if(isSvelteName(node)){ const stringLiteral = getStringLiteralBySvelteName(ctx, node); if(stringLiteral){ return [stringLiteral]; } } if(isSvelteMustacheTag(node)){ return getLiteralsBySvelteLiteralNode(ctx, node.expression); } if(isESStringLike(node)){ return getLiteralsBySvelteESLiteralNode(ctx, node); } return []; } function getLiteralsBySvelteESLiteralNode(ctx: Rule.RuleContext, node: ESBaseNode): Literal[] { const literals = getLiteralsByESLiteralNode(ctx, node); return literals.map(literal => { if(!hasESNodeParentExtension(node)){ return literal; } const multilineQuotes = getMultilineQuotes(node); return { ...literal, ...multilineQuotes }; }); } function getStringLiteralBySvelteName(ctx: Rule.RuleContext, node: SvelteName): StringLiteral { const raw = node.name; const braces = getBracesByString(ctx, raw); const isInterpolated = getIsInterpolated(ctx, raw); const quotes = getQuotes(raw); const content = getContent(raw, quotes, braces); const whitespaces = getWhitespace(content); const line = ctx.sourceCode.lines[isInterpolated ? node.parent.loc.start.line - 1 : node.loc.start.line - 1]; const indentation = getIndentation(line); const multilineQuotes = getMultilineQuotes(node); return { ...whitespaces, ...quotes, ...braces, ...multilineQuotes, content, indentation, isInterpolated, loc: node.loc, range: node.range, // include quotes in range raw, supportsMultiline: false, type: "StringLiteral" }; } function getStringLiteralBySvelteStringLiteral(ctx: Rule.RuleContext, node: SvelteLiteral): StringLiteral | undefined { const raw = ctx.sourceCode.getText(node as unknown as ESNode, 1, 1); const braces = getBracesByString(ctx, raw); const isInterpolated = getIsInterpolated(ctx, raw); const quotes = getQuotes(raw); const content = getContent(raw, quotes, braces); const whitespaces = getWhitespace(content); const line = ctx.sourceCode.lines[isInterpolated ? node.parent.loc.start.line - 1 : node.loc.start.line - 1]; const indentation = getIndentation(line); const multilineQuotes = getMultilineQuotes(node); return { ...whitespaces, ...quotes, ...braces, ...multilineQuotes, content, indentation, isInterpolated, loc: node.loc, range: [node.range[0] - 1, node.range[1] + 1], // include quotes in range raw, supportsMultiline: true, type: "StringLiteral" }; } function getBracesByString(ctx: Rule.RuleContext, raw: string): BracesMeta { const closingBraces = raw.trim().startsWith("}") ? "}" : undefined; const openingTemplateBraces = raw.trim().endsWith("${") ? "${" : undefined; const openingBrace = raw.trim().endsWith("{") ? "{" : undefined; const openingBraces = openingTemplateBraces ?? openingBrace; return { closingBraces, openingBraces }; } function getIsInterpolated(ctx: Rule.RuleContext, raw: string): boolean { const braces = getBracesByString(ctx, raw); return !!braces.closingBraces || !!braces.openingBraces; } function getMultilineQuotes(node: ESBaseNode & Rule.NodeParentExtension | SvelteLiteral | SvelteName): MultilineMeta { const surroundingBraces = SVELTE_CONTAINER_TYPES_TO_INSERT_BRACES.includes(node.parent.type); const multilineQuotes: LiteralValueQuotes[] = SVELTE_CONTAINER_TYPES_TO_REPLACE_QUOTES.includes(node.parent.type) ? ["'", "\"", "`"] : []; return { multilineQuotes, surroundingBraces }; } function isSvelteAttribute(node: | SvelteAttachTag | SvelteAttribute | SvelteDirective | SvelteGenericsDirective | SvelteShorthandAttribute | SvelteSpecialDirective | SvelteSpreadAttribute | SvelteStyleDirective): node is SvelteAttribute { return node.type === "SvelteAttribute"; } function isSvelteDirective(node: ESBaseNode): node is SvelteDirective { return node.type === "SvelteDirective"; } function isSvelteStringLiteral(node: ESBaseNode): node is SvelteLiteral { return node.type === "SvelteLiteral"; } function isSvelteName(node: ESBaseNode): node is SvelteName { return node.type === "SvelteName"; } function isSvelteMustacheTag(node: ESBaseNode): node is SvelteMustacheTagText { return node.type === "SvelteMustacheTag" && "kind" in node && node.kind === "text"; } function getSvelteMatcherFunctions(matchers: SelectorMatcher[]): MatcherFunctions { return getESMatcherFunctions(matchers, { isStringLikeNode(node) { return isSvelteName(node) || isSvelteStringLiteral(node); } }); } ================================================ FILE: src/parsers/vue.test.ts ================================================ import { describe, it } from "vitest"; import { enforceConsistentClassOrder } from "better-tailwindcss:rules/enforce-consistent-class-order.js"; import { enforceConsistentLineWrapping } from "better-tailwindcss:rules/enforce-consistent-line-wrapping.js"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { dedent } from "better-tailwindcss:tests/utils/template.js"; import { MatcherType, SelectorKind } from "better-tailwindcss:types/rule.js"; describe("vue", () => { it("should match attribute names via regex", () => { lint(enforceConsistentClassOrder, { invalid: [ { vue: ``, vueOutput: ``, errors: 1, options: [{ attributes: [".*Attribute"], order: "asc" }] } ] }); }); it("should work in objects in bound classes", () => { lint(enforceConsistentClassOrder, { invalid: [ { vue: ``, vueOutput: ``, errors: 1, options: [{ order: "asc" }] }, { vue: ``, vueOutput: ``, errors: 1, options: [{ order: "asc" }] } ] }); }); it("should work in arrays in bound classes", () => { lint(enforceConsistentClassOrder, { invalid: [ { vue: ``, vueOutput: ``, errors: 2, options: [{ order: "asc" }] }, { vue: ``, vueOutput: ``, errors: 2, options: [{ order: "asc" }] } ] }); }); it("should evaluate bound classes", () => { lint(enforceConsistentClassOrder, { invalid: [ { vue: ``, vueOutput: ``, errors: 1, options: [{ callees: ["defined"], order: "asc" }] }, { vue: ``, vueOutput: ``, errors: 1, options: [{ callees: ["defined"], order: "asc" }] } ] }); }); it("should automatically prefix bound classes", () => { lint(enforceConsistentClassOrder, { invalid: [ { vue: ``, vueOutput: ``, errors: 1, options: [{ attributes: [[":custom-class", [{ match: MatcherType.String }]]], order: "asc" }] }, { vue: ``, vueOutput: ``, errors: 1, options: [{ attributes: [["v-bind:custom-class", [{ match: MatcherType.String }]]], order: "asc" }] } ] }); }); it("should match bound classes via regex", () => { lint(enforceConsistentClassOrder, { invalid: [ { vue: ``, vueOutput: ``, errors: 1, options: [{ attributes: [[":.*Styles$", [{ match: MatcherType.String }]]], order: "asc" }] } ] }); }); // #95 it("should change the quotes in expressions to backticks", () => { const singleLine = "a b c d e f"; const multiLine = dedent` a b c d e f `; lint(enforceConsistentLineWrapping, { invalid: [ { vue: ``, vueOutput: ``, errors: 2, options: [{ classesPerLine: 3 }] } ] }); }); // #119 it("should not report inside member expressions", () => { lint(noUnnecessaryWhitespace, { valid: [ { vue: `` } ] }); }); // #211 it("should still handle object values even when they are immediately index accessed", () => { lint(noUnnecessaryWhitespace, { invalid: [ { vue: ``, vueOutput: ``, errors: 2, options: [{ attributes: [[".*", [{ match: MatcherType.ObjectValue }]]] }] } ] }); }); // #226 it("should not match index accessed object keys", () => { lint(noUnnecessaryWhitespace, { valid: [ { vue: "", options: [{ attributes: [["class", [{ match: MatcherType.ObjectKey }]]] }] } ] }); }); it("should match default export via variable selector", () => { lint(noUnnecessaryWhitespace, { invalid: [ { vue: ``, vueOutput: ``, errors: 2, options: [{ selectors: [ { kind: SelectorKind.Variable, name: "^default$" } ] }] } ] }); }); }); ================================================ FILE: src/parsers/vue.ts ================================================ import { ES_CONTAINER_TYPES_TO_INSERT_BRACES, ES_CONTAINER_TYPES_TO_REPLACE_QUOTES, getESMatcherFunctions, getLiteralsByESLiteralNode, hasESNodeParentExtension, isESStringLike } from "better-tailwindcss:parsers/es.js"; import { getLiteralNodesByMatchers } from "better-tailwindcss:utils/matchers.js"; import { addAttribute, deduplicateLiterals, getContent, getIndentation, getQuotes, getWhitespace, matchesName } from "better-tailwindcss:utils/utils.js"; import type { Rule } from "eslint"; import type { BaseNode as ESBaseNode, Node as ESNode } from "estree"; import type { AST } from "vue-eslint-parser"; import type { Literal, LiteralValueQuotes, MultilineMeta, StringLiteral } from "better-tailwindcss:types/ast.js"; import type { AttributeSelector, MatcherFunctions, SelectorMatcher } from "better-tailwindcss:types/rule.js"; export const VUE_CONTAINER_TYPES_TO_REPLACE_QUOTES = [ ...ES_CONTAINER_TYPES_TO_REPLACE_QUOTES ]; export const VUE_CONTAINER_TYPES_TO_INSERT_BRACES = [ ...ES_CONTAINER_TYPES_TO_INSERT_BRACES ]; export function getAttributesByVueStartTag(ctx: Rule.RuleContext, node: AST.VStartTag): (AST.VAttribute | AST.VDirective)[] { return node.attributes; } export function getLiteralsByVueAttribute(ctx: Rule.RuleContext, attribute: AST.VAttribute | AST.VDirective, selectors: AttributeSelector[]): Literal[] { if(attribute.value === null){ return []; } const name = getVueAttributeName(attribute); const value = attribute.value; const literals = selectors.reduce((literals, selector) => { if(!matchesName(getVueBoundName(selector.name).toLowerCase(), name?.toLowerCase())){ return literals; } if(!selector.match){ literals.push(...getLiteralsByVueLiteralNode(ctx, value)); return literals; } literals.push(...getLiteralsByVueMatchers(ctx, value, selector.match)); return literals; }, []); return literals .filter(deduplicateLiterals) .map(addAttribute(name)); } function getLiteralsByVueLiteralNode(ctx: Rule.RuleContext, node: ESBaseNode): Literal[] { if(!hasESNodeParentExtension(node)){ return []; } if(isVueLiteralNode(node)){ const literal = getStringLiteralByVueStringLiteral(ctx, node); return [literal]; } if(isESStringLike(node)){ return getLiteralsByVueESLiteralNode(ctx, node); } return []; } function getLiteralsByVueMatchers(ctx: Rule.RuleContext, node: ESBaseNode, matchers: SelectorMatcher[]): Literal[] { const matcherFunctions = getVueMatcherFunctions(matchers); const literalNodes = getLiteralNodesByMatchers(ctx, node, matcherFunctions); const literals = literalNodes.flatMap(literalNode => getLiteralsByVueLiteralNode(ctx, literalNode)); return literals.filter(deduplicateLiterals); } function getLiteralsByVueESLiteralNode(ctx: Rule.RuleContext, node: ESBaseNode & Rule.NodeParentExtension): Literal[] { const literals = getLiteralsByESLiteralNode(ctx, node); return literals.map(literal => { const multilineQuotes = getMultilineQuotes(node); return { ...literal, ...multilineQuotes }; }); } function getStringLiteralByVueStringLiteral(ctx: Rule.RuleContext, node: AST.VLiteral): StringLiteral { const raw = ctx.sourceCode.getText(node as unknown as ESNode); const line = ctx.sourceCode.lines[node.loc.start.line - 1]; const quotes = getQuotes(raw); const content = getContent(raw, quotes); const whitespaces = getWhitespace(content); const indentation = getIndentation(line); const multilineQuotes = getMultilineQuotes(node); return { ...whitespaces, ...quotes, ...multilineQuotes, content, indentation, loc: node.loc, priorLiterals: [], range: [node.range[0], node.range[1]], raw, supportsMultiline: true, type: "StringLiteral" }; } function getMultilineQuotes(node: ESBaseNode & Rule.NodeParentExtension | AST.VLiteral): MultilineMeta { const surroundingBraces = VUE_CONTAINER_TYPES_TO_INSERT_BRACES.includes(node.parent.type); const multilineQuotes: LiteralValueQuotes[] = VUE_CONTAINER_TYPES_TO_REPLACE_QUOTES.includes(node.parent.type) ? ["`"] : []; return { multilineQuotes, surroundingBraces }; } function getVueBoundName(name: string): string { return name.startsWith(":") ? `v-bind:${name.slice(1)}` : name; } function getVueAttributeName(attribute: AST.VAttribute | AST.VDirective): string | undefined { if(isVueAttribute(attribute)){ return attribute.key.name; } if(isVueDirective(attribute)){ if(attribute.key.argument?.type === "VIdentifier"){ return `v-${attribute.key.name.name}:${attribute.key.argument.name}`; } } } function isVueAttribute(attribute: AST.VAttribute | AST.VDirective): attribute is AST.VAttribute { return attribute.key.type === "VIdentifier"; } function isVueDirective(attribute: AST.VAttribute | AST.VDirective): attribute is AST.VDirective { return attribute.key.type === "VDirectiveKey"; } function isVueLiteralNode(node: ESBaseNode): node is AST.VLiteral { return node.type === "VLiteral"; } function getVueMatcherFunctions(matchers: SelectorMatcher[]): MatcherFunctions { return getESMatcherFunctions(matchers, { isStringLikeNode: isVueLiteralNode }); } ================================================ FILE: src/rules/enforce-canonical-classes.test.ts ================================================ import { describe, it } from "vitest"; import { enforceCanonicalClasses } from "better-tailwindcss:rules/enforce-canonical-classes.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { css } from "better-tailwindcss:tests/utils/template.js"; import { values } from "better-tailwindcss:tests/utils/values.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version.js"; describe.runIf(getTailwindCSSVersion().major >= 4)(enforceCanonicalClasses.name, () => { it("should convert unnecessary arbitrary value to canonical class", () => { lint(enforceCanonicalClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [{ message: values(enforceCanonicalClasses.messages!.single, { canonicalClass: "text-[red]", className: "[color:red]/100" }) }], files: { "styles.css": css` @import "tailwindcss"; ` }, options: [{ entryPoint: "styles.css" }] } ] }); }); it("should convert multiple unnecessary arbitrary values to canonical classes", () => { lint(enforceCanonicalClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: values(enforceCanonicalClasses.messages!.single, { canonicalClass: "print:flex", className: "[@media_print]:flex" }) }, { message: values(enforceCanonicalClasses.messages!.single, { canonicalClass: "text-[red]/50", className: "[color:red]/50" }) } ], files: { "styles.css": css` @import "tailwindcss"; ` }, options: [{ entryPoint: "styles.css" }] } ] }); }); it("should collapse multiple utilities into a single utility", () => { lint(enforceCanonicalClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "size-10", classNames: "w-10, h-10" }) }, { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "size-10", classNames: "w-10, h-10" }) }, { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "inset-0", classNames: "top-0, right-0, bottom-0, left-0" }) }, { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "inset-0", classNames: "top-0, right-0, bottom-0, left-0" }) }, { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "inset-0", classNames: "top-0, right-0, bottom-0, left-0" }) }, { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "inset-0", classNames: "top-0, right-0, bottom-0, left-0" }) } ], files: { "styles.css": css` @import "tailwindcss"; ` }, options: [{ collapse: true, entryPoint: "styles.css" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "size-10", classNames: "w-10, h-10" }) }, { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "size-10", classNames: "w-10, h-10" }) } ], files: { "styles.css": css` @import "tailwindcss"; ` }, options: [{ collapse: true, entryPoint: "styles.css" }] } ] }); }); it("should convert size correctly", () => { lint(enforceCanonicalClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "size-5", classNames: "w-[20px], h-[20px]" }) }, { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "size-5", classNames: "w-[20px], h-[20px]" }) } ], files: { "styles.css": css` @import "tailwindcss"; ` }, settings: { "better-tailwindcss": { entryPoint: "styles.css", rootFontSize: 16 } } } ] }); }); it("should convert to logical properties", () => { lint(enforceCanonicalClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "mx-2", classNames: "mr-2, ml-2" }) }, { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "mx-2", classNames: "mr-2, ml-2" }) } ], files: { "styles.css": css` @import "tailwindcss"; ` }, options: [{ entryPoint: "styles.css", logical: true, rootFontSize: 16 }] } ] }); }); it("should still work when unknown classes are passed", () => { lint(enforceCanonicalClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "size-10", classNames: "w-10, h-10" }) }, { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "size-10", classNames: "w-10, h-10" }) } ], files: { "styles.css": css` @import "tailwindcss"; ` }, options: [{ collapse: true, entryPoint: "styles.css" }] } ] }); }); // #369 it("should be possible to ignore classes that match specific patterns", () => { lint(enforceCanonicalClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [{ message: values(enforceCanonicalClasses.messages!.single, { canonicalClass: "text-[red]", className: "[color:red]/100" }) }], files: { "styles.css": css` @import "tailwindcss"; ` }, options: [{ entryPoint: "styles.css", ignore: ["^\\S*\\[.*in\\]$"] }] } ] }); }); // #304 it("should not remove unrelated classes when simplifying", { timeout: 30_000 }, () => { lint(enforceCanonicalClasses, { invalid: [ { angular: ``, angularOutput: ``, html: "", htmlOutput: "", jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [{ message: values(enforceCanonicalClasses.messages!.single, { canonicalClass: "aspect-4/3", className: "aspect-[4/3]" }) }], files: { "styles.css": css` @import "tailwindcss"; ` }, options: [{ collapse: true, entryPoint: "styles.css" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [{ message: values(enforceCanonicalClasses.messages!.single, { canonicalClass: "aspect-4/3", className: "aspect-[4/3]" }) }], files: { "styles.css": css` @import "tailwindcss"; ` }, options: [{ collapse: true, entryPoint: "styles.css" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "size-10", classNames: "w-10, h-10" }) }, { message: values(enforceCanonicalClasses.messages!.multiple, { canonicalClass: "size-10", classNames: "w-10, h-10" }) }, { message: values(enforceCanonicalClasses.messages!.single, { canonicalClass: "aspect-4/3", className: "aspect-[4/3]" }) } ], files: { "styles.css": css` @import "tailwindcss"; ` }, options: [{ collapse: true, entryPoint: "styles.css" }] } ] }); }); }); ================================================ FILE: src/rules/enforce-canonical-classes.ts ================================================ import { array, boolean, description, optional, pipe, strictObject, string } from "valibot"; import { createGetCanonicalClasses, getCanonicalClasses } from "better-tailwindcss:tailwindcss/canonical-classes.js"; import { async } from "better-tailwindcss:utils/context.js"; import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { getCachedRegex } from "better-tailwindcss:utils/regex.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { deduplicateClasses, splitClasses } from "better-tailwindcss:utils/utils.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const enforceCanonicalClasses = createRule({ autofix: true, category: "stylistic", description: "Enforce canonical class names.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/enforce-canonical-classes.md", name: "enforce-canonical-classes", recommended: true, schema: strictObject({ collapse: optional( pipe( boolean(), description("Whether to collapse multiple utilities into a single utility if possible.") ), true ), ignore: optional( pipe( array( string() ), description("A list of regular expression patterns for classes that should be ignored by the rule.") ), [] ), logical: optional( pipe( boolean(), description("Whether to convert between logical and physical properties when collapsing utilities.") ), true ) }), messages: { multiple: "The classes: \"{{ classNames }}\" can be simplified to \"{{canonicalClass}}\".", single: "The class: \"{{ className }}\" can be simplified to \"{{canonicalClass}}\"." }, initialize: ctx => { createGetCanonicalClasses(ctx); }, lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); function lintLiterals(ctx: Context, literals: Literal[]) { const { collapse, ignore, logical, rootFontSize } = ctx.options; const ignoredClassRegexes = ignore.map(ignoredClass => getCachedRegex(ignoredClass)); for(const literal of literals){ const classes = splitClasses(literal.content); const uniqueClasses = deduplicateClasses(classes); const filteredUniqueClasses = uniqueClasses.filter(className => !ignoredClassRegexes.some(ignoredClassRegex => ignoredClassRegex.test(className))); if(filteredUniqueClasses.length === 0){ continue; } const { canonicalClasses, warnings } = getCanonicalClasses(async(ctx), filteredUniqueClasses, { collapse, logicalToPhysical: logical, rem: rootFontSize }); lintClasses(ctx, literal, className => { const canonicalClass = canonicalClasses[className]; if(!canonicalClass){ return; } if(canonicalClass.input.length > 1){ return { data: { canonicalClass: canonicalClasses[className].output, classNames: canonicalClass.input.join(", ") }, fix: className === canonicalClass.input[0] ? canonicalClass.output : "", id: "multiple", warnings } as const; } if(canonicalClass.input.length === 1 && canonicalClass.output !== className){ return { data: { canonicalClass: canonicalClasses[className].output, className: canonicalClass.input[0] }, fix: canonicalClass.output, id: "single", warnings } as const; } }); } } ================================================ FILE: src/rules/enforce-consistent-class-order.test.ts ================================================ import { describe, it } from "vitest"; import { enforceConsistentClassOrder } from "better-tailwindcss:rules/enforce-consistent-class-order.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { css } from "better-tailwindcss:tests/utils/template.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version.js"; describe(enforceConsistentClassOrder.name, () => { it("should sort simple class names in string literals by the defined order", () => { lint( enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "asc" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "desc" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "official" }] } ], valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ order: "asc" }] }, { angular: ``, html: `img class="b a" />`, jsx: `() => `, svelte: `img class="b a" />`, vue: ``, options: [{ order: "desc" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ order: "official" }] } ] } ); }); it("should sort alphabetically in a locale-independent way for `asc` and `desc`", () => { lint( enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "asc" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "desc" }] } ] } ); }); it("should group all classes with the same variant together", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "official" }] } ] }); }); it("should keep the quotes as they are", () => { lint( enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "asc" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "asc" }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ order: "asc" }] }, { jsx: `() => `, jsxOutput: `() => `, errors: 1, options: [{ order: "asc" }] }, { jsx: `() => `, jsxOutput: `() => `, errors: 1, options: [{ order: "asc" }] } ] } ); }); it("should keep expressions as they are", () => { lint(enforceConsistentClassOrder, { valid: [ { jsx: `() => `, svelte: `` } ] }); }); it("should keep expressions where they are", () => { lint(enforceConsistentClassOrder, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ order: "asc" }] } ], valid: [ { jsx: `() => `, svelte: `` } ] }); }); it("should not rip away sticky classes", () => { const expression = "${true ? ' true ' : ' false '}"; const dirty = `c b a${expression}f e d`; const clean = `b c a${expression}f d e`; lint(enforceConsistentClassOrder, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ order: "asc" }] } ] }); }); it("should sort multiline strings but keep the whitespace as it is", () => { const unsortedMultilineString = ` d c b a `; const sortedMultilineString = ` a b c d `; lint( enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "asc" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "asc" }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ order: "asc" }] } ], valid: [ { angular: ``, html: ``, svelte: ``, vue: ``, options: [{ order: "asc" }] }, { angular: ``, html: ``, svelte: ``, vue: ``, options: [{ order: "asc" }] }, { jsx: `() => `, svelte: ``, options: [{ order: "asc" }] } ] } ); }); it("should sort in string literals in defined call signature arguments", () => { const dirtyDefined = "defined('b a d c');"; const cleanDefined = "defined('a b c d');"; const dirtyUndefined = "notDefined(\"b a d c\");"; lint( enforceConsistentClassOrder, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ callees: ["defined"], order: "asc" }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, vue: ``, options: [{ callees: ["defined"], order: "asc" }] } ] } ); lint( enforceConsistentClassOrder, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ callees: ["defined"], order: "asc" }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, vue: ``, options: [{ callees: ["defined"], order: "asc" }] } ] } ); }); it("should sort in call signature arguments in template literals", () => { const dirtyDefined = "${defined('f e')}"; const cleanDefined = "${defined('e f')}"; const dirtyUndefined = "${notDefined('f e')}"; const dirtyDefinedMultiline = ` b a d c ${dirtyDefined} h g j i `; const cleanDefinedMultiline = ` a b c d ${cleanDefined} g h i j `; const dirtyUndefinedMultiline = ` b a d c ${dirtyUndefined} h g j i `; const cleanUndefinedMultiline = ` a b c d ${dirtyUndefined} g h i j `; lint( enforceConsistentClassOrder, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 3, options: [{ callees: ["defined"], order: "asc" }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ callees: ["defined"], order: "asc" }] } ] } ); }); it("should sort in matching variable declarations", () => { const dirtyDefined = "const defined = \"b a\";"; const cleanDefined = "const defined = \"a b\";"; const dirtyUndefined = "const notDefined = \"b a\";"; const dirtyMultiline = `const defined = \` b a d c \`;`; const cleanMultiline = `const defined = \` a b c d \`;`; lint( enforceConsistentClassOrder, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "asc", variables: ["defined"] }] }, { jsx: dirtyMultiline, jsxOutput: cleanMultiline, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "asc", variables: ["defined"] }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, vue: ``, options: [{ order: "asc" }] } ] } ); }); it("should sort simple class names in tagged template literals", () => { lint( enforceConsistentClassOrder, { invalid: [ { jsx: "defined`b a`", jsxOutput: "defined`a b`", svelte: "", svelteOutput: "", vue: "defined`b a`", vueOutput: "defined`a b`", errors: 1, options: [{ order: "asc", tags: ["defined"] }] } ], valid: [ { jsx: "defined`a b`", svelte: "", vue: "defined`a b`", options: [{ order: "asc", tags: ["defined"] }] } ] } ); }); it("should group variants together in the `strict` sorting order", () => { lint(enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "strict" }] } ] }); }); it("should group arbitrary variants together in the `strict` sorting order", () => { lint(enforceConsistentClassOrder, { invalid: [ { jsx: ``, jsxOutput: ``, errors: 1, options: [{ order: "strict" }] } ] }); }); it("should sort arbitrary variants last in the `strict` sorting order", () => { lint(enforceConsistentClassOrder, { invalid: [ { jsx: ``, jsxOutput: ``, errors: 1, options: [{ order: "strict" }] } ] }); }); it("should sort unknown classes to the start by default", () => { lint( enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] } ); }); it("should sort unknown classes alphabetically in a locale-independent way", () => { lint( enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "official", unknownClassOrder: "asc", unknownClassPosition: "start" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ order: "official", unknownClassOrder: "desc", unknownClassPosition: "end" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should sort component classes to the start by default in tailwind >= 4", () => { lint( enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss"; @layer components { .custom-component { @apply font-bold; } } ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should sort component classes alphabetically in a locale-independent way in tailwind >= 4", () => { lint( enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss"; @layer components { .cx-12, .cy-24 { @apply font-bold; } } ` }, options: [{ componentClassOrder: "asc", componentClassPosition: "start", detectComponentClasses: true, entryPoint: "./tailwind.css" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss"; @layer components { .cx-12, .cy-24 { @apply font-bold; } } ` }, options: [{ componentClassOrder: "desc", componentClassPosition: "end", detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should differentiate between custom component classes and unknown classes in tailwind >= 4", () => { lint( enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss"; @layer components { .a, .b, .c, .d, .e, .f { @apply font-bold; } } ` }, options: [{ componentClassOrder: "asc", componentClassPosition: "start", detectComponentClasses: true, entryPoint: "./tailwind.css", unknownClassOrder: "desc", unknownClassPosition: "end" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should be possible to move both custom component classes and unknown classes to the end and preserve the order in tailwind >= 4", () => { lint( enforceConsistentClassOrder, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss"; @layer components { .a, .b, .c, .d, .e, .f { @apply font-bold; } } ` }, options: [{ componentClassOrder: "preserve", componentClassPosition: "end", detectComponentClasses: true, entryPoint: "./tailwind.css", unknownClassOrder: "preserve", unknownClassPosition: "end" }] } ] } ); }); }); ================================================ FILE: src/rules/enforce-consistent-class-order.ts ================================================ import { description, literal, optional, pipe, strictObject, union } from "valibot"; import { createGetClassOrder, getClassOrder } from "better-tailwindcss:tailwindcss/class-order.js"; import { createGetCustomComponentClasses, getCustomComponentClasses } from "better-tailwindcss:tailwindcss/custom-component-classes.js"; import { createGetDissectedClasses, getDissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import { async } from "better-tailwindcss:utils/context.js"; import { escapeNestedQuotes } from "better-tailwindcss:utils/quotes.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { display, splitClasses, splitWhitespaces } from "better-tailwindcss:utils/utils.js"; import type { DissectedClass } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import type { Warning } from "better-tailwindcss:types/async.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const enforceConsistentClassOrder = createRule({ autofix: true, category: "stylistic", description: "Enforce a consistent order for tailwind classes.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/enforce-consistent-class-order.md", name: "enforce-consistent-class-order", recommended: true, messages: { order: "Incorrect class order. Expected\n\n{{ notSorted }}\n\nto be\n\n{{ sorted }}" }, schema: strictObject({ componentClassOrder: optional( pipe( union([ literal("asc"), literal("desc"), literal("preserve") ]), description("Defines how component classes should be ordered among themselves.") ), "preserve" ), componentClassPosition: optional( pipe( union([ literal("start"), literal("end") ]), description("Defines where component classes should be placed in relation to the whole string literal.") ), "start" ), order: optional( pipe( union([ literal("asc"), literal("desc"), literal("official"), literal("strict") ]), description("The algorithm to use when sorting classes.") ), "official" ), unknownClassOrder: optional( pipe( union([ literal("asc"), literal("desc"), literal("preserve") ]), description("Defines how component classes should be ordered among themselves.") ), "preserve" ), unknownClassPosition: optional( pipe( union([ literal("start"), literal("end") ]), description("Defines where component classes should be placed in relation to the whole string literal.") ), "start" ) }), initialize: ctx => { const { detectComponentClasses } = ctx.options; createGetClassOrder(ctx); createGetDissectedClasses(ctx); if(detectComponentClasses){ createGetCustomComponentClasses(ctx); } }, lintLiterals: (ctx, literals) => { const { messageStyle } = ctx.options; for(const literal of literals){ const classChunks = splitClasses(literal.content); if(classChunks.length <= 1){ continue; } const whitespaceChunks = splitWhitespaces(literal.content); const unsortableClasses: [string, string] = ["", ""]; // remove sticky classes if(literal.closingBraces && whitespaceChunks[0] === ""){ whitespaceChunks.shift(); unsortableClasses[0] = classChunks.shift() ?? ""; } if(literal.openingBraces && whitespaceChunks[whitespaceChunks.length - 1] === ""){ whitespaceChunks.pop(); unsortableClasses[1] = classChunks.pop() ?? ""; } const [sortedClassChunks, warnings] = sortClassNames(ctx, classChunks); const classes: string[] = []; for(let i = 0; i < Math.max(sortedClassChunks.length, whitespaceChunks.length); i++){ whitespaceChunks[i] && classes.push(whitespaceChunks[i]); sortedClassChunks[i] && classes.push(sortedClassChunks[i]); } const escapedClasses = escapeNestedQuotes( [ unsortableClasses[0], ...classes, unsortableClasses[1] ].join(""), literal.openingQuote ?? literal.closingQuote ?? "`" ); const fixedClasses = [ literal.openingQuote ?? "", literal.isInterpolated && literal.closingBraces ? literal.closingBraces : "", escapedClasses, literal.isInterpolated && literal.openingBraces ? literal.openingBraces : "", literal.closingQuote ?? "" ].join(""); if(literal.raw === fixedClasses){ continue; } ctx.report({ data: { notSorted: display(messageStyle, literal.raw), sorted: display(messageStyle, fixedClasses) }, fix: fixedClasses, id: "order", range: literal.range, warnings }); } } }); function sortClassNames(ctx: Context, classes: string[]): [classes: string[], warnings?: (Warning | undefined)[]] { const { componentClassOrder, componentClassPosition, order, unknownClassOrder, unknownClassPosition } = ctx.options; if(order === "asc"){ return [classes.toSorted((a, b) => compareClasses(a, b))]; } if(order === "desc"){ return [classes.toSorted((a, b) => compareClasses(b, a))]; } if(classes.length <= 1){ return [classes]; } const { classOrder, warnings } = getClassOrder(async(ctx), classes); const { detectComponentClasses } = ctx.options; const customComponentClasses = detectComponentClasses ? getCustomComponentClasses(async(ctx)).customComponentClasses : []; const officiallySortedClasses = classOrder .toSorted((a, b) => { const [classA, aIndex] = a; const [classB, bIndex] = b; const componentClassSorting = getCustomOrder( componentClassPosition, componentClassOrder, classA, classB, className => customComponentClasses.includes(className) ); if(componentClassSorting !== undefined){ return componentClassSorting; } const unknownClassSorting = getCustomOrder( unknownClassPosition, unknownClassOrder, classA, classB, className => { return ( (classA === className && aIndex === null || classB === className && bIndex === null) && !customComponentClasses.includes(className) ); } ); if(unknownClassSorting !== undefined){ return unknownClassSorting; } if(aIndex === bIndex){ return 0; } if(aIndex === null){ return -1; } if(bIndex === null){ return +1; } return +(aIndex - bIndex > 0n) - +(aIndex - bIndex < 0n); }) .map(([className]) => className); if(order === "official"){ return [officiallySortedClasses, warnings]; } const { dissectedClasses } = getDissectedClasses(async(ctx), classes); const variantMap: VariantMap = {}; for(const originalClass in dissectedClasses){ const dissectedClass = dissectedClasses[originalClass]; // parse variants manually for custom component classes const variants = dissectedClass.variants ?? originalClass.match(/^(.*):/)?.[1]?.split(":") ?? []; variants.unshift(""); for(let v = 0, variantMapLevel = variantMap; v < variants.length; v++){ const isLastVariant = v === variants.length - 1; variantMapLevel[variants[v]] ??= { dissectedClasses: [], nested: {} }; if(isLastVariant){ variantMapLevel[variants[v]].dissectedClasses.push(dissectedClass); continue; } variantMapLevel = variantMapLevel[variants[v]].nested; } } const strictOrder = getStrictOrder(variantMap); return [strictOrder, warnings]; } type VariantMap = { [variant: string]: { dissectedClasses: DissectedClass[]; nested: VariantMap; }; }; function getStrictOrder(variantMap: VariantMap): string[] { const orderedClasses: string[] = []; const orderedVariants = Object.keys(variantMap).sort((a, b) => { const aIsArbitrary = isArbitrary(a); const bIsArbitrary = isArbitrary(b); // sort arbitrary variants last if(aIsArbitrary && !bIsArbitrary){ return +1; } if(!aIsArbitrary && bIsArbitrary){ return -1; } return 0; }); for(let v = 0; v < orderedVariants.length; v++){ const variant = orderedVariants[v]; const nextVariant = orderedVariants[v + 1]; const variantIsArbitrary = isArbitrary(variant); const nextVariantIsArbitrary = isArbitrary(nextVariant); const { dissectedClasses, nested } = variantMap[variant]; orderedClasses.push(...dissectedClasses.map(dissectedClass => dissectedClass.className)); if(dissectedClasses.length > 0 || !variantIsArbitrary && nextVariantIsArbitrary){ orderedClasses.push(...getStrictOrder(nested)); } } for(let v = 0; v < orderedVariants.length; v++){ const variant = orderedVariants[v]; const nextVariant = orderedVariants[v + 1]; const variantIsArbitrary = isArbitrary(variant); const nextVariantIsArbitrary = isArbitrary(nextVariant); const { dissectedClasses, nested } = variantMap[variant]; if(!(dissectedClasses.length > 0 || !variantIsArbitrary && nextVariantIsArbitrary)){ orderedClasses.push(...getStrictOrder(nested)); } } return orderedClasses; } function getCustomOrder(position: "end" | "start", order: "asc" | "desc" | "preserve", classA: string, classB: string, isCustomClass: (className: string) => boolean): number | undefined { const aIsCustomClass = isCustomClass(classA); const bIsCustomClass = isCustomClass(classB); if(position === "start"){ if(aIsCustomClass && !bIsCustomClass){ return -1; } if(!aIsCustomClass && bIsCustomClass){ return +1; } if(aIsCustomClass && bIsCustomClass){ if(order === "asc"){ return compareClasses(classA, classB); } if(order === "desc"){ return compareClasses(classB, classA); } return 0; } } if(position === "end"){ if(aIsCustomClass && !bIsCustomClass){ return +1; } if(!aIsCustomClass && bIsCustomClass){ return -1; } if(aIsCustomClass && bIsCustomClass){ if(order === "asc"){ return compareClasses(classA, classB); } if(order === "desc"){ return compareClasses(classB, classA); } return 0; } } } function compareClasses(classA: string, classB: string): number { if(classA === classB){ return 0; } return classA < classB ? -1 : +1; } function isArbitrary(variant?: string): boolean { if(!variant){ return false; } return variant.includes("[") && variant.includes("]"); } ================================================ FILE: src/rules/enforce-consistent-important-position.test.ts ================================================ import { describe, it } from "vitest"; import { enforceConsistentImportantPosition } from "better-tailwindcss:rules/enforce-consistent-important-position.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { css, ts } from "better-tailwindcss:tests/utils/template.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version"; describe(enforceConsistentImportantPosition.name, () => { it(`should move the important modifier correct position`, () => { lint( enforceConsistentImportantPosition, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "recommended" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "legacy" }] } ] } ); }); it(`should handle classes with variants correctly`, () => { lint( enforceConsistentImportantPosition, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "recommended" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "legacy" }] } ] } ); }); it(`should handle multiple variants`, () => { lint( enforceConsistentImportantPosition, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "recommended" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "legacy" }] } ] } ); }); it(`should handle multiple classes with mixed important positions`, () => { lint( enforceConsistentImportantPosition, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "recommended" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "legacy" }] } ] } ); }); it(`should handle arbitrary values correctly`, () => { lint( enforceConsistentImportantPosition, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "recommended" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "legacy" }] } ] } ); }); it(`should not report errors for correctly positioned important modifiers`, () => { lint( enforceConsistentImportantPosition, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ position: "recommended" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ position: "legacy" }] } ] } ); }); it(`should not report errors for classes without important modifiers`, () => { lint( enforceConsistentImportantPosition, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ position: "recommended" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ position: "legacy" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)(`should use "recommended" as default position when no option is provided in tailwind >= 4`, () => { lint( enforceConsistentImportantPosition, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 // No options provided - should default to "recommended" } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)(`should use "legacy" as default position when no option is provided in tailwind <= 3`, () => { lint( enforceConsistentImportantPosition, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 // No options provided - should default to "legacy" } ] } ); }); it(`should keep modifiers in the correct position when changing the important position`, () => { lint( enforceConsistentImportantPosition, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "recommended" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ position: "legacy" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should work with prefixed tailwind classes in tailwind <= 3", () => { lint( enforceConsistentImportantPosition, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.config.js": ts` export default { prefix: 'tw-', }; ` }, options: [{ position: "legacy", tailwindConfig: "./tailwind.config.js" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.config.js": ts` export default { prefix: 'tw-', }; ` }, options: [{ position: "legacy", tailwindConfig: "./tailwind.config.js" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should work with prefixed tailwind classes in tailwind >= 4", () => { lint( enforceConsistentImportantPosition, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] } ] } ); }); }); ================================================ FILE: src/rules/enforce-consistent-important-position.ts ================================================ import { description, literal, optional, pipe, strictObject, union } from "valibot"; import { createGetDissectedClasses, getDissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import { buildClass } from "better-tailwindcss:utils/class.js"; import { async } from "better-tailwindcss:utils/context.js"; import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { splitClasses } from "better-tailwindcss:utils/utils.js"; export const enforceConsistentImportantPosition = createRule({ autofix: true, category: "stylistic", description: "Enforce consistent important position for classes.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/enforce-consistent-important-position.md", name: "enforce-consistent-important-position", recommended: false, messages: { position: "Incorrect important position. '{{ className }}' should be '{{ fix }}'." }, schema: strictObject({ position: optional( pipe( union([ literal("legacy"), literal("recommended") ]), description("Preferred position for important classes. 'legacy' places the important modifier (!) at the start of the class name, 'recommended' places it at the end.") ) ) }), initialize: ctx => { createGetDissectedClasses(ctx); }, lintLiterals(ctx, literals) { const { position: configuredPosition } = ctx.options; const position = configuredPosition ?? ( ctx.version.major >= 4 ? "recommended" : "legacy" ); for(const literal of literals){ const classes = splitClasses(literal.content); const { dissectedClasses, warnings } = getDissectedClasses(async(ctx), classes); lintClasses(ctx, literal, (className, index, after) => { const dissectedClass = dissectedClasses[className]; if(!dissectedClass){ return; } const [importantAtStart, importantAtEnd] = dissectedClass.important; if( !importantAtStart && !importantAtEnd || position === "legacy" && importantAtStart || position === "recommended" && importantAtEnd ){ return; } if(ctx.version.major <= 3 && position === "recommended"){ warnings.push({ option: "position", title: `The "${position}" position is not supported in Tailwind CSS v3`, url: `${ctx.docs}#position` }); } const fix = position === "recommended" ? buildClass(ctx, { ...dissectedClass, important: [false, true] }) : buildClass(ctx, { ...dissectedClass, important: [true, false] }); return { data: { className, fix }, fix, id: "position", warnings } as const; }); } } }); ================================================ FILE: src/rules/enforce-consistent-line-wrapping.test.ts ================================================ import eslintParserHTML from "@html-eslint/parser"; import { ESLint } from "eslint"; import { describe, expect, it } from "vitest"; import { enforceConsistentLineWrapping } from "better-tailwindcss:rules/enforce-consistent-line-wrapping.js"; import { eslint } from "better-tailwindcss:tests/utils/eslint.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { prettier } from "better-tailwindcss:tests/utils/prettier.js"; import { css, dedent, jsx, ts } from "better-tailwindcss:tests/utils/template.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version"; import { MatcherType } from "better-tailwindcss:types/rule.js"; import eslintPluginBetterTailwindcss from "../configs/config.js"; describe(enforceConsistentLineWrapping.name, () => { it("should not wrap empty strings", () => { lint( enforceConsistentLineWrapping, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { jsx: `() => `, svelte: `` }, { jsx: `() => `, svelte: `` }, { jsx: `() => `, svelte: `` } ] } ); }); it("should not wrap short lines", () => { lint( enforceConsistentLineWrapping, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { jsx: `() => `, svelte: `` }, { jsx: `() => `, svelte: `` }, { jsx: `() => `, svelte: `` } ] } ); }); it("should collapse unnecessarily wrapped short lines", () => { const dirty = dedent` a b `; const clean = "a b"; lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ printWidth: 60 }] } ] } ); }); it("should not clean up whitespace in single line strings", () => { lint( enforceConsistentLineWrapping, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ printWidth: 60 }] } ] } ); }); it("should wrap and not collapse short lines containing expressions", () => { const expression = "${true ? 'true' : 'false'}"; const incorrect = dedent` a ${expression} `; const correct = dedent` a ${expression} `; lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2 }] } ] } ); }); it("should include previous characters to decide if lines should be wrapped", () => { const dirty = "this string literal is exactly 54 characters in length"; const clean = dedent` this string literal is exactly 54 characters in length `; lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ printWidth: 60 }] } ] } ); }); it("should not insert an empty line if the first class is already too long", () => { const dirty = "this-string-literal-is-exactly-54-characters-in-length"; const clean = dedent` this-string-literal-is-exactly-54-characters-in-length `; lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ printWidth: 50 }] } ] } ); }); it("should disable the `printWidth` limit when set to `0`", () => { lint( enforceConsistentLineWrapping, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ printWidth: 0 }] } ] } ); }); it("should change the quotes in defined call signatures to backticks", () => { const dirtyDefined = "defined('a b c d e f g h')"; const cleanDefined = dedent`defined(\` a b c d e f g h \`)`; const dirtyUndefined = "notDefined('a b c d e f g h')"; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ callees: ["defined"], classesPerLine: 3, indent: 2 }] } ], valid: [ { jsx: `() => `, svelte: ``, options: [{ callees: ["defined"], classesPerLine: 3, indent: 2 }] } ] } ); lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ callees: ["defined"], classesPerLine: 3, indent: 2 }] } ], valid: [ { jsx: `() => `, svelte: ``, options: [{ callees: ["defined"], classesPerLine: 3, indent: 2 }] } ] } ); }); it("should change the quotes in defined variables to backticks", () => { const dirtyDefined = `const defined = "a b c d e f g h"`; const cleanDefined = dedent`const defined = \` a b c d e f g h \``; const dirtyUndefined = `const notDefined = "a b c d e f g h"`; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2, variables: ["defined"] }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, options: [{ classesPerLine: 3, indent: 2, variables: ["defined"] }] } ] } ); }); it("should change the quotes in conditional expressions to backticks", () => { const dirtyConditionalExpression = `true ? "1 2 3 4 5 6 7 8" : "9 10 11 12 13 14 15 16"`; const cleanConditionalExpression = `true ? \`\n 1 2 3\n 4 5 6\n 7 8\n\` : \`\n 9 10 11\n 12 13 14\n 15 16\n\``; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 3, indent: 2 }] } ] } ); }); it("should change the quotes in logical expressions to backticks", () => { const dirtyLogicalExpression = `true && "1 2 3 4 5 6 7 8"`; const cleanLogicalExpression = `true && \`\n 1 2 3\n 4 5 6\n 7 8\n\``; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2 }] } ] } ); }); it("should change the quotes in arrays to backticks", () => { const dirtyArray = `["1 2 3 4 5 6 7 8", "9 10 11 12 13 14 15 16"]`; const cleanArray = `[\`\n 1 2 3\n 4 5 6\n 7 8\n\`, \`\n 9 10 11\n 12 13 14\n 15 16\n\`]`; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 3, indent: 2 }] } ] } ); }); it("should always preserve the original quotes in attributes", () => { const singleLine = " a b c d e f g h "; const multipleLines = dedent` a b c d e f g h `; lint(enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2 }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2 }] } ] }); }); it("should change the quotes to backticks in attribute expressions", () => { const singleLine = " a b c d e f g h "; const multipleLines = dedent` a b c d e f g h `; lint(enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2 }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2 }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2 }] } ] }); }); it("should wrap expressions correctly", () => { const expression = "${true ? 'true' : 'false'}"; const singleLineWithExpressionAtBeginning = `${expression} a b c d e f g h `; const multilineWithExpressionAtBeginning = dedent` ${expression} a b c d e f g h `; const singleLineWithExpressionInCenter = `a b c ${expression} d e f g h `; const multilineWithExpressionInCenter = dedent` a b c ${expression} d e f g h `; const singleLineWithExpressionAtEnd = `a b c d e f g h ${expression}`; const multilineWithExpressionAtEnd = dedent` a b c d e f g h ${expression} `; const singleLineWithClassesAroundExpression = `a b ${expression} c d e f g h `; const multilineWithClassesAroundExpression = dedent` a b ${expression} c d e f g h `; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 3, indent: 2 }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 3, indent: 2 }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 3, indent: 2 }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 4, indent: 2 }] } ] } ); }); it("should not place expressions on a new line when the expression is not surrounded by a space", () => { const expression = "${true ? 'true' : 'false'}"; const singleLineWithExpressionAtBeginningWithStickyClassAtEnd = `${expression}a b c d e f g h `; const multilineWithExpressionAtBeginningWithStickyClassAtEnd = dedent` ${expression}a b c d e f g h `; const singleLineWithExpressionInCenterWithStickyClassAtBeginning = `a b c${expression} d e f g h `; const multilineWithExpressionInCenterWithStickyClassAtBeginning = dedent` a b c${expression} d e f g h `; const singleLineWithExpressionInCenterWithStickyClassAtEnd = `a b c ${expression}d e f g h `; const multilineWithExpressionInCenterWithStickyClassAtEnd = dedent` a b c ${expression}d e f g h `; const singleLineWithExpressionAtEndWithStickyClassAtBeginning = `a b c d e f g h${expression}`; const multilineWithExpressionAtEndWithStickyClassAtBeginning = dedent` a b c d e f g h${expression} `; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 3, indent: 2 }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 3, indent: 2 }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 3, indent: 2 }] }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2, options: [{ classesPerLine: 3, indent: 2 }] } ] } ); }); it("should not add an unnecessary new line after a sticky class", () => { const expression = "${true ? 'true' : 'false'}"; const multilineWithWithStickyClassAtEnd = dedent` ${expression}a `; lint( enforceConsistentLineWrapping, { valid: [ { jsx: `() => `, svelte: ``, options: [{ classesPerLine: 3, indent: 2 }] } ] } ); }); it("should wrap string literals in variable declarations", () => { const dirtyDefined = "const defined = 'a b c d e f g h';"; const dirtyUndefined = "const notDefined = 'a b c d e f g h';"; const cleanDefined = dedent`const defined = \` a b c d e f g h \`;`; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2, variables: ["defined"] }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, options: [{ classesPerLine: 3, indent: 2, variables: ["defined"] }] } ] } ); }); it("should never wrap in an object key", () => { const dirtyObject = dedent`const obj = { "a b c d e f g h": "a b c d e f g h" };`; const cleanObject = dedent`const obj = { "a b c d e f g h": \` a b c d e f g h \` };`; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: dirtyObject, jsxOutput: cleanObject, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2, variables: [ ["obj", [{ match: MatcherType.ObjectKey }, { match: MatcherType.ObjectValue }]] ] }] } ] } ); }); it("should be possible to change the lineBreakStyle to windows", () => { const dirty = " a b c d e f g h "; const clean = "\r\n a b c\r\n d e f\r\n g h\r\n"; lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: 2, lineBreakStyle: "windows" }] } ] } ); }); it("should be possible to change the indentation style to tabs", () => { const dirty = " a b c d e f g h "; const clean = "\n\ta b c\n\td e f\n\tg h\n"; lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => ;`, jsxOutput: `() => ;`, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: "tab" }] } ] } ); }); it("should use tabWidth when checking printWidth", () => { const dirty = "a b c d"; const clean = "\n\ta b c\n\td\n"; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ classesPerLine: 0, indent: "tab", printWidth: 10, tabWidth: 4 }] } ] } ); }); it("should default tabWidth to 1 when it is not configured", () => { lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ classesPerLine: 0, indent: "tab", printWidth: 10 }] } ] } ); }); it("should not apply tabWidth when indentation uses spaces", () => { lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ classesPerLine: 0, indent: 2, printWidth: 10, tabWidth: 8 }] } ] } ); }); it("should still ignore printWidth when it is set to 0 even with tabWidth", () => { const dirty = "a b c d"; const clean = "\n\ta b c\n\td\n"; lint( enforceConsistentLineWrapping, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1, options: [{ classesPerLine: 3, indent: "tab", printWidth: 0, tabWidth: 4 }] } ] } ); }); it("should warn if `lineBreakStyle` is likely misconfigured", async () => { { const linter = new ESLint({ overrideConfig: [{ languageOptions: { parser: eslintParserHTML }, plugins: { "better-tailwindcss": eslintPluginBetterTailwindcss }, rules: { "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { classesPerLine: 3, indent: 2, lineBreakStyle: "unix" }] } }] }); const [result] = await linter.lintText(""); const { message } = result.messages.find(message => message.ruleId === "better-tailwindcss/enforce-consistent-line-wrapping")!; expect(message).toContain("Inconsistent line endings detected"); expect(message).toContain("Option `lineBreakStyle` may be misconfigured."); expect(message).toContain(`${enforceConsistentLineWrapping.rule.meta.docs.url}#linebreakstyle`); } { const linter = new ESLint({ overrideConfig: [{ languageOptions: { parser: eslintParserHTML }, plugins: { "better-tailwindcss": eslintPluginBetterTailwindcss }, rules: { "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { classesPerLine: 3, indent: 2, lineBreakStyle: "windows" }] } }] }); const [result] = await linter.lintText(""); const { message } = result.messages.find(message => message.ruleId === "better-tailwindcss/enforce-consistent-line-wrapping")!; expect(message).toContain("Inconsistent line endings detected"); expect(message).toContain("Option `lineBreakStyle` may be misconfigured."); expect(message).toContain(`${enforceConsistentLineWrapping.rule.meta.docs.url}#linebreakstyle`); } }); it("should warn if `indent` is likely misconfigured", async () => { const linter = new ESLint({ overrideConfig: [{ languageOptions: { parser: eslintParserHTML }, plugins: { "better-tailwindcss": eslintPluginBetterTailwindcss }, rules: { "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { classesPerLine: 3, indent: 2 }] } }] }); const [result] = await linter.lintText(""); const { message } = result.messages.find(message => message.ruleId === "better-tailwindcss/enforce-consistent-line-wrapping")!; expect(message).toContain("Inconsistent indentation detected"); expect(message).toContain("Option `indent` may be misconfigured."); expect(message).toContain(`${enforceConsistentLineWrapping.rule.meta.docs.url}#indent`); }); it("should not warn for double spaces between classes", async () => { const linter = new ESLint({ overrideConfig: [{ languageOptions: { parser: eslintParserHTML }, plugins: { "better-tailwindcss": eslintPluginBetterTailwindcss }, rules: { "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { classesPerLine: 3, indent: "tab" }] } }] }); const [result] = await linter.lintText(""); const { message } = result.messages.find(message => message.ruleId === "better-tailwindcss/enforce-consistent-line-wrapping")!; expect(message).not.toContain("Inconsistent indentation detected"); expect(message).not.toContain("Option `indent` may be misconfigured."); }); it("should not warn for leading spaces in single-line class strings", async () => { const linter = new ESLint({ overrideConfig: [{ languageOptions: { parser: eslintParserHTML }, plugins: { "better-tailwindcss": eslintPluginBetterTailwindcss }, rules: { "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { classesPerLine: 3, indent: "tab" }] } }] }); const [result] = await linter.lintText(""); const { message } = result.messages.find(message => message.ruleId === "better-tailwindcss/enforce-consistent-line-wrapping")!; expect(message).not.toContain("Inconsistent indentation detected"); expect(message).not.toContain("Option `indent` may be misconfigured."); }); // #52 it("should wrap expressions even if `group` is set to `never`", () => { const expression = "${true ? 'b' : 'c'}"; const correct = dedent` a ${expression} d `; lint( enforceConsistentLineWrapping, { valid: [ { jsx: `() => `, svelte: ``, options: [{ group: "never", indent: 2 }] } ] } ); }); it("should be possible to change group separation by emptyLines", () => { lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ group: "emptyLine", indent: 2 }] } ] } ); }); it("should be possible to change group separation to emptyLine", () => { lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ group: "emptyLine", indent: 2 }] } ] } ); }); it("should be wrap groups according to preferSingleLine", () => { lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ indent: 2, preferSingleLine: true }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ classesPerLine: 6, indent: 2, preferSingleLine: true, printWidth: 0 }] } ], valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ indent: 2, preferSingleLine: true }] } ] } ); }); it("should still start on a new line when `group` is set to `never` except if `preferSingleLine` is enabled", () => { lint( enforceConsistentLineWrapping, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ group: "never", preferSingleLine: false, printWidth: 100 }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ group: "never", preferSingleLine: true, printWidth: 100 }] } ] } ); }); it("should remove duplicate classes in string literals in defined tagged template literals", () => { lint( enforceConsistentLineWrapping, { invalid: [ { jsx: "defined` a b c d e f g `", jsxOutput: "defined`\n a b c\n d e f\n g\n`", svelte: "", svelteOutput: "", vue: "", vueOutput: "", errors: 1, options: [{ classesPerLine: 3, indent: 2, tags: ["defined"] }] } ], valid: [ { jsx: "notDefined` a b c d e f g`", svelte: "", vue: "notDefined` a b c d e f g`", options: [{ classesPerLine: 3, indent: 2, tags: ["defined"] }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should ignore prefixed variants in tailwind <= 3", () => { lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.config.prefix.js": ts` export default { prefix: 'tw-', }; ` }, options: [{ tailwindConfig: "./tailwind.config.prefix.js" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should ignore prefixed variants in tailwind >= 4", () => { lint( enforceConsistentLineWrapping, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] } ] } ); }); it("should not group arbitrary styles differently", () => { lint( enforceConsistentLineWrapping, { valid: [ { jsx: `() =>
` } ] } ); }); describe("prettier compatibility", () => { const iterations = [ jsx` () => ( ); `, jsx` () => ( ); `, jsx` () => ( ); `, jsx` () => ( ); `, jsx` () => ( ); ` ]; const cases = [ { input: iterations[0], name: "eslint line wrapping", output: iterations[1] }, { input: iterations[1], name: "prettier class attribute newline", output: iterations[2] }, { input: iterations[2], name: "eslint line collapsing", output: iterations[3] }, { input: iterations[3], name: "prettier class attribute collapsing", output: iterations[4] } ]; it.each(cases)("should conflict with prettier iteration $name", async currentCase => { const index = cases.indexOf(currentCase); const output = index % 2 === 0 ? await eslint( currentCase.input, [{ languageOptions: { parser: eslintParserHTML }, plugins: { "better-tailwindcss": eslintPluginBetterTailwindcss }, rules: { "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 32, strictness: "strict" }] } }] ) : await prettier( currentCase.input, { parser: "babel", printWidth: 32 } ); expect(output.trim()).toBe(currentCase.output); }); it(`should not conflict with prettier when "strictness" is set to "loose"`, async () => { const input = iterations[0]; const eslintOutput = await eslint( input, [{ languageOptions: { parser: eslintParserHTML }, plugins: { "better-tailwindcss": eslintPluginBetterTailwindcss }, rules: { "better-tailwindcss/enforce-consistent-line-wrapping": ["warn", { printWidth: 32, strictness: "loose" }] } }] ); const prettierOutput = await prettier( eslintOutput, { parser: "babel", printWidth: 32 } ); expect(eslintOutput.trim()).toBe(input.trim()); expect(eslintOutput.trim()).toBe(prettierOutput.trim()); }); }); }); ================================================ FILE: src/rules/enforce-consistent-line-wrapping.ts ================================================ import { boolean, description, literal, minValue, number, optional, pipe, strictObject, union } from "valibot"; import { createGetDissectedClasses, getDissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import { async } from "better-tailwindcss:utils/context.js"; import { escapeNestedQuotes } from "better-tailwindcss:utils/quotes.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { display, splitClasses } from "better-tailwindcss:utils/utils.js"; import type { DissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import type { BracesMeta, Literal, QuoteMeta, WhitespaceMeta } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; interface Meta extends QuoteMeta, BracesMeta, WhitespaceMeta { indentation?: string; } export const enforceConsistentLineWrapping = createRule({ autofix: true, category: "stylistic", description: "Enforce consistent line wrapping for tailwind classes.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/enforce-consistent-line-wrapping.md", name: "enforce-consistent-line-wrapping", recommended: true, messages: { missing: "Incorrect line wrapping. Expected\n\n{{ notReadable }}\n\nto be\n\n{{ readable }}", unnecessary: "Unnecessary line wrapping. Expected\n\n{{ notReadable }}\n\nto be\n\n{{ readable }}" }, schema: strictObject({ classesPerLine: optional( pipe( number(), minValue(0), description("The maximum amount of classes per line.") ), 0 ), group: optional( pipe( union([ literal("newLine"), literal("emptyLine"), literal("never") ]), description("Defines how different groups of classes should be separated.") ), "newLine" ), indent: optional( pipe( union([ literal("tab"), pipe(number(), minValue(0)) ]), description("Determines how the code should be indented.") ), 2 ), lineBreakStyle: optional( pipe( union([literal("unix"), literal("windows")]), description("The line break style.") ), "unix" ), preferSingleLine: optional( pipe( boolean(), description("Prefer a single line for different variants.") ), false ), printWidth: optional( pipe( number(), minValue(0), description("The maximum line length before it gets wrapped.") ), 80 ), strictness: optional( pipe( union([ literal("strict"), literal("loose") ]), description("Enable this option if prettier is used in your project.") ), "strict" ), tabWidth: optional( pipe( number(), minValue(1), description("The visual width of a tab character when evaluating printWidth.") ), 1 ) }), initialize: ctx => { createGetDissectedClasses(ctx); }, lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); function lintLiterals(ctx: Context, literals: Literal[]) { const { classesPerLine, group: groupSeparator, messageStyle, preferSingleLine, printWidth, strictness } = ctx.options; for(const literal of literals){ if(!literal.supportsMultiline){ continue; } const lineStartPosition = literal.indentation + getIndentation(ctx); const literalStartPosition = literal.loc.start.column; const prettierStartPosition = lineStartPosition + (literal.attribute ? literal.attribute.length + 1 : 0); const multilineClasses = new Lines(ctx, lineStartPosition); const singlelineClasses = new Lines(ctx, lineStartPosition); const classes = splitClasses(literal.content); const { dissectedClasses, warnings } = getDissectedClasses(async(ctx), classes); const invalidLineBreaks = isLineBreakStyleLikelyMisconfigured(ctx, literal.raw); const invalidIndentations = isIndentationLikelyMisconfigured(ctx, literal.raw); if(invalidLineBreaks){ warnings.push({ option: "lineBreakStyle", title: "Inconsistent line endings detected", url: `${ctx.docs}#linebreakstyle` }); } if(invalidIndentations){ warnings.push({ option: "indent", title: "Inconsistent indentation detected", url: `${ctx.docs}#indent` }); } const groupedClasses = groupClasses(classes, dissectedClasses); if(literal.openingQuote){ if(literal.multilineQuotes?.includes("`")){ multilineClasses.line.addMeta({ openingQuote: "`" }); } else { multilineClasses.line.addMeta({ openingQuote: literal.openingQuote }); } } if(literal.openingQuote && literal.closingQuote){ singlelineClasses.line.addMeta({ closingQuote: literal.closingQuote, openingQuote: literal.openingQuote }); } leadingTemplateLiteralNewLine: if(literal.isInterpolated && literal.closingBraces){ multilineClasses.line.addMeta({ closingBraces: literal.closingBraces }); // skip newline for sticky classes if(literal.leadingWhitespace === "" && groupedClasses){ break leadingTemplateLiteralNewLine; } // skip if no classes are present if(!groupedClasses){ break leadingTemplateLiteralNewLine; } if(groupSeparator === "emptyLine"){ multilineClasses.addLine(); } if( groupSeparator === "emptyLine" || groupSeparator === "newLine" || groupSeparator === "never" ){ multilineClasses.addLine(); multilineClasses.line.indent(); } } if(groupedClasses){ for(let g = 0; g < groupedClasses.length; g++){ const group = groupedClasses.at(g)!; const isFirstGroup = g === 0; if(group.classCount === 0){ continue; } if(isFirstGroup && ( literal.isInterpolated && !literal.closingBraces || !literal.isInterpolated )){ multilineClasses.addLine(); multilineClasses.line.indent(); } if(!isFirstGroup){ if(groupSeparator === "emptyLine"){ multilineClasses.addLine(); } if( groupSeparator === "emptyLine" || groupSeparator === "newLine"){ multilineClasses.addLine(); multilineClasses.line.indent(); } } for(let i = 0; i < group.classCount; i++){ const isFirstClass = i === 0; const isLastClass = i === group.classCount - 1; const className = group.at(i)!; const simulatedLine = multilineClasses.line .clone() .addClass(className); // wrap after the first sticky class if( isFirstClass && literal.leadingWhitespace === "" && literal.isInterpolated && literal.closingBraces ){ multilineClasses.line.addClass(className); // don't add a new line if the first class is also the last if(isLastClass){ break; } if(groupSeparator === "emptyLine"){ multilineClasses.addLine(); } if(groupSeparator === "emptyLine" || groupSeparator === "newLine"){ multilineClasses.addLine(); multilineClasses.line.indent(); } continue; } // wrap before the last sticky class if( isLastClass && literal.trailingWhitespace === "" && literal.isInterpolated && literal.openingBraces ){ // skip wrapping for the first class of a group if(isFirstClass){ multilineClasses.line.addClass(className); continue; } if(groupSeparator === "emptyLine"){ multilineClasses.addLine(); } if(groupSeparator === "emptyLine" || groupSeparator === "newLine"){ multilineClasses.addLine(); multilineClasses.line.indent(); } multilineClasses.line.addClass(className); continue; } // wrap if the length exceeds the limits if( simulatedLine.length > printWidth && printWidth !== 0 || multilineClasses.line.classCount >= classesPerLine && classesPerLine !== 0 ){ // but only if it is not the first class of a group or classes are not grouped if(!isFirstClass || groupSeparator === "never"){ multilineClasses.addLine(); multilineClasses.line.indent(); } } multilineClasses.line.addClass(className); singlelineClasses.line.addClass(className); } } } trailingTemplateLiteralNewLine: if(literal.isInterpolated && literal.openingBraces){ // skip newline for sticky classes if(literal.trailingWhitespace === "" && groupedClasses){ multilineClasses.line.addMeta({ openingBraces: literal.openingBraces }); break trailingTemplateLiteralNewLine; } if(groupSeparator === "emptyLine" && groupedClasses){ multilineClasses.addLine(); } if( groupSeparator === "emptyLine" || groupSeparator === "newLine" || groupSeparator === "never" ){ multilineClasses.addLine(); multilineClasses.line.indent(); } multilineClasses.line.addMeta({ openingBraces: literal.openingBraces }); } if(literal.closingQuote || literal.trailingSemicolon){ multilineClasses.addLine(); multilineClasses.line.indent(lineStartPosition - getIndentation(ctx)); if(literal.multilineQuotes?.includes("`")){ multilineClasses.line.addMeta({ closingQuote: "`" }); } else { multilineClasses.line.addMeta({ closingQuote: literal.closingQuote }); } } // collapse lines if there is no reason for line wrapping or if preferSingleLine is enabled collapse:{ // disallow collapsing if the literal contains variants, except preferSingleLine is enabled if(groupedClasses?.length !== 1 && !preferSingleLine){ break collapse; } // disallow collapsing for interpolated literals if(literal.isInterpolated && (literal.openingBraces || literal.closingBraces)){ break collapse; } // disallow collapsing if the original literal was a single line (keeps original whitespace) if(!literal.content.includes(getLineBreaks(ctx))){ break collapse; } // disallow collapsing if the single line contains more classes than the classesPerLine if(singlelineClasses.line.classCount > classesPerLine && classesPerLine !== 0){ break collapse; } // disallow collapsing if the single line including the element and all previous characters is longer than the printWidth if(literalStartPosition + singlelineClasses.line.length > printWidth && printWidth !== 0){ break collapse; } // add leading space for apply collapse if(literal.leadingApply && !literal.leadingApply.endsWith(" ")){ singlelineClasses.line.addMeta({ leadingWhitespace: " " }); } const fixedClasses = singlelineClasses.line.toString(false); if(literal.raw === fixedClasses){ continue; } ctx.report({ data: { notReadable: display(messageStyle, literal.raw), readable: display(messageStyle, fixedClasses) }, fix: fixedClasses, id: "unnecessary", range: literal.range, warnings }); return; } // skip if class string was empty if(multilineClasses.length === 2){ if(!literal.openingBraces && !literal.closingBraces && literal.content.trim() === ""){ continue; } } // skip line wrapping if preferSingleLine is enabled and the single line does not exceed the printWidth or classesPerLine if( preferSingleLine && ( literalStartPosition + singlelineClasses.line.length <= printWidth && printWidth !== 0 || singlelineClasses.line.classCount <= classesPerLine && classesPerLine !== 0 ) || printWidth === 0 && classesPerLine === 0 ){ continue; } // force skip if prettier would wrap the attribute to a new line and then the single line would fit if(strictness === "loose" && literalStartPosition + singlelineClasses.line.length > printWidth && printWidth !== 0 && prettierStartPosition + singlelineClasses.line.length <= printWidth){ continue; } // skip line wrapping if it is not necessary skip:{ // disallow skipping if class string contains multiple groups if(groupedClasses && groupedClasses.length > 1){ break skip; } // disallow skipping if the original literal was longer than the printWidth if( literalStartPosition + singlelineClasses.line.length > printWidth && printWidth !== 0 || singlelineClasses.line.classCount > classesPerLine && classesPerLine !== 0){ break skip; } // disallow skipping for interpolated literals if(literal.isInterpolated && (literal.openingBraces || literal.closingBraces)){ break skip; } const openingQuoteLength = literal.openingQuote?.length ?? 0; const closingBracesLength = literal.closingBraces?.length ?? 0; const firstLineLength = multilineClasses .at(1) .toString() .trim() .length + openingQuoteLength + closingBracesLength; // disallow skipping if the first line including the element and all previous characters is longer than the printWidth if(literalStartPosition + firstLineLength > printWidth && printWidth !== 0){ break skip; } // disallow skipping if the first line contains more classes than the classesPerLine if(multilineClasses.at(1).classCount > classesPerLine && classesPerLine !== 0){ break skip; } continue; } const fixedClasses = multilineClasses.toString(); if(literal.raw === fixedClasses){ continue; } ctx.report({ data: { notReadable: display(messageStyle, literal.raw), readable: display(messageStyle, fixedClasses) }, fix: literal.surroundingBraces ? `{${fixedClasses}}` : fixedClasses, id: "missing", range: literal.range, warnings }); } } function getIndentation(ctx: Context): number { const { indent } = ctx.options; return indent === "tab" ? 1 : indent ?? 0; } class Lines { private lines: Line[] = []; private currentLine: Line | undefined; private indentation = 0; private ctx: Context; constructor(ctx: Context, indentation: number) { this.ctx = ctx; this.indentation = indentation; this.addLine(); } public at(index: number) { return index >= 0 ? this.lines[index] : this.lines[this.lines.length + index]; } public get line() { return this.currentLine!; } public get length() { return this.lines.length; } public addLine() { const line = new Line(this.ctx, this.indentation); this.lines.push(line); this.currentLine = line; return this; } public toString() { const lineBreaks = getLineBreaks(this.ctx); return this.lines.map( line => line.toString() ).join(lineBreaks); } } class Line { private classes: string[] = []; private meta: Meta = {}; private ctx: Context; private indentation = 0; constructor(ctx: Context, indentation: number) { this.ctx = ctx; this.indentation = indentation; } public indent(start: number = this.indentation) { const { indent } = this.ctx.options; if(indent === "tab"){ this.meta.indentation = "\t".repeat(start); } else { this.meta.indentation = " ".repeat(start); } return this; } public get length() { const line = this.toString(); const { tabWidth } = this.ctx.options; if(tabWidth <= 1 || !line.includes("\t")){ return line.length; } let width = 0; for(let i = 0; i < line.length; i++){ width += line[i] === "\t" ? tabWidth : 1; } return width; } public get classCount() { return this.classes.length; } public get printWidth() { return this.toString().length; } public addMeta(meta: Meta) { this.meta = { ...this.meta, ...meta }; return this; } public addClass(className: string) { this.classes.push(className); return this; } public clone() { const line = new Line(this.ctx, this.indentation); line.classes = [...this.classes]; line.meta = { ...this.meta }; return line; } public toString(indent: boolean = true) { return this.join([ indent ? this.meta.indentation : "", this.meta.openingQuote, this.meta.closingBraces, this.meta.leadingWhitespace ?? "", escapeNestedQuotes( this.join(this.classes), this.meta.openingQuote ?? this.meta.closingQuote ?? "`" ), this.meta.trailingWhitespace ?? "", this.meta.openingBraces, this.meta.closingQuote ], ""); } private join(content: (string | undefined)[], separator: string = " ") { return content .filter(content => content !== undefined) .join(separator); } } function groupClasses(classes: string[], dissectedClasses: DissectedClasses) { if(classes.length === 0){ return; } const groups = new Groups(); for(const className of classes){ const isFirstClass = classes.indexOf(className) === 0; const isFirstGroup = groups.length === 1; const lastGroup = groups.at(-1); const lastClassName = lastGroup?.at(-1); if(lastClassName){ const lastDissectedClass = dissectedClasses[lastClassName]; const currentDissectedClass = dissectedClasses[className]; // parse variants manually for custom component classes const lastVariant = lastDissectedClass.variants?.join() ?? lastClassName.match(/^(.*):/)?.[1] ?? ""; const variant = currentDissectedClass.variants?.join() ?? className.match(/^(.*):/)?.[1] ?? ""; if(lastVariant !== variant && !(isFirstClass && isFirstGroup)){ groups.addGroup(); } } groups.group.addClass(className); } return groups; } class Groups { public readonly groups: Group[] = []; private currentGroup: Group | undefined; constructor() { this.addGroup(); } public get group() { return this.currentGroup!; } public at(index: number) { return this.groups.at(index); } public get length() { return this.groups.length; } public addGroup() { const group = new Group(); this.currentGroup = group; this.groups.push(this.currentGroup); return this; } } class Group { public readonly classes: string[] = []; public get classCount() { return this.classes.length; } public at(index: number) { return this.classes.at(index); } public addClass(className: string) { this.classes.push(className); return this; } } function getLineBreaks(ctx: Context) { const { lineBreakStyle } = ctx.options; return lineBreakStyle === "unix" ? "\n" : "\r\n"; } function isLineBreakStyleLikelyMisconfigured(ctx: Context, original: string) { const { lineBreakStyle } = ctx.options; const hasWindowsLineBreaks = original.includes("\r\n"); const hasUnixLineBreaks = /(^|[^\r])\n/.test(original); return ( hasWindowsLineBreaks && lineBreakStyle === "unix" || hasUnixLineBreaks && lineBreakStyle === "windows" ); } function isIndentationLikelyMisconfigured(ctx: Context, original: string) { const { indent } = ctx.options; const hasSpaceIndentation = /\r?\n +/.test(original); const hasTabIndentation = /\r?\n\t+/.test(original); return ( hasSpaceIndentation && indent === "tab" || hasTabIndentation && typeof indent === "number" ); } ================================================ FILE: src/rules/enforce-consistent-variable-syntax.test.ts ================================================ import { describe, it } from "vitest"; import { enforceConsistentVariableSyntax } from "better-tailwindcss:rules/enforce-consistent-variable-syntax.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { dedent } from "better-tailwindcss:tests/utils/template.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version"; describe(enforceConsistentVariableSyntax.name, () => { it.runIf(getTailwindCSSVersion().major >= 4)("should not report on the preferred syntax in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "variable" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should not report on the preferred syntax in tailwind <= 3", () => { lint( enforceConsistentVariableSyntax, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "variable" }] } ] } ); }); it("should convert shorthands to variables", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should convert variables to parenthesized shorthands in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should convert variables to arbitrary shorthands in tailwind <= 3", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should work when surrounded by underlines in arbitrary syntax in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should work when surrounded by underlines in arbitrary syntax in tailwind <= 3", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should work with variants in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should work with variants in tailwind <= 3", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should work with other classes in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should work with other classes <= tailwind 3", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should work with the important modifier in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should work with the important modifier in tailwind <= 3", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should preserve fallback values in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should preserve fallback values in tailwind <= 3", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should preserve css functions in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should preserve css functions in tailwind <= 3", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should work with nested variables in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should work with nested variables in tailwind <= 3", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should preserve the case sensitivity of the variable name in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should preserve the case sensitivity of the variable name in tailwind <= 3", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should preserve allow special characters in variable names in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should preserve allow special characters in variable names in tailwind <= 3", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should work with multiline classes in tailwind >= 4", () => { const multilineShorthand = dedent` bg-(--primary) hover:bg-(--secondary) `; const multilineArbitrary = dedent` bg-[var(--primary)] hover:bg-[var(--secondary)] `; lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should work with multiline classes in tailwind <= 3", () => { const multilineShorthand = dedent` bg-[--primary] hover:bg-[--secondary] `; const multilineArbitrary = dedent` bg-[var(--primary)] hover:bg-[var(--secondary)] `; lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ syntax: "variable" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should convert arbitrary shorthands to parenthesized shorthands in tailwind >= 4", () => { lint( enforceConsistentVariableSyntax, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ syntax: "shorthand" }] } ] } ); }); it("should not convert variable definitions", () => { lint( enforceConsistentVariableSyntax, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] } ] } ); }); it("should not convert arbitrary variables in arbitrary classes", () => { lint( enforceConsistentVariableSyntax, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] } ] } ); }); it("should not convert if multiple variables are used in the same class", () => { lint( enforceConsistentVariableSyntax, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)( "should not report on custom css functions", () => { lint(enforceConsistentVariableSyntax, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ syntax: "shorthand" }] } ] }); } ); }); ================================================ FILE: src/rules/enforce-consistent-variable-syntax.ts ================================================ import { description, literal, optional, pipe, strictObject, union } from "valibot"; import { createGetDissectedClasses, getDissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import { buildClass } from "better-tailwindcss:utils/class.js"; import { async } from "better-tailwindcss:utils/context.js"; import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { getCachedRegex } from "better-tailwindcss:utils/regex.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { splitClasses } from "better-tailwindcss:utils/utils.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const enforceConsistentVariableSyntax = createRule({ autofix: true, category: "stylistic", description: "Enforce consistent syntax for css variables.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/enforce-consistent-variable-syntax.md", name: "enforce-consistent-variable-syntax", recommended: false, messages: { incorrect: "Incorrect variable syntax: {{ className }}." }, schema: strictObject({ syntax: optional( pipe( union([ literal("shorthand"), literal("variable") ]), description("The syntax to enforce for css variables in tailwindcss class strings.") ), "shorthand" ) }), initialize: ctx => { createGetDissectedClasses(ctx); }, lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); function lintLiterals(ctx: Context, literals: Literal[]) { const { syntax } = ctx.options; for(const literal of literals){ const classes = splitClasses(literal.content); const { dissectedClasses, warnings } = getDissectedClasses(async(ctx), classes); lintClasses(ctx, literal, className => { const dissectedClass = dissectedClasses[className]; if(!dissectedClass){ return; } // skip variable definitions if(dissectedClass.base.includes(":")){ return; } const { after: afterParentheses, before: beforeParentheses, characters: charactersParentheses } = extractBalanced(dissectedClass.base); const { after: afterSquareBrackets, before: beforeSquareBrackets, characters: charactersSquareBrackets } = extractBalanced(dissectedClass.base, "[", "]"); if(syntax === "shorthand"){ if(!charactersSquareBrackets){ return; } if(isBeginningOfArbitraryVariable(charactersSquareBrackets)){ const { after, characters } = extractBalanced(charactersSquareBrackets); if(trimTailwindWhitespace(after).length > 0){ return; } const fixedClass = ctx.version.major >= 4 ? buildClass(ctx, { ...dissectedClass, base: [...beforeSquareBrackets, `(${characters})`, ...afterSquareBrackets].join("") }) : buildClass(ctx, { ...dissectedClass, base: [...beforeSquareBrackets, `[${characters}]`, ...afterSquareBrackets].join("") }); return { data: { className }, fix: fixedClass, id: "incorrect", warnings } as const; } if(isBeginningOfArbitraryShorthand(charactersSquareBrackets)){ if(ctx.version.major <= 3){ return; } const fixedClass = buildClass(ctx, { ...dissectedClass, base: [...beforeSquareBrackets, `(${charactersSquareBrackets})`, ...afterSquareBrackets].join("") }); return { data: { className }, fix: fixedClass, id: "incorrect", warnings } as const; } } if(syntax === "variable"){ if(charactersSquareBrackets && isBeginningOfArbitraryVariable(charactersSquareBrackets)){ return; } if(isBeginningOfArbitraryShorthand(charactersSquareBrackets)){ const fixedClass = buildClass(ctx, { ...dissectedClass, base: [...beforeSquareBrackets, `[var(${charactersSquareBrackets})]`, ...afterSquareBrackets].join("") }); return { data: { className }, fix: fixedClass, id: "incorrect", warnings } as const; } if(isBeginningOfArbitraryShorthand(charactersParentheses)){ const fixedClass = buildClass(ctx, { ...dissectedClass, base: [ ...beforeParentheses, `[var(${charactersParentheses})]`, ...afterParentheses ].join("") }); return { data: { className }, fix: fixedClass, id: "incorrect", warnings } as const; } } }); } } function isBeginningOfArbitraryShorthand(base: string): boolean { return getCachedRegex(/^_*--(?![\w-]+\()/).test(base); } function isBeginningOfArbitraryVariable(base: string): boolean { return getCachedRegex(/^_*var\(_*--/).test(base); } function extractBalanced(className: string, start = "(", end = ")") { const before: string[] = []; const characters: string[] = []; const after: string[] = []; for(let i = 0, parenthesesCount = 0, hasStarted: boolean = false, hasEnded: boolean = false; i < className.length; i++){ if(className[i] === start){ parenthesesCount++; if(!hasStarted){ hasStarted = true; continue; } } if(!hasStarted && !hasEnded){ before.push(className[i]); continue; } if(className[i] === end){ parenthesesCount--; if(parenthesesCount === 0){ hasEnded = true; continue; } } if(!hasEnded){ characters.push(className[i]); continue; } else { after.push(className[i]); } } return { after: after.join(""), before: before.join(""), characters: characters.join("") }; } function trimTailwindWhitespace(className: string): string { return className.replace(/^_+|_+$/g, ""); } ================================================ FILE: src/rules/enforce-consistent-variant-order.test.ts ================================================ import { describe, it } from "vitest"; import { enforceConsistentVariantOrder } from "better-tailwindcss:rules/enforce-consistent-variant-order.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version.js"; describe.runIf(getTailwindCSSVersion().major >= 4)(enforceConsistentVariantOrder.name, () => { it("should move global variants to the beginning", () => { lint(enforceConsistentVariantOrder, { invalid: [ { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] }); }); it("should treat media and feature-query variants as global", () => { lint(enforceConsistentVariantOrder, { invalid: [ { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ], valid: [ { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); it("should support sorting around arbitrary variants", () => { lint(enforceConsistentVariantOrder, { invalid: [ { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] }); }); it("should preserve modifiers when sorting variants", () => { lint(enforceConsistentVariantOrder, { invalid: [ { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] }); }); it("should treat child selector variants as non-global", () => { lint(enforceConsistentVariantOrder, { invalid: [ { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ], valid: [ { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); it("should support custom breakpoints", () => { lint(enforceConsistentVariantOrder, { invalid: [ { angular: ``, angularOutput: ``, astro: ``, astroOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "styles.css": ` @import "tailwindcss"; @theme { --breakpoint-desktop: 80rem; } ` }, options: [{ entryPoint: "styles.css" }] } ] }); }); it("should ignore unknown variants", () => { lint(enforceConsistentVariantOrder, { valid: [ { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); }); describe.runIf(getTailwindCSSVersion().major <= 3)(enforceConsistentVariantOrder.name, () => { it("should not report anything in Tailwind CSS v3", () => { lint(enforceConsistentVariantOrder, { valid: [ { angular: ``, astro: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); }); ================================================ FILE: src/rules/enforce-consistent-variant-order.ts ================================================ import { createGetDissectedClasses, getDissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import { createGetVariantOrder, getVariantOrder } from "better-tailwindcss:tailwindcss/variant-order.js"; import { buildClass } from "better-tailwindcss:utils/class.js"; import { async } from "better-tailwindcss:utils/context.js"; import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { VARIANT_ORDER_FLAGS } from "better-tailwindcss:utils/order.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { splitClasses } from "better-tailwindcss:utils/utils.js"; export const enforceConsistentVariantOrder = createRule({ autofix: true, category: "stylistic", description: "Enforce a consistent variant order for Tailwind classes.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/enforce-consistent-variant-order.md", name: "enforce-consistent-variant-order", recommended: false, messages: { order: "Incorrect variant order. '{{ className }}' should be '{{ fix }}'." }, initialize: ctx => { createGetDissectedClasses(ctx); createGetVariantOrder(ctx); }, lintLiterals: (ctx, literals) => { if(ctx.version.major <= 3){ return; } for(const literal of literals){ const classes = splitClasses(literal.content); const { dissectedClasses, warnings: dissectedWarnings } = getDissectedClasses(async(ctx), classes); const { variantOrder, warnings } = getVariantOrder(async(ctx), classes); const allWarnings = [...dissectedWarnings, ...warnings]; lintClasses(ctx, literal, className => { const dissectedClass = dissectedClasses[className]; if(!dissectedClass?.variants || dissectedClass.variants.length <= 1){ return; } if(dissectedClass.variants.some(variant => variantOrder[variant] === undefined)){ return; } const sortedVariants = dissectedClass.variants.toSorted((variantA, variantB) => { return compareVariantOrder( variantOrder[variantA], variantOrder[variantB] ); }); if(dissectedClass.variants.every((value, index) => value === sortedVariants[index])){ return false; } const fix = buildClass(ctx, { ...dissectedClass, variants: sortedVariants }); return { data: { className, fix }, fix, id: "order", warnings: allWarnings } as const; }); } } }); function compareVariantOrder(orderA: number | undefined, orderB: number | undefined): number { if( orderA === orderB || orderA === undefined || orderB === undefined || orderA < VARIANT_ORDER_FLAGS.GLOBAL && orderB < VARIANT_ORDER_FLAGS.GLOBAL ){ return 0; } if(orderB > orderA){ return +1; } return -1; } ================================================ FILE: src/rules/enforce-logical-properties.test.ts ================================================ import { describe, it } from "vitest"; import { enforceLogicalProperties } from "better-tailwindcss:rules/enforce-logical-properties.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { css } from "better-tailwindcss:tests/utils/template.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version.js"; const testCases = [ ["pl-4", "ps-4"], ["pr-4", "pe-4"], ["pt-4", "pbs-4"], ["pb-4", "pbe-4"], ["ml-4", "ms-4"], ["mr-4", "me-4"], ["mt-4", "mbs-4"], ["mb-4", "mbe-4"], ["scroll-pl-4", "scroll-ps-4"], ["scroll-pr-4", "scroll-pe-4"], ["scroll-pt-4", "scroll-pbs-4"], ["scroll-pb-4", "scroll-pbe-4"], ["scroll-ml-4", "scroll-ms-4"], ["scroll-mr-4", "scroll-me-4"], ["scroll-mt-4", "scroll-mbs-4"], ["scroll-mb-4", "scroll-mbe-4"], ["left-4", "inset-s-4"], ["right-4", "inset-e-4"], ["top-4", "inset-bs-4"], ["bottom-4", "inset-be-4"], ["border-l", "border-s"], ["border-r", "border-e"], ["border-t", "border-bs"], ["border-b", "border-be"], ["border-l-2", "border-s-2"], ["border-r-2", "border-e-2"], ["border-t-2", "border-bs-2"], ["border-b-2", "border-be-2"], ["rounded-l", "rounded-s"], ["rounded-r", "rounded-e"], ["rounded-l-lg", "rounded-s-lg"], ["rounded-r-lg", "rounded-e-lg"], ["rounded-tl", "rounded-ss"], ["rounded-tr", "rounded-se"], ["rounded-br", "rounded-ee"], ["rounded-bl", "rounded-es"], ["rounded-tl-lg", "rounded-ss-lg"], ["rounded-tr-lg", "rounded-se-lg"], ["rounded-br-lg", "rounded-ee-lg"], ["rounded-bl-lg", "rounded-es-lg"], ["text-left", "text-start"], ["text-right", "text-end"], ["float-left", "float-start"], ["float-right", "float-end"], ["clear-left", "clear-start"], ["clear-right", "clear-end"], ["h-4", "block-4"], ["w-4", "inline-4"], ["min-h-4", "min-block-4"], ["min-w-4", "min-inline-4"], ["max-h-4", "max-block-4"], ["max-w-4", "max-inline-4"] ] satisfies [string, string][]; describe.runIf(getTailwindCSSVersion().major >= 4)(enforceLogicalProperties.name, () => { it("should not report valid classes", () => { lint( enforceLogicalProperties, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should fix classes with variants", () => { lint( enforceLogicalProperties, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it("should keep the important modifier", () => { lint( enforceLogicalProperties, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] } ); }); it("should keep negative classes", () => { lint( enforceLogicalProperties, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it("should keep arbitrary values", () => { lint( enforceLogicalProperties, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it("should keep the tailwindcss prefix", () => { lint( enforceLogicalProperties, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] } ] } ); }); it.each(testCases)(`should report "%s"`, (input, output) => { lint( enforceLogicalProperties, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] } ); }); it("should split size classes into logical block and inline classes", () => { lint( enforceLogicalProperties, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); }); ================================================ FILE: src/rules/enforce-logical-properties.ts ================================================ import { createGetDissectedClasses, getDissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import { createGetUnknownClasses, getUnknownClasses } from "better-tailwindcss:tailwindcss/unknown-classes.js"; import { buildClass } from "better-tailwindcss:utils/class.js"; import { async } from "better-tailwindcss:utils/context.js"; import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { replacePlaceholders, splitClasses } from "better-tailwindcss:utils/utils.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const enforceLogicalProperties = createRule({ autofix: true, category: "stylistic", description: "Enforce logical property class names instead of physical directions.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/enforce-logical-properties.md", name: "enforce-logical-properties", recommended: false, messages: { multiple: "Physical class detected. Replace \"{{ className }}\" with logical classes \"{{fix}}\".", single: "Physical class detected. Replace \"{{ className }}\" with logical class \"{{fix}}\"." }, initialize: ctx => { createGetDissectedClasses(ctx); createGetUnknownClasses(ctx); }, lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); const mappings = [ [/^pl-(.*)$/, "ps-$1"], [/^pr-(.*)$/, "pe-$1"], [/^pt-(.*)$/, "pbs-$1"], [/^pb-(.*)$/, "pbe-$1"], [/^ml-(.*)$/, "ms-$1"], [/^mr-(.*)$/, "me-$1"], [/^mt-(.*)$/, "mbs-$1"], [/^mb-(.*)$/, "mbe-$1"], [/^scroll-ml-(.*)$/, "scroll-ms-$1"], [/^scroll-mr-(.*)$/, "scroll-me-$1"], [/^scroll-pl-(.*)$/, "scroll-ps-$1"], [/^scroll-pr-(.*)$/, "scroll-pe-$1"], [/^scroll-mt-(.*)$/, "scroll-mbs-$1"], [/^scroll-mb-(.*)$/, "scroll-mbe-$1"], [/^scroll-pt-(.*)$/, "scroll-pbs-$1"], [/^scroll-pb-(.*)$/, "scroll-pbe-$1"], [/^left-(.*)$/, "inset-s-$1"], [/^right-(.*)$/, "inset-e-$1"], [/^top-(.*)$/, "inset-bs-$1"], [/^bottom-(.*)$/, "inset-be-$1"], [/^border-l$/, "border-s"], [/^border-l-(.*)$/, "border-s-$1"], [/^border-r$/, "border-e"], [/^border-r-(.*)$/, "border-e-$1"], [/^border-t$/, "border-bs"], [/^border-t-(.*)$/, "border-bs-$1"], [/^border-b$/, "border-be"], [/^border-b-(.*)$/, "border-be-$1"], [/^rounded-l$/, "rounded-s"], [/^rounded-l-(.*)$/, "rounded-s-$1"], [/^rounded-r$/, "rounded-e"], [/^rounded-r-(.*)$/, "rounded-e-$1"], [/^rounded-tl$/, "rounded-ss"], [/^rounded-tl-(.*)$/, "rounded-ss-$1"], [/^rounded-tr$/, "rounded-se"], [/^rounded-tr-(.*)$/, "rounded-se-$1"], [/^rounded-br$/, "rounded-ee"], [/^rounded-br-(.*)$/, "rounded-ee-$1"], [/^rounded-bl$/, "rounded-es"], [/^rounded-bl-(.*)$/, "rounded-es-$1"], [/^text-left$/, "text-start"], [/^text-right$/, "text-end"], [/^float-left$/, "float-start"], [/^float-right$/, "float-end"], [/^clear-left$/, "clear-start"], [/^clear-right$/, "clear-end"], [/^h-(.*)$/, "block-$1"], [/^w-(.*)$/, "inline-$1"], [/^min-h-(.*)$/, "min-block-$1"], [/^min-w-(.*)$/, "min-inline-$1"], [/^max-h-(.*)$/, "max-block-$1"], [/^max-w-(.*)$/, "max-inline-$1"], [/^size-(.*)$/, ["block-$1", "inline-$1"]] ] satisfies [before: RegExp, after: string[] | string][]; function lintLiterals(ctx: Context, literals: Literal[]) { for(const literal of literals){ const classes = splitClasses(literal.content); const { dissectedClasses, warnings } = getDissectedClasses(async(ctx), classes); const possibleFixes = Object.values(dissectedClasses).flatMap(dissectedClass => { const replacementBases = getReplacementBases(dissectedClass.base); if(!replacementBases){ return []; } return replacementBases.map(base => buildClass(ctx, { ...dissectedClass, base })); }); const { unknownClasses } = getUnknownClasses(async(ctx), possibleFixes); lintClasses(ctx, literal, className => { const dissectedClass = dissectedClasses[className]; if(!dissectedClass){ return; } const replacementBases = getReplacementBases(dissectedClass.base); if(!replacementBases){ return; } const fixClasses = replacementBases.map(base => buildClass(ctx, { ...dissectedClass, base })); const hasUnknownFix = fixClasses.some(fixClass => unknownClasses.includes(fixClass)); if(hasUnknownFix){ return; } const fix = fixClasses.join(" "); const id = fixClasses.length > 1 ? "multiple" : "single"; return { data: { className, fix }, fix, id, warnings } as const; }); } } function getReplacementBases(base: string): string[] | undefined { for(const [pattern, replacement] of mappings){ const match = base.match(pattern); if(!match){ continue; } return Array.isArray(replacement) ? replacement.map(part => replacePlaceholders(part, match)) : [replacePlaceholders(replacement, match)]; } } ================================================ FILE: src/rules/enforce-shorthand-classes.test.ts ================================================ import { describe, it } from "vitest"; import { enforceShorthandClasses } from "better-tailwindcss:rules/enforce-shorthand-classes.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { css, ts } from "better-tailwindcss:tests/utils/template.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version"; describe(enforceShorthandClasses.name, () => { it("should not report on shorthand classes", () => { lint( enforceShorthandClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should not report on classes that have no shorthand", () => { lint( enforceShorthandClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should collapse multiple classes into their shorthand", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it("should not collapse classes if they have a different sign", () => { lint( enforceShorthandClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should collapse many classes into their shorthand", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4 } ] } ); }); it("should not collapse differing classes into an otherwise valid shorthand", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it("should work with variants", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it("should not report when one instance has a variant", () => { lint( enforceShorthandClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should not report when one instance has a different variant", () => { lint( enforceShorthandClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should handle arbitrary values correctly", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it("should not collapse arbitrary values with different values", () => { lint( enforceShorthandClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should handle complex variants correctly", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it("should not shorten mixed positive and negative values", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should not shorten mixed important and non important classes in tailwind <= 3", () => { lint( enforceShorthandClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should not shorten mixed important and non important classes in tailwind >= 4", () => { lint( enforceShorthandClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should still shorten mixed starting and ending important classes in tailwind >= 4", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should keep important at the start if all classes have important at the start in tailwind >= 4", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should shorten when all classes are important in tailwind <= 3", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should shorten when all classes are important in tailwind >= 4", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should work with prefixed tailwind classes in tailwind <= 3", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, files: { "tailwind.config.js": ts` export default { prefix: 'tw-', }; ` }, options: [{ tailwindConfig: "./tailwind.config.js" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, files: { "tailwind.config.js": ts` export default { prefix: 'tw-', }; ` }, options: [{ tailwindConfig: "./tailwind.config.js" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should work with prefixed tailwind classes in tailwind >= 4", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should not work if the shorthand class doesn't actually exist <= 3", () => { lint( enforceShorthandClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should not work if the shorthand class doesn't actually exist in tailwind >= 4", () => { lint( enforceShorthandClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should not add an additional class if the shorthand class is already present", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 } ] } ); }); it("should shorten multiple variants separately", () => { lint( enforceShorthandClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4 } ] } ); }); }); ================================================ FILE: src/rules/enforce-shorthand-classes.ts ================================================ import { createGetDissectedClasses, getDissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import { createGetUnknownClasses, getUnknownClasses } from "better-tailwindcss:tailwindcss/unknown-classes.js"; import { buildClass } from "better-tailwindcss:utils/class.js"; import { async } from "better-tailwindcss:utils/context.js"; import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { replacePlaceholders, splitClasses } from "better-tailwindcss:utils/utils.js"; import type { DissectedClass, DissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const enforceShorthandClasses = createRule({ autofix: true, category: "stylistic", description: "Enforce shorthand class names instead of longhand class names.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/enforce-shorthand-classes.md", name: "enforce-shorthand-classes", recommended: false, messages: { longhand: "Non shorthand class detected. Expected {{ longhands }} to be {{ shorthands }}", unnecessary: "Unnecessary whitespace" }, initialize: ctx => { createGetDissectedClasses(ctx); createGetUnknownClasses(ctx); }, lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); export type Shorthands = [RegExp[], string[]][][]; export const shorthands = [ [ [[/^w-(.*)$/, /^h-(.*)$/], ["size-$1"]] ], [ [[/^ml-(.*)$/, /^mr-(.*)$/, /^mt-(.*)$/, /^mb-(.*)$/], ["m-$1"]], [[/^mx-(.*)$/, /^my-(.*)$/], ["m-$1"]], [[/^ms-(.*)$/, /^me-(.*)$/], ["mx-$1"]], [[/^ml-(.*)$/, /^mr-(.*)$/], ["mx-$1"]], [[/^mt-(.*)$/, /^mb-(.*)$/], ["my-$1"]] ], [ [[/^pl-(.*)$/, /^pr-(.*)$/, /^pt-(.*)$/, /^pb-(.*)$/], ["p-$1"]], [[/^px-(.*)$/, /^py-(.*)$/], ["p-$1"]], [[/^ps-(.*)$/, /^pe-(.*)$/], ["px-$1"]], [[/^pl-(.*)$/, /^pr-(.*)$/], ["px-$1"]], [[/^pt-(.*)$/, /^pb-(.*)$/], ["py-$1"]] ], [ [[/^border-t-(.*)$/, /^border-b-(.*)$/, /^border-l-(.*)$/, /^border-r-(.*)$/], ["border-$1"]], [[/^border-x-(.*)$/, /^border-y-(.*)$/], ["border-$1"]], [[/^border-s-(.*)$/, /^border-e-(.*)$/], ["border-x-$1"]], [[/^border-l-(.*)$/, /^border-r-(.*)$/], ["border-x-$1"]], [[/^border-t-(.*)$/, /^border-b-(.*)$/], ["border-y-$1"]] ], [ [[/^border-spacing-x-(.*)$/, /^border-spacing-y-(.*)$/], ["border-spacing-$1"]] ], [ [[/^rounded-tl-(.*)$/, /^rounded-tr-(.*)$/, /^rounded-bl-(.*)$/, /^rounded-br-(.*)$/], ["rounded-$1"]], [[/^rounded-t-(.*)$/, /^rounded-b-(.*)$/], ["rounded-$1"]], [[/^rounded-l-(.*)$/, /^rounded-r-(.*)$/], ["rounded-$1"]], [[/^rounded-tl-(.*)$/, /^rounded-tr-(.*)$/], ["rounded-t-$1"]], [[/^rounded-bl-(.*)$/, /^rounded-br-(.*)$/], ["rounded-b-$1"]], [[/^rounded-tl-(.*)$/, /^rounded-bl-(.*)$/], ["rounded-l-$1"]], [[/^rounded-tr-(.*)$/, /^rounded-br-(.*)$/], ["rounded-r-$1"]] ], [ [[/^scroll-mt-(.*)$/, /^scroll-mb-(.*)$/, /^scroll-ml-(.*)$/, /^scroll-mr-(.*)$/], ["scroll-m-$1"]], [[/^scroll-mx-(.*)$/, /^scroll-my-(.*)$/], ["scroll-m-$1"]], [[/^scroll-ms-(.*)$/, /^scroll-me-(.*)$/], ["scroll-mx-$1"]], [[/^scroll-ml-(.*)$/, /^scroll-mr-(.*)$/], ["scroll-mx-$1"]], [[/^scroll-mt-(.*)$/, /^scroll-mb-(.*)$/], ["scroll-my-$1"]] ], [ [[/^scroll-pt-(.*)$/, /^scroll-pb-(.*)$/, /^scroll-pl-(.*)$/, /^scroll-pr-(.*)$/], ["scroll-p-$1"]], [[/^scroll-px-(.*)$/, /^scroll-py-(.*)$/], ["scroll-p-$1"]], [[/^scroll-pl-(.*)$/, /^scroll-pr-(.*)$/], ["scroll-px-$1"]], [[/^scroll-ps-(.*)$/, /^scroll-pe-(.*)$/], ["scroll-px-$1"]], [[/^scroll-pt-(.*)$/, /^scroll-pb-(.*)$/], ["scroll-py-$1"]] ], [ [[/^top-(.*)$/, /^right-(.*)$/, /^bottom-(.*)$/, /^left-(.*)$/], ["inset-$1"]], [[/^inset-x-(.*)$/, /^inset-y-(.*)$/], ["inset-$1"]] ], [ [[/^divide-x-(.*)$/, /^divide-y-(.*)$/], ["divide-$1"]] ], [ [[/^space-x-(.*)$/, /^space-y-(.*)$/], ["space-$1"]] ], [ [[/^gap-x-(.*)$/, /^gap-y-(.*)$/], ["gap-$1"]] ], [ [[/^translate-x-(.*)$/, /^translate-y-(.*)$/], ["translate-$1"]] ], [ [[/^rotate-x-(.*)$/, /^rotate-y-(.*)$/], ["rotate-$1"]] ], [ [[/^skew-x-(.*)$/, /^skew-y-(.*)$/], ["skew-$1"]] ], [ [[/^scale-x-(.*)$/, /^scale-y-(.*)$/, /^scale-z-(.*)$/], ["scale-$1", "scale-3d"]], [[/^scale-x-(.*)$/, /^scale-y-(.*)$/], ["scale-$1"]] ], [ [[/^content-(.*)$/, /^justify-content-(.*)$/], ["place-content-$1"]], [[/^items-(.*)$/, /^justify-items-(.*)$/], ["place-items-$1"]], [[/^self-(.*)$/, /^justify-self-(.*)$/], ["place-self-$1"]] ], [ [[/^overflow-hidden/, /^text-ellipsis/, /^whitespace-nowrap/], ["truncate"]] ] ] satisfies Shorthands; function lintLiterals(ctx: Context, literals: Literal[]) { for(const literal of literals){ const classes = splitClasses(literal.content); const { dissectedClasses, warnings } = getDissectedClasses(async(ctx), classes); const shorthandGroups = getShorthands(ctx, dissectedClasses); const { unknownClasses } = getUnknownClasses( async(ctx), shorthandGroups .flat() .flatMap(([, shorthands]) => shorthands) .flat() ); lintClasses(ctx, literal, (className, index, after) => { for(const shorthandGroup of shorthandGroups){ for(const [longhands, shorthands] of shorthandGroup){ const longhandClasses = longhands.map(longhand => buildClass(ctx, longhand)); if(!longhandClasses.includes(className)){ continue; } if(shorthands.some(shorthand => unknownClasses.includes(shorthand))){ continue; } if(shorthands.every(shorthand => after.includes(shorthand))){ return { fix: "", id: "unnecessary" } as const; } return { data: { longhands: longhandClasses.join(" "), shorthands: shorthands.join(" ") }, fix: shorthands.filter(shorthand => !after.includes(shorthand)).join(" "), id: "longhand", warnings } as const; } } }); } } function getShorthands(ctx: Context, dissectedClasses: DissectedClasses) { const possibleShorthandClassesGroups: [longhands: DissectedClass[], shorthands: string[]][][] = []; for(const shorthandGroup of shorthands){ const sortedShorthandGroup = shorthandGroup.sort((a, b) => b[0].length - a[0].length); const possibleShorthandClasses: [longhands: DissectedClass[], shorthands: string[]][] = []; shorthandLoop: for(const [patterns, substitutes] of sortedShorthandGroup){ const groupedByVariants = Object.values(dissectedClasses).reduce>((acc, dissectedClass) => { const variants = dissectedClass.variants?.join(dissectedClass.separator) ?? ""; acc[variants] ??= []; acc[variants].push(dissectedClass); return acc; }, {}); for(const variantGroup in groupedByVariants){ const longhands: DissectedClass[] = []; const groups: string[] = []; for(const pattern of patterns){ classNameLoop: for(const dissectedClass of groupedByVariants[variantGroup]){ const match = dissectedClass.base.match(pattern); if(!match){ continue classNameLoop; } for(let m = 0; m < match.length; m++){ if(groups[m] === undefined){ groups[m] = match[m]; continue; } if(m === 0){ continue; } if(groups[m] !== match[m]){ continue shorthandLoop; } } longhands.push(dissectedClass); } } const isImportantAtEnd = longhands.some(longhand => longhand.important[1]); const isImportantAtStart = !isImportantAtEnd && longhands.some(longhand => longhand.important[0]); const negative = longhands.some(longhand => longhand.negative); const prefix = longhands[0]?.prefix ?? ""; const variants = longhands[0]?.variants; const separator = longhands[0]?.separator ?? ":"; if( longhands.length !== patterns.length || longhands.some(longhand => (longhand?.important[0] || longhand?.important[1]) !== (isImportantAtStart || isImportantAtEnd)) || longhands.some(longhand => longhand?.negative !== negative) || longhands.some(longhand => longhand?.variants?.join(separator) !== variants?.join(separator)) ){ continue; } if(longhands.length === patterns.length){ possibleShorthandClasses.push([longhands, substitutes.map(substitute => buildClass(ctx, { base: replacePlaceholders(substitute, groups), important: [isImportantAtStart, isImportantAtEnd], negative, prefix, separator, variants }))]); } } } if(possibleShorthandClasses.length > 0){ possibleShorthandClassesGroups.push(possibleShorthandClasses.sort((a, b) => b[0].length - a[0].length)); } } return possibleShorthandClassesGroups; } ================================================ FILE: src/rules/no-conflicting-classes.test.ts ================================================ import { describe, it } from "vitest"; import { noConflictingClasses } from "better-tailwindcss:rules/no-conflicting-classes.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version"; describe.skipIf(getTailwindCSSVersion().major <= 3)(noConflictingClasses.name, () => { it("should not report on non-conflicting tailwind classes", () => { lint( noConflictingClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should report on conflicting tailwind classes", () => { lint( noConflictingClasses, { invalid: [ { angular: `
`, html: `
`, jsx: `() =>
`, svelte: `
`, vue: ``, errors: 2 } ] } ); }); it("should not report on different variants", () => { lint( noConflictingClasses, { valid: [ { angular: `
`, html: `
`, jsx: `() =>
`, svelte: `
`, vue: `` } ] } ); }); it("should not report on the variants itself", () => { lint( noConflictingClasses, { valid: [ { angular: `
`, html: `
`, jsx: `() =>
`, svelte: `
`, vue: `` } ] } ); }); it("should report on the same variants", () => { lint( noConflictingClasses, { invalid: [ { angular: `
`, html: `
`, jsx: `() =>
`, svelte: `
`, vue: ``, errors: 2 } ] } ); }); it("should even report on classes if one of them has an important flag", () => { lint( noConflictingClasses, { invalid: [ { angular: `
`, html: `
`, jsx: `() =>
`, svelte: `
`, vue: ``, errors: 2 } ] } ); }); it("should not report for css properties with an `undefined` value", () => { lint( noConflictingClasses, { valid: [ { angular: `
`, html: `
`, jsx: `() =>
`, svelte: `
`, vue: `` } ] } ); }); // #135 it("should report errors when multiple properties apply the same styles", () => { lint( noConflictingClasses, { invalid: [ { angular: `
`, html: `
`, jsx: `() =>
`, svelte: `
`, vue: ``, errors: 2 } ] } ); }); }); ================================================ FILE: src/rules/no-conflicting-classes.ts ================================================ import { createGetConflictingClasses, getConflictingClasses } from "better-tailwindcss:tailwindcss/conflicting-classes.js"; import { async } from "better-tailwindcss:utils/context.js"; import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { splitClasses } from "better-tailwindcss:utils/utils.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const noConflictingClasses = createRule({ autofix: true, category: "correctness", description: "Disallow classes that produce conflicting styles.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-conflicting-classes.md", name: "no-conflicting-classes", recommended: true, messages: { conflicting: "Conflicting class detected: \"{{ className }}\" and \"{{ conflictingClassString }}\" apply the same CSS properties: \"{{ conflictingPropertiesString }}\"." }, initialize(ctx) { createGetConflictingClasses(ctx); }, lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); function lintLiterals(ctx: Context, literals: Literal[]) { for(const literal of literals){ const classes = splitClasses(literal.content); const { conflictingClasses, warnings } = getConflictingClasses(async(ctx), classes); if(Object.keys(conflictingClasses).length === 0){ continue; } lintClasses(ctx, literal, className => { if(!conflictingClasses[className]){ return; } const conflicts = Object.entries(conflictingClasses[className]); if(conflicts.length === 0){ return; } const conflictingClassNames = conflicts.map(([conflictingClassName]) => conflictingClassName); const conflictingProperties = conflicts.reduce((acc, [, properties]) => { for(const property of properties){ if(!acc.includes(property.cssPropertyName)){ acc.push(property.cssPropertyName); } } return acc; }, []); const conflictingClassString = conflictingClassNames.join(", "); const conflictingPropertiesString = conflictingProperties.map(conflictingProperty => `"${conflictingProperty}"`).join(", "); return { data: { className, conflictingClassString, conflictingPropertiesString }, id: "conflicting", warnings } as const; }); } } ================================================ FILE: src/rules/no-deprecated-classes.test.ts ================================================ import { describe, it } from "vitest"; import { noDeprecatedClasses } from "better-tailwindcss:rules/no-deprecated-classes.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { css } from "better-tailwindcss:tests/utils/template.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version"; const testCases = [ ["shadow", "shadow-sm"], ["inset-shadow", "inset-shadow-sm"], ["drop-shadow", "drop-shadow-sm"], ["blur", "blur-sm"], ["backdrop-blur", "backdrop-blur-sm"], ["rounded", "rounded-sm"], ["bg-opacity-70", undefined], ["text-opacity-70", undefined], ["border-opacity-70", undefined], ["divide-opacity-70", undefined], ["ring-opacity-70", undefined], ["placeholder-opacity-70", undefined], ["flex-shrink", "shrink"], ["flex-shrink-1", "shrink-1"], ["flex-grow", "grow"], ["flex-grow-1", "grow-1"], ["overflow-ellipsis", "text-ellipsis"], ["decoration-slice", "box-decoration-slice"], ["decoration-clone", "box-decoration-clone"], // 4.1 deprecations ["bg-left-top", "bg-top-left"], ["bg-left-bottom", "bg-bottom-left"], ["bg-right-top", "bg-top-right"], ["bg-right-bottom", "bg-bottom-right"], ["object-left-top", "object-top-left"], ["object-left-bottom", "object-bottom-left"], ["object-right-top", "object-top-right"], ["object-right-bottom", "object-bottom-right"] ] satisfies [string, string | undefined][]; describe.runIf(getTailwindCSSVersion().major >= 4)(noDeprecatedClasses.name, () => { it("should not report valid classes", () => { lint( noDeprecatedClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should fix replaceable deprecated classes", () => { lint( noDeprecatedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] } ); }); it("should warn for irreplaceable deprecated classes", () => { lint( noDeprecatedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1 } ] } ); }); it("should work with variants", () => { lint( noDeprecatedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] } ); }); it("should keep the original value", () => { lint( noDeprecatedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] } ); }); it("should keep the important modifier", () => { lint( noDeprecatedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] } ); }); it("should keep the tailwindcss prefix", () => { lint( noDeprecatedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] } ] } ); }); it.each(testCases)(`should report "%s"`, (input, output) => { const hasFix = output !== undefined; if(hasFix){ lint( noDeprecatedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] } ); } else { lint( noDeprecatedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1 } ] } ); } }); }); ================================================ FILE: src/rules/no-deprecated-classes.ts ================================================ import { createGetDissectedClasses, getDissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import { buildClass } from "better-tailwindcss:utils/class.js"; import { async } from "better-tailwindcss:utils/context.js"; import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { replacePlaceholders, splitClasses } from "better-tailwindcss:utils/utils.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const noDeprecatedClasses = createRule({ autofix: true, category: "stylistic", description: "Disallow the use of deprecated Tailwind CSS classes.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-deprecated-classes.md", name: "no-deprecated-classes", recommended: true, messages: { irreplaceable: "Class \"{{ className }}\" is deprecated. Check the tailwindcss documentation for more information: https://tailwindcss.com/docs/upgrade-guide#removed-deprecated-utilities", replaceable: "Deprecated class detected. Replace \"{{ className }}\" with \"{{fix}}\"." }, initialize: ctx => { createGetDissectedClasses(ctx); }, lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); const deprecations = [ [ { major: 4, minor: 0 }, [ [/^shadow$/, "shadow-sm"], [/^inset-shadow$/, "inset-shadow-sm"], [/^drop-shadow$/, "drop-shadow-sm"], [/^blur$/, "blur-sm"], [/^backdrop-blur$/, "backdrop-blur-sm"], [/^rounded$/, "rounded-sm"], [/^bg-opacity-(.*)$/], [/^text-opacity-(.*)$/], [/^border-opacity-(.*)$/], [/^divide-opacity-(.*)$/], [/^ring-opacity-(.*)$/], [/^placeholder-opacity-(.*)$/], [/^flex-shrink$/, "shrink"], [/^flex-shrink-(.*)$/, "shrink-$1"], [/^flex-grow$/, "grow"], [/^flex-grow-(.*)$/, "grow-$1"], [/^overflow-ellipsis$/, "text-ellipsis"], [/^decoration-slice$/, "box-decoration-slice"], [/^decoration-clone$/, "box-decoration-clone"] ] ], [ { major: 4, minor: 1 }, [ [/^bg-left-top$/, "bg-top-left"], [/^bg-left-bottom$/, "bg-bottom-left"], [/^bg-right-top$/, "bg-top-right"], [/^bg-right-bottom$/, "bg-bottom-right"], [/^object-left-top$/, "object-top-left"], [/^object-left-bottom$/, "object-bottom-left"], [/^object-right-top$/, "object-top-right"], [/^object-right-bottom$/, "object-bottom-right"] ] ] ] satisfies [{ major: number; minor: number; }, [before: RegExp, after?: string][]][]; function lintLiterals(ctx: Context, literals: Literal[]) { const { major, minor } = ctx.version; for(const literal of literals){ const classes = splitClasses(literal.content); const { dissectedClasses, warnings } = getDissectedClasses(async(ctx), classes); lintClasses(ctx, literal, className => { const dissectedClass = dissectedClasses[className]; if(!dissectedClass){ return; } for(const [version, deprecation] of deprecations){ if(major < version.major || major === version.major && minor < version.minor){ continue; } for(const [pattern, replacement] of deprecation){ const match = dissectedClass.base.match(pattern); if(!match){ continue; } if(!replacement){ return { data: { className } as Record, id: "irreplaceable", warnings } as const; } const fix = buildClass(ctx, { ...dissectedClass, base: replacePlaceholders(replacement, match) }); return { data: { className, fix }, fix, id: "replaceable", warnings } as const; } } }); } } ================================================ FILE: src/rules/no-duplicate-classes.test.ts ================================================ import { describe, it } from "vitest"; import { noDuplicateClasses } from "better-tailwindcss:rules/no-duplicate-classes.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { dedent } from "better-tailwindcss:tests/utils/template.js"; describe(noDuplicateClasses.name, () => { it("should filter all duplicate classes", () => { lint(noDuplicateClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] }); }); it("should keep the quotes as they are", () => { lint(noDuplicateClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1 }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1 }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1 } ] }); }); it("should remove duplicate classes in multiline strings", () => { const dirty = dedent` b a b `; const clean = dedent` b a `; lint(noDuplicateClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] }); }); it("should remove duplicate classes around expressions in template literals", () => { const dirtyExpression = "${true ? 'true' : 'false'}"; const cleanExpression = "${true ? 'true' : 'false'}"; const dirtyWithExpressions = dedent` a b ${dirtyExpression} a c `; const cleanWithExpressions = dedent` a b ${cleanExpression} c `; lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1 } ] }); }); it("should remove duplicate classes around template literal elements", () => { const dirtyExpression = "${true ? 'true' : 'false'}"; const cleanExpression = "${true ? 'true' : 'false'}"; const dirtyExpressionAtStart = dedent` a b a ${dirtyExpression} `; const cleanExpressionAtStart = dedent` a b ${cleanExpression} `; const dirtyExpressionBetween = dedent` a b a ${dirtyExpression} c b c `; const cleanExpressionBetween = dedent` a b ${cleanExpression} c `; const dirtyExpressionAtEnd = dedent` ${dirtyExpression} a b a `; const cleanExpressionAtEnd = dedent` ${cleanExpression} a b `; lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1 } ] }); lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 3 } ] }); lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1 } ] }); }); it("should remove duplicate classes inside template literal elements", () => { const dirtyExpression = "${true ? ' a b a c ' : ' b a b c '}"; const cleanExpression = "${true ? ' a b ' : ' b a '}"; const dirtyStickyExpressionAtStart = `${dirtyExpression} c `; const cleanStickyExpressionAtStart = `${cleanExpression} c `; const dirtyStickyExpressionBetween = ` c ${dirtyExpression} d `; const cleanStickyExpressionBetween = ` c ${cleanExpression} d `; const dirtyStickyExpressionAtEnd = ` c ${dirtyExpression}`; const cleanStickyExpressionAtEnd = ` c ${cleanExpression}`; lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 4 } ] }); lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 4 } ] }); lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 4 } ] }); }); it("should remove duplicate classes inside nested template literal elements", () => { const dirtyExpression = "${true ? ` a b ${false} a c ` : ` b a b c `}"; const cleanExpression = "${true ? ` a b ${false} ` : ` b a `}"; const dirtyStickyExpressionAtStart = `${dirtyExpression} c `; const cleanStickyExpressionAtStart = `${cleanExpression} c `; const dirtyStickyExpressionBetween = ` c ${dirtyExpression} d `; const cleanStickyExpressionBetween = ` c ${cleanExpression} d `; const dirtyStickyExpressionAtEnd = ` c ${dirtyExpression}`; const cleanStickyExpressionAtEnd = ` c ${cleanExpression}`; lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 4 } ] }); lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 4 } ] }); lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 4 } ] }); }); it("should not remove sticky duplicate classes", () => { const dirtyExpression = "${true ? 'true' : 'false'}"; const cleanExpression = "${true ? 'true' : 'false'}"; const dirtyStickyExpressionAtStart = `${dirtyExpression}a b a b`; const cleanStickyExpressionAtStart = `${cleanExpression}a b a `; const dirtyStickyExpressionBetween = `a b a b${dirtyExpression}c d c d`; const cleanStickyExpressionBetween = `a b b${cleanExpression}c d c `; const dirtyStickyExpressionAtEnd = `a b a b${dirtyExpression}`; const cleanStickyExpressionAtEnd = `a b b${cleanExpression}`; lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1 } ] }); lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2 } ] }); lint(noDuplicateClasses, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 1 } ] }); }); it("should remove duplicate classes in defined call signature arguments", () => { const dirtyDefined = "defined(' a b a ');"; const cleanDefined = "defined(' a b ');"; const dirtyUndefined = "notDefined(\" a b a \");"; lint(noDuplicateClasses, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ callees: ["defined"] }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, vue: ``, options: [{ callees: ["defined"] }] } ] }); lint(noDuplicateClasses, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ callees: ["defined"] }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, vue: ``, options: [{ callees: ["defined"] }] } ] }); }); it("should remove duplicate classes in string literals in defined variable declarations", () => { const dirtyDefined = "const defined = \" a b a \";"; const cleanDefined = "const defined = \" a b \";"; const dirtyUndefined = "const notDefined = \" a b a \";"; const dirtyMultiline = `const defined = \` a b a c \`;`; const cleanMultiline = `const defined = \` a b c \`;`; lint(noDuplicateClasses, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ variables: ["defined"] }] }, { jsx: dirtyMultiline, jsxOutput: cleanMultiline, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1, options: [{ variables: ["defined"] }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, vue: `` } ] }); }); it("should remove duplicate classes in string literals in defined tagged template literals", () => { lint( noDuplicateClasses, { invalid: [ { jsx: "defined` a b a `", jsxOutput: "defined` a b `", svelte: "", svelteOutput: "", vue: "defined` a b a `", vueOutput: "defined` a b `", errors: 1, options: [{ tags: ["defined"] }] } ], valid: [ { jsx: "notDefined` a b a `", svelte: "", vue: "notDefined` a b a `", options: [{ tags: ["defined"] }] } ] } ); }); // #81 it("should not report duplicates for carriage return characters", () => { lint(noDuplicateClasses, { valid: [ { html: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); it("should not report duplicates for newline characters", () => { lint(noDuplicateClasses, { valid: [ { html: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); it("should report fixes with unchanged line endings", () => { lint(noDuplicateClasses, { invalid: [ { html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] }); lint(noDuplicateClasses, { invalid: [ { html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] }); }); }); ================================================ FILE: src/rules/no-duplicate-classes.ts ================================================ import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { isClassSticky, splitClasses } from "better-tailwindcss:utils/utils.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const noDuplicateClasses = createRule({ autofix: true, category: "stylistic", description: "Disallow duplicate class names in tailwind classes.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-duplicate-classes.md", name: "no-duplicate-classes", recommended: true, messages: { duplicate: "Duplicate classname: \"{{ className }}\"." }, lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); function lintLiterals(ctx: Context, literals: Literal[]) { for(const literal of literals){ const parentClasses = literal.priorLiterals ? getClassesFromLiteralNodes(literal.priorLiterals) : []; lintClasses(ctx, literal, (className, index, after) => { const duplicateClassIndex = after.findIndex((afterClass, afterIndex) => afterClass === className && afterIndex < index); // always keep sticky classes if(isClassSticky(literal, index) || isClassSticky(literal, duplicateClassIndex)){ return; } if(parentClasses.includes(className) || duplicateClassIndex !== -1){ return { data: { className }, fix: "", id: "duplicate" } as const; } }); } } function getClassesFromLiteralNodes(literals: Literal[]) { return literals.reduce((combinedClasses, literal) => { if(!literal){ return combinedClasses; } const classes = literal.content; const split = splitClasses(classes); for(const className of split){ if(!combinedClasses.includes(className)){ combinedClasses.push(className); } } return combinedClasses; }, []); } ================================================ FILE: src/rules/no-restricted-classes.test.ts ================================================ import { describe, it } from "vitest"; import { noRestrictedClasses } from "better-tailwindcss:rules/no-restricted-classes.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { dedent } from "better-tailwindcss:tests/utils/template.js"; describe(noRestrictedClasses.name, () => { it("should not report on unrestricted classes", () => { lint(noRestrictedClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); it("should report restricted classes", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, options: [{ restrict: ["container"] }] } ] }); }); it("should report restricted classes matching a regex", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, options: [{ restrict: ["^container$"] }] } ] }); }); it("should report restricted classes with variants", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 2, options: [{ restrict: ["^lg:.*"] }] } ] }); }); it("should report restricted classes containing reserved regex characters", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 2, options: [{ restrict: ["^\\*+:.*"] }] } ] }); }); it("should be possible to disallow the important modifier", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, options: [{ restrict: ["^.*!$"] }] } ] }); }); it("should be possible to provide custom error messages", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: [ { message: "Restricted class: Use '*-success' instead." } ], options: [{ restrict: [{ message: "Restricted class: Use '*-success' instead.", pattern: "^(.*)-green-(.*)$" }] }] } ] }); }); it("should be possible to use matched groups in the error messages", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: [ { message: "Restricted class: Use 'text-success' instead." } ], options: [{ restrict: [{ message: "Restricted class: Use '$1-success' instead.", pattern: "^(.*)-green-500$" }] }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: [ { message: "Restricted class: Use 'bg-success' instead." } ], options: [{ restrict: [{ message: "Restricted class: Use '$1-success' instead.", pattern: "^(.*)-green-500$" }] }] } ] }); }); it("should fix the classes when a fix is provided", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: "Restricted class: Use 'bg-success' instead." }, { message: "Restricted class: Use 'text-success' instead." } ], options: [{ restrict: [{ fix: "$1-success", message: "Restricted class: Use '$1-success' instead.", pattern: "^(text|bg)-green-500$" }] }] } ] }); }); it("should fix only the class name when a variant is used", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: "Restricted class: Use 'lg:text-success' instead." } ], options: [{ restrict: [{ fix: "$1$2-success", message: "Restricted class: Use '$1$2-success' instead.", pattern: "^([a-zA-Z0-9:/_-]*:)?(text|bg)-green-500$" }] }] } ] }); }); it("should fix classes with multiple variants", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: "Restricted class: Use 'lg:hover:text-success' instead." } ], options: [{ restrict: [{ fix: "$1$2-success", message: "Restricted class: Use '$1$2-success' instead.", pattern: "^([a-zA-Z0-9:/_-]*:)?(text|bg)-green-500$" }] }] } ] }); }); it("should match modifiers", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: "Restricted class: Use 'lg:hover:text-success/50' instead." } ], options: [{ restrict: [{ fix: "$1$2-success$3", message: "Restricted class: Use '$1$2-success$3' instead.", pattern: "^([a-zA-Z0-9:/_-]*:)?(text|bg)-green-500(\\/[0-9]{1,3})?$" }] }] } ] }); }); it("should work on multiline literals", () => { const dirty = dedent` bg-green-500 hover:text-green-500 `; const clean = dedent` bg-success hover:text-success `; lint(noRestrictedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: "Restricted class: Use 'bg-success' instead." }, { message: "Restricted class: Use 'hover:text-success' instead." } ], options: [{ restrict: [{ fix: "$1$2-success", message: "Restricted class: Use '$1$2-success' instead.", pattern: "^([a-zA-Z0-9:/_-]*:)?(text|bg)-green-500$" }] }] } ] }); }); it("should not report on classes with the same name but different variants", () => { lint(noRestrictedClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ restrict: [{ message: "Restricted class: Use 'hover:text-success' instead.", pattern: "^hover:text-green-500$" }] }] } ] }); }); it("should be possible to remove classes with a fix", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: "Restricted class: text-green-500 is not allowed." } ], options: [{ restrict: [{ fix: "", message: "Restricted class: text-green-500 is not allowed.", pattern: "^text-green-500$" }] }] } ] }); }); it("should work with mixed string and object restrictions", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: "Custom message for green" }, { message: "Restricted class: \"bg-red-500\"." } ], options: [{ restrict: [ { fix: "text-success", message: "Custom message for green", pattern: "^text-green-500$" }, "^bg-red-500$" ] }] } ] }); }); it("should fallback to empty string for invalid capture groups in messages", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: [ { message: "Restricted class: Use '' instead of 'text-green-500'." } ], options: [{ restrict: [{ message: "Restricted class: Use '$10' instead of '$1'.", pattern: "^(text-green-500)$" }] }] } ] }); }); it("should fallback to empty string for invalid capture groups in fixes", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: [ { message: "Restricted class: \"text-green-500\"." } ], options: [{ restrict: [{ fix: "$10", pattern: "^(text-green-500)$" }] }] } ] }); }); it("should report the first matching restrictions", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: [ { message: "Restricted class: Use 'text-success' instead." } ], options: [{ restrict: [ { message: "Restricted class: Use 'text-success' instead.", pattern: "^text-green-500$" }, { message: "Match any green color class", pattern: ".*green.*" }, "^text-green-500$" ] }] } ] }); }); it("should work with restriction objects without fix and message", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: [ { message: "Restricted class: \"text-green-500\"." } ], options: [{ restrict: [{ pattern: "^text-green-500$" }] }] } ] }); }); it("should fallback to the default message when empty", () => { lint(noRestrictedClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: [ { message: "Restricted class: \"text-green-500\"." } ], options: [{ restrict: [{ message: "", pattern: "^text-green-500$" }] }] } ] }); }); }); ================================================ FILE: src/rules/no-restricted-classes.ts ================================================ import { array, description, optional, pipe, strictObject, string, union } from "valibot"; import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { replacePlaceholders } from "better-tailwindcss:utils/utils.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const noRestrictedClasses = createRule({ autofix: true, category: "correctness", description: "Disallow restricted classes.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-restricted-classes.md", name: "no-restricted-classes", recommended: false, schema: strictObject({ restrict: optional( array( union([ strictObject( { fix: optional( pipe( string(), description("A replacement class") ) ), message: optional( pipe( string(), description("The message to report when a class is restricted.") ) ), pattern: pipe( string(), description("The regex pattern to match restricted classes.") ) } ), string() ]) ), [] ) }), lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); function lintLiterals(ctx: Context, literals: Literal[]) { const { restrict: restrictions } = ctx.options; for(const literal of literals){ lintClasses(ctx, literal, (className, classes) => { for(const restriction of restrictions){ const pattern = typeof restriction === "string" ? restriction : restriction.pattern; const matches = className.match(pattern); if(!matches){ continue; } const message = typeof restriction === "string" || !restriction.message ? `Restricted class: "${className}".` : replacePlaceholders(restriction.message, matches); if(typeof restriction === "string"){ return { message } as const; } if(restriction.fix !== undefined){ return { fix: replacePlaceholders(restriction.fix, matches), message } as const; } return { message } as const; } }); } } ================================================ FILE: src/rules/no-unknown-classes.test.ts ================================================ import { describe, it } from "vitest"; import { noUnknownClasses } from "better-tailwindcss:rules/no-unknown-classes.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { css, ts } from "better-tailwindcss:tests/utils/template.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version"; describe(noUnknownClasses.name, () => { it("should not report standard tailwind classes", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should not report standard tailwind classes with variants", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should not report standard tailwind classes with many variants", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should report standard tailwind classes with an unknown variant in many variants", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1 } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should not report on dynamic utility values in tailwind >= 4", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should report on dynamic utility values in tailwind <= 3", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1 } ] } ); }); it("should report unknown classes", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1 } ] } ); }); it("should be possible to whitelist classes in options", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ ignore: ["unknown"] }] } ] } ); }); it("should be possible to whitelist classes in options via regex", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, options: [{ ignore: ["^ignored-.*$"] }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should not report on registered utility classes in tailwind <= 3", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "plugin.js": ts` export function plugin() { return function({ addUtilities }) { addUtilities({ ".in-plugin": { color: "red" } }); }; } `, "tailwind.config.color.js": ts` import { plugin } from "./plugin.js"; export default { plugins: [ plugin() ], theme: { extend: { colors: { config: "red" } } } }; ` }, options: [{ tailwindConfig: "./tailwind.config.color.js" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should not report on registered utility classes in tailwind >= 4", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "plugin.js": ts` import createPlugin from "tailwindcss/plugin"; export default createPlugin(({ addUtilities }) => { addUtilities({ ".in-plugin": { color: "red" } }); }); `, "tailwind.config.js": ts` export default { theme: { extend: { colors: { config: "red" } } } }; `, "tailwind.css": css` @import "tailwindcss"; @config "./tailwind.config.js"; @plugin "./plugin.js"; @utility in-utility { @apply text-red-500; } ` }, options: [{ entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should ignore custom component classes defined in the component layer in tailwind >= 4", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss"; @layer components { .custom-component { @apply font-bold; } } ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ], valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, files: { "tailwind.css": css` @import "tailwindcss"; @layer components { .custom-component { @apply font-bold; } } ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should ignore custom component classes defined in imported files in tailwind >= 4", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "components.css": css` @layer components { .custom-component { @apply font-bold; } } `, "tailwind.css": css` @import "tailwindcss"; @import "./components.css"; ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "nested/dir/components.css": css` @layer components { .custom-component { @apply font-bold; } } `, "nested/import.css": css` @import "./dir/components.css"; `, "tailwind.css": css` @import "tailwindcss"; @import "./nested/import.css"; ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should ignore classes defined in imported files with layer(components) in tailwind >= 4", () => { lint( noUnknownClasses, { // immediate layer import invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "components.css": css` .custom-component { font-weight: bold; } `, "tailwind.css": css` @import "./components.css" layer(components); ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] }, { // layer import via nested file angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "nested/dir/components.css": css` .custom-component { @apply font-bold; } `, "nested/import.css": css` @import "./dir/components.css"; `, "tailwind.css": css` @import "tailwindcss"; @import "./nested/import.css" layer(components); ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] }, { // layer import in nested file angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "nested/dir/components.css": css` .custom-component { @apply font-bold; } `, "nested/import.css": css` @import "./dir/components.css" layer(components); `, "tailwind.css": css` @import "tailwindcss"; @import "./nested/import.css"; ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should ignore classes defined in imported files in nested components.custom layer in tailwind >= 4", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "nested/dir/components.css": css` .custom-component { @apply font-bold; } `, "nested/import.css": css` @import "./dir/components.css" layer(custom); `, "tailwind.css": css` @import "tailwindcss"; @import "./nested/import.css" layer(components); ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "nested/dir/components.css": css` @layer custom { .custom-component { @apply font-bold; } } `, "nested/import.css": css` @import "./dir/components.css"; `, "tailwind.css": css` @import "tailwindcss"; @import "./nested/import.css" layer(components); ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "nested/dir/components.css": css` @layer components { @layer custom { .custom-component { @apply font-bold; } } } `, "nested/import.css": css` @import "./dir/components.css"; `, "tailwind.css": css` @import "tailwindcss"; @import "./nested/import.css"; ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should not ignore custom classes from other layers in tailwind >= 4", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 2, files: { "tailwind.css": css` @import "tailwindcss"; @layer custom { .custom-component { font-weight: bold; } } ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 2, files: { "tailwind.css": css` @import "tailwindcss"; @layer custom { @layer components { .custom-component { font-weight: bold; } } } ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 2, files: { "./components.css": css` @layer components { .custom-component { font-weight: bold; } } `, "tailwind.css": css` @import "tailwindcss"; @import "./components.css" layer(custom); ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 2, files: { "tailwind.css": css` @import "tailwindcss"; .custom-component { font-weight: bold; } ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should not crash when trying to read custom component classes in a file that doesn't exists in tailwind >= 4", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 2, files: { "tailwind.css": css` @import "tailwindcss"; @import "./does-not-exist.css"; ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should support variants in custom component classes in tailwind >= 4", () => { lint( noUnknownClasses, { // immediate layer import invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "components.css": css` .custom-component { font-weight: bold; } `, "tailwind.css": css` @import "tailwindcss"; @import "./components.css" layer(components); ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "components.css": css` .custom-component { font-weight: bold; } `, "tailwind.css": css` @import "tailwindcss"; @import "./components.css" layer(components); ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should support prefixes in custom component classes in tailwind >= 4", () => { lint( noUnknownClasses, { // immediate layer import invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "components.css": css` .custom-component { font-weight: bold; } `, "tailwind.css": css` @import "tailwindcss" prefix(tw); @import "./components.css" layer(components); ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should work with prefixed tailwind classes tailwind <= 3", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "tailwind.config.prefix.js": ts` export default { prefix: 'tw-', }; ` }, options: [{ tailwindConfig: "./tailwind.config.prefix.js" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should work with prefixed tailwind classes tailwind >= 4", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should not report on DaisyUI classes in tailwind <= 3", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, files: { "tailwind.config.ts": ts` import daisyui from "daisyui"; export default { plugins: [ daisyui ], }; ` }, options: [{ tailwindConfig: "./tailwind.config.ts" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should not report on DaisyUI classes in tailwind >= 4", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "tailwind.css": css` @import "tailwindcss"; @plugin "daisyui"; ` }, options: [{ entryPoint: "./tailwind.css" }] } ] } ); }); it("should not report on groups and peers", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it("should not report on named groups and peers", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should not report on prefixed groups and peers in tailwind <= 3", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, files: { "tailwind.config.js": ts` export default { prefix: 'tw-', }; ` }, options: [{ tailwindConfig: "./tailwind.config.js" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, files: { "tailwind.config.js": ts` export default { prefix: 'tw-', }; ` }, options: [{ tailwindConfig: "./tailwind.config.js" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major <= 3)("should not report on prefixed named groups and peers in tailwind <= 3", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, files: { "tailwind.config.prefix.js": ts` export default { prefix: 'tw-', }; ` }, options: [{ tailwindConfig: "./tailwind.config.prefix.js" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, files: { "tailwind.config.js": ts` export default { prefix: 'tw-', }; ` }, options: [{ tailwindConfig: "./tailwind.config.js" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should not report on prefixed groups and peers in tailwind >= 4", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should not report on prefixed named groups and peers in tailwind >= 4", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, files: { "tailwind.css": css` @import "tailwindcss" prefix(tw); ` }, options: [{ entryPoint: "./tailwind.css" }] } ] } ); }); it("should not report on tailwind utility classes with modifiers", () => { lint( noUnknownClasses, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should support tsconfig paths in tailwind >= 4", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "nested/components/custom-components.css": css` @layer components { .custom-component { @apply font-bold; } } `, "nested/plugins/custom-plugin.js": ts` import createPlugin from "tailwindcss/plugin"; export default createPlugin(({ addUtilities }) => { addUtilities({ ".custom-plugin": { fontWeight: "bold" } }); }); `, "nested/utilities/custom-utilities.css": css` @utility custom-utility { font-weight: bold; } `, "tailwind.css": css` @import "tailwindcss"; @import "@components/custom-components.css"; @import "@utilities/custom-utilities.css"; @plugin "@plugins/custom-plugin.js"; `, "tsconfig.json": ts` { "compilerOptions": { "paths": { "@components/*": ["./nested/components/*"], "@utilities/*": ["./nested/utilities/*"], "@plugins/*": ["./nested/plugins/*"] } } } ` }, options: [{ detectComponentClasses: true, entryPoint: "./tailwind.css" }] } ] } ); }); it.runIf(getTailwindCSSVersion().major >= 4)("should use the provided tsconfig instead of finding one tailwind >= 4", () => { lint( noUnknownClasses, { invalid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: ``, errors: 1, files: { "correct/custom-utilities.css": css` @utility custom-utility { font-weight: bold; } `, "tailwind.css": css` @import "tailwindcss"; @import "@correct/custom-utilities.css"; `, "tsconfig-custom.json": ts` { "compilerOptions": { "paths": { "@correct/*": ["./correct/*"], } } } `, "tsconfig.json": ts` { "compilerOptions": { "paths": { "@unused/*": ["./unused/*"] } } } ` }, options: [{ entryPoint: "./tailwind.css", tsconfig: "./tsconfig-custom.json" }] } ] } ); }); }); ================================================ FILE: src/rules/no-unknown-classes.ts ================================================ import { array, description, optional, pipe, strictObject, string } from "valibot"; import { createGetCustomComponentClasses, getCustomComponentClasses } from "better-tailwindcss:tailwindcss/custom-component-classes.js"; import { createGetPrefix, getPrefix } from "better-tailwindcss:tailwindcss/prefix.js"; import { createGetUnknownClasses, getUnknownClasses } from "better-tailwindcss:tailwindcss/unknown-classes.js"; import { async } from "better-tailwindcss:utils/context.js"; import { escapeForRegex } from "better-tailwindcss:utils/escape.js"; import { lintClasses } from "better-tailwindcss:utils/lint.js"; import { getCachedRegex } from "better-tailwindcss:utils/regex.js"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { splitClasses } from "better-tailwindcss:utils/utils.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const noUnknownClasses = createRule({ autofix: true, category: "correctness", description: "Disallow any css classes that are not registered in tailwindcss.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-unknown-classes.md", name: "no-unknown-classes", recommended: true, messages: { unknown: "Unknown class detected: {{ className }}" }, schema: strictObject({ ignore: optional( pipe( array( string() ), description("A list of regular expression patterns for classes that should be ignored by the rule.") ), [] ) }), initialize: ctx => { const { detectComponentClasses } = ctx.options; createGetPrefix(ctx); createGetUnknownClasses(ctx); if(detectComponentClasses){ createGetCustomComponentClasses(ctx); } }, lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); function lintLiterals(ctx: Context, literals: Literal[]) { const { ignore } = ctx.options; const { prefix, suffix } = getPrefix(async(ctx)); const ignoredGroups = getCachedRegex(`^${escapeForRegex(`${prefix}${suffix}`)}group(?:\\/(\\S*))?$`); const ignoredPeers = getCachedRegex(`^${escapeForRegex(`${prefix}${suffix}`)}peer(?:\\/(\\S*))?$`); const customComponentClassRegexes = getCustomComponentClassRegexes(ctx); for(const literal of literals){ const classes = splitClasses(literal.content); const { unknownClasses, warnings } = getUnknownClasses(async(ctx), classes); if(unknownClasses.length === 0){ continue; } lintClasses(ctx, literal, className => { if(!unknownClasses.includes(className)){ return; } if( ignore.some(ignoredClass => getCachedRegex(ignoredClass).test(className)) || customComponentClassRegexes?.some(customComponentClassesRegex => customComponentClassesRegex.test(className)) || ignoredGroups.test(className) || ignoredPeers.test(className) ){ return; } return { data: { className }, id: "unknown", warnings } as const; }); } } function getCustomComponentClassRegexes(ctx: Context): RegExp[] | undefined { const { detectComponentClasses } = ctx.options; if(!detectComponentClasses){ return; } const { customComponentClasses } = getCustomComponentClasses(async(ctx)); const { prefix, suffix } = getPrefix(async(ctx)); return customComponentClasses.map(className => getCachedRegex(`^${escapeForRegex(`${prefix}${suffix}`)}(?:.*:)?${escapeForRegex(className)}$`)); } ================================================ FILE: src/rules/no-unnecessary-whitespace.test.ts ================================================ import { describe, it } from "vitest"; import { noUnnecessaryWhitespace } from "better-tailwindcss:rules/no-unnecessary-whitespace.js"; import { lint } from "better-tailwindcss:tests/utils/lint.js"; import { dedent } from "better-tailwindcss:tests/utils/template.js"; describe(noUnnecessaryWhitespace.name, () => { it("should trim leading and trailing white space in literals", () => { lint(noUnnecessaryWhitespace, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 3 } ] }); }); it("should trim unnecessary whitespace in concatenated strings", () => { lint(noUnnecessaryWhitespace, { invalid: [ { angular: ``, angularOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 4 }, { angular: ``, angularOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6 } ], valid: [ { angular: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); it("should trim unnecessary whitespace in conditionally concatenated strings", () => { lint(noUnnecessaryWhitespace, { invalid: [ { angular: ``, angularOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 6 } ], valid: [ { angular: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); it("should trim unnecessary whitespace in conditionally concatenated template literal strings", () => { lint(noUnnecessaryWhitespace, { invalid: [ { angular: '', angularOutput: '', jsx: "() => ", jsxOutput: "() => ", svelte: "", svelteOutput: "", vue: '', vueOutput: '', errors: 6 } ], valid: [ { angular: '', jsx: "() => ", svelte: "", vue: '' } ] }); }); it("should remove whitespace in empty strings", () => { lint(noUnnecessaryWhitespace, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] }); }); it("should not report on empty strings", () => { lint(noUnnecessaryWhitespace, { valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); it("should collapse empty multiline strings", () => { const dirtyEmptyMultilineString = ` `; const cleanEmptyMultilineString = ""; lint(noUnnecessaryWhitespace, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 1 } ] }); }); it("should keep the quotes as they are", () => { lint(noUnnecessaryWhitespace, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 3 }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 3 }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 3 }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 3 }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 3 } ] }); }); it("should keep one whitespace around template elements", () => { lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 7 } ] }); }); it("should keep no whitespace at the end of the line in multiline strings", () => { const dirty = dedent` a b c `; const clean = dedent` a b c `; lint(noUnnecessaryWhitespace, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 3 } ] }); }); it("should remove unnecessary whitespace inside and around multiline template literal elements", () => { const dirtyExpression = "${true ? ' true ' : ' false '}"; const cleanExpression = "${true ? 'true' : 'false'}"; const dirtyExpressionAtStart = dedent` ${dirtyExpression} a `; const cleanExpressionAtStart = dedent` ${cleanExpression} a `; const dirtyExpressionBetween = dedent` a ${dirtyExpression} b `; const cleanExpressionBetween = dedent` a ${cleanExpression} b `; const dirtyExpressionAtEnd = dedent` a ${dirtyExpression} `; const cleanExpressionAtEnd = dedent` a ${cleanExpression} `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 6 } ] }); lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 7 } ] }); lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 6 } ] }); }); it("should remove unnecessary whitespace inside and around single line template literal elements", () => { const dirtyExpression = "${true ? ' true ' : ' false '}"; const cleanExpression = "${true ? 'true' : 'false'}"; const dirtyExpressionAtStartAtStart = ` ${dirtyExpression} a `; const cleanExpressionAtStart = `${cleanExpression} a`; const dirtyExpressionBetween = ` a ${dirtyExpression} b `; const cleanExpressionBetween = `a ${cleanExpression} b`; const dirtyExpressionAtEnd = ` a ${dirtyExpression} `; const cleanExpressionAtEnd = `a ${cleanExpression}`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 7 } ] }); lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 8 } ] }); lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 7 } ] }); }); it("should not create a whitespace around sticky template literal elements", () => { const dirtyExpression = "${true ? ' true ' : ' false '}"; const cleanExpression = "${true ? 'true' : 'false'}"; const dirtyStickyExpressionAtStart = ` ${dirtyExpression}a b `; const cleanStickyExpressionAtStart = `${cleanExpression}a b`; const dirtyStickyExpressionBetween = ` a b${dirtyExpression}c d `; const cleanStickyExpressionBetween = `a b${cleanExpression}c d`; const dirtyStickyExpressionAtEnd = ` a${dirtyExpression} `; const cleanStickyExpressionAtEnd = `a${cleanExpression}`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 7 } ] }); lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 8 } ] }); lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 6 } ] }); }); it("should remove newlines whenever possible", () => { const uncleanedMultilineString = ` d c b a `; const cleanedMultilineString = ` d c b a `; const cleanedSinglelineString = "d c b a"; lint(noUnnecessaryWhitespace, { invalid: [ { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2 }, { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 2 }, { angular: ``, angularOutput: ``, html: ``, htmlOutput: ``, jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 5, options: [{ allowMultiline: false }] } ], valid: [ { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` }, { angular: ``, html: ``, jsx: `() => `, svelte: ``, vue: `` } ] }); }); it("should remove unnecessary whitespace in defined call signature arguments", () => { const dirtyDefined = "defined(' f e ');"; const cleanDefined = "defined('f e');"; const dirtyUndefined = "notDefined(\" f e \");"; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 3, options: [{ callees: ["defined"] }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, vue: ``, options: [{ callees: ["defined"] }] } ] }); lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 3, options: [{ callees: ["defined"] }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, vue: ``, options: [{ callees: ["defined"] }] } ] }); }); it("should also work in defined call signature arguments in template literals", () => { const dirtyDefined = "${defined(' f e ')}"; const cleanDefined = "${defined('f e')}"; const dirtyUndefined = "${notDefined(' f e ')}"; const dirtyDefinedMultiline = ` b a d c ${dirtyDefined} h g j i `; const cleanDefinedMultiline = ` b a d c ${cleanDefined} h g j i `; const dirtyUndefinedMultiline = ` b a d c ${dirtyUndefined} h g j i `; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: `() => `, jsxOutput: `() => `, svelte: ``, svelteOutput: ``, errors: 3, options: [{ callees: ["defined"] }] } ], valid: [ { jsx: `() => `, svelte: `` } ] }); }); it("should remove unnecessary whitespace in string literals in defined variable declarations", () => { const dirtyDefined = "const defined = \" b a \";"; const cleanDefined = "const defined = \"b a\";"; const dirtyUndefined = "const notDefined = \" b a \";"; const dirtyMultiline = `const defined = \` b a d c \`;`; const cleanMultiline = `const defined = \` b a d c \`;`; lint(noUnnecessaryWhitespace, { invalid: [ { jsx: dirtyDefined, jsxOutput: cleanDefined, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 3, options: [{ variables: ["defined"] }] }, { jsx: dirtyMultiline, jsxOutput: cleanMultiline, svelte: ``, svelteOutput: ``, vue: ``, vueOutput: ``, errors: 2, options: [{ variables: ["defined"] }] } ], valid: [ { jsx: dirtyUndefined, svelte: ``, vue: `` } ] }); }); it("should remove unnecessary whitespace in string literals in defined tagged template literals", () => { lint( noUnnecessaryWhitespace, { invalid: [ { jsx: "defined` b a `", jsxOutput: "defined`b a`", svelte: "", svelteOutput: "", vue: "defined` b a `", vueOutput: "defined`b a`", errors: 3, options: [{ tags: ["defined"] }] } ], valid: [ { jsx: "notDefined` b a `", svelte: "", vue: "notDefined` b a `", options: [{ tags: ["defined"] }] } ] } ); }); // #144 it("should not remove the whitespace between two template literals", () => { lint(noUnnecessaryWhitespace, { valid: [ { angular: "", jsx: "() => ", svelte: "", vue: "" }, { angular: "", jsx: "() => ", svelte: "", vue: "" }, { angular: "", jsx: "() => ", svelte: "", vue: "" } ] }); }); }); ================================================ FILE: src/rules/no-unnecessary-whitespace.ts ================================================ import { boolean, description, optional, pipe, strictObject } from "valibot"; import { createRule } from "better-tailwindcss:utils/rule.js"; import { splitClasses, splitWhitespaces } from "better-tailwindcss:utils/utils.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Context } from "better-tailwindcss:types/rule.js"; export const noUnnecessaryWhitespace = createRule({ autofix: true, category: "stylistic", description: "Disallow unnecessary whitespace between Tailwind CSS classes.", docs: "https://github.com/schoero/eslint-plugin-better-tailwindcss/blob/main/docs/rules/no-unnecessary-whitespace.md", name: "no-unnecessary-whitespace", recommended: true, messages: { unnecessary: "Unnecessary whitespace." }, schema: strictObject({ allowMultiline: optional(pipe( boolean(), description("Allow multi-line class declarations. 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/enforce-consistent-line-wrapping](./enforce-consistent-line-wrapping.md).") ), true) }), lintLiterals: (ctx, literals) => lintLiterals(ctx, literals) }); function lintLiterals(ctx: Context, literals: Literal[]) { const { allowMultiline } = ctx.options; for(const literal of literals){ const classChunks = splitClasses(literal.content); const whitespaceChunks = splitWhitespaces(literal.content); for(let whitespaceIndex = 0, stringIndex = 0; whitespaceIndex < whitespaceChunks.length; whitespaceIndex++){ const isFirstChunk = whitespaceIndex === 0; const isLastChunk = whitespaceIndex === whitespaceChunks.length - 1; const startIndex = stringIndex + (literal.openingQuote?.length || 0) + (literal.closingBraces?.length || 0); const whitespace = whitespaceChunks[whitespaceIndex]; stringIndex += whitespace.length; const endIndex = startIndex + whitespace.length; const className = classChunks[whitespaceIndex] ?? ""; stringIndex += className.length; const [literalStart] = literal.range; const keepLeadingWhitespace = literal.isConcatenatedLeft === true; const keepTrailingWhitespace = literal.isConcatenatedRight === true; // whitespaces only if(classChunks.length === 0 && !literal.closingBraces && !literal.openingBraces){ if(keepLeadingWhitespace || keepTrailingWhitespace){ if(whitespace.length <= 1){ continue; } ctx.report({ fix: " ", id: "unnecessary", range: [ literalStart + startIndex, literalStart + endIndex ] }); continue; } if(whitespace === ""){ continue; } ctx.report({ fix: "", id: "unnecessary", range: [ literalStart + startIndex, literalStart + endIndex ] }); continue; } // trailing whitespace before multiline string if(whitespace.includes("\n") && allowMultiline === true){ const whitespaceWithoutLeadingSpaces = whitespace.replace(/^ +/, ""); if(whitespace === whitespaceWithoutLeadingSpaces){ continue; } ctx.report({ fix: whitespaceWithoutLeadingSpaces, id: "unnecessary", range: [ literalStart + startIndex, literalStart + endIndex ] }); continue; } // whitespace between interpolated literals if( !isFirstChunk && !isLastChunk || ( literal.isInterpolated && literal.closingBraces && isFirstChunk && !isLastChunk || literal.isInterpolated && literal.openingBraces && isLastChunk && !isFirstChunk || literal.isInterpolated && literal.closingBraces && literal.openingBraces ) ){ if(whitespace.length <= 1){ continue; } ctx.report({ fix: " ", id: "unnecessary", range: [ literalStart + startIndex, literalStart + endIndex ] }); continue; } // leading or trailing whitespace if(isFirstChunk || isLastChunk){ const keepCurrentWhitespace = isFirstChunk && keepLeadingWhitespace || isLastChunk && keepTrailingWhitespace; if(keepCurrentWhitespace){ if(whitespace.length <= 1){ continue; } ctx.report({ fix: " ", id: "unnecessary", range: [ literalStart + startIndex, literalStart + endIndex ] }); continue; } if(whitespace === ""){ continue; } ctx.report({ fix: "", id: "unnecessary", range: [ literalStart + startIndex, literalStart + endIndex ] }); continue; } } } } ================================================ FILE: src/tailwindcss/canonical-classes.async.v4.ts ================================================ import { getUnknownClasses } from "./unknown-classes.async.v4.js"; import type { CanonicalClasses, CanonicalClassOptions } from "./canonical-classes.js"; export function getCanonicalClasses(tailwindContext: any, classes: string[], options: CanonicalClassOptions): CanonicalClasses { const result: CanonicalClasses = {}; if(typeof tailwindContext?.canonicalizeCandidates !== "function"){ for(const className of classes){ result[className] = { input: [className], output: className }; } return result; } // tailwind currently crashes when unknown classes are passed to canonicalizeCandidates const unknownClasses = getUnknownClasses(tailwindContext, classes); const knownClasses = classes.filter(className => !unknownClasses.includes(className)); const canonicalizedClasses = tailwindContext.canonicalizeCandidates?.(knownClasses, options); const removedClasses = knownClasses.filter(className => !canonicalizedClasses.includes(className)); const originalClasses = knownClasses.filter(className => canonicalizedClasses.includes(className)); const canonicalClasses = canonicalizedClasses.filter(className => !classes.includes(className)); for(const originalClass of originalClasses){ result[originalClass] = { input: [originalClass], output: originalClass }; } for(const unknownClass of unknownClasses){ result[unknownClass] = { input: [unknownClass], output: unknownClass }; } if(canonicalClasses.length === 0){ return result; } for(const canonicalClass of canonicalClasses){ const necessaryClasses = removedClasses.filter(removedClass => { const subset = removedClasses.filter(className => className !== removedClass); const subsetCanonical = tailwindContext.canonicalizeCandidates( subset, options ); return !subsetCanonical.includes(canonicalClass); }); for(const originalClass of necessaryClasses){ result[originalClass] = { input: necessaryClasses, output: canonicalClass }; } } return result; } ================================================ FILE: src/tailwindcss/canonical-classes.ts ================================================ import { resolve } from "node:path"; import { createSyncFn } from "synckit"; import { getWorkerOptions } from "better-tailwindcss:utils/worker.js"; import type { Warning } from "better-tailwindcss:types/async.js"; import type { Context } from "better-tailwindcss:types/rule.js"; import type { AsyncContext } from "better-tailwindcss:utils/context.js"; export type CanonicalClasses = { [originalClass: string]: { input: string[]; output: string; }; }; export type CanonicalClassOptions = { collapse: boolean | undefined; logicalToPhysical: boolean | undefined; rem: number | undefined; }; export type GetCanonicalClasses = (ctx: AsyncContext, classes: string[], options: CanonicalClassOptions) => { canonicalClasses: CanonicalClasses; warnings: (Warning | undefined)[]; }; export let getCanonicalClasses: GetCanonicalClasses = () => { throw new Error("getCanonicalClasses() called before being initialized"); }; export function createGetCanonicalClasses(ctx: Context): GetCanonicalClasses { const workerPath = getWorkerPath(ctx); const workerOptions = getWorkerOptions(); const runWorker = createSyncFn(workerPath, workerOptions); getCanonicalClasses = (ctx, classes, options) => runWorker("getCanonicalClasses", ctx, classes, options); return getCanonicalClasses; } function getWorkerPath(ctx: Context) { return resolve(import.meta.dirname, `./tailwind.async.worker.v${ctx.version.major}.js`); } ================================================ FILE: src/tailwindcss/class-order.async.v3.ts ================================================ import type { ClassOrder } from "./class-order.js"; export function getClassOrder(tailwindContext: any, classes: string[]): ClassOrder { return tailwindContext.getClassOrder(classes); } ================================================ FILE: src/tailwindcss/class-order.async.v4.ts ================================================ import type { ClassOrder } from "./class-order.js"; export function getClassOrder(tailwindContext: any, classes: string[]): ClassOrder { return tailwindContext.getClassOrder(classes); } ================================================ FILE: src/tailwindcss/class-order.ts ================================================ import { resolve } from "node:path"; import { createSyncFn } from "synckit"; import { getWorkerOptions } from "better-tailwindcss:utils/worker.js"; import type { Warning } from "better-tailwindcss:types/async.js"; import type { Context } from "better-tailwindcss:types/rule.js"; import type { AsyncContext } from "better-tailwindcss:utils/context.js"; export type ClassOrder = [className: string, order: bigint | null][]; export type GetClassOrder = (ctx: AsyncContext, classes: string[]) => { classOrder: ClassOrder; warnings: (Warning | undefined)[]; }; export let getClassOrder: GetClassOrder = () => { throw new Error("getClassOrder() called before being initialized"); }; export function createGetClassOrder(ctx: Context): GetClassOrder { const workerPath = getWorkerPath(ctx); const workerOptions = getWorkerOptions(); const runWorker = createSyncFn(workerPath, workerOptions); getClassOrder = (ctx, classes) => runWorker("getClassOrder", ctx, classes); return getClassOrder; } function getWorkerPath(ctx: Context) { return resolve(import.meta.dirname, `./tailwind.async.worker.v${ctx.version.major}.js`); } ================================================ FILE: src/tailwindcss/conflicting-classes.async.v4.ts ================================================ import type { ConflictingClasses } from "./conflicting-classes.js"; export async function getConflictingClasses(tailwindContext: any, classes: string[]): Promise { const conflicts: ConflictingClasses = {}; const classRules = classes.reduce>((classRules, className) => ({ ...classRules, [className]: tailwindContext.parseCandidate(className).reduce((classRules, candidate) => { const [rule] = tailwindContext.compileAstNodes(candidate); return { ...classRules, ...getRuleContext(rule?.node?.nodes) }; }, {}) }), {}); for(const className in classRules){ otherClassLoop: for(const otherClassName in classRules){ if(className === otherClassName){ continue otherClassLoop; } const classRule = classRules[className]; const otherClassRule = classRules[otherClassName]; const paths = Object.keys(classRule); const otherPaths = Object.keys(otherClassRule); if(paths.length !== otherPaths.length){ continue otherClassLoop; } for(const path of paths){ for(const otherPath of otherPaths){ if(path !== otherPath){ continue otherClassLoop; } if(classRule[path].length !== otherClassRule[otherPath].length){ continue otherClassLoop; } for(const classRuleProperty of classRule[path]){ if(!otherClassRule[otherPath].find(otherProp => { return otherProp.cssPropertyName === classRuleProperty.cssPropertyName; })){ continue otherClassLoop; } } for(const otherClassRuleProperty of otherClassRule[otherPath]){ conflicts[className] ??= {}; conflicts[className][otherClassName] ??= []; conflicts[className][otherClassName].push(otherClassRuleProperty); } } } } } return conflicts; } export type StyleRule = { kind: "rule"; nodes: AstNode[]; selector: string; }; export type AtRule = { kind: "at-rule"; name: string; nodes: AstNode[]; params: string; }; export type Declaration = { important: boolean; kind: "declaration"; property: string; value: string | undefined; }; export type Comment = { kind: "comment"; value: string; }; export type Context = { context: Record; kind: "context"; nodes: AstNode[]; }; export type AtRoot = { kind: "at-root"; nodes: AstNode[]; }; export type Rule = AtRule | StyleRule; export type AstNode = AtRoot | AtRule | Comment | Context | Declaration | StyleRule; interface Property { cssPropertyName: string; important: boolean; cssPropertyValue?: string; } interface RuleContext { [hierarchy: string]: Property[]; } function getRuleContext(nodes: AstNode[]): RuleContext { const context: RuleContext = {}; if(!nodes){ return context; } const checkNested = (nodes: AstNode[], context: RuleContext, path: string = "") => { for(const node of nodes.filter(node => !!node)){ if(node.kind === "declaration"){ context[path] ??= []; if(node.value === undefined){ continue; } context[path].push({ cssPropertyName: node.property, cssPropertyValue: node.value, important: node.important }); continue; } if(node.kind === "rule"){ return void checkNested(node.nodes, context, path + node.selector); } if(node.kind === "at-rule"){ return void checkNested(node.nodes, context, path + node.name + node.params); } } }; checkNested(nodes, context); return context; } ================================================ FILE: src/tailwindcss/conflicting-classes.ts ================================================ // runner.js import { resolve } from "node:path"; import { createSyncFn } from "synckit"; import { getWorkerOptions } from "better-tailwindcss:utils/worker.js"; import type { Warning } from "better-tailwindcss:types/async.js"; import type { Context } from "better-tailwindcss:types/rule.js"; import type { AsyncContext } from "better-tailwindcss:utils/context.js"; export type ConflictingClasses = { [className: string]: { [conflictingClassName: string]: { cssPropertyName: string; important: boolean; cssPropertyValue?: string; }[]; }; }; export type GetConflictingClasses = (ctx: AsyncContext, classes: string[]) => { conflictingClasses: ConflictingClasses; warnings: (Warning | undefined)[]; }; export let getConflictingClasses: GetConflictingClasses = () => { throw new Error("getConflictingClasses() called before being initialized"); }; export function createGetConflictingClasses(ctx: Context): GetConflictingClasses { const workerPath = getWorkerPath(ctx); const workerOptions = getWorkerOptions(); const runWorker = createSyncFn(workerPath, workerOptions); getConflictingClasses = (ctx, classes) => runWorker("getConflictingClasses", ctx, classes); return getConflictingClasses; } function getWorkerPath(ctx: Context) { return resolve(import.meta.dirname, `./tailwind.async.worker.v${ctx.version.major}.js`); } ================================================ FILE: src/tailwindcss/context.async.v3.ts ================================================ import { withCache } from "../async-utils/cache.js"; import { normalize } from "../async-utils/path.js"; import type { AsyncContext } from "../utils/context.js"; export const createTailwindContext = async (ctx: AsyncContext) => withCache("tailwind-context", ctx.tailwindConfigPath, async () => { const { default: defaultConfig } = await import(normalize(`${ctx.installation}/defaultConfig.js`)); const setupContextUtils = await import(normalize(`${ctx.installation}/lib/lib/setupContextUtils.js`)); const { default: loadConfig } = await import(normalize(`${ctx.installation}/loadConfig.js`)); const { default: resolveConfig } = await import(normalize(`${ctx.installation}/resolveConfig.js`)); const config = resolveConfig( ctx.tailwindConfigPath === "default" ? defaultConfig : loadConfig(ctx.tailwindConfigPath) ); return setupContextUtils.createContext?.(config) ?? setupContextUtils.default?.createContext?.(config); }); ================================================ FILE: src/tailwindcss/context.async.v4.ts ================================================ import { readFile } from "node:fs/promises"; import { dirname } from "node:path"; import { pathToFileURL } from "node:url"; import { createJiti } from "jiti"; import { withCache } from "../async-utils/cache.js"; import { normalize } from "../async-utils/path.js"; import { resolveCss, resolveJs } from "../async-utils/resolvers.js"; import type { AsyncContext } from "../utils/context.js"; export const createTailwindContext = async (ctx: AsyncContext) => withCache("tailwind-context", ctx.tailwindConfigPath, async () => { const jiti = createJiti(getCurrentFilename(), { fsCache: false, moduleCache: false }); const importBasePath = dirname(ctx.tailwindConfigPath); const tailwindPath = resolveJs(ctx, "tailwindcss", importBasePath); // eslint-disable-next-line eslint-plugin-typescript/naming-convention const { __unstable__loadDesignSystem } = await import(normalize(tailwindPath)); const css = await readFile(ctx.tailwindConfigPath, "utf-8"); // Load the design system and set up a compatible context object that is // usable by the rest of the plugin const design = await __unstable__loadDesignSystem(css, { base: importBasePath, loadModule: createLoader(ctx, jiti, { filepath: ctx.tailwindConfigPath, legacy: false, onError: (id, err, resourceType) => { console.error(`Unable to load ${resourceType}: ${id}`, err); if(resourceType === "config"){ return {}; } else if(resourceType === "plugin"){ return () => {}; } } }), loadStylesheet: async (id: string, base: string) => { try { const resolved = resolveCss(ctx, id, base); return { base: dirname(resolved), content: await readFile(resolved, "utf-8") }; } catch { return { base: "", content: "" }; } } }); return design; }); function createLoader(ctx: AsyncContext, jiti: ReturnType, { filepath, legacy, onError }: { filepath: string; legacy: boolean; onError: (id: string, error: unknown, resourceType: string) => T; }) { const cacheKey = `${+Date.now()}`; async function loadFile(id: string, base: string, resourceType: string) { try { const resolved = resolveJs(ctx, id, base); const url = pathToFileURL(resolved); url.searchParams.append("t", cacheKey); return await jiti.import(url.href, { default: true }); } catch (err){ return onError(id, err, resourceType); } } if(legacy){ const baseDir = dirname(filepath); return async (id: string) => loadFile(id, baseDir, "module"); } return async (id: string, base: string, resourceType: string) => { return { base, module: await loadFile(id, base, resourceType) }; }; } function getCurrentFilename() { // eslint-disable-next-line eslint-plugin-typescript/prefer-ts-expect-error // @ts-ignore - `import.meta` doesn't exist in CommonJS -> will be transformed in build step return import.meta.url; } ================================================ FILE: src/tailwindcss/custom-component-classes.async.v3.ts ================================================ import type { AsyncContext } from "../utils/context.js"; import type { CustomComponentClasses } from "./custom-component-classes.js"; export async function getCustomComponentClasses(_ctx: AsyncContext): Promise { return []; } ================================================ FILE: src/tailwindcss/custom-component-classes.async.v4.ts ================================================ import { readFile } from "node:fs/promises"; import { dirname } from "node:path"; import { fork } from "@eslint/css-tree"; import { tailwind4 } from "tailwind-csstree"; import { withCache } from "../async-utils/cache.js"; import { resolveCss } from "../async-utils/resolvers.js"; import type { CssNode } from "@eslint/css-tree"; import type { AsyncContext } from "../utils/context.js"; import type { CustomComponentClasses } from "./custom-component-classes.js"; interface ImportInfo { path: string; layer?: string | undefined; } interface CssFile { ast: CssNode; imports: ImportInfo[]; } interface CssFiles { [resolvedPath: string]: CssFile; } const { findAll, generate, parse, walk } = fork(tailwind4); export async function getCustomComponentClasses(ctx: AsyncContext): Promise { const resolvedPath = resolveCss(ctx, ctx.tailwindConfigPath); if(!resolvedPath){ return []; } const files = await parseCssFilesDeep(ctx, resolvedPath); return getCustomComponentUtilities(files, resolvedPath); } async function parseCssFilesDeep(ctx: AsyncContext, resolvedPath: string): Promise { const cssFiles: CssFiles = {}; const cssFile = await parseCssFile(ctx, resolvedPath); if(!cssFile){ return cssFiles; } cssFiles[resolvedPath] = cssFile; for(const { path } of cssFile.imports){ const importedFiles = await parseCssFilesDeep(ctx, path); for(const importedFile in importedFiles){ cssFiles[importedFile] = importedFiles[importedFile]; } } return cssFiles; } const parseCssFile = async (ctx: AsyncContext, resolvedPath: string): Promise => withCache("css-file", resolvedPath, async () => { try { const content = await readFile(resolvedPath, "utf-8"); const ast = parse(content); const importNodes = findAll(ast, node => node.type === "Atrule" && node.name === "import" && node.prelude?.type === "AtrulePrelude"); const imports = importNodes.reduce((imports, importNode) => { if(importNode.type !== "Atrule" || !importNode.prelude){ return imports; } const prelude = generate(importNode.prelude); const importStatement = prelude.match(/["'](?[^"']+)["'](?.*)/); if(!importStatement){ return imports; } const { importPath, rest } = importStatement.groups || {}; const layerMatch = rest?.match(/layer(?:\((?[^)]+)\))?/); const layer = layerMatch ? layerMatch.groups?.layerName || "anonymous" : undefined; const cwd = dirname(resolvedPath); const resolvedImportPath = resolveCss(ctx, importPath, cwd); if(resolvedImportPath){ imports.push({ layer, path: resolvedImportPath }); } return imports; }, []); return { ast, imports } satisfies CssFile; } catch {} }); function getCustomComponentUtilities(files: CssFiles, filePath: string, currentLayer: string[] = []): string[] { const classes = new Set(); const file = files[filePath]; if(!file){ return []; } for(const { layer, path } of file.imports){ const nextLayer = [...currentLayer]; if(layer){ nextLayer.push(layer); } const importedClasses = getCustomComponentUtilities(files, path, nextLayer); for(const importedClass of importedClasses){ classes.add(importedClass); } } const localLayers: string[] = []; walk(file.ast, { enter: (node: CssNode) => { if(node.type === "Atrule" && node.name === "layer" && node.prelude?.type === "AtrulePrelude" && node.block){ const layerName = generate(node.prelude).trim(); localLayers.push(layerName); } if(node.type === "ClassSelector"){ if([...currentLayer, ...localLayers][0] === "components"){ classes.add(node.name); } } }, leave: (node: CssNode) => { if(node.type === "Atrule" && node.name === "layer" && node.block){ localLayers.pop(); } } }); return Array.from(classes); } ================================================ FILE: src/tailwindcss/custom-component-classes.ts ================================================ // runner.js import { resolve } from "node:path"; import { createSyncFn } from "synckit"; import { getWorkerOptions } from "better-tailwindcss:utils/worker.js"; import type { Warning } from "better-tailwindcss:types/async.js"; import type { Context } from "better-tailwindcss:types/rule.js"; import type { AsyncContext } from "better-tailwindcss:utils/context.js"; export type CustomComponentClasses = string[]; export type GetCustomComponentClasses = (ctx: AsyncContext) => { customComponentClasses: CustomComponentClasses; warnings: (Warning | undefined)[]; }; export let getCustomComponentClasses: GetCustomComponentClasses = () => { throw new Error("getCustomComponentClasses() called before being initialized"); }; export function createGetCustomComponentClasses(ctx: Context): GetCustomComponentClasses { const workerPath = getWorkerPath(ctx); const workerOptions = getWorkerOptions(); const runWorker = createSyncFn(workerPath, workerOptions); getCustomComponentClasses = ctx => runWorker("getCustomComponentClasses", ctx); return getCustomComponentClasses; } function getWorkerPath(ctx: Context) { return resolve(import.meta.dirname, `./tailwind.async.worker.v${ctx.version.major}.js`); } ================================================ FILE: src/tailwindcss/dissect-classes.async.v3.ts ================================================ import { escapeForRegex } from "../async-utils/escape.js"; import { normalize } from "../async-utils/path.js"; import { getCachedRegex } from "../async-utils/regex.js"; import { getPrefix } from "./prefix.async.v3.js"; import type { AsyncContext } from "../utils/context.js"; import type { DissectedClass, DissectedClasses } from "./dissect-classes.js"; export async function getDissectedClasses(ctx: AsyncContext, tailwindContext: any, classes: string[]): Promise { const utils = await import(normalize(`${ctx.installation}/lib/util/splitAtTopLevelOnly.js`)); const prefix = getPrefix(tailwindContext); const separator = tailwindContext.tailwindConfig.separator ?? ":"; return classes.reduce>((acc, className) => { const splitChunks = utils.splitAtTopLevelOnly?.(className, separator) ?? utils.default?.splitAtTopLevelOnly?.(className, separator); const variants = splitChunks.slice(0, -1); let base = className .replace(new RegExp(`^${escapeForRegex(variants.join(separator) + separator)}`), "") .replace(new RegExp(`^${escapeForRegex(prefix)}`), ""); const isNegative = base.startsWith("-"); base = base.replace(getCachedRegex(/^-/), ""); const isImportantAtStart = base.startsWith("!"); base = base.replace(getCachedRegex(/^!/), ""); const isImportantAtEnd = base.endsWith("!"); base = base.replace(getCachedRegex(/!$/), ""); acc[className] = { base, className, important: [isImportantAtStart, isImportantAtEnd], negative: isNegative, prefix, separator, variants }; return acc; }, {}); } ================================================ FILE: src/tailwindcss/dissect-classes.async.v4.ts ================================================ import { escapeForRegex } from "../async-utils/escape.js"; import { getCachedRegex } from "../async-utils/regex.js"; import { getPrefix } from "./prefix.async.v4.js"; import type { DissectedClass, DissectedClasses } from "./dissect-classes.js"; export function getDissectedClasses(tailwindContext: any, classes: string[]): DissectedClasses { const prefix = getPrefix(tailwindContext); const separator = ":"; return classes.reduce>((acc, className) => { const [parsed] = tailwindContext.parseCandidate(className); const variants = parsed?.variants?.map(variant => tailwindContext.printVariant(variant)).reverse(); let base = className .replace(getCachedRegex(`^${escapeForRegex(prefix + separator)}`), "") .replace(getCachedRegex(`^${escapeForRegex((variants?.join(separator) ?? "") + separator)}`), ""); const isNegative = base.startsWith("-"); base = base.replace(getCachedRegex(/^-/), ""); const isImportantAtStart = base.startsWith("!"); base = base.replace(getCachedRegex(/^!/), ""); const isImportantAtEnd = base.endsWith("!"); base = base.replace(getCachedRegex(/!$/), ""); acc[className] = { base, className, important: [isImportantAtStart, isImportantAtEnd], negative: isNegative, prefix, separator, variants }; return acc; }, {}); } ================================================ FILE: src/tailwindcss/dissect-classes.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createGetDissectedClasses } from "better-tailwindcss:tailwindcss/dissect-classes.js"; import { createTestContext } from "better-tailwindcss:tests/utils/context.js"; import { css } from "better-tailwindcss:tests/utils/template.js"; import { TestDirectory } from "better-tailwindcss:tests/utils/tmp.js"; import { getTailwindCSSVersion } from "better-tailwindcss:tests/utils/version"; import { async } from "better-tailwindcss:utils/context.js"; function dissectClass(className: string) { const ctx = createTestContext(); const getDissectedClasses = createGetDissectedClasses(ctx); const { dissectedClasses } = getDissectedClasses(async(ctx), [className]); return dissectedClasses[className]; } describe("getDissectedClass", () => { let fs: TestDirectory; beforeEach(() => { fs = new TestDirectory({ "global.css": css`@import "tailwindcss";` }); }); afterEach(() => { fs.cleanUp(); }); describe("variants", () => { it("should not return any variants for a class without variants", () => { expect(dissectClass("text-red-500").variants).toEqual([]); }); it("should return the variant for a class with a variant", () => { expect(dissectClass("hover:text-red-500").variants).toEqual(["hover"]); }); it("should return multiple variants for a class with multiple variants", () => { expect(dissectClass("lg:hover:text-red-500").variants).toEqual(["lg", "hover"]); }); it("should not return any variants for an arbitrary class", () => { expect(dissectClass("[color:red]").variants).toEqual([]); }); it("should return the variant for an arbitrary class with a variant", () => { expect(dissectClass("hover:[color:red]").variants).toEqual(["hover"]); }); it("should return the variant for an arbitrary variant", () => { expect(dissectClass("[&:hover]:text-red-500").variants).toEqual(["[&:hover]"]); }); it("should return the correct variants for arbitrary variants mixed with normal variants", () => { expect(dissectClass("lg:[&:hover]:text-red-500").variants).toEqual(["lg", "[&:hover]"]); }); it("should work with functional variants", () => { expect(dissectClass("aria-disabled:text-red-500").variants).toEqual(["aria-disabled"]); expect(dissectClass("aria-[disabled]:text-red-500").variants).toEqual(["aria-[disabled]"]); }); it("should work with compound variants", () => { expect(dissectClass("has-[&_p]:text-red-500").variants).toEqual(["has-[&_p]"]); }); it("should not crash on unknown classes", () => { expect(() => dissectClass("unknown-class")).not.toThrow(); expect(() => dissectClass("hover:unknown-class")).not.toThrow(); expect(() => dissectClass("lg:hover:unknown-class")).not.toThrow(); }); }); describe("important", () => { it("should return true for a class with an important modifier", () => { expect(dissectClass("text-red-500!").important).toEqual([false, true]); expect(dissectClass("!text-red-500").important).toEqual([true, false]); }); }); describe("base", () => { it.runIf(getTailwindCSSVersion().major >= 4)("should return the base class name in tailwind >= 4", () => { expect(dissectClass("text-red-500").base).toBe("text-red-500"); expect(dissectClass("hover:text-red-500").base).toBe("text-red-500"); expect(dissectClass("lg:hover:text-red-500").base).toBe("text-red-500"); expect(dissectClass("lg:hover:text-red-500!").base).toBe("text-red-500"); expect(dissectClass("lg:hover:text-red-500/50").base).toBe("text-red-500/50"); expect(dissectClass("lg:hover:text-red-500/50!").base).toBe("text-red-500/50"); }); it.runIf(getTailwindCSSVersion().major <= 3)("should return the base class name in tailwind <= 3", () => { expect(dissectClass("text-red-500").base).toBe("text-red-500"); expect(dissectClass("hover:text-red-500").base).toBe("text-red-500"); expect(dissectClass("lg:hover:text-red-500").base).toBe("text-red-500"); expect(dissectClass("lg:hover:!text-red-500").base).toBe("text-red-500"); expect(dissectClass("lg:hover:text-red-500/50").base).toBe("text-red-500/50"); expect(dissectClass("lg:hover:!text-red-500/50").base).toBe("text-red-500/50"); }); }); describe("negative", () => { it("should return true for a class with a negative modifier", () => { expect(dissectClass("-top-50").negative).toBe(true); expect(dissectClass("hover:-top-50").negative).toBe(true); expect(dissectClass("lg:hover:-top-50").negative).toBe(true); expect(dissectClass("lg:hover:-top-50!").negative).toBe(true); }); it("should return false for a class without a negative modifier", () => { expect(dissectClass("top-50").negative).toBe(false); expect(dissectClass("hover:top-50").negative).toBe(false); expect(dissectClass("lg:hover:top-50").negative).toBe(false); expect(dissectClass("lg:hover:top-50!").negative).toBe(false); }); }); }); ================================================ FILE: src/tailwindcss/dissect-classes.ts ================================================ import { resolve } from "node:path"; import { createSyncFn } from "synckit"; import { getWorkerOptions } from "better-tailwindcss:utils/worker.js"; import type { Warning } from "better-tailwindcss:types/async.js"; import type { Context } from "better-tailwindcss:types/rule.js"; import type { AsyncContext } from "better-tailwindcss:utils/context.js"; export interface DissectedClass { base: string; className: string; important: [start: boolean, end: boolean]; negative: boolean; prefix: string; separator: string; /** Will be undefined in tailwindcss 4 for non-tailwind classes. */ variants: string[] | undefined; } export interface DissectedClasses { [className: string]: DissectedClass; } export type GetDissectedClasses = (ctx: AsyncContext, classes: string[]) => { dissectedClasses: DissectedClasses; warnings: (Warning | undefined)[]; }; export let getDissectedClasses: GetDissectedClasses = () => { throw new Error("getDissectedClasses() called before being initialized"); }; export function createGetDissectedClasses(ctx: Context): GetDissectedClasses { const workerPath = getWorkerPath(ctx); const workerOptions = getWorkerOptions(); const runWorker = createSyncFn(workerPath, workerOptions); getDissectedClasses = (ctx, classes) => runWorker("getDissectedClasses", ctx, classes); return getDissectedClasses; } function getWorkerPath(ctx: Context) { return resolve(import.meta.dirname, `./tailwind.async.worker.v${ctx.version.major}.js`); } ================================================ FILE: src/tailwindcss/prefix.async.v3.ts ================================================ import type { Prefix } from "./prefix.js"; export function getPrefix(tailwindContext: any): Prefix { return tailwindContext.tailwindConfig.prefix ?? ""; } export function getSuffix(tailwindContext: any): string { return ""; } ================================================ FILE: src/tailwindcss/prefix.async.v4.ts ================================================ import type { Prefix, Suffix } from "./prefix.js"; export function getPrefix(tailwindContext: any): Prefix { return tailwindContext.theme.prefix ?? ""; } export function getSuffix(tailwindContext: any): Suffix { return !!tailwindContext.theme.prefix ? ":" : ""; } ================================================ FILE: src/tailwindcss/prefix.ts ================================================ import { resolve } from "node:path"; import { createSyncFn } from "synckit"; import { getWorkerOptions } from "better-tailwindcss:utils/worker.js"; import type { Warning } from "better-tailwindcss:types/async.js"; import type { Context } from "better-tailwindcss:types/rule.js"; import type { AsyncContext } from "better-tailwindcss:utils/context.js"; export type Prefix = string; export type Suffix = string; export type GetPrefix = (ctx: AsyncContext) => { prefix: Prefix; suffix: Suffix; warnings: (Warning | undefined)[]; }; export let getPrefix: GetPrefix = () => { throw new Error("getPrefix() called before being initialized"); }; export function createGetPrefix(ctx: Context): GetPrefix { const workerPath = getWorkerPath(ctx); const workerOptions = getWorkerOptions(); const runWorker = createSyncFn(workerPath, workerOptions); getPrefix = ctx => runWorker("getPrefix", ctx); return getPrefix; } function getWorkerPath(ctx: Context) { return resolve(import.meta.dirname, `./tailwind.async.worker.v${ctx.version.major}.js`); } ================================================ FILE: src/tailwindcss/tailwind.async.worker.v3.ts ================================================ import { runAsWorker } from "synckit"; import { getClassOrder } from "./class-order.async.v3.js"; import { createTailwindContext } from "./context.async.v3.js"; import { getCustomComponentClasses } from "./custom-component-classes.async.v3.js"; import { getDissectedClasses } from "./dissect-classes.async.v3.js"; import { getPrefix, getSuffix } from "./prefix.async.v3.js"; import { getUnknownClasses } from "./unknown-classes.async.v3.js"; import { getVariantOrder } from "./variant-order.async.v3.js"; import type { OperationHandlers, Operations } from "../async-utils/operations.js"; import type { CanonicalClasses } from "./canonical-classes.js"; import type { ConflictingClasses } from "./conflicting-classes.js"; const handlers: OperationHandlers = { getCanonicalClasses: async (ctx, classes, _options) => { const canonicalClasses = classes.reduce((acc, className) => { acc[className] = { input: [className], output: className }; return acc; }, {}); return { canonicalClasses, warnings: ctx.warnings }; }, getClassOrder: async (ctx, classes) => { const tailwindContext = await createTailwindContext(ctx); const classOrder = getClassOrder(tailwindContext, classes); return { classOrder, warnings: ctx.warnings }; }, getConflictingClasses: async (ctx, _classes) => { const conflictingClasses: ConflictingClasses = {}; return { conflictingClasses, warnings: ctx.warnings }; }, getCustomComponentClasses: async ctx => { const customComponentClasses = await getCustomComponentClasses(ctx); return { customComponentClasses, warnings: ctx.warnings }; }, getDissectedClasses: async (ctx, classes) => { const tailwindContext = await createTailwindContext(ctx); const dissectedClasses = await getDissectedClasses(ctx, tailwindContext, classes); return { dissectedClasses, warnings: ctx.warnings }; }, getPrefix: async ctx => { const tailwindContext = await createTailwindContext(ctx); const prefix = getPrefix(tailwindContext); const suffix = getSuffix(tailwindContext); return { prefix, suffix, warnings: ctx.warnings }; }, getUnknownClasses: async (ctx, classes) => { const tailwindContext = await createTailwindContext(ctx); const unknownClasses = await getUnknownClasses(ctx, tailwindContext, classes); return { unknownClasses, warnings: ctx.warnings }; }, getVariantOrder: async ctx => { const variantOrder = getVariantOrder(); return { variantOrder, warnings: ctx.warnings }; } }; runAsWorker(async (operation: Operation, ...args: Parameters) => { return handlers[operation](...args); }); ================================================ FILE: src/tailwindcss/tailwind.async.worker.v4.ts ================================================ import { runAsWorker } from "synckit"; import { getCanonicalClasses } from "./canonical-classes.async.v4.js"; import { getClassOrder } from "./class-order.async.v4.js"; import { getConflictingClasses } from "./conflicting-classes.async.v4.js"; import { createTailwindContext } from "./context.async.v4.js"; import { getCustomComponentClasses } from "./custom-component-classes.async.v4.js"; import { getDissectedClasses } from "./dissect-classes.async.v4.js"; import { getPrefix, getSuffix } from "./prefix.async.v4.js"; import { getUnknownClasses } from "./unknown-classes.async.v4.js"; import { getVariantOrder } from "./variant-order.async.v4.js"; import type { OperationHandlers, Operations } from "../async-utils/operations.js"; const handlers: OperationHandlers = { getCanonicalClasses: async (ctx, classes, options) => { const tailwindContext = await createTailwindContext(ctx); const canonicalClasses = getCanonicalClasses(tailwindContext, classes, options); return { canonicalClasses, warnings: ctx.warnings }; }, getClassOrder: async (ctx, classes) => { const tailwindContext = await createTailwindContext(ctx); const classOrder = getClassOrder(tailwindContext, classes); return { classOrder, warnings: ctx.warnings }; }, getConflictingClasses: async (ctx, classes) => { const tailwindContext = await createTailwindContext(ctx); const conflictingClasses = await getConflictingClasses(tailwindContext, classes); return { conflictingClasses, warnings: ctx.warnings }; }, getCustomComponentClasses: async ctx => { const customComponentClasses = await getCustomComponentClasses(ctx); return { customComponentClasses, warnings: ctx.warnings }; }, getDissectedClasses: async (ctx, classes) => { const tailwindContext = await createTailwindContext(ctx); const dissectedClasses = getDissectedClasses(tailwindContext, classes); return { dissectedClasses, warnings: ctx.warnings }; }, getPrefix: async ctx => { const tailwindContext = await createTailwindContext(ctx); const prefix = getPrefix(tailwindContext); const suffix = getSuffix(tailwindContext); return { prefix, suffix, warnings: ctx.warnings }; }, getUnknownClasses: async (ctx, classes) => { const tailwindContext = await createTailwindContext(ctx); const unknownClasses = getUnknownClasses(tailwindContext, classes); return { unknownClasses, warnings: ctx.warnings }; }, getVariantOrder: async (ctx, classes) => { const tailwindContext = await createTailwindContext(ctx); const variantOrder = getVariantOrder(tailwindContext, classes); return { variantOrder, warnings: ctx.warnings }; } }; runAsWorker(async (operation: Operation, ...args: Parameters) => { return handlers[operation](...args); }); ================================================ FILE: src/tailwindcss/unknown-classes.async.v3.ts ================================================ import { normalize } from "../async-utils/path.js"; import type { AsyncContext } from "../utils/context.js"; import type { UnknownClass } from "./unknown-classes.js"; export async function getUnknownClasses(ctx: AsyncContext, tailwindContext: any, classes: string[]): Promise { const rules = await import(normalize(`${ctx.installation}/lib/lib/generateRules.js`)); return classes .filter(className => { const generated = rules.generateRules?.([className], tailwindContext) ?? rules.default?.generateRules?.([className], tailwindContext); return generated.length === 0; }); } ================================================ FILE: src/tailwindcss/unknown-classes.async.v4.ts ================================================ import type { UnknownClass } from "./unknown-classes.js"; export function getUnknownClasses(tailwindContext: any, classes: string[]): UnknownClass[] { const css = tailwindContext.candidatesToCss(classes); return classes.filter((_, index) => css.at(index) === null); } ================================================ FILE: src/tailwindcss/unknown-classes.ts ================================================ import { resolve } from "node:path"; import { createSyncFn } from "synckit"; import { getWorkerOptions } from "better-tailwindcss:utils/worker.js"; import type { Warning } from "better-tailwindcss:types/async.js"; import type { Context } from "better-tailwindcss:types/rule.js"; import type { AsyncContext } from "better-tailwindcss:utils/context.js"; export type UnknownClass = string; export type GetUnknownClasses = (ctx: AsyncContext, classes: string[]) => { unknownClasses: UnknownClass[]; warnings: (Warning | undefined)[]; }; export let getUnknownClasses: GetUnknownClasses = () => { throw new Error("getUnknownClasses() called before being initialized"); }; export function createGetUnknownClasses(ctx: Context): GetUnknownClasses { const workerPath = getWorkerPath(ctx); const workerOptions = getWorkerOptions(); const runWorker = createSyncFn(workerPath, workerOptions); getUnknownClasses = (ctx, classes) => runWorker("getUnknownClasses", ctx, classes); return getUnknownClasses; } function getWorkerPath(ctx: Context) { return resolve(import.meta.dirname, `./tailwind.async.worker.v${ctx.version.major}.js`); } ================================================ FILE: src/tailwindcss/variant-order.async.v3.ts ================================================ import type { VariantOrder } from "./variant-order.js"; export function getVariantOrder(): VariantOrder { return {}; } ================================================ FILE: src/tailwindcss/variant-order.async.v4.ts ================================================ import { VARIANT_ORDER_FLAGS } from "../async-utils/order.js"; import type { VariantOrder } from "./variant-order.js"; export function getVariantOrder(tailwindContext: any, classes: string[]): VariantOrder { const candidates = classes.map(className => tailwindContext.parseCandidate(className)); const variantOrder = tailwindContext.getVariantOrder(); const variants = tailwindContext.getVariants(); const variantsByName = new Map( (variants ?? []).map(variant => { return [variant.name, variant]; }) ); const variantOrderByName = new Map( [...variantOrder.entries()].map(([variant, order]) => { return [tailwindContext.printVariant(variant), order]; }) ); return candidates.reduce((acc, parsedCandidates) => { for(const candidate of parsedCandidates ?? []){ for(const variantCandidate of candidate?.variants ?? []){ const variantName = tailwindContext.printVariant(variantCandidate); const variant = variantsByName.get(variantName); const twOrder = variantOrderByName.get(variantName) || 0; const globalOrder = hasGlobalSelector(variant) ? VARIANT_ORDER_FLAGS.GLOBAL : 0; acc[variantName] ??= globalOrder | twOrder; } } return acc; }, {}); } function hasGlobalSelector(variant: any): boolean { const selectors = variant?.selectors?.(); if(!Array.isArray(selectors) || selectors.length <= 0){ return false; } return selectors.every(selector => { return typeof selector === "string" && !selector.includes("&"); }); } ================================================ FILE: src/tailwindcss/variant-order.ts ================================================ import { resolve } from "node:path"; import { createSyncFn } from "synckit"; import { getWorkerOptions } from "better-tailwindcss:utils/worker.js"; import type { Warning } from "better-tailwindcss:types/async.js"; import type { Context } from "better-tailwindcss:types/rule.js"; import type { AsyncContext } from "better-tailwindcss:utils/context.js"; export type VariantOrder = Record; export type GetVariantOrder = (ctx: AsyncContext, classes: string[]) => { variantOrder: VariantOrder; warnings: (Warning | undefined)[]; }; export let getVariantOrder: GetVariantOrder = () => { throw new Error("getVariantOrder() called before being initialized"); }; export function createGetVariantOrder(ctx: Context): GetVariantOrder { const workerPath = getWorkerPath(ctx); const workerOptions = getWorkerOptions(); const runWorker = createSyncFn(workerPath, workerOptions); getVariantOrder = (ctx, classes) => runWorker("getVariantOrder", ctx, classes); return getVariantOrder; } function getWorkerPath(ctx: Context) { return resolve(import.meta.dirname, `./tailwind.async.worker.v${ctx.version.major}.js`); } ================================================ FILE: src/types/ast.ts ================================================ export type LiteralValueQuotes = "'" | "\"" | "`"; export interface Range { range: [number, number]; } export interface Loc { loc: { end: { column: number; line: number; }; start: { column: number; line: number; }; }; } export interface MultilineMeta { multilineQuotes?: LiteralValueQuotes[] | undefined; supportsMultiline?: boolean | undefined; surroundingBraces?: boolean | undefined; } export interface WhitespaceMeta { leadingWhitespace?: string | undefined; trailingWhitespace?: string | undefined; } export interface QuoteMeta { closingQuote?: LiteralValueQuotes | undefined; openingQuote?: LiteralValueQuotes | undefined; } export interface BracesMeta { closingBraces?: string | undefined; openingBraces?: string | undefined; } export interface CSSMeta { leadingApply?: string | undefined; trailingSemicolon?: string | undefined; } export interface Indentation { indentation: number; } interface NodeBase extends Range, Loc { [key: PropertyKey]: unknown; type: string; } interface LiteralBase extends NodeBase, MultilineMeta, QuoteMeta, BracesMeta, WhitespaceMeta, CSSMeta, Indentation, Range, Loc { content: string; raw: string; attribute?: string | undefined; isConcatenatedLeft?: boolean | undefined; isConcatenatedRight?: boolean | undefined; isInterpolated?: boolean | undefined; priorLiterals?: Literal[] | undefined; } export interface TemplateLiteral extends LiteralBase { type: "TemplateLiteral"; } export interface StringLiteral extends LiteralBase { type: "StringLiteral"; } export interface CSSClassListLiteral extends LiteralBase { type: "CSSClassListLiteral"; } export type Literal = CSSClassListLiteral | StringLiteral | TemplateLiteral; ================================================ FILE: src/types/async.ts ================================================ export type Async any> = (...params: Parameters) => Promise>; export interface Warning = Record> { option: keyof Options & string; title: string; url?: string; } ================================================ FILE: src/types/estree.ts ================================================ import type { Rule } from "eslint"; type Nullable = { [Key in keyof Object]: Object[Key] | null; }; export type WithParent = BaseNode & Nullable>; ================================================ FILE: src/types/rule.ts ================================================ import type { JSRuleDefinition } from "eslint"; import type { BaseIssue, BaseSchema, Default, InferOutput, OptionalSchema, StrictObjectSchema } from "valibot"; import type { CommonOptions } from "better-tailwindcss:options/descriptions.js"; import type { Literal } from "better-tailwindcss:types/ast.js"; import type { Warning } from "better-tailwindcss:types/async.js"; export enum MatcherType { /** Matches return values of anonymous functions via nested matchers. */ AnonymousFunctionReturn = "anonymousFunctionReturn", /** Matches all object keys that are strings. */ ObjectKey = "objectKeys", /** Matches all object values that are strings. */ ObjectValue = "objectValues", /** Matches all strings that are not matched by another matcher. */ String = "strings" } export enum SelectorKind { Attribute = "attribute", Callee = "callee", Tag = "tag", Variable = "variable" } export type Regex = string; /* Legacy matchers */ export type StringMatcher = { match: MatcherType.String; }; export type ObjectKeyMatcher = { match: MatcherType.ObjectKey; pathPattern?: Regex | undefined; }; export type ObjectValueMatcher = { match: MatcherType.ObjectValue; pathPattern?: Regex | undefined; }; export const MATCHER_RESULT = { MATCH: true, NO_MATCH: false, UNCROSSABLE_BOUNDARY: "UNCROSSABLE_BOUNDARY" } as const; type MatcherFunctionResult = typeof MATCHER_RESULT[keyof typeof MATCHER_RESULT]; export type MatcherFunction = (node: unknown) => MatcherFunctionResult | MatcherFunctions; export type MatcherFunctions = MatcherFunction[]; export type Matcher = ObjectKeyMatcher | ObjectValueMatcher | StringMatcher; /* New selector matchers */ export type SelectorStringMatcher = { type: MatcherType.String; }; export type SelectorAnonymousFunctionReturnMatcher = { match: (SelectorObjectKeyMatcher | SelectorObjectValueMatcher | SelectorStringMatcher)[]; type: MatcherType.AnonymousFunctionReturn; }; export type SelectorObjectKeyMatcher = { type: MatcherType.ObjectKey; path?: Regex | undefined; }; export type SelectorObjectValueMatcher = { type: MatcherType.ObjectValue; path?: Regex | undefined; }; export type SelectorMatcher = | SelectorAnonymousFunctionReturnMatcher | SelectorObjectKeyMatcher | SelectorObjectValueMatcher | SelectorStringMatcher; type BaseSelector = { kind: Kind; name: Regex; match?: SelectorMatcher[] | undefined; }; export type Target = "all" | "first" | "last" | number; export type CallTarget = Target; export type ArgumentTarget = Target; export type AttributeSelector = BaseSelector; export type CalleeSelector = { kind: SelectorKind.Callee; /** @deprecated Use targetCall instead. */ callTarget?: CallTarget | undefined; match?: SelectorMatcher[] | undefined; name?: Regex | undefined; path?: Regex | undefined; targetArgument?: ArgumentTarget | undefined; targetCall?: CallTarget | undefined; }; export type TagSelector = { kind: SelectorKind.Tag; match?: SelectorMatcher[] | undefined; name?: Regex | undefined; path?: Regex | undefined; }; export type VariableSelector = BaseSelector; export type Selector = AttributeSelector | CalleeSelector | TagSelector | VariableSelector; export type Selectors = Selector[]; export type SelectorByKind = Extract; export type Version = { major: number; minor: number; patch: number; }; export type TailwindConfig = { entryPoint?: string; tailwindConfig?: string; }; export type TSConfig = { tsconfig?: string; }; export type Schema = StrictObjectSchema>, Default>, undefined>>>, undefined>; export type JsonSchema = InferOutput; export type RuleCategory = "correctness" | "stylistic"; export interface CreateRuleOptions< Name extends string, Messages extends Record, OptionsSchema extends Schema = Schema, Options extends Record = CommonOptions & JsonSchema, Category extends RuleCategory = RuleCategory, Recommended extends boolean = boolean > { /** Whether the rule should automatically fix problems. */ autofix: boolean; /** The category of the rule. */ category: Category; /** A brief description of the rule. */ description: string; /** The URL to the rule documentation. */ docs: string; /** Lint the literals in the given context. */ lintLiterals: (ctx: RuleContext, literals: Literal[]) => void; /** The name of the rule. */ name: Name; /** Whether the rule is enabled in the recommended configs. */ recommended: Recommended; initialize?: (ctx: RuleContext) => void; /** The messages for the rule. */ messages?: Messages; /** The schema for the rule options. */ schema?: OptionsSchema; } export interface ESLintRule< Name extends string = string, Messages extends Record = Record, Options extends Record = Record, Category extends RuleCategory = RuleCategory, Recommended extends boolean = boolean > { category: Category; messages: Messages | undefined; name: Name; get options(): Options; recommended: Recommended; rule: JSRuleDefinition<{ MessageIds: keyof Messages & string; RuleOptions: [Required]; }>; } export interface RuleContext< Messages extends Record | undefined, Options extends Record > { cwd: string; docs: string; /** The installation path of Tailwind CSS. */ installation: string; options: Options; report: < const MsgId extends MessageId >(info: ( | ( MsgId extends string ? Messages extends Record ? MsgId extends keyof Messages ? { data: Record, string> extends infer Data ? keyof Data extends never ? never : Data : never; id: MsgId; fix?: string; warnings?: (Warning | undefined)[] | undefined; } : never : never : never ) | { fix?: string; message?: string; warnings?: (Warning | undefined)[] | undefined; } ) & { range: [number, number]; } ) => void; /** The Tailwind CSS Version. */ version: Version; } export type Context = RuleContext; export type MessageId | undefined> = Messages extends Record ? keyof Messages : never; type Trim = Content extends ` ${infer Rest}` ? Trim : Content extends `${infer Rest} ` ? Trim : Content; export type ExtractVariables