multiline-html-element-content-newline.js 5.9 KB

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