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

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