From 600d40d6e23cbf987fe5e252e06168ea497b1b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Le=20Ma=C3=AEtre?= Date: Sat, 26 Oct 2024 20:59:32 +0200 Subject: [PATCH 1/4] feat: add no-multiple-whitespace rule --- lib/index.js | 1 + lib/rules/no-multiple-whitespace.js | 209 ++++++++++++++++++++++ package.json | 1 + tests/lib/rules/no-multiple-whitespace.js | 134 ++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 lib/rules/no-multiple-whitespace.js create mode 100644 tests/lib/rules/no-multiple-whitespace.js diff --git a/lib/index.js b/lib/index.js index 53824e76..3246d282 100644 --- a/lib/index.js +++ b/lib/index.js @@ -20,6 +20,7 @@ module.exports = { 'no-contradicting-classname': require(base + 'no-contradicting-classname'), 'no-custom-classname': require(base + 'no-custom-classname'), 'no-unnecessary-arbitrary-value': require(base + 'no-unnecessary-arbitrary-value'), + 'no-multiple-whitespace': require(base + 'no-multiple-whitespace'), }, configs: { recommended: require('./config/recommended'), diff --git a/lib/rules/no-multiple-whitespace.js b/lib/rules/no-multiple-whitespace.js new file mode 100644 index 00000000..db20a00e --- /dev/null +++ b/lib/rules/no-multiple-whitespace.js @@ -0,0 +1,209 @@ +/** + * @fileoverview Requires exactly one space between each class + */ +'use strict'; + +const docsUrl = require('../util/docsUrl'); +const astUtil = require('../util/ast'); +const getOption = require('../util/settings'); +const parserUtil = require('../util/parser'); + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +// Predefine message for use in context.report conditional. +// messageId will still be usable in tests. +const MULTIPLE_WHITESPACE_DETECTED_MSG = 'Multiple whitespace detected'; + +module.exports = { + meta: { + docs: { + description: 'Remove unnecessary whitespaces between Tailwind CSS classnames', + category: 'Best Practices', + recommended: true, + url: docsUrl('no-multiple-whitespace'), + }, + messages: { + multipleWhitespaceDetected: MULTIPLE_WHITESPACE_DETECTED_MSG, + }, + fixable: 'code', + }, + + create: function (context) { + const callees = getOption(context, 'callees'); + const skipClassAttribute = getOption(context, 'skipClassAttribute'); + const tags = getOption(context, 'tags'); + const classRegex = getOption(context, 'classRegex'); + + //---------------------------------------------------------------------- + // Helpers + //---------------------------------------------------------------------- + + /** + * Parse the classnames and report multiple whitespace + * @param {ASTNode} node The root node of the current parsing + * @param {ASTNode} arg The child node of node + * @returns {void} + */ + const parseForMultipleWhitespace = (node, arg = null) => { + let originalClassNamesValue = null; + let start = null; + let end = null; + let prefix = ''; + let suffix = ''; + + if (arg === null) { + originalClassNamesValue = astUtil.extractValueFromNode(node); + const range = astUtil.extractRangeFromNode(node); + if (node.type === 'TextAttribute') { + start = range[0]; + end = range[1]; + } else { + start = range[0] + 1; + end = range[1] - 1; + } + } else { + switch (arg.type) { + case 'Identifier': + return; + case 'TemplateLiteral': + arg.expressions.forEach((exp) => { + parseForMultipleWhitespace(node, exp); + }); + arg.quasis.forEach((quasis) => { + parseForMultipleWhitespace(node, quasis); + }); + return; + case 'ConditionalExpression': + parseForMultipleWhitespace(node, arg.consequent); + parseForMultipleWhitespace(node, arg.alternate); + return; + case 'LogicalExpression': + parseForMultipleWhitespace(node, arg.right); + return; + case 'ArrayExpression': + arg.elements.forEach((el) => { + parseForMultipleWhitespace(node, el); + }); + return; + case 'ObjectExpression': + const isUsedByClassNamesPlugin = node.callee && node.callee.name === 'classnames'; + const isVue = node.key && node.key.type === 'VDirectiveKey'; + arg.properties.forEach((prop) => { + const propVal = isUsedByClassNamesPlugin || isVue ? prop.key : prop.value; + parseForMultipleWhitespace(node, propVal); + }); + return; + case 'Property': + parseForMultipleWhitespace(node, arg.key); + return; + + case 'Literal': + originalClassNamesValue = arg.value; + start = arg.range[0] + 1; + end = arg.range[1] - 1; + break; + case 'TemplateElement': + originalClassNamesValue = arg.value.raw; + if (originalClassNamesValue === '') { + return; + } + start = arg.range[0]; + end = arg.range[1]; + // https://github.com/eslint/eslint/issues/13360 + // The problem is that range computation includes the backticks (`test`) + // but value.raw does not include them, so there is a mismatch. + // start/end does not include the backticks, therefore it matches value.raw. + const txt = context.getSourceCode().getText(arg); + prefix = astUtil.getTemplateElementPrefix(txt, originalClassNamesValue); + suffix = astUtil.getTemplateElementSuffix(txt, originalClassNamesValue); + originalClassNamesValue = astUtil.getTemplateElementBody(txt, prefix, suffix); + break; + } + } + + + let { whitespaces } = astUtil.extractClassnamesFromValue(originalClassNamesValue); + + if(whitespaces.some(whitespace => whitespace.length > 1) || originalClassNamesValue.trim() !== originalClassNamesValue) { + context.report({ + node: node, + messageId: 'multipleWhitespaceDetected', + fix: function (fixer) { + const newText = originalClassNamesValue.trim().replace(/\s+/g, ' ').trim(); + return fixer.replaceTextRange([start, end], newText); + }, + }) + } + }; + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + const attributeVisitor = function (node) { + if (!astUtil.isClassAttribute(node, classRegex) || skipClassAttribute) { + return; + } + if (astUtil.isLiteralAttributeValue(node)) { + parseForMultipleWhitespace(node); + } else if (node.value && node.value.type === 'JSXExpressionContainer') { + parseForMultipleWhitespace(node, node.value.expression); + } + }; + + const callExpressionVisitor = function (node) { + const calleeStr = astUtil.calleeToString(node.callee); + if (callees.findIndex((name) => calleeStr === name) === -1) { + return; + } + + node.arguments.forEach((arg) => { + parseForMultipleWhitespace(node, arg); + }); + }; + + const scriptVisitor = { + JSXAttribute: attributeVisitor, + TextAttribute: attributeVisitor, + CallExpression: callExpressionVisitor, + TaggedTemplateExpression: function (node) { + if (!tags.includes(node.tag.name ?? node.tag.object?.name ?? node.tag.callee?.name)) { + return; + } + + parseForMultipleWhitespace(node, node.quasi); + }, + }; + + const templateVisitor = { + CallExpression: callExpressionVisitor, + /* + Tagged templates inside data bindings + https://github.com/vuejs/vue/issues/9721 + */ + VAttribute: function (node) { + switch (true) { + case !astUtil.isValidVueAttribute(node, classRegex): + return; + case astUtil.isVLiteralValue(node): + parseForMultipleWhitespace(node); + break; + case astUtil.isArrayExpression(node): + node.value.expression.elements.forEach((arg) => { + parseForMultipleWhitespace(node, arg); + }); + break; + case astUtil.isObjectExpression(node): + node.value.expression.properties.forEach((prop) => { + parseForMultipleWhitespace(node, prop); + }); + break; + } + }, + }; + + return parserUtil.defineTemplateBodyVisitor(context, templateVisitor, scriptVisitor); + }, +}; diff --git a/package.json b/package.json index 6ae8f8a6..d87a8cfe 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "scripts": { "test": "npm run test:base && npm run test:integration", "test:base": "mocha \"tests/lib/**/*.js\"", + "test:whitespace": "mocha \"tests/lib/rules/no-multiple-whitespace.js\"", "test:integration": "mocha \"tests/integrations/*.js\" --timeout 60000" }, "files": [ diff --git a/tests/lib/rules/no-multiple-whitespace.js b/tests/lib/rules/no-multiple-whitespace.js new file mode 100644 index 00000000..c7ed6c97 --- /dev/null +++ b/tests/lib/rules/no-multiple-whitespace.js @@ -0,0 +1,134 @@ +/** + * @fileoverview Requires exactly one space between each class + */ +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +var rule = require("../../../lib/rules/no-multiple-whitespace"); +var RuleTester = require("eslint").RuleTester; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +var parserOptions = { + ecmaVersion: 2019, + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, +}; + +var generateError = () => { + return { + messageId: "multipleWhitespaceDetected", + }; +}; + +var ruleTester = new RuleTester({ parserOptions }); + +ruleTester.run("multiple whitespace", rule, { + valid: [ + { + code: ` +
+ `, + }, + { + code: ` + + `, + }, + ], + + invalid: [ + { + code: ` +
+ `, + output: ` +
+ `, + errors: [generateError()], + }, + + { + code: ` +
+ `, + output: ` +
+ `, + errors: [generateError()], + }, + + { + code: ` +
+ `, + output: ` +
+ `, + errors: [generateError()], + }, + + { + code: ` +
+ `, + output: ` +
+ `, + errors: [generateError()], + }, + + { + code: ` + + `, + output: ` + + `, + errors: [generateError()], + }, + + { + code: ` + + `, + output: ` + + `, + errors: [generateError()], + filename: "test.vue", + parser: require.resolve("vue-eslint-parser"), + }, + + { + code: ` + + `, + output: ` + + `, + errors: [generateError()], + filename: "test.vue", + parser: require.resolve("vue-eslint-parser"), + }, + + { + code: ` + + `, + output: ` + + `, + errors: [generateError()], + filename: "test.vue", + parser: require.resolve("vue-eslint-parser"), + }, + ], +}); From b98af40ab029a92e03c25f6d3977bcd97affece5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Le=20Ma=C3=AEtre?= Date: Sat, 26 Oct 2024 21:27:43 +0200 Subject: [PATCH 2/4] docs: no-multiple-whitespace --- README.md | 1 + docs/rules/no-multiple-whitespace.md | 21 +++++++++++++++++++++ lib/config/rules.js | 1 + 3 files changed, 23 insertions(+) create mode 100644 docs/rules/no-multiple-whitespace.md diff --git a/README.md b/README.md index e9ddd0d9..64452ec9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Learn more about each supported rules by reading their documentation: - [`no-custom-classname`](docs/rules/no-custom-classname.md): only allow classnames from Tailwind CSS and the values from the `whitelist` option - [`no-contradicting-classname`](docs/rules/no-contradicting-classname.md): 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. - [`no-unnecessary-arbitrary-value`](docs/rules/no-unnecessary-arbitrary-value.md): e.g. replacing `m-[1.25rem]` by its configuration based classname `m-5` +- [`no-multiple-whitespace`](docs/rules/no-multiple-whitespace.md): removes unnecessary whitespaces between Tailwind CSS classnames Using ESLint extension for Visual Studio Code, you will get these messages ![detected-errors](.github/output.png) diff --git a/docs/rules/no-multiple-whitespace.md b/docs/rules/no-multiple-whitespace.md new file mode 100644 index 00000000..d8cfcfe8 --- /dev/null +++ b/docs/rules/no-multiple-whitespace.md @@ -0,0 +1,21 @@ +# Removes unnecessary whitespaces between classnames (no-multiple-whitespace) + +Removes any unnecessary whitespaces between Tailwind CSS classnames, keeping only one space between each class. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```html + +``` + +Examples of **correct** code for this rule: + +```html + +``` + +## Further Reading + +This rule automatically fixes the issue by removing the unnecessary whitespaces. diff --git a/lib/config/rules.js b/lib/config/rules.js index 2399d016..5f0d8bd9 100644 --- a/lib/config/rules.js +++ b/lib/config/rules.js @@ -9,6 +9,7 @@ module.exports = { 'tailwindcss/enforces-shorthand': 'warn', 'tailwindcss/migration-from-tailwind-2': 'warn', 'tailwindcss/no-arbitrary-value': 'off', + 'tailwindcss/no-multiple-whitespace': 'warn', 'tailwindcss/no-custom-classname': 'warn', 'tailwindcss/no-contradicting-classname': 'error', 'tailwindcss/no-unnecessary-arbitrary-value': 'warn', From 1b64989c67a2bb65059ba44f85944cd11796148c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Le=20Ma=C3=AEtre?= Date: Fri, 13 Dec 2024 18:39:50 +0100 Subject: [PATCH 3/4] chore: remove custom script --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index d87a8cfe..6ae8f8a6 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "scripts": { "test": "npm run test:base && npm run test:integration", "test:base": "mocha \"tests/lib/**/*.js\"", - "test:whitespace": "mocha \"tests/lib/rules/no-multiple-whitespace.js\"", "test:integration": "mocha \"tests/integrations/*.js\" --timeout 60000" }, "files": [ From f48dffc354ccc1fc03d5ca1a997cac947caa3713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89ric=20Le=20Ma=C3=AEtre?= Date: Sat, 18 Jan 2025 22:13:04 +0100 Subject: [PATCH 4/4] fix: Handling multiline class names --- lib/rules/no-multiple-whitespace.js | 28 +++++++++++++---------- tests/lib/rules/no-multiple-whitespace.js | 10 ++++++++ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/lib/rules/no-multiple-whitespace.js b/lib/rules/no-multiple-whitespace.js index db20a00e..4f4db93a 100644 --- a/lib/rules/no-multiple-whitespace.js +++ b/lib/rules/no-multiple-whitespace.js @@ -123,18 +123,22 @@ module.exports = { } } - - let { whitespaces } = astUtil.extractClassnamesFromValue(originalClassNamesValue); - - if(whitespaces.some(whitespace => whitespace.length > 1) || originalClassNamesValue.trim() !== originalClassNamesValue) { - context.report({ - node: node, - messageId: 'multipleWhitespaceDetected', - fix: function (fixer) { - const newText = originalClassNamesValue.trim().replace(/\s+/g, ' ').trim(); - return fixer.replaceTextRange([start, end], newText); - }, - }) + // Class names on multiple lines + if (/\r|\n/.test(originalClassNamesValue)) { + return; + } else { + let { whitespaces } = astUtil.extractClassnamesFromValue(originalClassNamesValue); + + if(whitespaces.some(whitespace => whitespace.length > 1) || originalClassNamesValue.trim() !== originalClassNamesValue) { + context.report({ + node: node, + messageId: 'multipleWhitespaceDetected', + fix: function (fixer) { + const newText = originalClassNamesValue.trim().replace(/\s+/g, ' ').trim(); + return fixer.replaceTextRange([start, end], newText); + }, + }) + } } }; diff --git a/tests/lib/rules/no-multiple-whitespace.js b/tests/lib/rules/no-multiple-whitespace.js index c7ed6c97..6bb0e221 100644 --- a/tests/lib/rules/no-multiple-whitespace.js +++ b/tests/lib/rules/no-multiple-whitespace.js @@ -42,6 +42,16 @@ ruleTester.run("multiple whitespace", rule, { `, }, + { + code: ` + ctl(\` + sm:w-6 + container + w-12 + flex + lg:w-4 + \`);`, + }, ], invalid: [