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

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