html-self-closing.js 6.6 KB

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