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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  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:
  68. 'https://eslint.vuejs.org/rules/multiline-html-element-content-newline.html'
  69. },
  70. fixable: 'whitespace',
  71. schema: [
  72. {
  73. type: 'object',
  74. properties: {
  75. ignoreWhenEmpty: {
  76. type: 'boolean'
  77. },
  78. ignores: {
  79. type: 'array',
  80. items: { type: 'string' },
  81. uniqueItems: true,
  82. additionalItems: false
  83. },
  84. allowEmptyLines: {
  85. type: 'boolean'
  86. }
  87. },
  88. additionalProperties: false
  89. }
  90. ],
  91. messages: {
  92. unexpectedAfterClosingBracket:
  93. 'Expected 1 line break after opening tag (`<{{name}}>`), but {{actual}} line breaks found.',
  94. unexpectedBeforeOpeningBracket:
  95. 'Expected 1 line break before closing tag (`</{{name}}>`), but {{actual}} line breaks found.'
  96. }
  97. },
  98. /** @param {RuleContext} context */
  99. create(context) {
  100. const options = parseOptions(context.options[0])
  101. const ignores = options.ignores
  102. const ignoreWhenEmpty = options.ignoreWhenEmpty
  103. const allowEmptyLines = options.allowEmptyLines
  104. const template =
  105. context.parserServices.getTemplateBodyTokenStore &&
  106. context.parserServices.getTemplateBodyTokenStore()
  107. const sourceCode = context.getSourceCode()
  108. /** @type {VElement | null} */
  109. let inIgnoreElement = null
  110. /**
  111. * @param {VElement} node
  112. */
  113. function isIgnoredElement(node) {
  114. return (
  115. ignores.includes(node.name) ||
  116. ignores.includes(casing.pascalCase(node.rawName)) ||
  117. ignores.includes(casing.kebabCase(node.rawName))
  118. )
  119. }
  120. /**
  121. * @param {number} lineBreaks
  122. */
  123. function isInvalidLineBreaks(lineBreaks) {
  124. if (allowEmptyLines) {
  125. return lineBreaks === 0
  126. } else {
  127. return lineBreaks !== 1
  128. }
  129. }
  130. return utils.defineTemplateBodyVisitor(context, {
  131. VElement(node) {
  132. if (inIgnoreElement) {
  133. return
  134. }
  135. if (isIgnoredElement(node)) {
  136. // ignore element name
  137. inIgnoreElement = node
  138. return
  139. }
  140. if (node.startTag.selfClosing || !node.endTag) {
  141. // self closing
  142. return
  143. }
  144. const element = /** @type {VElement & { endTag: VEndTag }} */ (node)
  145. if (!isMultilineElement(element)) {
  146. return
  147. }
  148. /**
  149. * @type {SourceCode.CursorWithCountOptions}
  150. */
  151. const getTokenOption = {
  152. includeComments: true,
  153. filter: (token) => token.type !== 'HTMLWhitespace'
  154. }
  155. if (
  156. ignoreWhenEmpty &&
  157. element.children.length === 0 &&
  158. template.getFirstTokensBetween(
  159. element.startTag,
  160. element.endTag,
  161. getTokenOption
  162. ).length === 0
  163. ) {
  164. return
  165. }
  166. const contentFirst = /** @type {Token} */ (template.getTokenAfter(
  167. element.startTag,
  168. getTokenOption
  169. ))
  170. const contentLast = /** @type {Token} */ (template.getTokenBefore(
  171. element.endTag,
  172. getTokenOption
  173. ))
  174. const beforeLineBreaks =
  175. contentFirst.loc.start.line - element.startTag.loc.end.line
  176. const afterLineBreaks =
  177. element.endTag.loc.start.line - contentLast.loc.end.line
  178. if (isInvalidLineBreaks(beforeLineBreaks)) {
  179. context.report({
  180. node: template.getLastToken(element.startTag),
  181. loc: {
  182. start: element.startTag.loc.end,
  183. end: contentFirst.loc.start
  184. },
  185. messageId: 'unexpectedAfterClosingBracket',
  186. data: {
  187. name: element.rawName,
  188. actual: getPhrase(beforeLineBreaks)
  189. },
  190. fix(fixer) {
  191. /** @type {Range} */
  192. const range = [element.startTag.range[1], contentFirst.range[0]]
  193. return fixer.replaceTextRange(range, '\n')
  194. }
  195. })
  196. }
  197. if (isEmpty(element, sourceCode)) {
  198. return
  199. }
  200. if (isInvalidLineBreaks(afterLineBreaks)) {
  201. context.report({
  202. node: template.getFirstToken(element.endTag),
  203. loc: {
  204. start: contentLast.loc.end,
  205. end: element.endTag.loc.start
  206. },
  207. messageId: 'unexpectedBeforeOpeningBracket',
  208. data: {
  209. name: element.name,
  210. actual: getPhrase(afterLineBreaks)
  211. },
  212. fix(fixer) {
  213. /** @type {Range} */
  214. const range = [contentLast.range[1], element.endTag.range[0]]
  215. return fixer.replaceTextRange(range, '\n')
  216. }
  217. })
  218. }
  219. },
  220. 'VElement:exit'(node) {
  221. if (inIgnoreElement === node) {
  222. inIgnoreElement = null
  223. }
  224. }
  225. })
  226. }
  227. }