diff --git a/docs/rules/README.md b/docs/rules/README.md index 2af1c06ca..735dc87d0 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -351,6 +351,7 @@ For example: | [vue/no-useless-v-bind](./no-useless-v-bind.md) | disallow unnecessary `v-bind` directives | :wrench: | | [vue/no-v-text](./no-v-text.md) | disallow use of v-text | | | [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: | +| [vue/prefer-separate-static-class](./prefer-separate-static-class.md) | require static class names in template to be in a separate `class` attribute | :wrench: | | [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported | | | [vue/require-emit-validator](./require-emit-validator.md) | require type definitions in emits | :bulb: | | [vue/require-expose](./require-expose.md) | require declare public properties using `expose` | :bulb: | diff --git a/docs/rules/prefer-separate-static-class.md b/docs/rules/prefer-separate-static-class.md new file mode 100644 index 000000000..155c4fb5c --- /dev/null +++ b/docs/rules/prefer-separate-static-class.md @@ -0,0 +1,43 @@ +--- +pageClass: rule-details +sidebarDepth: 0 +title: vue/prefer-separate-static-class +description: require static class names in template to be in a separate `class` attribute +--- +# vue/prefer-separate-static-class + +> require static class names in template to be in a separate `class` attribute + +- :exclamation: ***This rule has not been released yet.*** +- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule. + +## :book: Rule Details + +This rule reports static class names in dynamic class attributes. + + + +```vue + +``` + + + +## :wrench: Options + +Nothing. + +## :mag: Implementation + +- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/prefer-separate-static-class.js) +- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/prefer-separate-static-class.js) diff --git a/lib/index.js b/lib/index.js index 0adbf3f74..3c628c559 100644 --- a/lib/index.js +++ b/lib/index.js @@ -154,6 +154,7 @@ module.exports = { 'operator-linebreak': require('./rules/operator-linebreak'), 'order-in-components': require('./rules/order-in-components'), 'padding-line-between-blocks': require('./rules/padding-line-between-blocks'), + 'prefer-separate-static-class': require('./rules/prefer-separate-static-class'), 'prefer-template': require('./rules/prefer-template'), 'prop-name-casing': require('./rules/prop-name-casing'), 'require-component-is': require('./rules/require-component-is'), diff --git a/lib/rules/prefer-separate-static-class.js b/lib/rules/prefer-separate-static-class.js new file mode 100644 index 000000000..504d95d60 --- /dev/null +++ b/lib/rules/prefer-separate-static-class.js @@ -0,0 +1,231 @@ +/** + * @author Flo Edelmann + * See LICENSE file in root directory for full license. + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const { defineTemplateBodyVisitor, getStringLiteralValue } = require('../utils') + +// ------------------------------------------------------------------------------ +// Helpers +// ------------------------------------------------------------------------------ + +/** + * @param {ASTNode} node + * @returns {node is Literal | TemplateLiteral} + */ +function isStringLiteral(node) { + return ( + (node.type === 'Literal' && typeof node.value === 'string') || + (node.type === 'TemplateLiteral' && node.expressions.length === 0) + ) +} + +/** + * @param {Expression | VForExpression | VOnExpression | VSlotScopeExpression | VFilterSequenceExpression} expressionNode + * @returns {(Literal | TemplateLiteral | Identifier)[]} + */ +function findStaticClasses(expressionNode) { + if (isStringLiteral(expressionNode)) { + return [expressionNode] + } + + if (expressionNode.type === 'ArrayExpression') { + return expressionNode.elements.flatMap((element) => { + if (element === null || element.type === 'SpreadElement') { + return [] + } + return findStaticClasses(element) + }) + } + + if (expressionNode.type === 'ObjectExpression') { + return expressionNode.properties.flatMap((property) => { + if ( + property.type === 'Property' && + property.value.type === 'Literal' && + property.value.value === true && + (isStringLiteral(property.key) || + (property.key.type === 'Identifier' && !property.computed)) + ) { + return [property.key] + } + return [] + }) + } + + return [] +} + +/** + * @param {VAttribute | VDirective} attributeNode + * @returns {attributeNode is VAttribute & { value: VLiteral }} + */ +function isStaticClassAttribute(attributeNode) { + return ( + !attributeNode.directive && + attributeNode.key.name === 'class' && + attributeNode.value !== null + ) +} + +/** + * Removes the node together with the comma before or after the node. + * @param {RuleFixer} fixer + * @param {ParserServices.TokenStore} tokenStore + * @param {ASTNode} node + */ +function* removeNodeWithComma(fixer, tokenStore, node) { + const prevToken = tokenStore.getTokenBefore(node) + if (prevToken.type === 'Punctuator' && prevToken.value === ',') { + yield fixer.removeRange([prevToken.range[0], node.range[1]]) + return + } + + const [nextToken, nextNextToken] = tokenStore.getTokensAfter(node, { + count: 2 + }) + if ( + nextToken.type === 'Punctuator' && + nextToken.value === ',' && + (nextNextToken.type !== 'Punctuator' || + (nextNextToken.value !== ']' && nextNextToken.value !== '}')) + ) { + yield fixer.removeRange([node.range[0], nextNextToken.range[0]]) + return + } + + yield fixer.remove(node) +} + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'require static class names in template to be in a separate `class` attribute', + categories: undefined, + url: 'https://eslint.vuejs.org/rules/prefer-separate-static-class.html' + }, + fixable: 'code', + schema: [], + messages: { + preferSeparateStaticClass: + 'Static class "{{className}}" should be in a static `class` attribute.' + } + }, + /** @param {RuleContext} context */ + create(context) { + return defineTemplateBodyVisitor(context, { + /** @param {VDirectiveKey} directiveKeyNode */ + "VAttribute[directive=true] > VDirectiveKey[name.name='bind'][argument.name='class']"( + directiveKeyNode + ) { + const attributeNode = directiveKeyNode.parent + if (!attributeNode.value || !attributeNode.value.expression) { + return + } + + const expressionNode = attributeNode.value.expression + const staticClassNameNodes = findStaticClasses(expressionNode) + + for (const staticClassNameNode of staticClassNameNodes) { + const className = + staticClassNameNode.type === 'Identifier' + ? staticClassNameNode.name + : getStringLiteralValue(staticClassNameNode, true) + + if (className === null) { + continue + } + + context.report({ + node: staticClassNameNode, + messageId: 'preferSeparateStaticClass', + data: { className }, + *fix(fixer) { + let dynamicClassDirectiveRemoved = false + + yield* removeFromClassDirective() + yield* addToClassAttribute() + + /** + * Remove class from dynamic `:class` directive. + */ + function* removeFromClassDirective() { + if (isStringLiteral(expressionNode)) { + yield fixer.remove(attributeNode) + dynamicClassDirectiveRemoved = true + return + } + + const listElement = + staticClassNameNode.parent.type === 'Property' + ? staticClassNameNode.parent + : staticClassNameNode + + const listNode = listElement.parent + if ( + listNode.type === 'ArrayExpression' || + listNode.type === 'ObjectExpression' + ) { + const elements = + listNode.type === 'ObjectExpression' + ? listNode.properties + : listNode.elements + + if (elements.length === 1 && listNode === expressionNode) { + yield fixer.remove(attributeNode) + dynamicClassDirectiveRemoved = true + return + } + + const tokenStore = + context.parserServices.getTemplateBodyTokenStore() + + if (elements.length === 1) { + yield* removeNodeWithComma(fixer, tokenStore, listNode) + return + } + + yield* removeNodeWithComma(fixer, tokenStore, listElement) + } + } + + /** + * Add class to static `class` attribute. + */ + function* addToClassAttribute() { + const existingStaticClassAttribute = + attributeNode.parent.attributes.find(isStaticClassAttribute) + if (existingStaticClassAttribute) { + const literalNode = existingStaticClassAttribute.value + yield fixer.replaceText( + literalNode, + `"${literalNode.value} ${className}"` + ) + return + } + + // new static `class` attribute + const separator = dynamicClassDirectiveRemoved ? '' : ' ' + yield fixer.insertTextBefore( + attributeNode, + `class="${className}"${separator}` + ) + } + } + }) + } + } + }) + } +} diff --git a/tests/lib/rules/prefer-separate-static-class.js b/tests/lib/rules/prefer-separate-static-class.js new file mode 100644 index 000000000..498fc90dd --- /dev/null +++ b/tests/lib/rules/prefer-separate-static-class.js @@ -0,0 +1,333 @@ +/** + * @author Flo Edelmann + * See LICENSE file in root directory for full license. + */ +'use strict' + +const RuleTester = require('eslint').RuleTester +const rule = require('../../../lib/rules/prefer-separate-static-class') + +const tester = new RuleTester({ + parser: require.resolve('vue-eslint-parser'), + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module' + } +}) + +tester.run('prefer-separate-static-class', rule, { + valid: [ + { + filename: 'test.vue', + code: `` + }, + { + filename: 'test.vue', + code: `` + }, + { + filename: 'test.vue', + code: '' + }, + { + filename: 'test.vue', + code: `` + }, + { + filename: 'test.vue', + code: `` + }, + { + filename: 'test.vue', + code: `` + }, + { + filename: 'test.vue', + code: `` + }, + { + filename: 'test.vue', + code: `` + }, + { + filename: 'test.vue', + code: `` + } + ], + invalid: [ + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 30, + endColumn: 44 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 24, + endColumn: 38 + } + ] + }, + { + filename: 'test.vue', + code: '', + output: '', + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 24, + endColumn: 38 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 24, + endColumn: 38 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 25, + endColumn: 39 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 25, + endColumn: 39 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "foo" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 25, + endColumn: 28 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 26, + endColumn: 40 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 25, + endColumn: 39 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 58, + endColumn: 72 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 25, + endColumn: 39 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 48, + endColumn: 62 + } + ] + }, + { + filename: 'test.vue', + code: ``, + output: ``, + errors: [ + { + message: + 'Static class "staticClass" should be in a static `class` attribute.', + line: 1, + endLine: 1, + column: 40, + endColumn: 51 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + errors: [ + { + message: + 'Static class "static-class" should be in a static `class` attribute.', + line: 7, + endLine: 7, + column: 40, + endColumn: 54 + } + ] + }, + { + filename: 'test.vue', + code: ` + + `, + output: ` + + `, + errors: [ + { + message: + 'Static class "static-class-a" should be in a static `class` attribute.', + line: 7, + endLine: 7, + column: 15, + endColumn: 31 + }, + { + message: + 'Static class "static-class-b" should be in a static `class` attribute.', + line: 8, + endLine: 8, + column: 16, + endColumn: 32 + } + ] + } + ] +})