singleline-html-element-content-newline.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /**
  2. * @author Yosuke Ota
  3. * See LICENSE file in root directory for full license.
  4. */
  5. 'use strict'
  6. const utils = require('../utils')
  7. const casing = require('../utils/casing')
  8. const INLINE_ELEMENTS = require('../utils/inline-non-void-elements.json')
  9. /**
  10. * @param {VElement & { endTag: VEndTag } } element
  11. */
  12. function isSinglelineElement(element) {
  13. return element.loc.start.line === element.endTag.loc.start.line
  14. }
  15. /**
  16. * @param {any} options
  17. */
  18. function parseOptions(options) {
  19. return Object.assign(
  20. {
  21. ignores: ['pre', 'textarea', ...INLINE_ELEMENTS],
  22. ignoreWhenNoAttributes: true,
  23. ignoreWhenEmpty: true
  24. },
  25. options
  26. )
  27. }
  28. /**
  29. * Check whether the given element is empty or not.
  30. * This ignores whitespaces, doesn't ignore comments.
  31. * @param {VElement & { endTag: VEndTag } } node The element node to check.
  32. * @param {SourceCode} sourceCode The source code object of the current context.
  33. * @returns {boolean} `true` if the element is empty.
  34. */
  35. function isEmpty(node, sourceCode) {
  36. const start = node.startTag.range[1]
  37. const end = node.endTag.range[0]
  38. return sourceCode.text.slice(start, end).trim() === ''
  39. }
  40. module.exports = {
  41. meta: {
  42. type: 'layout',
  43. docs: {
  44. description:
  45. 'require a line break before and after the contents of a singleline element',
  46. categories: ['vue3-strongly-recommended', 'strongly-recommended'],
  47. url: 'https://eslint.vuejs.org/rules/singleline-html-element-content-newline.html'
  48. },
  49. fixable: 'whitespace',
  50. schema: [
  51. {
  52. type: 'object',
  53. properties: {
  54. ignoreWhenNoAttributes: {
  55. type: 'boolean'
  56. },
  57. ignoreWhenEmpty: {
  58. type: 'boolean'
  59. },
  60. ignores: {
  61. type: 'array',
  62. items: { type: 'string' },
  63. uniqueItems: true,
  64. additionalItems: false
  65. }
  66. },
  67. additionalProperties: false
  68. }
  69. ],
  70. messages: {
  71. unexpectedAfterClosingBracket:
  72. 'Expected 1 line break after opening tag (`<{{name}}>`), but no line breaks found.',
  73. unexpectedBeforeOpeningBracket:
  74. 'Expected 1 line break before closing tag (`</{{name}}>`), but no line breaks found.'
  75. }
  76. },
  77. /** @param {RuleContext} context */
  78. create(context) {
  79. const options = parseOptions(context.options[0])
  80. const ignores = options.ignores
  81. const ignoreWhenNoAttributes = options.ignoreWhenNoAttributes
  82. const ignoreWhenEmpty = options.ignoreWhenEmpty
  83. const template =
  84. context.parserServices.getTemplateBodyTokenStore &&
  85. context.parserServices.getTemplateBodyTokenStore()
  86. const sourceCode = context.getSourceCode()
  87. /** @type {VElement | null} */
  88. let inIgnoreElement = null
  89. /** @param {VElement} node */
  90. function isIgnoredElement(node) {
  91. return (
  92. ignores.includes(node.name) ||
  93. ignores.includes(casing.pascalCase(node.rawName)) ||
  94. ignores.includes(casing.kebabCase(node.rawName))
  95. )
  96. }
  97. return utils.defineTemplateBodyVisitor(context, {
  98. /** @param {VElement} node */
  99. VElement(node) {
  100. if (inIgnoreElement) {
  101. return
  102. }
  103. if (isIgnoredElement(node)) {
  104. // ignore element name
  105. inIgnoreElement = node
  106. return
  107. }
  108. if (node.startTag.selfClosing || !node.endTag) {
  109. // self closing
  110. return
  111. }
  112. const elem = /** @type {VElement & { endTag: VEndTag } } */ (node)
  113. if (!isSinglelineElement(elem)) {
  114. return
  115. }
  116. if (ignoreWhenNoAttributes && elem.startTag.attributes.length === 0) {
  117. return
  118. }
  119. /** @type {SourceCode.CursorWithCountOptions} */
  120. const getTokenOption = {
  121. includeComments: true,
  122. filter: (token) => token.type !== 'HTMLWhitespace'
  123. }
  124. if (
  125. ignoreWhenEmpty &&
  126. elem.children.length === 0 &&
  127. template.getFirstTokensBetween(
  128. elem.startTag,
  129. elem.endTag,
  130. getTokenOption
  131. ).length === 0
  132. ) {
  133. return
  134. }
  135. const contentFirst = /** @type {Token} */ (
  136. template.getTokenAfter(elem.startTag, getTokenOption)
  137. )
  138. const contentLast = /** @type {Token} */ (
  139. template.getTokenBefore(elem.endTag, getTokenOption)
  140. )
  141. context.report({
  142. node: template.getLastToken(elem.startTag),
  143. loc: {
  144. start: elem.startTag.loc.end,
  145. end: contentFirst.loc.start
  146. },
  147. messageId: 'unexpectedAfterClosingBracket',
  148. data: {
  149. name: elem.rawName
  150. },
  151. fix(fixer) {
  152. /** @type {Range} */
  153. const range = [elem.startTag.range[1], contentFirst.range[0]]
  154. return fixer.replaceTextRange(range, '\n')
  155. }
  156. })
  157. if (isEmpty(elem, sourceCode)) {
  158. return
  159. }
  160. context.report({
  161. node: template.getFirstToken(elem.endTag),
  162. loc: {
  163. start: contentLast.loc.end,
  164. end: elem.endTag.loc.start
  165. },
  166. messageId: 'unexpectedBeforeOpeningBracket',
  167. data: {
  168. name: elem.rawName
  169. },
  170. fix(fixer) {
  171. /** @type {Range} */
  172. const range = [contentLast.range[1], elem.endTag.range[0]]
  173. return fixer.replaceTextRange(range, '\n')
  174. }
  175. })
  176. },
  177. /** @param {VElement} node */
  178. 'VElement:exit'(node) {
  179. if (inIgnoreElement === node) {
  180. inIgnoreElement = null
  181. }
  182. }
  183. })
  184. }
  185. }