/**
|
* @fileoverview Enforce line breaks style after opening and before closing block-level tags.
|
* @author Yosuke Ota
|
*/
|
'use strict'
|
const utils = require('../utils')
|
|
/**
|
* @typedef { 'always' | 'never' | 'consistent' | 'ignore' } OptionType
|
* @typedef { { singleline?: OptionType, multiline?: OptionType, maxEmptyLines?: number } } ContentsOptions
|
* @typedef { ContentsOptions & { blocks?: { [element: string]: ContentsOptions } } } Options
|
* @typedef { Required<ContentsOptions> } ArgsOptions
|
*/
|
|
/**
|
* @param {string} text Source code as a string.
|
* @returns {number}
|
*/
|
function getLinebreakCount(text) {
|
return text.split(/\r\n|[\r\n\u2028\u2029]/gu).length - 1
|
}
|
|
/**
|
* @param {number} lineBreaks
|
*/
|
function getPhrase(lineBreaks) {
|
switch (lineBreaks) {
|
case 1:
|
return '1 line break'
|
default:
|
return `${lineBreaks} line breaks`
|
}
|
}
|
|
// ------------------------------------------------------------------------------
|
// Rule Definition
|
// ------------------------------------------------------------------------------
|
|
const ENUM_OPTIONS = { enum: ['always', 'never', 'consistent', 'ignore'] }
|
module.exports = {
|
meta: {
|
type: 'layout',
|
docs: {
|
description:
|
'enforce line breaks after opening and before closing block-level tags',
|
categories: undefined,
|
url: 'https://eslint.vuejs.org/rules/block-tag-newline.html'
|
},
|
fixable: 'whitespace',
|
schema: [
|
{
|
type: 'object',
|
properties: {
|
singleline: ENUM_OPTIONS,
|
multiline: ENUM_OPTIONS,
|
maxEmptyLines: { type: 'number', minimum: 0 },
|
blocks: {
|
type: 'object',
|
patternProperties: {
|
'^(?:\\S+)$': {
|
type: 'object',
|
properties: {
|
singleline: ENUM_OPTIONS,
|
multiline: ENUM_OPTIONS,
|
maxEmptyLines: { type: 'number', minimum: 0 }
|
},
|
additionalProperties: false
|
}
|
},
|
additionalProperties: false
|
}
|
},
|
additionalProperties: false
|
}
|
],
|
messages: {
|
unexpectedOpeningLinebreak:
|
"There should be no line break after '<{{tag}}>'.",
|
unexpectedClosingLinebreak:
|
"There should be no line break before '</{{tag}}>'.",
|
expectedOpeningLinebreak:
|
"Expected {{expected}} after '<{{tag}}>', but {{actual}} found.",
|
expectedClosingLinebreak:
|
"Expected {{expected}} before '</{{tag}}>', but {{actual}} found.",
|
missingOpeningLinebreak: "A line break is required after '<{{tag}}>'.",
|
missingClosingLinebreak: "A line break is required before '</{{tag}}>'."
|
}
|
},
|
/** @param {RuleContext} context */
|
create(context) {
|
const df =
|
context.parserServices.getDocumentFragment &&
|
context.parserServices.getDocumentFragment()
|
if (!df) {
|
return {}
|
}
|
|
const sourceCode = context.getSourceCode()
|
|
/**
|
* @param {VStartTag} startTag
|
* @param {string} beforeText
|
* @param {number} beforeLinebreakCount
|
* @param {'always' | 'never'} beforeOption
|
* @param {number} maxEmptyLines
|
* @returns {void}
|
*/
|
function verifyBeforeSpaces(
|
startTag,
|
beforeText,
|
beforeLinebreakCount,
|
beforeOption,
|
maxEmptyLines
|
) {
|
if (beforeOption === 'always') {
|
if (beforeLinebreakCount === 0) {
|
context.report({
|
loc: {
|
start: startTag.loc.end,
|
end: startTag.loc.end
|
},
|
messageId: 'missingOpeningLinebreak',
|
data: { tag: startTag.parent.name },
|
fix(fixer) {
|
return fixer.insertTextAfter(startTag, '\n')
|
}
|
})
|
} else if (maxEmptyLines < beforeLinebreakCount - 1) {
|
context.report({
|
loc: {
|
start: startTag.loc.end,
|
end: sourceCode.getLocFromIndex(
|
startTag.range[1] + beforeText.length
|
)
|
},
|
messageId: 'expectedOpeningLinebreak',
|
data: {
|
tag: startTag.parent.name,
|
expected: getPhrase(maxEmptyLines + 1),
|
actual: getPhrase(beforeLinebreakCount)
|
},
|
fix(fixer) {
|
return fixer.replaceTextRange(
|
[startTag.range[1], startTag.range[1] + beforeText.length],
|
'\n'.repeat(maxEmptyLines + 1)
|
)
|
}
|
})
|
}
|
} else {
|
if (beforeLinebreakCount > 0) {
|
context.report({
|
loc: {
|
start: startTag.loc.end,
|
end: sourceCode.getLocFromIndex(
|
startTag.range[1] + beforeText.length
|
)
|
},
|
messageId: 'unexpectedOpeningLinebreak',
|
data: { tag: startTag.parent.name },
|
fix(fixer) {
|
return fixer.removeRange([
|
startTag.range[1],
|
startTag.range[1] + beforeText.length
|
])
|
}
|
})
|
}
|
}
|
}
|
/**
|
* @param {VEndTag} endTag
|
* @param {string} afterText
|
* @param {number} afterLinebreakCount
|
* @param {'always' | 'never'} afterOption
|
* @param {number} maxEmptyLines
|
* @returns {void}
|
*/
|
function verifyAfterSpaces(
|
endTag,
|
afterText,
|
afterLinebreakCount,
|
afterOption,
|
maxEmptyLines
|
) {
|
if (afterOption === 'always') {
|
if (afterLinebreakCount === 0) {
|
context.report({
|
loc: {
|
start: endTag.loc.start,
|
end: endTag.loc.start
|
},
|
messageId: 'missingClosingLinebreak',
|
data: { tag: endTag.parent.name },
|
fix(fixer) {
|
return fixer.insertTextBefore(endTag, '\n')
|
}
|
})
|
} else if (maxEmptyLines < afterLinebreakCount - 1) {
|
context.report({
|
loc: {
|
start: sourceCode.getLocFromIndex(
|
endTag.range[0] - afterText.length
|
),
|
end: endTag.loc.start
|
},
|
messageId: 'expectedClosingLinebreak',
|
data: {
|
tag: endTag.parent.name,
|
expected: getPhrase(maxEmptyLines + 1),
|
actual: getPhrase(afterLinebreakCount)
|
},
|
fix(fixer) {
|
return fixer.replaceTextRange(
|
[endTag.range[0] - afterText.length, endTag.range[0]],
|
'\n'.repeat(maxEmptyLines + 1)
|
)
|
}
|
})
|
}
|
} else {
|
if (afterLinebreakCount > 0) {
|
context.report({
|
loc: {
|
start: sourceCode.getLocFromIndex(
|
endTag.range[0] - afterText.length
|
),
|
end: endTag.loc.start
|
},
|
messageId: 'unexpectedOpeningLinebreak',
|
data: { tag: endTag.parent.name },
|
fix(fixer) {
|
return fixer.removeRange([
|
endTag.range[0] - afterText.length,
|
endTag.range[0]
|
])
|
}
|
})
|
}
|
}
|
}
|
/**
|
* @param {VElement} element
|
* @param {ArgsOptions} options
|
* @returns {void}
|
*/
|
function verifyElement(element, options) {
|
const { startTag, endTag } = element
|
if (startTag.selfClosing || endTag == null) {
|
return
|
}
|
const text = sourceCode.text.slice(startTag.range[1], endTag.range[0])
|
|
const trimText = text.trim()
|
if (!trimText) {
|
return
|
}
|
|
const option =
|
options.multiline === options.singleline
|
? options.singleline
|
: /[\n\r\u2028\u2029]/u.test(text.trim())
|
? options.multiline
|
: options.singleline
|
if (option === 'ignore') {
|
return
|
}
|
const beforeText = /** @type {RegExpExecArray} */ (/^\s*/u.exec(text))[0]
|
const afterText = /** @type {RegExpExecArray} */ (/\s*$/u.exec(text))[0]
|
const beforeLinebreakCount = getLinebreakCount(beforeText)
|
const afterLinebreakCount = getLinebreakCount(afterText)
|
|
/** @type {'always' | 'never'} */
|
let beforeOption
|
/** @type {'always' | 'never'} */
|
let afterOption
|
if (option === 'always' || option === 'never') {
|
beforeOption = option
|
afterOption = option
|
} else {
|
// consistent
|
if (beforeLinebreakCount > 0 === afterLinebreakCount > 0) {
|
return
|
}
|
beforeOption = 'always'
|
afterOption = 'always'
|
}
|
|
verifyBeforeSpaces(
|
startTag,
|
beforeText,
|
beforeLinebreakCount,
|
beforeOption,
|
options.maxEmptyLines
|
)
|
|
verifyAfterSpaces(
|
endTag,
|
afterText,
|
afterLinebreakCount,
|
afterOption,
|
options.maxEmptyLines
|
)
|
}
|
|
/**
|
* Normalizes a given option value.
|
* @param { Options | undefined } option An option value to parse.
|
* @returns { (element: VElement) => void } Verify function.
|
*/
|
function normalizeOptionValue(option) {
|
if (!option) {
|
return normalizeOptionValue({})
|
}
|
|
/** @type {ContentsOptions} */
|
const contentsOptions = option
|
/** @type {ArgsOptions} */
|
const options = {
|
singleline: contentsOptions.singleline || 'consistent',
|
multiline: contentsOptions.multiline || 'always',
|
maxEmptyLines: contentsOptions.maxEmptyLines || 0
|
}
|
const { blocks } = option
|
if (!blocks) {
|
return (element) => verifyElement(element, options)
|
}
|
|
return (element) => {
|
const { name } = element
|
const elementsOptions = blocks[name]
|
if (!elementsOptions) {
|
verifyElement(element, options)
|
} else {
|
normalizeOptionValue({
|
singleline: elementsOptions.singleline || options.singleline,
|
multiline: elementsOptions.multiline || options.multiline,
|
maxEmptyLines:
|
elementsOptions.maxEmptyLines != null
|
? elementsOptions.maxEmptyLines
|
: options.maxEmptyLines
|
})(element)
|
}
|
}
|
}
|
|
const documentFragment = df
|
|
const verify = normalizeOptionValue(context.options[0])
|
|
/**
|
* @returns {VElement[]}
|
*/
|
function getTopLevelHTMLElements() {
|
return documentFragment.children.filter(utils.isVElement)
|
}
|
|
return utils.defineTemplateBodyVisitor(
|
context,
|
{},
|
{
|
/** @param {Program} node */
|
Program(node) {
|
if (utils.hasInvalidEOF(node)) {
|
return
|
}
|
|
for (const element of getTopLevelHTMLElements()) {
|
verify(element)
|
}
|
}
|
}
|
)
|
}
|
}
|