Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add no-multiple-whitespace rule #370

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions docs/rules/no-multiple-whitespace.md
Original file line number Diff line number Diff line change
@@ -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
<span class=" bg-red-50 text-xl "></span>
```

Examples of **correct** code for this rule:

```html
<span class="bg-red-50 text-xl"></span>
```

## Further Reading

This rule automatically fixes the issue by removing the unnecessary whitespaces.
1 change: 1 addition & 0 deletions lib/config/rules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
213 changes: 213 additions & 0 deletions lib/rules/no-multiple-whitespace.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
Loading