html-self-closing.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. /**
  2. * @author Toru Nagashima
  3. * @copyright 2016 Toru Nagashima. All rights reserved.
  4. * See LICENSE file in root directory for full license.
  5. */
  6. 'use strict'
  7. const utils = require('../utils')
  8. /**
  9. * These strings wil be displayed in error messages.
  10. */
  11. const ELEMENT_TYPE_MESSAGES = Object.freeze({
  12. NORMAL: 'HTML elements',
  13. VOID: 'HTML void elements',
  14. COMPONENT: 'Vue.js custom components',
  15. SVG: 'SVG elements',
  16. MATH: 'MathML elements',
  17. UNKNOWN: 'unknown elements'
  18. })
  19. /**
  20. * @typedef {object} Options
  21. * @property {'always' | 'never'} NORMAL
  22. * @property {'always' | 'never'} VOID
  23. * @property {'always' | 'never'} COMPONENT
  24. * @property {'always' | 'never'} SVG
  25. * @property {'always' | 'never'} MATH
  26. * @property {null} UNKNOWN
  27. */
  28. /**
  29. * Normalize the given options.
  30. * @param {any} options The raw options object.
  31. * @returns {Options} Normalized options.
  32. */
  33. function parseOptions(options) {
  34. return {
  35. NORMAL: (options && options.html && options.html.normal) || 'always',
  36. VOID: (options && options.html && options.html.void) || 'never',
  37. COMPONENT: (options && options.html && options.html.component) || 'always',
  38. SVG: (options && options.svg) || 'always',
  39. MATH: (options && options.math) || 'always',
  40. UNKNOWN: null
  41. }
  42. }
  43. /**
  44. * Get the elementType of the given element.
  45. * @param {VElement} node The element node to get.
  46. * @returns {keyof Options} The elementType of the element.
  47. */
  48. function getElementType(node) {
  49. if (utils.isCustomComponent(node)) {
  50. return 'COMPONENT'
  51. }
  52. if (utils.isHtmlElementNode(node)) {
  53. if (utils.isHtmlVoidElementName(node.name)) {
  54. return 'VOID'
  55. }
  56. return 'NORMAL'
  57. }
  58. if (utils.isSvgElementNode(node)) {
  59. return 'SVG'
  60. }
  61. if (utils.isMathElementNode(node)) {
  62. return 'MATH'
  63. }
  64. return 'UNKNOWN'
  65. }
  66. /**
  67. * Check whether the given element is empty or not.
  68. * This ignores whitespaces, doesn't ignore comments.
  69. * @param {VElement} node The element node to check.
  70. * @param {SourceCode} sourceCode The source code object of the current context.
  71. * @returns {boolean} `true` if the element is empty.
  72. */
  73. function isEmpty(node, sourceCode) {
  74. const start = node.startTag.range[1]
  75. const end = node.endTag == null ? node.range[1] : node.endTag.range[0]
  76. return sourceCode.text.slice(start, end).trim() === ''
  77. }
  78. module.exports = {
  79. meta: {
  80. type: 'layout',
  81. docs: {
  82. description: 'enforce self-closing style',
  83. categories: ['vue3-strongly-recommended', 'vue2-strongly-recommended'],
  84. url: 'https://eslint.vuejs.org/rules/html-self-closing.html'
  85. },
  86. fixable: 'code',
  87. schema: {
  88. definitions: {
  89. optionValue: {
  90. enum: ['always', 'never', 'any']
  91. }
  92. },
  93. type: 'array',
  94. items: [
  95. {
  96. type: 'object',
  97. properties: {
  98. html: {
  99. type: 'object',
  100. properties: {
  101. normal: { $ref: '#/definitions/optionValue' },
  102. void: { $ref: '#/definitions/optionValue' },
  103. component: { $ref: '#/definitions/optionValue' }
  104. },
  105. additionalProperties: false
  106. },
  107. svg: { $ref: '#/definitions/optionValue' },
  108. math: { $ref: '#/definitions/optionValue' }
  109. },
  110. additionalProperties: false
  111. }
  112. ],
  113. maxItems: 1
  114. },
  115. messages: {
  116. requireSelfClosing:
  117. 'Require self-closing on {{elementType}} (<{{name}}>).',
  118. disallowSelfClosing:
  119. 'Disallow self-closing on {{elementType}} (<{{name}}/>).'
  120. }
  121. },
  122. /** @param {RuleContext} context */
  123. create(context) {
  124. const sourceCode = context.getSourceCode()
  125. const options = parseOptions(context.options[0])
  126. let hasInvalidEOF = false
  127. return utils.defineTemplateBodyVisitor(
  128. context,
  129. {
  130. VElement(node) {
  131. if (hasInvalidEOF || node.parent.type === 'VDocumentFragment') {
  132. return
  133. }
  134. const elementType = getElementType(node)
  135. const mode = options[elementType]
  136. if (
  137. mode === 'always' &&
  138. !node.startTag.selfClosing &&
  139. isEmpty(node, sourceCode)
  140. ) {
  141. context.report({
  142. node,
  143. loc: node.loc,
  144. messageId: 'requireSelfClosing',
  145. data: {
  146. elementType: ELEMENT_TYPE_MESSAGES[elementType],
  147. name: node.rawName
  148. },
  149. fix(fixer) {
  150. const tokens =
  151. sourceCode.parserServices.getTemplateBodyTokenStore()
  152. const close = tokens.getLastToken(node.startTag)
  153. if (close.type !== 'HTMLTagClose') {
  154. return null
  155. }
  156. return fixer.replaceTextRange(
  157. [close.range[0], node.range[1]],
  158. '/>'
  159. )
  160. }
  161. })
  162. }
  163. if (mode === 'never' && node.startTag.selfClosing) {
  164. context.report({
  165. node,
  166. loc: node.loc,
  167. messageId: 'disallowSelfClosing',
  168. data: {
  169. elementType: ELEMENT_TYPE_MESSAGES[elementType],
  170. name: node.rawName
  171. },
  172. fix(fixer) {
  173. const tokens =
  174. sourceCode.parserServices.getTemplateBodyTokenStore()
  175. const close = tokens.getLastToken(node.startTag)
  176. if (close.type !== 'HTMLSelfClosingTagClose') {
  177. return null
  178. }
  179. if (elementType === 'VOID') {
  180. return fixer.replaceText(close, '>')
  181. }
  182. // If only `close` is targeted for replacement, it conflicts with `component-name-in-template-casing`,
  183. // so replace the entire element.
  184. // return fixer.replaceText(close, `></${node.rawName}>`)
  185. const elementPart = sourceCode.text.slice(
  186. node.range[0],
  187. close.range[0]
  188. )
  189. return fixer.replaceText(
  190. node,
  191. `${elementPart}></${node.rawName}>`
  192. )
  193. }
  194. })
  195. }
  196. }
  197. },
  198. {
  199. Program(node) {
  200. hasInvalidEOF = utils.hasInvalidEOF(node)
  201. }
  202. }
  203. )
  204. }
  205. }