prefer-separate-static-class.js 6.7 KB

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