├── .nvmrc
├── .gitignore
├── tests
├── integrations
│ ├── flat-config
│ │ ├── .npmrc
│ │ ├── a.vue
│ │ ├── eslint.config.js
│ │ └── package.json
│ ├── legacy-config
│ │ ├── .npmrc
│ │ ├── a.vue
│ │ ├── .eslintrc
│ │ └── package.json
│ ├── flat-config.js
│ └── legacy-config.js
├── .prettierrc.json
└── lib
│ ├── rules
│ ├── dummy.css
│ ├── another.css
│ ├── no-arbitrary-value.js
│ ├── enforces-negative-arbitrary-values.js
│ ├── no-unnecessary-arbitrary-value.js
│ └── migration-from-tailwind-2.js
│ ├── index.js
│ └── util
│ └── groupMethods.js
├── .github
├── logo.png
├── output.png
├── youtube-eslint-plugin-tailwindcss-round.png
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── workflows
│ └── build.yml
└── pull_request_template.md
├── sponsors
└── daily.dev.jpg
├── .vscode
└── settings.json
├── lib
├── .prettierrc.json
├── util
│ ├── removeDuplicatesFromArray.js
│ ├── types
│ │ ├── angle.js
│ │ ├── length.js
│ │ └── color.js
│ ├── docsUrl.js
│ ├── regex.js
│ ├── generated.js
│ ├── removeDuplicatesFromClassnamesAndWhitespaces.js
│ ├── parser.js
│ ├── settings.js
│ ├── customConfig.js
│ ├── cssFiles.js
│ ├── prettier
│ │ └── order.js
│ └── ast.js
├── config
│ ├── recommended.js
│ ├── rules.js
│ └── flat-recommended.js
├── index.js
└── rules
│ ├── no-arbitrary-value.js
│ ├── enforces-negative-arbitrary-values.js
│ ├── no-custom-classname.js
│ ├── no-contradicting-classname.js
│ ├── classnames-order.js
│ ├── migration-from-tailwind-2.js
│ └── no-unnecessary-arbitrary-value.js
├── .editorconfig
├── LICENSE
├── CONTRIBUTING.md
├── package.json
├── docs
└── rules
│ ├── no-arbitrary-value.md
│ ├── no-contradicting-classname.md
│ ├── classnames-order.md
│ ├── enforces-shorthand.md
│ ├── enforces-negative-arbitrary-values.md
│ ├── no-unnecessary-arbitrary-value.md
│ ├── migration-from-tailwind-2.md
│ └── no-custom-classname.md
└── CODE_OF_CONDUCT.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | v18.12.0
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | .idea
4 |
--------------------------------------------------------------------------------
/tests/integrations/flat-config/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/tests/integrations/legacy-config/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/.github/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hyoban/eslint-plugin-tailwindcss/HEAD/.github/logo.png
--------------------------------------------------------------------------------
/.github/output.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hyoban/eslint-plugin-tailwindcss/HEAD/.github/output.png
--------------------------------------------------------------------------------
/sponsors/daily.dev.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hyoban/eslint-plugin-tailwindcss/HEAD/sponsors/daily.dev.jpg
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true
4 | }
--------------------------------------------------------------------------------
/lib/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "semi": true,
4 | "singleQuote": true,
5 | "trailingComma": "es5"
6 | }
7 |
--------------------------------------------------------------------------------
/tests/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "semi": true,
4 | "singleQuote": false,
5 | "trailingComma": "es5"
6 | }
7 |
--------------------------------------------------------------------------------
/.github/youtube-eslint-plugin-tailwindcss-round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hyoban/eslint-plugin-tailwindcss/HEAD/.github/youtube-eslint-plugin-tailwindcss-round.png
--------------------------------------------------------------------------------
/tests/integrations/flat-config/a.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Classnames will be ordered
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
--------------------------------------------------------------------------------
/tests/integrations/legacy-config/a.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Classnames will be ordered
5 |
6 |
7 |
--------------------------------------------------------------------------------
/lib/util/removeDuplicatesFromArray.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | function removeDuplicatesFromArray(arr) {
4 | return [...new Set(arr)];
5 | }
6 |
7 | module.exports = removeDuplicatesFromArray;
8 |
--------------------------------------------------------------------------------
/lib/util/types/angle.js:
--------------------------------------------------------------------------------
1 | const units = ['deg', 'grad', 'rad', 'turn'];
2 |
3 | const mergedAngleValues = [
4 | `\\-?(\\d{1,}(\\.\\d{1,})?|\\.\\d{1,})(${units.join('|')})`,
5 | `calc\\(.{1,}\\)`,
6 | `var\\(\\-\\-[A-Za-z\\-]{1,}\\)`,
7 | ];
8 |
9 | module.exports = {
10 | mergedAngleValues,
11 | };
12 |
--------------------------------------------------------------------------------
/lib/util/docsUrl.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // Copied from https://github.com/yannickcr/eslint-plugin-react/blob/master/lib/util/docsUrl.js
4 | function docsUrl(ruleName) {
5 | return `https://github.com/francoismassart/eslint-plugin-tailwindcss/tree/master/docs/rules/${ruleName}.md`;
6 | }
7 |
8 | module.exports = docsUrl;
9 |
--------------------------------------------------------------------------------
/tests/integrations/legacy-config/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "vue-eslint-parser",
4 | "parserOptions": {
5 | "sourceType": "module"
6 | },
7 | "extends": ["plugin:vue/vue3-recommended", "plugin:tailwindcss/recommended"],
8 | "rules": {
9 | "vue/multi-word-component-names": "off",
10 | "tailwindcss/classnames-order": "warn"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/tests/integrations/flat-config/eslint.config.js:
--------------------------------------------------------------------------------
1 | import vue from "eslint-plugin-vue";
2 | import tailwind from "eslint-plugin-tailwindcss";
3 |
4 | export default [
5 | ...vue.configs["flat/recommended"],
6 | ...tailwind.configs["flat/recommended"],
7 | {
8 | rules: {
9 | "vue/multi-word-component-names": "off",
10 | "tailwindcss/classnames-order": "warn",
11 | },
12 | },
13 | ];
14 |
--------------------------------------------------------------------------------
/tests/integrations/flat-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "integration-test-for-flat-config",
4 | "version": "1.0.0",
5 | "type": "module",
6 | "description": "Integration test for flat config",
7 | "dependencies": {
8 | "eslint": "^8.57.0",
9 | "eslint-plugin-vue": "^9.24.0",
10 | "eslint-plugin-tailwindcss": "file:../../..",
11 | "vue-eslint-parser": "^9.4.2"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/lib/util/regex.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Escapes a string to be used in a regular expression
3 | * Copied from https://stackoverflow.com/a/3561711.
4 | * @param {string} input
5 | * @returns {string}
6 | */
7 | function escapeRegex(input) {
8 | return input.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
9 | }
10 |
11 | const separatorRegEx = /([\t\n\f\r ]+)/;
12 |
13 | module.exports = {
14 | escapeRegex,
15 | separatorRegEx,
16 | };
17 |
--------------------------------------------------------------------------------
/tests/integrations/legacy-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "integration-test-for-flat-config",
4 | "version": "1.0.0",
5 | "type": "module",
6 | "description": "Integration test for flat config",
7 | "dependencies": {
8 | "eslint": "^8.57.0",
9 | "eslint-plugin-vue": "^9.24.0",
10 | "eslint-plugin-tailwindcss": "file:../../..",
11 | "vue-eslint-parser": "^9.4.2"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/lib/config/recommended.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Recommended coniguration for legacy style
3 | * @see https://eslint.org/docs/latest/use/configure/configuration-files
4 | * @author François Massart
5 | */
6 | 'use strict';
7 |
8 | const rules = require('./rules');
9 |
10 | module.exports = {
11 | plugins: ['tailwindcss'],
12 | parserOptions: {
13 | ecmaFeatures: {
14 | jsx: true,
15 | },
16 | },
17 | rules,
18 | };
19 |
--------------------------------------------------------------------------------
/lib/util/generated.js:
--------------------------------------------------------------------------------
1 | var generateRulesFallback = require('tailwindcss/lib/lib/generateRules').generateRules;
2 |
3 | function generate(className, context) {
4 | // const order = generateRulesFallback(new Set([className]), context).sort(([a], [z]) => bigSign(z - a))[0]?.[0] ?? null;
5 | const gen = generateRulesFallback(new Set([className]), context);
6 | // console.debug(gen);
7 | return gen;
8 | }
9 |
10 | module.exports = generate;
11 |
--------------------------------------------------------------------------------
/lib/config/rules.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Default rules configuration
3 | * @author François Massart
4 | */
5 |
6 | module.exports = {
7 | 'tailwindcss/classnames-order': 'warn',
8 | 'tailwindcss/enforces-negative-arbitrary-values': 'warn',
9 | 'tailwindcss/enforces-shorthand': 'warn',
10 | 'tailwindcss/migration-from-tailwind-2': 'warn',
11 | 'tailwindcss/no-arbitrary-value': 'off',
12 | 'tailwindcss/no-custom-classname': 'warn',
13 | 'tailwindcss/no-contradicting-classname': 'error',
14 | 'tailwindcss/no-unnecessary-arbitrary-value': 'warn',
15 | };
16 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [francoismassart]
2 | patreon: # Replace with a single Patreon username
3 | open_collective: # Replace with a single Open Collective username
4 | ko_fi: # Replace with a single Ko-fi username
5 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
6 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
7 | liberapay: # Replace with a single Liberapay username
8 | issuehunt: # Replace with a single IssueHunt username
9 | otechie: # Replace with a single Otechie username
10 | custom: [https://thanks.dev/r/gh/francoismassart]
11 |
--------------------------------------------------------------------------------
/lib/config/flat-recommended.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Recommended coniguration for flat style
3 | * @see https://eslint.org/docs/latest/use/configure/configuration-files-new
4 | * @author François Massart
5 | */
6 | 'use strict';
7 |
8 | const rules = require('./rules');
9 |
10 | module.exports = [
11 | {
12 | name: 'tailwindcss:base',
13 | plugins: {
14 | get tailwindcss() {
15 | return require('../index');
16 | },
17 | },
18 | languageOptions: {
19 | parserOptions: {
20 | ecmaFeatures: {
21 | jsx: true,
22 | },
23 | },
24 | },
25 | },
26 | {
27 | name: 'tailwindcss:rules',
28 | rules,
29 | },
30 | ];
31 |
--------------------------------------------------------------------------------
/tests/lib/rules/dummy.css:
--------------------------------------------------------------------------------
1 | /* Only used for running tests */
2 | .some {
3 | color: black;
4 | }
5 | .white-listed {
6 | color: yellow;
7 | }
8 | .classnames {
9 | color: red;
10 | }
11 | .one,
12 | .two {
13 | color: red;
14 | }
15 |
16 | @media screen and (min-width: 480px) {
17 | body {
18 | background-color: lightgreen;
19 | }
20 | }
21 |
22 | #main {
23 | border: 1px solid black;
24 | }
25 | @layer base {
26 | ul li {
27 | padding: 5px;
28 | }
29 | .base {
30 | display: block;
31 | }
32 | }
33 |
34 | @tailwind utilities;
35 |
36 | .btn {
37 | @apply border-red;
38 | }
39 |
40 | .parent {
41 | .child,
42 | .btn {
43 | background: none;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: "[Feature request] "
5 | labels: 'enhancement'
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/lib/util/removeDuplicatesFromClassnamesAndWhitespaces.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | function removeDuplicatesFromClassnamesAndWhitespaces(orderedClassNames, whitespaces, headSpace, tailSpace) {
4 | let previous = orderedClassNames[0];
5 | const offset = (!headSpace && !tailSpace) || tailSpace ? -1 : 0;
6 | for (let i = 1; i < orderedClassNames.length; i++) {
7 | const cls = orderedClassNames[i];
8 | // This function assumes that the list of classNames is ordered, so just comparing to the previous className is enough
9 | if (cls === previous) {
10 | orderedClassNames.splice(i, 1);
11 | whitespaces.splice(i + offset, 1);
12 | i--;
13 | }
14 | previous = cls;
15 | }
16 | }
17 |
18 | module.exports = removeDuplicatesFromClassnamesAndWhitespaces;
19 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[BUG] "
5 | labels: 'bug'
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Environment (please complete the following information):**
27 | - OS: [e.g. macOS, windows 10]
28 | - Softwares + version used:
29 | - [e.g. VSCode 1.54.3]
30 | - [... Terminal 2.9.5, npm 6.14.5, node v14.5.0]
31 |
32 | **Additional context**
33 | Add any other context about the problem here.
34 |
35 | **eslint config file or live demo**
36 | By providing a link to a live demo, a demo video or a github repo fixing the issue will be easier.
37 |
--------------------------------------------------------------------------------
/tests/lib/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Use a consistent orders for the Tailwind CSS classnames, based on property then on variants
3 | * @author François Massart
4 | */
5 | "use strict";
6 |
7 | var plugin = require("../../lib/index");
8 |
9 | var assert = require("assert");
10 | var fs = require("fs");
11 | var path = require("path");
12 |
13 | var rules = fs.readdirSync(path.resolve(__dirname, "../../lib/rules/")).map(function (f) {
14 | return path.basename(f, ".js");
15 | });
16 |
17 | describe("all rule files should be exported by the plugin", function () {
18 | rules.forEach(function (ruleName) {
19 | it(`should export ${ruleName}`, function () {
20 | assert.equal(plugin.rules[ruleName], require(path.join("../../lib/rules", ruleName)));
21 | });
22 | });
23 | });
24 |
25 | describe("configurations", function () {
26 | it(`should export a "recommended" configuration`, function () {
27 | assert(plugin.configs.recommended);
28 | });
29 |
30 | it(`should export a "flat/recommended" configuration`, function () {
31 | assert(plugin.configs["flat/recommended"]);
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Francois Massart
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/lib/rules/another.css:
--------------------------------------------------------------------------------
1 | /* override input always getting focus styling from package "focus-visible" */
2 | .focus-outline[data-focus-visible-added].focus-visible.focus,
3 | .focus-outline[data-focus-visible-added].focus-visible.focus:focus,
4 | input[type="text"].focus\:outline-none[data-focus-visible-added]:focus,
5 | input[type="email"].focus\:outline-none[data-focus-visible-added]:focus {
6 | box-shadow: none;
7 | outline-width: 0;
8 | }
9 | /* .custom-carousel li:first-child {
10 | padding-left: 20vw;
11 | } */
12 |
13 | :global(.slide:not(.selected)) {
14 | opacity: 0.2;
15 | }
16 |
17 | .gallery-snackbar {
18 | @apply w-full;
19 | }
20 |
21 | .custom-slide {
22 | height: 70vh;
23 | }
24 |
25 | @media (min-width: 768px) {
26 | .custom-carousel li:not(:first-child) {
27 | @apply pl-16;
28 | }
29 |
30 | .custom-carousel li:not(:last-child) {
31 | @apply pr-16;
32 | }
33 |
34 | .custom-carousel :global(.carousel-root) {
35 | padding: 0 calc(10vw + 16px);
36 | }
37 |
38 | .gallery-snackbar {
39 | width: calc(80vw - 32px);
40 | }
41 |
42 | .indicator {
43 | margin-left: calc(10vw + 16px);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 | on: [push]
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v4
8 | # waiting on: https://github.com/actions/setup-node/issues/531
9 | - run: corepack enable
10 | - uses: actions/setup-node@v4
11 | with:
12 | node-version: 21
13 | cache: npm
14 | - run: npm ci
15 |
16 | - name: test build package on node@21 (current)
17 | run: |
18 | node --version
19 | npm --version
20 | npm run test
21 |
22 | # Not using a matrix here since it's simpler
23 | # to just duplicate it and not spawn new instances
24 |
25 | - uses: actions/setup-node@v4
26 | with:
27 | node-version: 20
28 | - name: test build package on node@20 (LTS)
29 | run: |
30 | node --version
31 | npm --version
32 | npm run test
33 |
34 | - uses: actions/setup-node@v4
35 | with:
36 | node-version: 18
37 | - name: test build package on node@18 (LTS)
38 | run: |
39 | node --version
40 | npm --version
41 | npm run test
42 |
--------------------------------------------------------------------------------
/lib/util/parser.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @see parserServices https://eslint.org/docs/developer-guide/working-with-rules#the-context-object
3 | * @param {Object} context
4 | * @param {Function} templateBodyVisitor
5 | * @param {Function} scriptVisitor
6 | * @returns
7 | */
8 | function defineTemplateBodyVisitor(context, templateBodyVisitor, scriptVisitor) {
9 | const parserServices = getParserServices(context);
10 | if (parserServices == null || parserServices.defineTemplateBodyVisitor == null) {
11 | // Default parser
12 | return scriptVisitor;
13 | }
14 |
15 | // Using "vue-eslint-parser" requires this setup
16 | // @see https://eslint.org/docs/developer-guide/working-with-rules#the-context-object
17 | return parserServices.defineTemplateBodyVisitor(templateBodyVisitor, scriptVisitor);
18 | }
19 |
20 | /**
21 | * This function is API compatible with eslint v8.x and eslint v9 or later.
22 | * @see https://eslint.org/blog/2023/09/preparing-custom-rules-eslint-v9/#from-context-to-sourcecode
23 | */
24 | function getParserServices(context) {
25 | return context.sourceCode ? context.sourceCode.parserServices : context.parserServices;
26 | }
27 |
28 | module.exports = {
29 | defineTemplateBodyVisitor,
30 | };
31 |
--------------------------------------------------------------------------------
/tests/integrations/flat-config.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const { strict: assert } = require("assert");
4 | const cp = require("child_process");
5 | const path = require("path");
6 | const semver = require("semver");
7 |
8 | const ESLINT_BIN_PATH = [".", "node_modules", ".bin", "eslint"].join(path.sep);
9 |
10 | describe("Integration with flat config", () => {
11 | let originalCwd;
12 |
13 | before(() => {
14 | originalCwd = process.cwd();
15 | process.chdir(path.join(__dirname, "flat-config"));
16 | cp.execSync("npm i -f", { stdio: "inherit" });
17 | });
18 | after(() => {
19 | process.chdir(originalCwd);
20 | });
21 |
22 | it("should work with flat config", () => {
23 | if (
24 | !semver.satisfies(
25 | process.version,
26 | require(path.join(__dirname, "flat-config/node_modules/eslint/package.json")).engines.node
27 | )
28 | ) {
29 | return;
30 | }
31 |
32 | const lintResult = cp.execSync(`${ESLINT_BIN_PATH} a.vue --format=json`, {
33 | encoding: "utf8",
34 | });
35 | const result = JSON.parse(lintResult);
36 | assert.strictEqual(result.length, 1);
37 | assert.deepStrictEqual(result[0].messages[0].messageId, "invalidOrder");
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @fileoverview Rules enforcing best practices while using Tailwind CSS
3 | * @author François Massart
4 | */
5 | 'use strict';
6 |
7 | //------------------------------------------------------------------------------
8 | // Plugin Definition
9 | //------------------------------------------------------------------------------
10 |
11 | // import all rules in lib/rules
12 | var base = __dirname + '/rules/';
13 | module.exports = {
14 | rules: {
15 | 'classnames-order': require(base + 'classnames-order'),
16 | 'enforces-negative-arbitrary-values': require(base + 'enforces-negative-arbitrary-values'),
17 | 'enforces-shorthand': require(base + 'enforces-shorthand'),
18 | 'migration-from-tailwind-2': require(base + 'migration-from-tailwind-2'),
19 | 'no-arbitrary-value': require(base + 'no-arbitrary-value'),
20 | 'no-contradicting-classname': require(base + 'no-contradicting-classname'),
21 | 'no-custom-classname': require(base + 'no-custom-classname'),
22 | 'no-unnecessary-arbitrary-value': require(base + 'no-unnecessary-arbitrary-value'),
23 | },
24 | configs: {
25 | recommended: require('./config/recommended'),
26 | 'flat/recommended': require('./config/flat-recommended'),
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/tests/integrations/legacy-config.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const { strict: assert } = require("assert");
4 | const cp = require("child_process");
5 | const path = require("path");
6 | const semver = require("semver");
7 |
8 | const ESLINT_BIN_PATH = [".", "node_modules", ".bin", "eslint"].join(path.sep);
9 |
10 | describe("Integration with legacy config", () => {
11 | let originalCwd;
12 |
13 | before(() => {
14 | originalCwd = process.cwd();
15 | process.chdir(path.join(__dirname, "legacy-config"));
16 | cp.execSync("npm i -f", { stdio: "inherit" });
17 | });
18 | after(() => {
19 | process.chdir(originalCwd);
20 | });
21 |
22 | it("should work with legacy config", () => {
23 | if (
24 | !semver.satisfies(
25 | process.version,
26 | require(path.join(__dirname, "legacy-config/node_modules/eslint/package.json")).engines.node
27 | )
28 | ) {
29 | return;
30 | }
31 |
32 | const lintResult = cp.execSync(`${ESLINT_BIN_PATH} a.vue --format=json`, {
33 | encoding: "utf8",
34 | });
35 | const result = JSON.parse(lintResult);
36 | assert.strictEqual(result.length, 1);
37 | assert.deepStrictEqual(result[0].messages[0].messageId, "invalidOrder");
38 | });
39 | });
40 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to `esLint-plugin-tailwindcss`
2 |
3 | ## Contributing
4 |
5 | When contributing to this repository, please first discuss the change you wish to make via issue,
6 | email, or any other method with the owners of this repository before making a change.
7 |
8 | Please note we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in all your interactions with the project.
9 |
10 | ## Development
11 |
12 | You can use [Corepack](https://nodejs.org/api/corepack.html) to ensure you're using the same package
13 | manager. Run `corepack enabled` before running `npm install`.
14 |
15 | ## Pull Request Process
16 |
17 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a
18 | build.
19 | 2. Update the README.md with details of changes to the interface, this includes new environment
20 | variables, exposed ports, useful file locations and container parameters.
21 | 3. Increase the version numbers in any examples files and the README.md to the new version that this
22 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/).
23 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you
24 | do not have permission to do that, you may request the second reviewer to merge it for you.
25 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # Pull Request Name
2 |
3 | ## Description
4 |
5 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context.
6 | List any dependencies that are required for this change.
7 |
8 | Fixes # (issue)
9 |
10 | ## Type of change
11 |
12 | Please delete options that are not relevant.
13 |
14 | - [ ] Bug fix (non-breaking change which fixes an issue)
15 | - [ ] New feature (non-breaking change which adds functionality)
16 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
17 | - [ ] This change requires a documentation update
18 |
19 | ## How Has This Been Tested?
20 |
21 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce.
22 | Please also list any relevant details for your test configuration
23 |
24 | - [ ] Test A
25 | - [ ] Test B
26 |
27 | **Test Configuration**:
28 |
29 | - OS + version: e.g. macOS Mojave
30 | - NPM version: ...
31 | - Node version: ...
32 |
33 | ## Checklist:
34 |
35 | - [ ] My code follows the style guidelines of this project
36 | - [ ] I have performed a self-review of my own code
37 | - [ ] I have commented my code, particularly in hard-to-understand areas
38 | - [ ] I have made corresponding changes to the documentation
39 | - [ ] My changes generate no new warnings
40 | - [ ] I have added tests that prove my fix is effective or that my feature works
41 | - [ ] Any dependent changes have been merged and published in downstream modules
42 | - [ ] I have checked my code and corrected any misspellings
43 |
--------------------------------------------------------------------------------
/lib/util/settings.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | let resolveDefaultConfigPathAlias;
3 |
4 | try {
5 | const { resolveDefaultConfigPath } = require('tailwindcss/lib/util/resolveConfigPath');
6 | resolveDefaultConfigPathAlias = resolveDefaultConfigPath;
7 | } catch (err) {
8 | resolveDefaultConfigPathAlias = null;
9 | }
10 |
11 | function getOption(context, name) {
12 | // Options (defined at rule level)
13 | const options = context.options[0] || {};
14 | if (options[name] != undefined) {
15 | return options[name];
16 | }
17 | // Settings (defined at plugin level, shared accross rules)
18 | if (context.settings && context.settings.tailwindcss && context.settings.tailwindcss[name] != undefined) {
19 | return context.settings.tailwindcss[name];
20 | }
21 | // Fallback to defaults
22 | switch (name) {
23 | case 'callees':
24 | return ['classnames', 'clsx', 'ctl', 'cva', 'tv'];
25 | case 'ignoredKeys':
26 | return ['compoundVariants', 'defaultVariants'];
27 | case 'classRegex':
28 | return '^class(Name)?$';
29 | case 'config':
30 | if (resolveDefaultConfigPathAlias === null) {
31 | return 'tailwind.config.js';
32 | } else {
33 | return resolveDefaultConfigPathAlias();
34 | }
35 | case 'cssFiles':
36 | return ['**/*.css', '!**/node_modules', '!**/.*', '!**/dist', '!**/build'];
37 | case 'cssFilesRefreshRate':
38 | return 5_000;
39 | case 'removeDuplicates':
40 | return true;
41 | case 'skipClassAttribute':
42 | return false;
43 | case 'tags':
44 | return [];
45 | case 'whitelist':
46 | return [];
47 | }
48 | }
49 |
50 | module.exports = getOption;
51 |
--------------------------------------------------------------------------------
/lib/util/types/length.js:
--------------------------------------------------------------------------------
1 | const removeDuplicatesFromArray = require('../removeDuplicatesFromArray');
2 |
3 | // Units
4 | const fontUnits = ['cap', 'ch', 'em', 'ex', 'ic', 'lh', 'rem', 'rlh'];
5 | const viewportUnits = ['vb', 'vh', 'vi', 'vw', 'vmin', 'vmax'];
6 | const absoluteUnits = ['px', 'mm', 'cm', 'in', 'pt', 'pc'];
7 | const perInchUnits = ['lin', 'pt', 'mm'];
8 | const otherUnits = ['%'];
9 | const mergedUnits = removeDuplicatesFromArray([
10 | ...fontUnits,
11 | ...viewportUnits,
12 | ...absoluteUnits,
13 | ...perInchUnits,
14 | ...otherUnits,
15 | ]);
16 | const selectedUnits = mergedUnits.filter((el) => {
17 | // All units minus this blacklist
18 | return !['cap', 'ic', 'vb', 'vi'].includes(el);
19 | });
20 |
21 | const absoluteValues = ['0', 'xx\\-small', 'x\\-small', 'small', 'medium', 'large', 'x\\-large', 'xx\\-large'];
22 | const relativeValues = ['larger', 'smaller'];
23 | const globalValues = ['inherit', 'initial', 'unset'];
24 | const mergedValues = [...absoluteValues, ...relativeValues, ...globalValues];
25 |
26 | const mergedLengthValues = [`\\-?\\d*\\.?\\d*(${mergedUnits.join('|')})`, ...mergedValues];
27 | mergedLengthValues.push('length\\:var\\(\\-\\-[a-z\\-]{1,}\\)');
28 |
29 | const mergedUnitsRegEx = `\\[(\\d{1,}(\\.\\d{1,})?|(\\.\\d{1,})?)(${mergedUnits.join('|')})\\]`;
30 |
31 | const selectedUnitsRegEx = `\\[(\\d{1,}(\\.\\d{1,})?|(\\.\\d{1,})?)(${selectedUnits.join('|')})\\]`;
32 |
33 | const anyCalcRegEx = `\\[calc\\(.{1,}\\)\\]`;
34 |
35 | const validZeroRegEx = `^(0(\\.0{1,})?|\\.0{1,})(${mergedUnits.join('|')})?$`;
36 |
37 | module.exports = {
38 | mergedUnits,
39 | selectedUnits,
40 | mergedUnitsRegEx,
41 | selectedUnitsRegEx,
42 | anyCalcRegEx,
43 | mergedValues,
44 | mergedLengthValues,
45 | validZeroRegEx,
46 | };
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "eslint-plugin-tailwindcss",
3 | "version": "3.18.0",
4 | "description": "Rules enforcing best practices while using Tailwind CSS",
5 | "keywords": [
6 | "eslint",
7 | "eslintplugin",
8 | "eslint-plugin",
9 | "tailwind",
10 | "tailwindcss"
11 | ],
12 | "author": "François Massart",
13 | "repository": {
14 | "type": "git",
15 | "url": "https://github.com/francoismassart/eslint-plugin-tailwindcss"
16 | },
17 | "homepage": "https://github.com/francoismassart/eslint-plugin-tailwindcss",
18 | "bugs": "https://github.com/francoismassart/eslint-plugin-tailwindcss/issues",
19 | "main": "lib/index.js",
20 | "scripts": {
21 | "test": "npm run test:base && npm run test:integration",
22 | "test:base": "mocha \"tests/lib/**/*.js\"",
23 | "test:integration": "mocha \"tests/integrations/*.js\" --timeout 60000"
24 | },
25 | "files": [
26 | "lib"
27 | ],
28 | "peerDependencies": {
29 | "tailwindcss": "^3.4.0"
30 | },
31 | "dependencies": {
32 | "fast-glob": "^3.2.5",
33 | "postcss": "^8.4.4"
34 | },
35 | "devDependencies": {
36 | "@angular-eslint/template-parser": "^15.2.0",
37 | "@tailwindcss/aspect-ratio": "^0.4.2",
38 | "@tailwindcss/forms": "^0.5.3",
39 | "@tailwindcss/line-clamp": "^0.4.2",
40 | "@tailwindcss/typography": "^0.5.8",
41 | "@typescript-eslint/parser": "^5.50.0",
42 | "autoprefixer": "^10.4.0",
43 | "daisyui": "^2.6.4",
44 | "eslint": "^8.57.0",
45 | "mocha": "^10.2.0",
46 | "semver": "^7.6.0",
47 | "tailwindcss": "^3.4.0",
48 | "typescript": "4.3.5",
49 | "vue-eslint-parser": "^9.4.2"
50 | },
51 | "packageManager": "npm@10.2.5+sha256.8002e3e7305d2abd4016e1368af48d49b066c269079eeb10a56e7d6598acfdaa",
52 | "engines": {
53 | "node": ">=18.12.0"
54 | },
55 | "license": "MIT"
56 | }
57 |
--------------------------------------------------------------------------------
/lib/util/customConfig.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | const resolveConfig = require('tailwindcss/resolveConfig');
6 | let twLoadConfig;
7 |
8 | try {
9 | twLoadConfig = require('tailwindcss/lib/lib/load-config');
10 | } catch (err) {
11 | twLoadConfig = null;
12 | }
13 |
14 | // for nativewind preset
15 | process.env.TAILWIND_MODE = 'build';
16 |
17 | const CHECK_REFRESH_RATE = 1_000;
18 | let lastCheck = null;
19 | let mergedConfig = new Map();
20 | let lastModifiedDate = new Map();
21 |
22 | /**
23 | * @see https://stackoverflow.com/questions/9210542/node-js-require-cache-possible-to-invalidate
24 | * @param {string} module The path to the module
25 | * @returns the module's export
26 | */
27 | function requireUncached(module) {
28 | delete require.cache[require.resolve(module)];
29 | if (twLoadConfig === null) {
30 | // Using native loading
31 | return require(module);
32 | } else {
33 | // Using Tailwind CSS's loadConfig utility
34 | return twLoadConfig.loadConfig(module);
35 | }
36 | }
37 |
38 | /**
39 | * Load the config from a path string or parsed from an object
40 | * @param {string|Object} config
41 | * @returns `null` when unchanged, `{}` when not found
42 | */
43 | function loadConfig(config) {
44 | let loadedConfig = null;
45 | if (typeof config === 'string') {
46 | const resolvedPath = path.isAbsolute(config) ? config : path.join(path.resolve(), config);
47 | try {
48 | const stats = fs.statSync(resolvedPath);
49 | const mtime = `${stats.mtime || ''}`;
50 | if (stats === null) {
51 | // Default to no config
52 | loadedConfig = {};
53 | } else if (lastModifiedDate.get(resolvedPath) !== mtime) {
54 | // Load the config based on path
55 | lastModifiedDate.set(resolvedPath, mtime);
56 | loadedConfig = requireUncached(resolvedPath);
57 | } else {
58 | // Unchanged config
59 | loadedConfig = null;
60 | }
61 | } catch (err) {
62 | // Default to no config
63 | loadedConfig = {};
64 | } finally {
65 | return loadedConfig;
66 | }
67 | } else {
68 | if (typeof config === 'object' && config !== null) {
69 | return config;
70 | }
71 | return {};
72 | }
73 | }
74 |
75 | function resolve(twConfig) {
76 | const newConfig = mergedConfig.get(twConfig) === undefined;
77 | const now = Date.now();
78 | const expired = now - lastCheck > CHECK_REFRESH_RATE;
79 | if (newConfig || expired) {
80 | lastCheck = now;
81 | const userConfig = loadConfig(twConfig);
82 | // userConfig is null when config file was not modified
83 | if (userConfig !== null) {
84 | mergedConfig.set(twConfig, resolveConfig(userConfig));
85 | }
86 | }
87 | return mergedConfig.get(twConfig);
88 | }
89 |
90 | module.exports = {
91 | resolve,
92 | };
93 |
--------------------------------------------------------------------------------
/lib/util/cssFiles.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fg = require('fast-glob');
4 | const fs = require('fs');
5 | const postcss = require('postcss');
6 | const lastClassFromSelectorRegexp = /\.([^\.\,\s\n\:\(\)\[\]\'~\+\>\*\\]*)/gim;
7 | const removeDuplicatesFromArray = require('./removeDuplicatesFromArray');
8 |
9 | const cssFilesInfos = new Map();
10 | let lastUpdate = null;
11 | let classnamesFromFiles = [];
12 |
13 | /**
14 | * Read CSS files and extract classnames
15 | * @param {Array} patterns Glob patterns to locate files
16 | * @param {Number} refreshRate Interval
17 | * @returns {Array} List of classnames
18 | */
19 | const generateClassnamesListSync = (patterns, refreshRate = 5_000) => {
20 | const now = Date.now();
21 | const isExpired = lastUpdate === null || now - lastUpdate > refreshRate;
22 |
23 | if (!isExpired) {
24 | // console.log(`generateClassnamesListSync from cache (${classnamesFromFiles.length} classes)`);
25 | return classnamesFromFiles;
26 | }
27 |
28 | // console.log('generateClassnamesListSync EXPIRED');
29 | // Update classnames from CSS files
30 | lastUpdate = now;
31 | const filesToBeRemoved = new Set([...cssFilesInfos.keys()]);
32 | const files = fg.sync(patterns, { suppressErrors: true, stats: true });
33 | for (const file of files) {
34 | let mtime = '';
35 | let canBeSkipped = cssFilesInfos.has(file.path);
36 | if (canBeSkipped) {
37 | // This file is still used
38 | filesToBeRemoved.delete(file.path);
39 | // Check modification date
40 | const stats = fs.statSync(file.path);
41 | mtime = `${stats.mtime || ''}`;
42 | canBeSkipped = cssFilesInfos.get(file.path).mtime === mtime;
43 | }
44 | if (canBeSkipped) {
45 | // File did not change since last run
46 | continue;
47 | }
48 | // Parse CSS file
49 | const data = fs.readFileSync(file.path, 'utf-8');
50 | const root = postcss.parse(data);
51 | let detectedClassnames = new Set();
52 | root.walkRules((rule) => {
53 | const matches = [...rule.selector.matchAll(lastClassFromSelectorRegexp)];
54 | const classnames = matches.map((arr) => arr[1]);
55 | detectedClassnames = new Set([...detectedClassnames, ...classnames]);
56 | });
57 | // Save the detected classnames
58 | cssFilesInfos.set(file.path, {
59 | mtime: mtime,
60 | classNames: [...detectedClassnames],
61 | });
62 | }
63 | // Remove erased CSS from the Map
64 | const deletedFiles = [...filesToBeRemoved];
65 | for (let i = 0; i < deletedFiles.length; i++) {
66 | cssFilesInfos.delete(deletedFiles[i]);
67 | }
68 | // Build the final list
69 | classnamesFromFiles = [];
70 | cssFilesInfos.forEach((css) => {
71 | classnamesFromFiles = [...classnamesFromFiles, ...css.classNames];
72 | });
73 | // Unique classnames
74 | return removeDuplicatesFromArray(classnamesFromFiles);
75 | };
76 |
77 | module.exports = generateClassnamesListSync;
78 |
--------------------------------------------------------------------------------
/docs/rules/no-arbitrary-value.md:
--------------------------------------------------------------------------------
1 | # Forbid using arbitrary values in classnames (no-arbitrary-value)
2 |
3 | Tailwind CSS 3 is Just In Time, all the time. It brings flexibility, great compilation perfs and arbitrary values.
4 | Arbitrary values are great but can be problematic too if you wish to restrict developer to stick with the values defined in the Tailwind CSS config file.
5 |
6 | **By default this rule is turned `off`, if you want to use it set it to `warn` or `error`.**
7 |
8 | ## Rule Details
9 |
10 | Examples of **incorrect** code for this rule:
11 |
12 | ```html
13 |
border width
14 | ```
15 |
16 | Examples of **correct** code for this rule:
17 |
18 | ```html
19 | border width
20 | ```
21 |
22 | ### Options
23 |
24 | ```js
25 | ...
26 | "tailwindcss/no-arbitrary-value": [, {
27 | "callees": Array,
28 | "config": |