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

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