prefer-separate-static-class.js 6.5 KB

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