diff --git a/README.md b/README.md index 58efd46..41d21aa 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 0000000..d8cfcfe --- /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 2399d01..5f0d8bd 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', diff --git a/lib/index.js b/lib/index.js index 53824e7..3246d28 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 0000000..4f4db93 --- /dev/null +++ b/lib/rules/no-multiple-whitespace.js @@ -0,0 +1,213 @@ +/** + * @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; + } + } + + // 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); + }, + }) + } + } + }; + + //---------------------------------------------------------------------- + // 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/tests/lib/rules/no-multiple-whitespace.js b/tests/lib/rules/no-multiple-whitespace.js new file mode 100644 index 0000000..6bb0e22 --- /dev/null +++ b/tests/lib/rules/no-multiple-whitespace.js @@ -0,0 +1,144 @@ +/** + * @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: ` + + `, + }, + { + code: ` + ctl(\` + sm:w-6 + container + w-12 + flex + lg:w-4 + \`);`, + }, + ], + + 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"), + }, + ], +});