diff --git a/__tests__/detection/detectTailwind.test.ts b/__tests__/detection/detectTailwind.test.ts new file mode 100644 index 00000000..48fb3132 --- /dev/null +++ b/__tests__/detection/detectTailwind.test.ts @@ -0,0 +1,13 @@ +import { detectTailwind } from '../../src/getDependencies'; + +describe('detectTailwind', () => { + test('with tailwindcss', () => { + const map = new Map([['tailwindcss', '1.0.0']]); + + expect(detectTailwind(map)).toBe(true); + }); + + test('without any', () => { + expect(detectTailwind(new Map())).toBe(false); + }); +}); diff --git a/__tests__/getDependencies.test.ts b/__tests__/getDependencies.test.ts index c454b3ef..75305f64 100644 --- a/__tests__/getDependencies.test.ts +++ b/__tests__/getDependencies.test.ts @@ -83,6 +83,7 @@ describe('getDependencies', () => { "hasJestDom": false, "hasNest": false, "hasNodeTypes": false, + "hasTailwind": false, "hasTestingLibrary": false, "react": { "hasReact": false, diff --git a/__tests__/plugins/__snapshots__/tailwindcss.test.ts.snap b/__tests__/plugins/__snapshots__/tailwindcss.test.ts.snap new file mode 100644 index 00000000..038278f0 --- /dev/null +++ b/__tests__/plugins/__snapshots__/tailwindcss.test.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`with tailwind 1`] = ` +{ + "tailwindcss/classnames-order": "warn", + "tailwindcss/enforces-negative-arbitrary-values": "warn", + "tailwindcss/enforces-shorthand": "warn", + "tailwindcss/migration-from-tailwind-2": "warn", + "tailwindcss/no-arbitrary-value": "off", + "tailwindcss/no-contradicting-classname": "error", + "tailwindcss/no-custom-classname": "warn", +} +`; diff --git a/__tests__/plugins/eslint-core.test.ts b/__tests__/plugins/eslint-core.test.ts index 5c8911d0..0b1c9e15 100644 --- a/__tests__/plugins/eslint-core.test.ts +++ b/__tests__/plugins/eslint-core.test.ts @@ -22,7 +22,7 @@ test('allows passing rules', () => { }, }); - expect(result[ruleName]).toBe(ruleValue); + expect(result?.[ruleName]).toBe(ruleValue); }); [ diff --git a/__tests__/plugins/import.test.ts b/__tests__/plugins/import.test.ts index 5dc3841f..df291b2a 100644 --- a/__tests__/plugins/import.test.ts +++ b/__tests__/plugins/import.test.ts @@ -14,7 +14,7 @@ test('allows passing rules', () => { }, }); - expect(result[ruleName]).toBe(ruleValue); + expect(result?.[ruleName]).toBe(ruleValue); }); test('with typescript', () => { diff --git a/__tests__/plugins/promise.test.ts b/__tests__/plugins/promise.test.ts index 52a2489c..4b835edd 100644 --- a/__tests__/plugins/promise.test.ts +++ b/__tests__/plugins/promise.test.ts @@ -14,7 +14,7 @@ test('allows passing rules', () => { }, }); - expect(result[ruleName]).toBe(ruleValue); + expect(result?.[ruleName]).toBe(ruleValue); }); test('with typescript', () => { diff --git a/__tests__/plugins/sonarjs.test.ts b/__tests__/plugins/sonarjs.test.ts index 429728b0..779940b2 100644 --- a/__tests__/plugins/sonarjs.test.ts +++ b/__tests__/plugins/sonarjs.test.ts @@ -14,7 +14,7 @@ test('allows passing rules', () => { }, }); - expect(result[ruleName]).toBe(ruleValue); + expect(result?.[ruleName]).toBe(ruleValue); }); test('with typescript', () => { diff --git a/__tests__/plugins/tailwindcss.test.ts b/__tests__/plugins/tailwindcss.test.ts new file mode 100644 index 00000000..0fbf54b9 --- /dev/null +++ b/__tests__/plugins/tailwindcss.test.ts @@ -0,0 +1,25 @@ +import { createTailwindPlugin } from '../../src/plugins/tailwindcss'; +import { defaultProject } from '../shared'; + +test('allows passing rules', () => { + const ruleName = 'foo'; + const ruleValue = 'off'; + + const result = createTailwindPlugin({ + ...defaultProject, + rules: { + [ruleName]: ruleValue, + }, + }); + + expect(result?.[ruleName]).toBe(ruleValue); +}); + +test('with tailwind', () => { + expect( + createTailwindPlugin({ + ...defaultProject, + hasTailwind: true, + }) + ).toMatchSnapshot(); +}); diff --git a/__tests__/plugins/unicorn.test.ts b/__tests__/plugins/unicorn.test.ts index d5e0d612..28a60987 100644 --- a/__tests__/plugins/unicorn.test.ts +++ b/__tests__/plugins/unicorn.test.ts @@ -16,7 +16,7 @@ test('allows passing rules', () => { }, }); - expect(result[ruleName]).toBe(ruleValue); + expect(result?.[ruleName]).toBe(ruleValue); }); test('with typescript', () => { diff --git a/__tests__/shared.ts b/__tests__/shared.ts index 49981da5..5b3b26c8 100644 --- a/__tests__/shared.ts +++ b/__tests__/shared.ts @@ -6,6 +6,7 @@ export const defaultProject: Parameters[0] = { hasNest: false, hasNodeTypes: false, hasTestingLibrary: false, + hasTailwind: true, react: { hasReact: false, isCreateReactApp: false, diff --git a/integration/cra-js/deps.json b/integration/cra-js/deps.json index db160487..d111b5f6 100644 --- a/integration/cra-js/deps.json +++ b/integration/cra-js/deps.json @@ -20,5 +20,6 @@ "config": null, "hasTypeScript": false, "version": null - } + }, + "hasTailwind": false } diff --git a/integration/cra-ts/deps.json b/integration/cra-ts/deps.json index e32fbd29..23e01dff 100644 --- a/integration/cra-ts/deps.json +++ b/integration/cra-ts/deps.json @@ -45,5 +45,6 @@ }, "hasTypeScript": true, "version": "^4.4.2" - } + }, + "hasTailwind": false } diff --git a/integration/jest/deps.json b/integration/jest/deps.json index 512f31ca..ba7ea3b2 100644 --- a/integration/jest/deps.json +++ b/integration/jest/deps.json @@ -46,5 +46,6 @@ }, "hasTypeScript": true, "version": "4.6.4" - } + }, + "hasTailwind": false } diff --git a/integration/js-ts-migration-mix-checkJs-off/deps.json b/integration/js-ts-migration-mix-checkJs-off/deps.json index 1ef801dd..3c8a6e85 100644 --- a/integration/js-ts-migration-mix-checkJs-off/deps.json +++ b/integration/js-ts-migration-mix-checkJs-off/deps.json @@ -46,5 +46,6 @@ }, "hasTypeScript": true, "version": "4.6.4" - } + }, + "hasTailwind": false } diff --git a/integration/js-ts-migration-mix-checkJs-on/deps.json b/integration/js-ts-migration-mix-checkJs-on/deps.json index c4bd5664..dc9b5285 100644 --- a/integration/js-ts-migration-mix-checkJs-on/deps.json +++ b/integration/js-ts-migration-mix-checkJs-on/deps.json @@ -46,5 +46,6 @@ }, "hasTypeScript": true, "version": "4.6.4" - } + }, + "hasTailwind": false } diff --git a/integration/js-ts-migration-mix-force-js-linting/deps.json b/integration/js-ts-migration-mix-force-js-linting/deps.json index 8a707dbf..5d2681f5 100644 --- a/integration/js-ts-migration-mix-force-js-linting/deps.json +++ b/integration/js-ts-migration-mix-force-js-linting/deps.json @@ -45,5 +45,6 @@ }, "hasTypeScript": true, "version": "4.6.4" - } + }, + "hasTailwind": false } diff --git a/integration/nest-ts/deps.json b/integration/nest-ts/deps.json index 423a6994..1eea9076 100644 --- a/integration/nest-ts/deps.json +++ b/integration/nest-ts/deps.json @@ -35,5 +35,6 @@ }, "hasTypeScript": true, "version": "^4.4.4" - } + }, + "hasTailwind": false } diff --git a/integration/next-js/deps.json b/integration/next-js/deps.json index 259de8e2..05032732 100644 --- a/integration/next-js/deps.json +++ b/integration/next-js/deps.json @@ -20,5 +20,6 @@ "config": null, "hasTypeScript": false, "version": null - } + }, + "hasTailwind": false } diff --git a/integration/next-ts/deps.json b/integration/next-ts/deps.json index b9dc9208..705dd119 100644 --- a/integration/next-ts/deps.json +++ b/integration/next-ts/deps.json @@ -48,5 +48,6 @@ }, "hasTypeScript": true, "version": "4.9.4" - } + }, + "hasTailwind": false } diff --git a/integration/remix-js/deps.json b/integration/remix-js/deps.json index 4ab55dd1..847887b4 100644 --- a/integration/remix-js/deps.json +++ b/integration/remix-js/deps.json @@ -20,5 +20,6 @@ "config": null, "hasTypeScript": false, "version": null - } + }, + "hasTailwind": false } diff --git a/integration/remix-ts/deps.json b/integration/remix-ts/deps.json index 8f504a78..806a87f8 100644 --- a/integration/remix-ts/deps.json +++ b/integration/remix-ts/deps.json @@ -45,5 +45,6 @@ }, "hasTypeScript": true, "version": "^4.1.2" - } + }, + "hasTailwind": false } diff --git a/package.json b/package.json index 403fc8dd..1bc00d66 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "eslint-plugin-simple-import-sort": "10.0.0", "eslint-plugin-sonarjs": "0.19.0", "eslint-plugin-storybook": "0.6.11", + "eslint-plugin-tailwindcss": "3.10.1", "eslint-plugin-testing-library": "5.10.2", "eslint-plugin-unicorn": "46.0.0", "lodash.merge": "4.6.2", diff --git a/src/createConfig.ts b/src/createConfig.ts index 3180150a..35080c6c 100644 --- a/src/createConfig.ts +++ b/src/createConfig.ts @@ -14,6 +14,7 @@ import { createImportRules } from './plugins/import'; import { createSimpleImportSortRules } from './plugins/import-sort'; import { createPromiseRules } from './plugins/promise'; import { createSonarjsRules } from './plugins/sonarjs'; +import { createTailwindRules } from './plugins/tailwindcss'; import { createUnicornRules } from './plugins/unicorn'; import { type ESLintConfig, @@ -98,6 +99,7 @@ export const createConfig = ({ ...createImportRules(dependencies), ...createSonarjsRules(dependencies), ...createSimpleImportSortRules(dependencies), + ...createTailwindRules(dependencies), ...rules, }, flags diff --git a/src/getDependencies.ts b/src/getDependencies.ts index 0345dcbf..dd35f931 100644 --- a/src/getDependencies.ts +++ b/src/getDependencies.ts @@ -106,6 +106,12 @@ export const detectStorybook = ( }; }; +export const detectTailwind = ( + dependencies: Map +): Dependencies['hasTailwind'] => { + return dependencies.has('tailwindcss'); +}; + export const detectNest = ( dependencies: Map ): Dependencies['hasNest'] => { @@ -142,6 +148,7 @@ export const getDependencies = ({ const hasTestingLibrary = detectTestingLibrary(deps); const storybook = detectStorybook(deps); const hasNest = detectNest(deps); + const hasTailwind = detectTailwind(deps); return { hasJest, @@ -152,6 +159,7 @@ export const getDependencies = ({ storybook, react, typescript, + hasTailwind, }; } catch (error) { // eslint-disable-next-line no-console @@ -180,6 +188,7 @@ export const getDependencies = ({ hasTypeScript: false, version: null, }, + hasTailwind: false, }; } }; diff --git a/src/plugins/tailwindcss.ts b/src/plugins/tailwindcss.ts new file mode 100644 index 00000000..3aab88cc --- /dev/null +++ b/src/plugins/tailwindcss.ts @@ -0,0 +1,70 @@ +import type { RulesetCreator } from '../types'; + +export const createTailwindPlugin: RulesetCreator = ({ + rules: customRules, + ...dependencies +}) => ({ + ...createTailwindRules(dependencies), + ...customRules, +}); + +/** + * @see https://github.com/francoismassart/eslint-plugin-tailwindcss + * + */ +export const createTailwindRules: RulesetCreator = ({ hasTailwind }) => { + if (!hasTailwind) { + return null; + } + + return { + /** + * order classnames for consistency and it makes merge conflict a bit easier to resolve + * + * @see https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/master/docs/rules/classnames-order.md + */ + 'tailwindcss/classnames-order': 'warn', + + /** + * make sure to use negative arbitrary values classname without the negative classname e.g. -top-[5px] should become top-[-5px] + * + * @see https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/master/docs/rules/enforces-negative-arbitrary-values.md + */ + 'tailwindcss/enforces-negative-arbitrary-values': 'warn', + + /** + * merge multiple classnames into shorthand if possible e.g. mx-5 my-5 should become m-5 + * + * @see https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/master/docs/rules/enforces-shorthand.md + */ + 'tailwindcss/enforces-shorthand': 'warn', + + /** + * for easy upgrade from Tailwind CSS v2 to v3. Warning: at the moment you should temporary turn off the no-custom-classname rule if you want to see the warning from migration-from-tailwind-2 + * + * @see https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/master/docs/rules/migration-from-tailwind-2.md + */ + 'tailwindcss/migration-from-tailwind-2': 'warn', + + /** + * forbid using arbitrary values in classnames (turned off by default) + * + * @see https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/master/docs/rules/no-arbitrary-value.md + */ + 'tailwindcss/no-arbitrary-value': 'off', + + /** + * only allow classnames from Tailwind CSS and the values from the whitelist option + * + * @see https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/master/docs/rules/no-custom-classname.md + */ + 'tailwindcss/no-custom-classname': 'warn', + + /** + * e.g. avoid p-2 p-3, different Tailwind CSS classnames (pt-2 & pt-3) but targeting the same property several times for the same variant. + * + * @see https://github.com/francoismassart/eslint-plugin-tailwindcss/blob/master/docs/rules/no-contradicting-classname.md + */ + 'tailwindcss/no-contradicting-classname': 'error', + }; +}; diff --git a/src/types.ts b/src/types.ts index d987090d..06fb5f60 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,7 @@ export type Dependencies = { hasTypeScript: boolean; version: null | string; }; + hasTailwind: boolean; }; export type ESLintConfig = Omit< @@ -67,7 +68,7 @@ export type RulesetCreator = ( args: Dependencies & { rules?: Linter.RulesRecord; } -) => Linter.RulesRecord; +) => Linter.RulesRecord | null; /** * internal type including the custom `overrideType` property diff --git a/yarn.lock b/yarn.lock index 0ffa4ddb..5ab70619 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2835,6 +2835,14 @@ eslint-plugin-storybook@0.6.11: requireindex "^1.1.0" ts-dedent "^2.2.0" +eslint-plugin-tailwindcss@3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-tailwindcss/-/eslint-plugin-tailwindcss-3.10.1.tgz#727193f78cc22b9b358ef5f99de2177d173a5209" + integrity sha512-NLPZ6b6nd/8CgGNMQ6NDiPUfBLQpSGu/u9RyX3MCZOwzNs2dFt1OamNAiRuo3Ixh7Gv4t5UcAcdNt8z74UDJkA== + dependencies: + fast-glob "^3.2.5" + postcss "^8.4.4" + eslint-plugin-testing-library@5.10.2: version "5.10.2" resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.10.2.tgz#12f231ad9b52b6aef45c801fd00aa129a932e0c2" @@ -3055,7 +3063,7 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.2.11, fast-glob@^3.2.9: +fast-glob@^3.2.11, fast-glob@^3.2.5, fast-glob@^3.2.9: version "3.2.12" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== @@ -5809,6 +5817,15 @@ postcss@8.4.14: picocolors "^1.0.0" source-map-js "^1.0.2" +postcss@^8.4.4: + version "8.4.21" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" + integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== + dependencies: + nanoid "^3.3.4" + picocolors "^1.0.0" + source-map-js "^1.0.2" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"