Skip to content

Commit 8a0b2c8

Browse files
authored
Add new rule vue/prefer-separate-static-class (#1729)
* Add new rule `vue/prefer-separate-static-class` * Also find static identifier object keys * Add auto-fix * Fix removing whole class directive if it's not empty * Simplify check with `property.computed` * Change rule type to `suggestion` * Make rule docs more consistent * Drop unnecessary `references` parameter
1 parent fe82fb5 commit 8a0b2c8

File tree

5 files changed

+609
-0
lines changed

5 files changed

+609
-0
lines changed

docs/rules/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ For example:
351351
| [vue/no-useless-v-bind](./no-useless-v-bind.md) | disallow unnecessary `v-bind` directives | :wrench: |
352352
| [vue/no-v-text](./no-v-text.md) | disallow use of v-text | |
353353
| [vue/padding-line-between-blocks](./padding-line-between-blocks.md) | require or disallow padding lines between blocks | :wrench: |
354+
| [vue/prefer-separate-static-class](./prefer-separate-static-class.md) | require static class names in template to be in a separate `class` attribute | :wrench: |
354355
| [vue/require-direct-export](./require-direct-export.md) | require the component to be directly exported | |
355356
| [vue/require-emit-validator](./require-emit-validator.md) | require type definitions in emits | :bulb: |
356357
| [vue/require-expose](./require-expose.md) | require declare public properties using `expose` | :bulb: |
+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
pageClass: rule-details
3+
sidebarDepth: 0
4+
title: vue/prefer-separate-static-class
5+
description: require static class names in template to be in a separate `class` attribute
6+
---
7+
# vue/prefer-separate-static-class
8+
9+
> require static class names in template to be in a separate `class` attribute
10+
11+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
12+
- :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.
13+
14+
## :book: Rule Details
15+
16+
This rule reports static class names in dynamic class attributes.
17+
18+
<eslint-code-block fix :rules="{'vue/prefer-separate-static-class': ['error']}">
19+
20+
```vue
21+
<template>
22+
<!-- ✗ BAD -->
23+
<div :class="'static-class'" />
24+
<div :class="{'static-class': true, 'dynamic-class': foo}" />
25+
<div :class="['static-class', dynamicClass]" />
26+
27+
<!-- ✓ GOOD -->
28+
<div class="static-class" />
29+
<div class="static-class" :class="{'dynamic-class': foo}" />
30+
<div class="static-class" :class="[dynamicClass]" />
31+
</template>
32+
```
33+
34+
</eslint-code-block>
35+
36+
## :wrench: Options
37+
38+
Nothing.
39+
40+
## :mag: Implementation
41+
42+
- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/prefer-separate-static-class.js)
43+
- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/prefer-separate-static-class.js)

