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

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