html-self-closing.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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.isMathMLElementNode(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.endTag.range[0] : node.range[1]
  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', '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. },
  116. /** @param {RuleContext} context */
  117. create(context) {
  118. const sourceCode = context.getSourceCode()
  119. const options = parseOptions(context.options[0])
  120. let hasInvalidEOF = false
  121. return utils.defineTemplateBodyVisitor(
  122. context,
  123. {
  124. VElement(node) {
  125. if (hasInvalidEOF) {
  126. return
  127. }
  128. const elementType = getElementType(node)
  129. const mode = options[elementType]
  130. if (
  131. mode === 'always' &&
  132. !node.startTag.selfClosing &&
  133. isEmpty(node, sourceCode)
  134. ) {
  135. context.report({
  136. node,
  137. loc: node.loc,
  138. message: 'Require self-closing on {{elementType}} (<{{name}}>).',
  139. data: {
  140. elementType: ELEMENT_TYPE_MESSAGES[elementType],
  141. name: node.rawName
  142. },
  143. fix(fixer) {
  144. const tokens =
  145. context.parserServices.getTemplateBodyTokenStore()
  146. const close = tokens.getLastToken(node.startTag)
  147. if (close.type !== 'HTMLTagClose') {
  148. return null
  149. }
  150. return fixer.replaceTextRange(
  151. [close.range[0], node.range[1]],
  152. '/>'
  153. )
  154. }
  155. })
  156. }
  157. if (mode === 'never' && node.startTag.selfClosing) {
  158. context.report({
  159. node,
  160. loc: node.loc,
  161. message:
  162. 'Disallow self-closing on {{elementType}} (<{{name}}/>).',
  163. data: {
  164. elementType: ELEMENT_TYPE_MESSAGES[elementType],
  165. name: node.rawName
  166. },
  167. fix(fixer) {
  168. const tokens =
  169. context.parserServices.getTemplateBodyTokenStore()
  170. const close = tokens.getLastToken(node.startTag)
  171. if (close.type !== 'HTMLSelfClosingTagClose') {
  172. return null
  173. }
  174. if (elementType === 'VOID') {
  175. return fixer.replaceText(close, '>')
  176. }
  177. // If only `close` is targeted for replacement, it conflicts with `component-name-in-template-casing`,
  178. // so replace the entire element.
  179. // return fixer.replaceText(close, `></${node.rawName}>`)
  180. const elementPart = sourceCode.text.slice(
  181. node.range[0],
  182. close.range[0]
  183. )
  184. return fixer.replaceText(
  185. node,
  186. `${elementPart}></${node.rawName}>`
  187. )
  188. }
  189. })
  190. }
  191. }
  192. },
  193. {
  194. Program(node) {
  195. hasInvalidEOF = utils.hasInvalidEOF(node)
  196. }
  197. }
  198. )
  199. }
  200. }