lib/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ module.exports = {
154154
'operator-linebreak': require('./rules/operator-linebreak'),
155155
'order-in-components': require('./rules/order-in-components'),
156156
'padding-line-between-blocks': require('./rules/padding-line-between-blocks'),
157+
'prefer-separate-static-class': require('./rules/prefer-separate-static-class'),
157158
'prefer-template': require('./rules/prefer-template'),
158159
'prop-name-casing': require('./rules/prop-name-casing'),
159160
'require-component-is': require('./rules/require-component-is'),
+231
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* @author Flo Edelmann
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
'use strict'
6+
7+
// ------------------------------------------------------------------------------
8+
// Requirements
9+
// ------------------------------------------------------------------------------
10+
11+
const { defineTemplateBodyVisitor, getStringLiteralValue } = require('../utils')
12+
13+
// ------------------------------------------------------------------------------
14+
// Helpers
15+
// ------------------------------------------------------------------------------
16+
17+
/**
18+
* @param {ASTNode} node
19+
* @returns {node is Literal | TemplateLiteral}
20+
*/
21+
function isStringLiteral(node) {
22+
return (
23+
(node.type === 'Literal' && typeof node.value === 'string') ||
24+
(node.type === 'TemplateLiteral' && node.expressions.length === 0)
25+
)
26+
}
27+
28+
/**
29+
* @param {Expression | VForExpression | VOnExpression | VSlotScopeExpression | VFilterSequenceExpression} expressionNode
30+
* @returns {(Literal | TemplateLiteral | Identifier)[]}
31+
*/
32+
function findStaticClasses(expressionNode) {
33+
if (isStringLiteral(expressionNode)) {
34+
return [expressionNode]
35+
}
36+
37+
if (expressionNode.type === 'ArrayExpression') {
38+
return expressionNode.elements.flatMap((element) => {
39+
if (element === null || element.type === 'SpreadElement') {
40+
return []
41+
}
42+
return findStaticClasses(element)
43+
})
44+
}
45+
46+
if (expressionNode.type === 'ObjectExpression') {
47+
return expressionNode.properties.flatMap((property) => {
48+
if (
49+
property.type === 'Property' &&
50+
property.value.type === 'Literal' &&
51+
property.value.value === true &&
52+
(isStringLiteral(property.key) ||
53+
(property.key.type === 'Identifier' && !property.computed))
54+
) {
55+
return [property.key]
56+
}
57+
return []
58+
})
59+
}
60+
61+
return []
62+
}
63+
64+
/**
65+
* @param {VAttribute | VDirective} attributeNode
66+
* @returns {attributeNode is VAttribute & { value: VLiteral }}
67+
*/
68+
function isStaticClassAttribute(attributeNode) {
69+
return (
70+
!attributeNode.directive &&
71+
attributeNode.key.name === 'class' &&
72+
attributeNode.value !== null
73+
)
74+
}
75+
76+
/**
77+
* Removes the node together with the comma before or after the node.
78+
* @param {RuleFixer} fixer
79+
* @param {ParserServices.TokenStore} tokenStore
80+
* @param {ASTNode} node
81+
*/
82+
function* removeNodeWithComma(fixer, tokenStore, node) {
83+
const prevToken = tokenStore.getTokenBefore(node)
84+
if (prevToken.type === 'Punctuator' && prevToken.value === ',') {
85+
yield fixer.removeRange([prevToken.range[0], node.range[1]])
86+
return
87+
}
88+
89+
const [nextToken, nextNextToken] = tokenStore.getTokensAfter(node, {
90+
count: 2
91+
})
92+
if (
93+
nextToken.type === 'Punctuator' &&
94+
nextToken.value === ',' &&
95+
(nextNextToken.type !== 'Punctuator' ||
96+
(nextNextToken.value !== ']' && nextNextToken.value !== '}'))
97+
) {
98+
yield fixer.removeRange([node.range[0], nextNextToken.range[0]])
99+
return
100+
}
101+
102+
yield fixer.remove(node)
103+
}
104+
105+
// ------------------------------------------------------------------------------
106+
// Rule Definition
107+
// ------------------------------------------------------------------------------
108+
109+
module.exports = {
110+
meta: {
111+
type: 'suggestion',
112+
docs: {
113+
description:
114+
'require static class names in template to be in a separate `class` attribute',
115+
categories: undefined,
116+
url: 'https://eslint.vuejs.org/rules/prefer-separate-static-class.html'
117+
},
118+
fixable: 'code',
119+
schema: [],
120+
messages: {
121+
preferSeparateStaticClass:
122+
'Static class "{{className}}" should be in a static `class` attribute.'
123+
}
124+
},
125+
/** @param {RuleContext} context */
126+
create(context) {
127+
return defineTemplateBodyVisitor(context, {
128+
/** @param {VDirectiveKey} directiveKeyNode */
129+
"VAttribute[directive=true] > VDirectiveKey[name.name='bind'][argument.name='class']"(
130+
directiveKeyNode
131+
) {
132+
const attributeNode = directiveKeyNode.parent
133+
if (!attributeNode.value || !attributeNode.value.expression) {
134+
return
135+
}
136+
137+
const expressionNode = attributeNode.value.expression
138+
const staticClassNameNodes = findStaticClasses(expressionNode)
139+
140+
for (const staticClassNameNode of staticClassNameNodes) {
141+
const className =
142+
staticClassNameNode.type === 'Identifier'
143+
? staticClassNameNode.name
144+
: getStringLiteralValue(staticClassNameNode, true)
145+
146+
if (className === null) {
147+
continue
148+
}
149+
150+
context.report({
151+
node: staticClassNameNode,
152+
messageId: 'preferSeparateStaticClass',
153+
data: { className },
154+
*fix(fixer) {
155+
let dynamicClassDirectiveRemoved = false
156+
157+
yield* removeFromClassDirective()
158+
yield* addToClassAttribute()
159+
160+
/**
161+
* Remove class from dynamic `:class` directive.
162+
*/
163+
function* removeFromClassDirective() {
164+
if (isStringLiteral(expressionNode)) {
165+
yield fixer.remove(attributeNode)
166+
dynamicClassDirectiveRemoved = true
167+
return
168+
}
169+
170+
const listElement =
171+
staticClassNameNode.parent.type === 'Property'
172+
? staticClassNameNode.parent
173+
: staticClassNameNode
174+
175+
const listNode = listElement.parent
176+
if (
177+
listNode.type === 'ArrayExpression' ||
178+
listNode.type === 'ObjectExpression'
179+
) {
180+
const elements =
181+
listNode.type === 'ObjectExpression'
182+
? listNode.properties
183+
: listNode.elements
184+
185+
if (elements.length === 1 && listNode === expressionNode) {
186+
yield fixer.remove(attributeNode)
187+
dynamicClassDirectiveRemoved = true
188+
return
189+
}
190+
191+
const tokenStore =
192+
context.parserServices.getTemplateBodyTokenStore()
193+
194+
if (elements.length === 1) {
195+
yield* removeNodeWithComma(fixer, tokenStore, listNode)
196+
return
197+
}
198+
199+
yield* removeNodeWithComma(fixer, tokenStore, listElement)
200+
}
201+
}
202+
203+
/**
204+
* Add class to static `class` attribute.
205+
*/
206+
function* addToClassAttribute() {
207+
const existingStaticClassAttribute =
208+
attributeNode.parent.attributes.find(isStaticClassAttribute)
209+
if (existingStaticClassAttribute) {
210+
const literalNode = existingStaticClassAttribute.value
211+
yield fixer.replaceText(
212+
literalNode,
213+
`"${literalNode.value} ${className}"`
214+
)
215+
return
216+
}
217+
218+
// new static `class` attribute
219+
const separator = dynamicClassDirectiveRemoved ? '' : ' '
220+
yield fixer.insertTextBefore(
221+
attributeNode,
222+
`class="${className}"${separator}`
223+
)
224+
}
225+
}
226+
})
227+
}
228+
}
229+
})
230+
}
231+
}

0 commit comments

Comments
 (0)